前后端分离Oauth2.0 - springsecurity + spring-authorization-server —授权码模式

序言

  1. 对于目前有很多的公司在对旧的web工程做重构,拆分服务,使用前端vue,后端springboot微服务,重构的要点之一是认证授权框架的选型。
  2. 对于原有的 spring-security-oauth Spring官方 已经宣布不在进行维护,其已经被spring-security + spring-authorization-server 所提供的oauth2.1支持所取代。
  3. 文章将介绍 spring-authorization-server 支持的 oauth2.1 ,springboot 整合 springsecurity + spring-authorization-server,对 oauth2.1 的授权码模式做实践案例,展示实践结果,过程中遇到的问题。
  4. 注意文章不在介绍 springboot + springsecurity 的整合,整合文章在csdn中有很多实践案例,可以网上搜索。

思路:前后端分离Oauth2.0 - springsecurity + spring-authorization-server —序言

实践流程

  1. 访问 https://www.authorization.life , 将跳转到 https://www.authorization.life/login 登录页面。
  2. 输入用户名密码,用户名:[email protected] 密码:admin
  3. 在登录接口请求成功,状态是200,在前端直接请求 /oauth2/authorize 接口,请求路径:https://www.authorization.life/auth-life/oauth2/authorize?response_type=code&client_id=passport&scope=TENANT&state=authorization-life&redirect_uri=https://www.authorization.life/login/home
  4. client信息验证通过之后,将跳转到配置的client信息中的重定向地址(redirectUri)中: https://www.authorization.life/login/home?code=gyLKC_d06yIPo-69hbKuVOFfFjps3F-EPRbAwilmQZPYO0TBkY2GORjhyZ1CXxeUeeC8d5rHY8g8j3Wykhiv_T17P-QYsbFDWvBzJcvfKk0oF8Z8Nj_CgLhSLFiIskL4&state=authorization-life
  5. login-front前端工程中的 home 页面中做一些操作,通过 网址中的 code 请求 /oauth2/token 接口 ,获取自定义的 jwt形式的 accessToken,然后将其保存到cookie中,为下一次请求接口使用。

nginx配置

#user  nobody;
worker_processes  auto;
events {
    worker_connections  1024;
}
http {
    include       mime.types;
    default_type  application/octet-stream;
		# nginx: [emerg] could not build server_names_hash, you should increase server_names_hash_bucket_size: 32   解决此错误需要增加下两行配置
		server_names_hash_max_size 			2048;# 【值为域名长度总和】;
		server_names_hash_bucket_size 	2048;# 【上升值】
		# /sockjs-node 访问异常->
		# 参考:https://blog.csdn.net/qq27229639/article/details/103069055
		#       https://www.ancii.com/anbgjpemb
		map $http_upgrade $connection_upgrade {
    		default upgrade;
    		''      close;
    }

    sendfile        on;
    keepalive_timeout  65;
    
    server {
        listen       80;
        server_name  www.authorization.life;
        rewrite ^(.*)$ https://$server_name$1 permanent;
    }
    
    # HTTPS server 参考配置: https://nenufm.com/archives/g
    server {
        listen       443 ssl;
        server_name  www.authorization.life;

        ssl_certificate      D://authorization_life_aliyun//authorization_life_chain.pem;
        ssl_certificate_key  D://authorization_life_aliyun//authorization_life_key.key;

		    # ssl验证相关配置
		    ssl_protocols 			TLSv1.3 SSLv3; #安全链接可选的加密协议
        	ssl_ciphers         EECDH+AESGCM:EDH+AESGCM;
       	 	ssl_ecdh_curve 			secp384r1; #为ECDHE密码指定 SEPO384Q1
		    ssl_session_timeout  10m;    #缓存有效期
        	ssl_session_cache   shared:SSL:10m;
			ssl_prefer_server_ciphers   on; 
    		ssl_session_tickets         off; # Requires nginx >= 1.5.9
    		ssl_stapling                on; # Requires nginx >= 1.3.7
    		ssl_stapling_verify         on; # Requires nginx => 1.3.7

		#后端服务gateway
        location / {
		        proxy_pass http://127.0.0.1:9000;
        }
        # 前端登录工程
        location /login {
		        proxy_pass http://127.0.0.1:8080;
		        proxy_http_version 1.1;
    				proxy_set_header Upgrade $http_upgrade;
    				proxy_set_header Connection $connection_upgrade;
        }

    }

}

数据库

官方提供的三张表

前后端分离Oauth2.0 - springsecurity + spring-authorization-server —授权码模式_第1张图片

  • oauth2_registered_client(oauth2.0中的客户端信息) ————使用自定义的 lifetime_oauth_client 表。
  • oauth2_authorization_consent (oauth2.0中的授权同意信息)————使用redis进行存储数据信息。
  • oauth2_authorization (oauth2.0中的当前登录用户信息)————使用redis进行存储数据信息。
    在这里插入图片描述
  • lifetime_user 是系统的用户表。
  • lifetime_user_group 是用户组,用户组可以是平台系统中延伸的子系统,或者授权信息,授权域。

框架依赖版本

        
        
        2.7.3
        2021.0.3
        2.2.8.RELEASE
        2.13.3

        
        5.7.3
        9.23
        9.27
        0.3.1
-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
            
                com.nimbusds
                oauth2-oidc-sdk
                ${oauth2-oidc-sdk.version}
            
            
                com.nimbusds
                nimbus-jose-jwt
                ${nimbus-jose-jwt.version}
            
            
                org.springframework.security
                spring-security-oauth2-authorization-server
                ${oauth2-authorization-server.version}
            
            
            
                com.fasterxml.jackson
                jackson-bom
                ${jackson-bom.version}
                pom
            
            
                org.springframework.boot
                spring-boot-dependencies
                ${spring-boot.version}
                pom
                import
            
            
                org.springframework.cloud
                spring-cloud-dependencies
                ${spring-cloud.version}
                pom
                import
            
            
                com.alibaba.cloud
                spring-cloud-alibaba-dependencies
                ${spring-cloud-alibaba.version}
                pom
                import
            
            
                org.springframework.security
                spring-security-bom
                ${spring-security.version}
                pom
                import
            
            

集成

配置 OAuth2AuthorizationServerConfigurer

目的是为了将 oauth2.0的配置托管给 SpringSecurity,为了让 SpringSecurity对特定的路径在filter中进行拦截,convert转换参数并进行provider的校验。
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,
                                                                      OAuth2TokenCustomizer<JwtEncodingContext> oAuth2TokenCustomizer) throws Exception {
        OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer<>();

 RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
        // 配置请求拦截
        http.requestMatcher(endpointsMatcher)
                .authorizeRequests(authorizeRequests -> authorizeRequests
//                        // 无需认证即可访问
                        .antMatchers(SecurityConstant.IGNORE_PERM_URLS).permitAll()
                        //除以上的请求之外,都需要token
                        .anyRequest().authenticated())
                .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
                //配置formLogin
                .formLogin(Customizer.withDefaults())
                //将oauth2.0的配置托管给 SpringSecurity
                .apply(authorizationServerConfigurer);

        // 设置accesstoken为jwt形式
        http.setSharedObject(OAuth2TokenCustomizer.class, oAuth2TokenCustomizer);

        // 配置 异常处理
        http
                .exceptionHandling()
                //当未登录的情况下 该如何跳转。
                .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint());

        return http.build();
    }

配置 RegisteredClientService

此处将使用自定义的 lifetime_oauth_client 中的信息进行封装并返回,主要重写了 findById 、findByClientId方法,将通过前端传参中的clientid作为查询条件从数据库中进行查询数据。

    /**
     * 根据数据库中的client信息转换
     *
     * @param clientId    clientId
     * @param oauthClient 数据库client
     * @return RegisteredClient
     */
    private RegisteredClient getRegisteredClient(String clientId, OauthClient oauthClient) {
        RegisteredClient.Builder builder = RegisteredClient.withId(clientId)
                .clientId(oauthClient.getClientId())
                .clientSecret(oauthClient.getClientSecret())
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
                .clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
                .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
                .redirectUri(oauthClient.getRedirectUri())
                // JWT的配置项 包括TTL  是否复用refreshToken等等
                .clientSettings(ClientSettings.builder()
                        //是否需要用户确认一下客户端需要获取用户的哪些权限
                        .requireAuthorizationConsent(false)
                        .build())
                .tokenSettings(TokenSettings.builder()
                        //配置使用自定义的jwtToken格式化,配置此处才会使用到 CustomizerOAuth2Token , 或者不配置此格式化的配置,将默认生成jwt的形式
                        .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
                        //是否可重用刷新令牌
                        .reuseRefreshTokens(true)
                        //accessToken 的有效期  单位:秒
                        .accessTokenTimeToLive(Duration.of(oauthClient.getAccessTokenTimeout(), ChronoUnit.SECONDS))
                        //refreshToken 的有效期   单位:秒
                        .refreshTokenTimeToLive(Duration.of(oauthClient.getRefreshTokenTimeout(), ChronoUnit.SECONDS))
                        .build());
        //批量设置当前的授权类型
        Arrays.stream(oauthClient.getGrantTypes().split(StrPool.COMMA))
                .map(grantType -> {
                    if (CharSequenceUtil.equals(grantType, AuthorizationGrantType.AUTHORIZATION_CODE.getValue())) {
                        return AuthorizationGrantType.AUTHORIZATION_CODE;
                    } else if (CharSequenceUtil.equals(grantType, AuthorizationGrantType.REFRESH_TOKEN.getValue())) {
                        return AuthorizationGrantType.REFRESH_TOKEN;
                    } else if (CharSequenceUtil.equals(grantType, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())) {
                        return AuthorizationGrantType.CLIENT_CREDENTIALS;
                    } else if (CharSequenceUtil.equals(grantType, AuthorizationGrantType.PASSWORD.getValue())) {
                        return AuthorizationGrantType.PASSWORD;
                    } else if (CharSequenceUtil.equals(grantType, AuthorizationGrantType.JWT_BEARER.getValue())) {
                        return AuthorizationGrantType.JWT_BEARER;
                    } else {
                        throw new RegClientException("不支持的授权模式, [" + grantType + "]");
                    }
                }).forEach(builder::authorizationGrantType);
        Arrays.stream(oauthClient.getScopes().split(StrPool.COMMA))
                .forEach(builder::scope);
        return builder.build();
    }

    /**
     * 注册client
     *
     * @param clientService 自定义的client端信息
     * @return RegisteredClientRepository
     */
    @Bean
    public RegisteredClientRepository registeredClientRepository(OauthClientService clientService) {
        return new RegisteredClientService(clientService);
    }

配置 OAuth2AuthorizationService 、RedisOAuth2AuthorizationConsentService

保存授权信息,由这个服务来保存,存储到redis中。从save到获取其中都是参考了官网中的jdbc的示例,OAuth2AuthorizationService的另一实现,针对不同的token类型做存储。

在此处将不再赘述。可以在文章结尾查看源码。

配置自定义access_token的实现

  • 继承 OAuth2TokenCustomizer 重写 customize(JwtEncodingContext context) 方法,可以将accessToken的信息存储到redis中,为之后的验证和获取做准备。
  • OAuth2Authorization authorization = context.getAuthorization(); 中的信息是当前登录用户的信息。
  • 工程中实现的是,重新自定义了token,把一些用户的主要信息存放到redis中,并把自定义的token信息放到了当前登录用户中,方便在退出登录时删除redis中的信息。

重点之一

对于access_token,需要哪些信息,将掌控到什么程度,在这里具体的体现,由开发人员指定,为系统中更敏捷的开发做准备。


    /**
     * JWT的加密算法,说明:https://www.rfc-editor.org/rfc/rfc7515
     *
     * @return JWKSource
     */
    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = Jwks.generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    /**
     * 将重写jwtToken的中的信息,并将其存储到redis中。
     *
     * @param context JwtEncodingContext
     */
    @Override
    public void customize(JwtEncodingContext context) {
        //此处的token字符串是前端拿到的jwtToken信息中解密后的字符串,在这里将自定义jwtToken的实现,将定制jwt的 header 和 claims,将此token存放到 claim 中
        String token = UUID.randomUUID().toString(true);
        Authentication principal = context.getPrincipal();
        Authentication authorizationGrant = context.getAuthorizationGrant();
        OAuth2Authorization authorization = context.getAuthorization();
        Set<String> authorizedScopes = context.getAuthorizedScopes();
        ProviderContext providerContext = context.getProviderContext();
        RegisteredClient registeredClient = context.getRegisteredClient();
        log.info("principal-{}", JSONUtil.toJsonStr(principal));
        log.info("authorization-{}", JSONUtil.toJsonStr(authorization));
        log.info("authorizedScopes-{}", JSONUtil.toJsonStr(authorizedScopes));
        log.info("authorizationGrant-{}", JSONUtil.toJsonStr(authorizationGrant));
        log.info("providerContext-{}", JSONUtil.toJsonStr(providerContext));
        log.info("registeredClient-{}", JSONUtil.toJsonStr(registeredClient));
        UserDetail userDetail = null;
        // 目的是为了定制jwt 的header 和 claims
        if (principal instanceof OAuth2ClientAuthenticationToken) {
            //如果当前登录的是client,则进行封装client
//            userDetail = securityAuthUserService.createUserDetailByClientId(registeredClient.getClientId());
        }
//        else if (principal.getPrincipal() instanceof UserDetail) {
//            //如果当前登录的是系统用户,则进行封装userDetail
//            userDetail = securityAuthUserService.createUserDetailByUser((UserDetails) principal.getPrincipal());
//        }
        else if (principal.getPrincipal() instanceof User) {
            //如果当前登录的是系统用户,则进行封装userDetail
            userDetail = securityAuthUserService.createUserDetailByUser((User) principal.getPrincipal());
        }
        //如果解析失败,则抛出异常信息。
        if (Objects.isNull(userDetail)) {
            log.error("在自定义token实现中, 用户信息解析异常。");
            userDetail = new UserDetail();
        }

        //也需要将此token存放到当前登录用户中,为了在退出登录时进行获取redis中的信息并将其删除
        userDetail.setToken(token);
        //将用户信息放置到redis中,并设置其过期时间为 client中的过期时间
        strRedisHelper.strSet(LifeSecurityConstants.getUserTokenKey(token), userDetail,
                registeredClient.getTokenSettings().getAccessTokenTimeToLive().getSeconds(), TimeUnit.SECONDS);
        log.info("生成的用户-token是-{},此token作为key,用户信息作为value存储到redis中", token);
        //也可以在此处将当前登录用户的信息存放到jwt中,但是这样就不再安全。
        context.getClaims().claim(LifeSecurityConstants.TOKEN, token).build();
    }

配置退出登录处理器 SsoLogoutHandle

重写 logout 方法,对 当前登录用户中的token进行删除,并返回 退出登录成功提示。


    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        log.info("进入退出登录处理器。");
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
        try {
            UserDetail userDetail = UserHelper.getUserDetail();
            log.debug("当前登录用户-UserDetail-是:" + userDetail);
            if (Objects.nonNull(userDetail)) {
                String userToken = userDetail.getToken();
                log.debug("当前登录用户的token-是:" + userToken);
                String cacheUserToken = KvpFormat.of(SecurityConstant.USER_DETAIL).add("token", userToken).format();
                redisHelper.delete(cacheUserToken);
                redisHelper.delete(KvpFormat.of(SecurityConstant.TOKEN_STORE).add("userId", userDetail.getUserId().toString()).format());
            }
            SecurityContextHolder.clearContext();
            String token = request.getHeader(HttpHeaders.AUTHORIZATION);
            log.debug("请求头-Authorization-是:" + token);
            if (StrUtil.isBlank(token)) {
                PrintWriter out = response.getWriter();
                out.write(JSONUtil.toJsonStr(new Res<>(Res.ERROR, "未找到token,请确认已登录。", null)));
                out.flush();
                out.close();
            }
            token = token.split(" ")[1];
            OAuth2Authorization auth2Authorization = oAuth2AuthorizationService.findByToken(token, OAuth2TokenType.ACCESS_TOKEN);
            log.debug("查询出来-OAuth2Authorization-是:" + JSONUtil.toJsonStr(auth2Authorization));
            if (Objects.nonNull(auth2Authorization)) {
                oAuth2AuthorizationService.remove(auth2Authorization);
            }
            PrintWriter out = response.getWriter();
            out.write(JSONUtil.toJsonStr(new Res<>(Res.SUCCESS, Res.SUCCESS_DESC, null)));
            out.flush();
            out.close();
        } catch (IOException e) {
            log.error("退出登录处理器处理失败,", e);
        }
    }

配置RouterFunction

此处的配置将指定了 springsecurity中的自定义登录页面,对于 RouterFunction 可自行网上查询。

重点之二

对于前后端分离中如何指定登录页面,如何跳转到登录页面,这个有很长一段时间由于知识的不足而不知如何处理,此配置将解决此问题,nginx中配置 / 代理 gwteway网关工程, 当访问根目录时将重定向到 /login 路径上,nginx中对 /login 路径做代理到 前端工程中, 其 /login 所指向的前端工程就是 自定义的登录页面。

@Slf4j
@Configuration
public class RouterFunctionConfig {

    /**
     * 访问根目录时将重定向到登录模块
     *
     * @return 登录模块
     */
    @Bean
    public RouterFunction<ServerResponse> loginRouterFunction() {
        return RouterFunctions.route(
                RequestPredicates.GET("/"),
                request -> ServerResponse.temporaryRedirect(URI.create(request.uri() + "login")).build());
    }

}

配置 JwtTokenGatewayFilterFactory

将解析 /oauth/token 接口中返回的accessToken信息,获取其中的 token字段对应的redis存储的用户信息,并验证是否能解析,在转换为用户信息的之后,将对请求路径做校验,判断是否拥有此权限。

重点之三

此处是gateway中做的accessToken解析,但是路由后的服务可能是不知道的,这个时候,我们需要对此用户信息再次做一层转换,转换为Jwt形式的token,在公共的core模块中对此token进行解析,并放置到 SecurityContextHolder.getContext()中。

// JwtTokenGatewayFilterFactory 
    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            String token = getToken(request);
            // 获取当前用户的信息
            String userDetailStr = StrUtil.isBlank(token) ? null : redisHelper.strGet(LifeSecurityConstants.getUserTokenKey(token));
            // 若jwt不存在,则封入一个空字符串,到权限拦截器处理。因为有些api是不需要登录的,故在此不处理。
            UserDetail userDetail = StrUtil.isNotBlank(userDetailStr) ? JsonHelper.readValue(userDetailStr, UserDetail.class) : null;
            userDetailStr = Optional.ofNullable(userDetailStr).orElse(StrUtil.EMPTY);
            // 创建JWS对象
            JWSObject jwsObject = new JWSObject(jwsHeader, new Payload(userDetailStr));
            // 签名并序列化转换为真正存储用户信息的jwtToken
            String jwtToken = Jwts.signAndSerialize(jwsObject, signer);
            ServerWebExchange jwtExchange = exchange.mutate()
                    .request(request.mutate()
                            .header(Jwts.HEADER_JWT, jwtToken).build())
                    .build();
            return chain.filter(jwtExchange)
                    .contextWrite(ctx -> ctx.put(RequestContext.CTX_KEY,
                            ctx.<RequestContext>getOrEmpty(RequestContext.CTX_KEY)
                                    .orElse(new RequestContext())
                                    .setUserDetail(userDetail)));
        };
    }
// JwtAuthenticationFilter
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        SecurityContext context = SecurityContextHolder.getContext();
        Authentication authentication = context.getAuthentication();
        log.info("authentication-{}",JSONUtil.toJsonStr(authentication));
        log.info("请求路径是-{}", JSONUtil.toJsonStr(request.getRequestURI()));
        String jwt = request.getHeader(Jwts.HEADER_JWT);
        log.info("进入到-JwtAuthenticationFilter-过滤器-jwtToken-{}", jwt);
        if (StrUtil.isBlank(jwt)) {
            chain.doFilter(request, response);
            return;
        }
        JWSObject jwsObject = Jwts.parse(jwt);
        if (!Jwts.verify(jwsObject, verifier)) {
            log.error("Jwt verify failed! JWT: [{}]", jwt);
            chain.doFilter(request, response);
            return;
        }
        UserDetail userDetail = jwsObject.getPayload().toType(payload -> StrUtil.isBlank(payload.toString()) ?
                UserDetail.anonymous() : JsonHelper.readValue(payload.toString(), UserDetail.class));
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetail, null, null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        chain.doFilter(request, response);
    }


到此处 springboot + springsecurity + spring-authorization-server 整合完毕.

遇到的问题

前端的 /sockjs-node 访问异常

解决参考:
https://blog.csdn.net/qq27229639/article/details/103069055
https://www.ancii.com/anbgjpemb

配置RouterFunction

需要放置到一个 单独的 @Configuration 配置类中,不然会不生效的。

实践结果:

Github : https://github.com/qjyn1314/authorization-life

你可能感兴趣的:(spring-boot,gateway,spring,spring,boot,spring,cloud)