Java|Spring Security + Spring gateway + Oauth2 + JWT 整合实战

背景:我想在微服务中实现注册/登陆/登出功能,并且让部分服务只能在用户已登录状态下被访问。

核心诉求点:

系统架构图如下:

认证系统设计

登陆请求:用户使用账号+密码,通过网关到 auth-service 账号密码校验,校验通过后返还 token 到客户端。

访问api服务:用户将获取的 token 放到请求 Header 中,首先通过网关到 auth-service 进行 token 校验,校验通过才可以访问其他服务。

基础原理

服务器如何认证请求?

发展史

最初,Web基本是静态资源,是一份可以通过网络来查看的“文档”,每个HTTP请求对于服务器来说都是一样的,不需要知道是“谁”来访问了。随着交互式Web的兴起,如在线购物网站、论坛,识别是哪一个用户进行了访问就变成了问题(例如我想要把某个商品加到我的购物车,那么服务器需要先知道我是谁,才能把商品加到对应的购物车中)。因为HTTP请求是无状态的,那么就需要在请求中附带一些标识,用来区分不同用户。

基于 Session 的鉴权

在客户端登陆后,服务器将数据保存在 session 库中,并返还一个 session_id 给客户端,客户端将这个 session_id 存储到 cookie 中,在之后的请求中都附带上这个 session_id ,这样服务器在响应请求时就可以根据 session_id 去 session 库中获取数据,从而识别出是哪个用户。

基于 Session 验证的问题:

基于 Token 的鉴权

相比 session,token 不需要在服务端存储用户的认证信息或者会话信息。token 鉴权的流程大致如下:

OAuth 2.0

OAuth是一个关于授权的开放网络标准,在全世界得到广泛应用。OAuth 2.0 的标准参考 RFC 6749

OAuth在"客户端"与"服务提供商"之间,设置了一个授权层(authorization layer)。“客户端"不能直接登录"服务提供商”,只能登录授权层,以此将用户与客户端区分开来。“客户端"登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。

“客户端"登录授权层以后,“服务提供商"根据令牌的权限范围和有效期,向"客户端"开放用户储存的资料。

OAuth 2.0 的流程图如下:

OAuth 2.0 - Protocol Flow

     +--------+                               +---------------+
     |        |--(A)- Authorization Request ->|   Resource    |
     |        |                               |     Owner     |
     |        |<-(B)-- Authorization Grant ---|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(C)-- Authorization Grant -->| Authorization |
     | Client |                               |     Server    |
     |        |<-(D)----- Access Token -------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(E)----- Access Token ------>|    Resource   |
     |        |                               |     Server    |
     |        |<-(F)--- Protected Resource ---|               |
     +--------+                               +---------------+
     
     
    (A)用户打开客户端以后,客户端要求用户给予授权。

    (B)用户同意给予客户端授权。

    (C)客户端使用上一步获得的授权,向认证服务器申请令牌。

    (D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。

    (E)客户端使用令牌,向资源服务器申请获取资源。

    (F)资源服务器确认令牌无误,同意向客户端开放资源。

其中,客户端的授权模式有4种:

授权码模式是功能最完整、流程最严密的授权模式。但由于我的服务要求并不高,且在系统中客户端、认证服务器和资源服务器都是自身的服务器,对于用户来说不存在安全问题,所以采用了密码模式来实现。

密码模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向"服务商提供商"索要授权。

在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。

密码模式的流程图如下:

Resource Owner Password Credentials Flow

     +----------+
     | Resource |
     |  Owner   |
     |          |
     +----------+
          v
          |    Resource Owner
         (A) Password Credentials
          |
          v
     +---------+                                  +---------------+
     |         |>--(B)---- Resource Owner ------->|               |
     |         |         Password Credentials     | Authorization |
     | Client  |                                  |     Server    |
     |         |<--(C)---- Access Token ---------<|               |
     |         |    (w/ Optional Refresh Token)   |               |
     +---------+                                  +---------------+
     
    (A)用户向客户端提供用户名和密码。

    (B)客户端将用户名和密码发给认证服务器,向后者请求令牌。

    (C)认证服务器确认无误后,向客户端提供访问令牌。

JWT

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC 7519)。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该 token 也可直接被用于认证,也可被加密。

构成

JWT是由三段信息构成的,将这三段信息文本用.链接一起就构成了Jwt字符串。三段信息分别是:

Java 实战

本次项目使用 Spring Cloud 作为微服务架构,使用 Eureka 作为注册中心,Gateway 作为服务网关。示例里包含一定的业务逻辑代码,与本人在做的毕设相关,但不影响理解。

服务架构如下:

.
├── auth-service		--port:10110
├── eureka-server		--port:10099
├── gateway				--port:10086
└── api-service			--port:10001

认证服务 auth-service

引入依赖

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

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

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

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

    <!-- spring cloud security security     注意:oauth2包中已包含 无需再引入 -->
    <!--        <dependency>-->
    <!--            <groupId>org.springframework.cloud</groupId>-->
    <!--            <artifactId>spring-cloud-starter-security</artifactId>-->
    <!--        </dependency>-->

    <!-- spring cloud security oauth2 -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>

    <!-- jwt -->
    <dependency>
        <groupId>com.nimbusds</groupId>
        <artifactId>nimbus-jose-jwt</artifactId>
    </dependency>
</dependencies>

生成 RSA 证书

使用keytool生成RSA证书jwt.jks,复制到resource目录下。

keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks

实现Spring Secutity相关接口

UserDetailsService

实现UserDetailsService接口,用于根据用户名(账号)加载用户信息。

// UserDetailsServiceImpl.java
@Component
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private AuthUserHelper authUserHelper;

    /**
     * 获取用户信息
     *  - 密码
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return authUserHelper.getUserByAccount(username);
    }
}


// 其中由authUserHelper提取的方法为根据username获取user信息
// 获取的TravelSecurityUser类为实现了UserDetails类的自定义类,包含了项目中业务上需要用的一些额外信息
// 如果不需要自定义内容,使用Spring Security实现好的User类即可
// AuthUserHelper.java
@Component
public class AuthUserHelper {
    @Autowired
    private UserDAO userDAO;
    @Autowired
    private CorpDAO corpDAO;
    @Autowired
    private PasswordEncoder passwordEncoder;

    public UserDetails getUserByAccount(String userAccount) throws UsernameNotFoundException {
        // 1. 检查账号
        // 注意这里数据库里存的密码不是明文,是已经加密过的了
        // 如果密码不是加密后的,需要使用passwordEncoder加密一下(当然还是建议不存储用户的明文密码,存在严重安全隐患)
        UserParam userParam = new UserParam();
        userParam.createCriteria()
                .andUserAccountEqualTo(userAccount);
        List<UserDO> userDOS = userDAO.selectByParam(userParam);
        if (CollectionUtils.isEmpty(userDOS)) {
            // 用户名不存在
            throw new UsernameNotFoundException(AuthMessageErrorEnum.USER_NOT_EXIST.getMessage());
        }

        // 2. 校验权限
        UserDO userDO = userDOS.get(0);
        List<GrantedAuthority> authorities = new ArrayList<>();
        // 普通职工权限默认拥有
        authorities.add(AuthorityRoleEnum.CORP_EMPLOYEE.getGrantedAuthority());

        // 校验是否是企业主管理员
        CorpParam corpParam = new CorpParam();
        corpParam.createCriteria()
                .andManagerUserIdEqualTo(userDO.getUserId())
                .andCorpIdEqualToWhenPresent(userDO.getCorpId());
        List<CorpDO> corpDOS = corpDAO.selectByParam(corpParam);
        if (CollectionUtils.isNotEmpty(corpDOS)) {
            authorities.add(AuthorityRoleEnum.CORP_MAIN_ADMIN.getGrantedAuthority());
        }

        return new TravelSecurityUser(
                userDO.getCorpId(), userDO.getUserId(),
                userDO.getUserAccount(), userDO.getUserPassword(), authorities);
    }
}

AuthorizationServerConfigurerAdapter

实现AuthorizationServerConfigurerAdapter接口,配置认证服务相关设置,需要配置加载用户信息的服务UserDetailsServiceImpl及RSA的钥匙对KeyPair

// OAuth2AuthServerConfig
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private JwtTokenEnhancer jwtTokenEnhancer;
    @Autowired
    private UserDetailsServiceImpl userDetailsService;


    //配置客户端应用的相关信息
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        super.configure(clients);

        // 将客户端的信息写入内存中
        clients.inMemory()
                .withClient("client-app")                       //客户端ID
                .secret(passwordEncoder.encode("123456"))   	//客户端密码
                .scopes("all")                                  //用户对该客户端可以申请的权限
                .accessTokenValiditySeconds(3600)               //令牌的有效期(单位为秒)
                .refreshTokenValiditySeconds(86400)             // refresh token有效期
                .authorizedGrantTypes("password", "refresh_token");     //用户要访问该客户端的时候采取的授权类型,本项目采用密码模式。
    }

    //配置令牌管理器和令牌的存储方式
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        super.configure(endpoints);
        TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> delegates = new ArrayList<>();
        delegates.add(jwtTokenEnhancer);				//添加自定义信息的增强器(实现了TokenEnhancer接口)
        delegates.add(accessTokenConverter());			//jwt转换token的增强器
        enhancerChain.setTokenEnhancers(delegates); 	//配置token内容增强器
        endpoints.authenticationManager(authenticationManager)	// 由 authenticationManager 来管理
                .userDetailsService(userDetailsService) //配置加载用户信息的服务
                .accessTokenConverter(accessTokenConverter())
                .tokenEnhancer(enhancerChain);
    }

    //配置谁能来验 token (有一些请求连验 token 的资格都没有)
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        super.configure(security);
        security.allowFormAuthenticationForClients();	// 允许以form表单形式提交 走ClientCredentialsTokenEndpointFilter
        												// 通过body的form表单中的client_id和client_secret未加密字段来校验
        												// 如果不允许 走BasicAuthenticationFilter
        												// 一般来说通过basic auth,是通过解析在header的Authorization加上Basic+client_id+client_secret转换的加密串来校验
        												// basic auth(虽然这个加密也相当于是明文的)
        security.checkTokenAccess("isAuthenticated()");
    }

    @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"), "W.w215.+".toCharArray());
        return keyStoreKeyFactory.getKeyPair("jwt", "生成rsa证书时的密码".toCharArray());
    }
}

暴露公钥

由于我们的网关服务需要RSA的公钥来验证签名是否合法,所以认证服务需要有个接口把公钥暴露出来。

@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();
    }
}

OAuth2WebSecurityConfig

配置 Spring Security,允许访问公钥接口。

@Configuration
@EnableWebSecurity  //支持 WebSecurity
public class OAuth2WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
                .antMatchers("/rsa/publicKey").permitAll()		// 允许访问公钥接口
                .anyRequest().authenticated();
    }

    //配置如何构建 AuthenticationManager
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .userDetailsService(userDetailsService)		//获取用户详细信息(用户名、权限等)
                .passwordEncoder(passwordEncoder);			//密码加密器
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

验证

这里使用 Postman 进行测试,选择 Auth Type 为 Basic Auth(或者直接在body的form中写入client_idclient_secret字段),写上 username 和 password(也就是 OAuth2AuthServerConfig 里设置的在内存中的 client_idsecret)。

image-20220329160130938

再设置好登陆的用户名和密码,以及 OAuth2AuthServerConfig 里配置好允许的认证类型(password)和请求的权限(all)。

image-20220329160227160

可以看到服务端响应的请求如下:

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJheWFuIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY0ODU0NDUwOCwiY29ycF9pZCI6MiwiYXV0aG9yaXRpZXMiOlsiQ09SUF9VU0VSIl0sImp0aSI6ImM0NDRiNzU3LTdhZmMtNGQ5OC1hNzEzLWJlYTkyYmQ1ZjVkNyIsImNsaWVudF9pZCI6ImNsaWVudC1hcHAifQ.ZIeEzyk25ix5SL1lMXQhe6jC76y2t3NND-mpwl9QTLvpPYkO6jdpsDikTOh75E-UhJvSTaxS3jB3JrBt3tL50IjXrxDhNvpGlvIpF-cEL6mxSXZhF3ZOOfj1BHJHOVk3ppNVNqJkt4s_4kjg_a8U1uZlHg7-5eub7RFIuAW01R4u1oxZ3EtftTmdGqHw3PiL0OpNUH9q-Y-DT56vTPGeNdBTMS1CoJQRev5FX8jhOXFJfFsmTwVa7U_Cy_glKNRftUNX6trDzOARwG0JI23BMB2iCe8K6DKQXxOIlo57tMAkZBIPVrDwmnuElPYfmagcUp-0ejzmMD2mqH-CKX6vmg",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJheWFuIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImM0NDRiNzU3LTdhZmMtNGQ5OC1hNzEzLWJlYTkyYmQ1ZjVkNyIsImV4cCI6MTY0ODYyNzMwOCwiY29ycF9pZCI6MiwiYXV0aG9yaXRpZXMiOlsiQ09SUF9VU0VSIl0sImp0aSI6IjVmYjVmNjRiLTU0YTEtNDk2Zi1hOTU3LWVmODY5Y2I1M2YyMiIsImNsaWVudF9pZCI6ImNsaWVudC1hcHAifQ.ippaehx2sKwqg2dvbjOELd_ab8pM-X8fki7DpvTsAoAHtAFBdsdm2r3A_lNbOCl9ZxwetzC6UFQA5tg0np8Y0cdwHkX_CuB2ldlPg_ro27cnPxEQJZ_eap8auWKxbT-IOA6-iaYW5Q0VPNCtNJdnAeeK6kXxTzctD82Dk_wJ3SBhBh7W6hwsoYQGmuqd89f_CiEgkLumXO2uQa3paC9v8eQEoiqRebRb7s1Mx3ZkzCWCkAto108Y7KEb40x4GT6HNQtuth30T9AcLSgHqO2--sYq14vL3A0YsO8Tx823C6T1Rp_-462OB0GF2HsIJiQpHidReB75TvDmAhiV4xB__Q",
    "expires_in": 3599,
    "scope": "all",
    "user_id": 2,
    "corp_id": 2,
    "jti": "c444b757-7afc-4d98-a713-bea92bd5f5d7"
}

到 jwt 官网 jwt - debugger 使用 debugger 工具对access_token进行验证:

jwt.io

可以看到 jwt.payload 区已经携带了 user_namescopeauthoritiesclient_id以及 token 有效时间等信息(这里的user_idcorp_id是我在 JwtTokenEnhancer 内容增强器里补充的 claim)。另外下面也可以用公钥去验证签名。

网关 gateway

引入依赖

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

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</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.security</groupId>
        <artifactId>spring-security-oauth2-client</artifactId>
    </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>8.16</version>
    </dependency>

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

    <!-- 自定义元数据 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-configuration-processor</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

配置应用信息

application.yml中添加相关配置,主要是路由规则的配置、Oauth2中RSA公钥的配置及路由白名单的配置。

server:
  port: 10086 # 网关端口

spring:
  application:
    name: gateway # 服务名称
  cloud:
    gateway:
      globalcors:
        add-to-simple-url-handler-mapping: true
        cors-configurations:
          '[/**]':
            allowedOrigins: "*" # 允许哪些网站的跨域请求 allowedOrigins: “*” 允许所有网站
            allowedMethods: # 允许的跨域请求方式
              - "GET"
              - "POST"
              - "DELETE"
              - "PUT"
              - "OPTIONS"
            allowedHeaders: "*" # 允许在请求中携带的头信息
            allowCredentials: true # 是否允许携带cookie
            maxAge: 360000 # 这次跨域检测的有效期
      routes: # 网关路由配置
        - id: api-service        	# 路由id,自定义,只要唯一即可
          uri: lb://member-service  # 路由的目标地址 lb就是负载均衡,后面跟服务名称
          predicates:            	# 路由断言,也就是判断请求是否符合路由规则的条件
            - Path=/member/**       # 按照路径匹配,只要以/member开头就符合要求

        - id: auth-service
          uri: lb://auth-service
          predicates:
            - Path=/auth/**         # 按照路径匹配,只要以/auth开头就符合要求
          filters:
            - StripPrefix=1         # 在将请求发送到下游之前从请求中剥离的路径个数
      discovery:
        locator:
          enabled: true             # 开启从注册中心动态创建路由的功能
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: 'http://localhost:10110/rsa/publicKey' #配置RSA的公钥访问地址

# eureka 配置
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10099/eureka

# 自定义配置
secure:
  ignore:
    urls: #配置白名单路径
      - "/actuator/**"
      - "/auth/oauth/token"

安全配置

ResourceServerConfig

对网关服务进行配置安全配置,由于Gateway使用的是WebFlux,所以需要使用@EnableWebFluxSecurity注解开启。

/**
 * 资源服务器配置
 *
 * @author github @TezaLeMon
 * @date 2022/3/21
 */
@EnableWebFluxSecurity
public class ResourceServerConfig {
    @Autowired
    private IgnoreUrlsConfig ignoreUrlsConfig;
    @Autowired
    private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
    @Autowired
    private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
    @Autowired
    private AuthorizationManager authorizationManager;
    @Autowired
    private IgnoreUrlsRemoveJwtFilter ignoreUrlsRemoveJwtFilter;

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

    @Bean
    public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstant.AUTHORITY_PREFIX);
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstant.AUTHORITY_CLAIM_NAME);
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
    }
}

AuthorizationManager 自定义鉴权管理器

在认证服务器我已经默认至少给用户CORP_EMPLOYEE角色,所以这里鉴权时相当于只要是认证用户就会放行(其实已经属于多余操作了),如果自己有相关的需求可以在此基础上修改(比如设置某些 api 只能被特定的角色访问)。

/**
 * 鉴权管理器,用于判断是否有资源的访问权限
 *
 * @author github @TezaLeMon
 * @date 2022/3/22
 */
@Component
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
        // 获取当前路径可访问角色列表 todo 目前配置为网关层允许所有role通过,在应用层进行权限校验
//        URI uri = authorizationContext.getExchange().getRequest().getURI();
        List<String> authorities = Arrays.stream(AuthorityRoleEnum.values()).map(a -> AuthConstant.AUTHORITY_PREFIX + a.getRoleCode()).collect(Collectors.toList());
        //认证通过且角色匹配的用户可访问当前路径
        return mono
                .filter(Authentication::isAuthenticated)
                .flatMapIterable(Authentication::getAuthorities)
                .map(GrantedAuthority::getAuthority)
                .any(authorities::contains)
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
    }
}

AuthGlobalFilter 全局过滤器

这个过滤器不是必须的,主要是用于在鉴权通过后将 JWT 令牌中的用户信息解析出来,然后存入请求的Header中,这样后续服务就不需要解析JWT令牌了,可以直接从请求的Header中获取到用户信息。

@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

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

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

验证

这里将地址改为网关的地址,并且加上前缀 /auth(别忘了在设置路由时设置了以/auth开头的请求转发到认证服务器,并且在转发前剥离一段路径)。可以看到我们的请求成功由网关转发到认证服务器了。

image-20220329201052080

应用层服务 api-service

引入依赖

应用层不做鉴权,写正常的api接口即可。

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

获取鉴权后网关层写到header的信息

UserSession

用于存储携带过来的信息。

@Component
public class UserSession {
    private static final ThreadLocal<Long> corpId = new ThreadLocal<>();
    private static final ThreadLocal<Long> userId = new ThreadLocal<>();

    public static void setUser(Long corpId, Long userId) {
        UserSession.corpId.set(corpId);
        UserSession.userId.set(userId);
    }

    public static void clear() {
        UserSession.corpId.remove();
        UserSession.userId.remove();
    }

    public static Long getCorpId() {
        return corpId.get();
    }

    public static Long getUserId() {
        return userId.get();
    }

}

AuthFilter

使用 filter 将 header 中的信息提取出来。

主要流程:

graph TB;
	A([AuthFilter])
	-->B(获取request.header)
    -->C("获取header中的user字段(JSON形式)")
    -->D("JSON.parse(user),得到由网关写入的内容(即user_id和corp_id)")
    -->E(将得到的信息写入UserSession)
    -->F("filter链继续执行<br>filterChain.doFilter(servletRequest, servletResponse)")
    -->G(清空UserSession里的信息)

代码如下:

@Slf4j
@Component
public class AuthFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        //从Header中获取用户信息
        try {
            ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            String userStr = null;
            if (servletRequestAttributes != null) {
                userStr = servletRequestAttributes.getRequest().getHeader("user");
            }
            JSONObject userJsonObject = JSONObject.parseObject(userStr);
            // 获取用户信息
            UserSession.setUser(userJsonObject.getLong("corp_id"), userJsonObject.getLong("user_id"));
        } catch (Exception e) {
            log.error("ltd.teza.member.filter.AuthFilter 获取用户信息异常,可能是未登陆导致。", e);
        } finally {
            filterChain.doFilter(servletRequest, servletResponse);
            // 清除用户信息
            UserSession.clear();
        }
    }
}

实现测试controller

@RestController
@RequestMapping("/member")
@Slf4j
public class MemberHelloController implements MemberHelloClient {

    @GetMapping("hello")
    public ServiceResult<String> hello() {
        log.info("member-service --> ltd.teza.controller.HelloController.hello --> test log.info");
//        log.error("member-service --> ltd.teza.controller.HelloController.hello --> test log.error");
        ServiceResult<String> result = new ServiceResult<>(true);
        result.setModule("hello! this is member-service!~");
        return result;
    }

    @GetMapping("getUser")
    public String getUser() {
        Long userId = UserSession.getUserId();
        Long corpId = UserSession.getCorpId();
        return "corpId=" + corpId + "\nuserId=" + userId;
    }
}

验证

通过网关访问来调取接口,在 header 中附加上之前获取的 access_token(记得前缀加上Bearer )。可以看到网关没有拦截请求,且在api层也获取到了用户信息。

image-20220331154925251

接下来试试不携带 token 来访问。可以看到在网关就直接拒绝了请求。

image-20220331155341474

PS:当 access_token 过期时,我们可以用 refresh_token 来获取新的 access_token(正常来说 refresh_token 的有效期要比 access_token 长的多),这样就可以减少用户需要重新输入账号密码来登陆的情况,这里就不再赘述了。

image-20220331160606196

参考资料:

RFC 6749

理解OAuth 2.0

什么是 JWT – JSON WEB TOKEN

RFC 7519

jwt.io微服务权限终极解决方案,Spring Cloud Gateway + Oauth2 实现统一认证和鉴权!