项目架构

本文采用 Eureka 作为注册中心,Spring Cloud Gateway 作为网关服务,JWT 令牌库使用 nimbus-jose-jwt

将服务分为以下几个层次:

  • security-gateway:网关层,负责接收所有网络请求、转发以及权限鉴定
  • security-auth:认证层,负责对登录用户进行认证
  • security‐discovery:注册中心
  • security-api:资源层,提供被访问的资源,用户被鉴权之后才可被访问

这样的设计使得各个服务各司其职,认证层进行认证,网关进行转发和鉴权,资源服务只专注于自己的业务逻辑,无需关心权限。也就是说安全校验逻辑只存在于认证服务和网关服务中。

微服务

权限数据库设计

在前文【Spring Security + Redis + JWT 实现动态权限管理】的基础上,构建用户组-用户-角色-资源的关系进行权限控制。与前文区别在于,最终用户拥有的权限 = 用户组对应的权限 + 用户本身对应的权限。

之所以设计用户组是为了便于管理庞大的用户数量,通过用户组可以给用户统一进行授权,同一个用户组内的用户具有公共的权限,可以减少赋权的工作量,同时允许用户拥有自己单独的权限,可以做到因人而异,更加具有适应性。

数据库表设计

具体实现

认证服务

pom.xml 添加相关依赖,主要包括 Spring Security、Oauth2、JWT、Redis、eureka等相关依赖。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
        <version>${spring-cloud-starter-oauth2.version}</version>
    </dependency>

    <dependency>
        <groupId>com.nimbusds</groupId>
        <artifactId>nimbus-jose-jwt</artifactId>
        <version>${nimbus-jose-jwt.version}</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>

    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.4</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>

</dependencies>

配置文件,主要配置 eureka 信息

server:
  port: 9401
spring:
  application:
    name: security-auth
  datasource:
    username: root
    password: "root"
    url: jdbc:mysql://localhost:3306/Security?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
    driver-class-name: com.mysql.jdbc.Driver

mybatis:
  mapper-locations: classpath:mapper/*.xml
  configuration:
    map-underscore-to-camel-case: true
#    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8848/eureka/
  instance:
    prefer-ip-address: true
    instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${spring.application.instance_id:${server.port}}


需要自定义 UserDetailsService ,将用户信息和权限注入进来,为后面的认证做准备(UserMapper、SysRoleMapper接口可参考前文所述)

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper sysUserMapper;

    @Autowired
    private SysRoleMapper sysRoleMapper;

    //自定义的登录逻辑
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = sysUserMapper.selectByName(username);
        //根据用户名去数据库进行查询,如不存在则抛出异常
        if (user == null){
            throw new UsernameNotFoundException("用户不存在");
        }
        List<GrantedAuthority> authorities = new ArrayList<>();
        // 使用用户、角色、资源、用户组建立关系,使用角色控制权限, 用户权限 = 用户个人权限+用户组权限
        // 查询用户对应的权限
        List<String> codeList = sysRoleMapper.selectUserRole(user.getUsername());
        codeList.forEach(code ->{
            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(code);
            authorities.add(simpleGrantedAuthority);
        });
        return new User(username, user.getPassword(), authorities);
    }
}

配置认证服务相关配置信息,采用账号密码模式进行认证

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private UserDetailServiceImpl userDetailService;

    @Resource
    private JwtTokenEnhancer jwtTokenEnhancer;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtAccessTokenConverter accessTokenConverter;

    @Resource
    private DataSource dataSource;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Bean
    public ClientDetailsService jdbcClientDetailsService() {
        ClientDetailsService clientDetailsService = new JdbcClientDetailsService(dataSource);
        ((JdbcClientDetailsService) clientDetailsService).setPasswordEncoder(passwordEncoder);
        return clientDetailsService;
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients()
                .passwordEncoder(passwordEncoder)
                .tokenKeyAccess("permitAll()")
                .checkTokenAccess("isAuthenticated()");
    }

    // 设置客户端信息从数据库中读取
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(jdbcClientDetailsService());
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> delegates = new ArrayList<>();
        delegates.add(jwtTokenEnhancer);
        delegates.add(accessTokenConverter);
        enhancerChain.setTokenEnhancers(delegates); // 配置JWT的内容增强器
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userDetailService) // 配置加载用户信息的服务
                .accessTokenConverter(accessTokenConverter)
                .tokenEnhancer(enhancerChain);
    }
}

设置 token 的方式为 JWT,并自定义 JWT 内部的其他信息

@Configuration
public class TokenConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setKeyPair(keyPair());
        return jwtAccessTokenConverter;
    }

    @Bean
    public KeyPair keyPair() {
        // 从classpath下的证书中获取秘钥对
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
        return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
    }
}

JWT 增强器,自定义 JWT 内部信息

@Component
public class JwtTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
        String name = oAuth2Authentication.getName();
        Map<String, Object> info = new HashMap<>();
        // 把用户名设置到JWT中
        info.put("name", name);
        ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);
        return oAuth2AccessToken;
    }
}

暴露公钥接口以便验证签名是否合法

@RestController
public class KeyPairController {

    @Autowired
    private KeyPair keyPair;

    @GetMapping("/rsa/publicKey")
    public Map<String, Object> getKey() {
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }
}

配置 Spring Security 信息,开放公钥接口并且设置允许表单形式登录

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailServiceImpl userDetailService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
                .antMatchers("/rsa/publicKey").permitAll()
                .anyRequest().authenticated().and()
                .formLogin();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 自定义数据库登录逻辑
        auth.userDetailsService(userDetailService)
                .passwordEncoder(passwordEncoder);
    }

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
}
网关

pom.xml

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-config</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-resource-server</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-client</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-jose</artifactId>
    </dependency>

    <dependency>
        <groupId>com.nimbusds</groupId>
        <artifactId>nimbus-jose-jwt</artifactId>
        <version>${nimbus-jose-jwt.version}</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>

    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.4</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-configuration-processor</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

配置文件主要配置路由转发规则以及 Oauth2 中 RSA 公钥

server:
  port: 9201
spring:
  application:
    name: security-gateway
  datasource:
    username: root
    password: "root"
    url: jdbc:mysql://localhost:3306/Security?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
    driver-class-name: com.mysql.jdbc.Driver
  cloud:
    gateway:
      routes: #配置路由路径
        - id: security-api
          uri: lb://security-api
          predicates:
            - Path=/api/**
          filters:
            - StripPrefix=1
        - id: security-auth
          uri: lb://security-auth
          predicates:
            - Path=/auth/**
          filters:
            - StripPrefix=1
      discovery:
        locator:
          enabled: true #开启从注册中心动态创建路由的功能
          lower-case-service-id: true #使用小写服务名,默认是大写
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: 'http://localhost:9401/rsa/publicKey' #配置RSA的公钥访问地址
  redis:
    database: 0
    port: 6379
    host: localhost
    password:
secure:
  ignore:
    urls: #配置白名单路径
      - "/actuator/**"
      - "/auth/oauth/token"

eureka:
  client:
    service-url:  # eureka的地址信息
      defaultZone: http://localhost:8848/eureka/
  #  将ip配置到eureka里面,不给就是host名会配置到那个里面
  instance:
    prefer-ip-address: true
    instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${spring.application.instance_id:${server.port}}

mybatis:
  mapper-locations: classpath:mapper/*.xml
  configuration:
    map-underscore-to-camel-case: true
#    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

对网关服务进行安全信息配置,并使用注解开启

@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {

    @Autowired
    private AuthorizationManager authorizationManager;

    @Autowired
    private WhiteUrlsConfig whiteUrlsConfig;

    @Autowired
    private RestAccessDeniedHandler restAccessDeniedHandler;

    @Autowired
    private RestAuthenticationEntryPoint restAuthenticationEntryPoint;

    @Autowired
    private WhiteUrlsRemoveJwtFilter whiteUrlsRemoveJwtFilter;

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http.oauth2ResourceServer().jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());
        //自定义处理JWT请求头过期或签名错误的结果
        http.oauth2ResourceServer().authenticationEntryPoint(restAuthenticationEntryPoint);
        //对白名单路径,直接移除JWT请求头
        http.addFilterBefore(whiteUrlsRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
        http.authorizeExchange()
                .pathMatchers(ArrayUtil.toArray(whiteUrlsConfig.getUrls(), String.class)).permitAll() // 白名单配置
                .anyExchange().access(authorizationManager)                        // 鉴权管理器配置
                .and().exceptionHandling()
                .accessDeniedHandler(restAccessDeniedHandler)                      // 处理未授权异常
                .authenticationEntryPoint(restAuthenticationEntryPoint)            // 处理未认证异常
                .and().csrf().disable();
        return http.build();
    }

    // JWT解析器
    @Bean
    public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(CommonConstant.AUTHORITY_CLAIM_NAME);
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
    }
}

设置 jwtGrantedAuthoritiesConverter.setAuthorityPrefix()的内容可以视 sys_role 表 code 字段的具体情况而定,能够与Spring Security默认的角色标识是 ROLE 开头而Oauth2默认的角色标识是 SCOPE 匹配即可。

CommonConstant.AUTHORITY_CLAIM_NAME 常量的内容是"authorities"

白名单过滤器,过滤白名单内的请求,可以直接放行

@Data
@Component
@EqualsAndHashCode(callSuper = false)
@ConfigurationProperties(prefix="secure.ignore")
public class WhiteUrlsConfig {
    private List<String> urls;
}

同时,白名单内的请求可以去掉 JWT 请求头,减少负载信息

@Component
public class WhiteUrlsRemoveJwtFilter implements WebFilter {

    @Autowired
    private WhiteUrlsConfig whiteUrlsConfig;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        // 获取当前路径
        ServerHttpRequest request = exchange.getRequest();
        URI uri = request.getURI();
        PathMatcher pathMatcher = new AntPathMatcher();
        List<String> whiteUrls = whiteUrlsConfig.getUrls();
        for (String url : whiteUrls) {
            // 若为白名单路径则移除JWT请求头
            if (pathMatcher.match(url, uri.getPath())) {
                request = exchange.getRequest().mutate().header("Authorization", "").build();
                exchange = exchange.mutate().request(request).build();
                return chain.filter(exchange);
            }
        }
        return chain.filter(exchange);
    }
}

实现ReactiveAuthorizationManager鉴权决策器接口实现具体鉴权功能

@Component
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    private static final Logger logger = LoggerFactory.getLogger(AuthorizationManager.class);

    @Autowired
    private RedisUtils redisUtils;

    @Autowired
    private SysRoleMapper sysRoleMapper;

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
        // 获取当前访问路径
        URI uri = authorizationContext.getExchange().getRequest().getURI();
        // 获取可访问当前路径的所有角色
        Object roles = redisUtils.hashGet(RedisConstant.RESOURCE_ROLES_PATH, uri.getPath());
        logger.info("current request path: {}", uri.getPath());
        List<String> authorities = roles == null ?
                sysRoleMapper.selectUrlRole(uri.getPath()) : Convert.toList(String.class, roles);
        logger.info("current path authorities: {}", JSON.toJSON(authorities));
        //认证通过且角色匹配的用户可访问当前路径
        return mono
                .filter(Authentication::isAuthenticated)
                .flatMapIterable(Authentication::getAuthorities)
                .map(GrantedAuthority::getAuthority)
                .any(authorities::contains)
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
    }
}

同时,为了便于资源服务使用,通过实现全局过滤器将 JWT 信息解析后“换成”用户个人信息并写入回请求头。

@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    private final static Logger logger = LoggerFactory.getLogger(AuthGlobalFilter.class);

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (StrUtil.isEmpty(token)) {
            return chain.filter(exchange);
        }
        try {
            // 从token中解析用户信息并设置到Header中去
            String realToken = token.replace("Bearer ", "");
            JWSObject jwsObject = JWSObject.parse(realToken);
            String userStr = jwsObject.getPayload().toString();
            logger.info("AuthGlobalFilter.filter() user:{}",userStr);
            ServerHttpRequest request = exchange.getRequest().mutate().header("user", userStr).build();
            exchange = exchange.mutate().request(request).build();
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

自定义无权限访问处理器(可参考前文实现)

@Component
public class RestAccessDeniedHandler implements ServerAccessDeniedHandler {

    @Override
    public Mono<Void> handle(ServerWebExchange serverWebExchange, AccessDeniedException e) {
        ServerHttpResponse response = serverWebExchange.getResponse();
        response.setStatusCode(HttpStatus.OK);
        response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        String body= JSONUtil.toJsonStr(CommonResultUtil.fail(ResponseCode.NO_PERMISSION));
        DataBuffer buffer =  response.bufferFactory().wrap(body.getBytes(Charset.forName("UTF-8")));
        return response.writeWith(Mono.just(buffer));
    }
}
注册中心

pom.xml

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

</dependencies>

配置文件

spring:
  application:
    name: security‐discovery
server:
  port: 8848 #启动端口

eureka:
  instance:
    hostname: localhost
  client:
    fetch-registry: false #是否从Eureka Server获取注册信息
    register-with-eureka: false #是否将自己注册到Eureka Server
    service-url:
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/ #服务地址

启动类

@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServer {
    public static void main(String[] args) {
        SpringApplication.run(DiscoveryServer.class, args);
    }
}
资源服务

配置文件

server:
  port: 9501
spring:
  application:
    name: security-api

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8848/eureka/ #注册中心地
  instance:
    prefer-ip-address: true
    instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${spring.application.instance_id:${server.port}}

新建一个 controller 层测试效果

@RestController
@RequestMapping("/test")
public class TestController {

    @GetMapping("/get")
    public String test(){
        return "hello, world!";
    }

    @GetMapping("/get2")
    public String test2(){
        return "hello, cloud!";
    }

    @GetMapping("/other")
    public String test3(){
        return "hello, other!";
    }
}

访问时,先使用密码模式获取 JWT 令牌,访问地址:http://localhost:9201/auth/oauth/token 即可获得返回的 token 信息

获取token

将返回的 token 放入请求头,访问有权限访问的接口即可正常访问

有接口访问权限

访问无权访问的接口,提示权限不足

无接口访问权限

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐