Spring Security OAuth2 第一节 简介 Spring Security's OAuth2结构
第二节 Spring新版本迁移 OAuth 2.0客户端和资源服务器从Spring Security OAuth 2.x移动到Spring Security 5.2.x的指南。由于Spring Security 不提供授权服务器支持,因此迁移Spring Security OAuth 授权服务器超出了本文档的范围
2.1 客户端 Client 通过添加@EnableOAuth2Client注释可以启用Spring Security OAuth对Authorization Code flow的支持,而其他的flows则需要构造并公开OAuth2ClientContext实例。
Spring Security的OAuth 2.0 Client则是通过oauth2Client的DSL方法启用支持的。
2.1.1 RestTemplate and WebClient Spring Security OAuth扩展了RestTemplate, 引入了OAuth2RestTemplate,这个类需要注册为@Bean并实例化。
Spring Security则选择了支持组合并提供了OAuth2AuthorizedClientService,用来帮助创建RestTemplate拦截器或WebClient交换过滤器函数。Spring Security同时为基于Servlet-和基于WebFlux-的应用程序提供了ExchangeFilterFunction。
2.1.2 Simplified Client Resolution 在Spring Security OAuth中检索当前授权的客户端,需要自动注入(autowire)OAuth2ClientContext实例。Spring Security OAuth通过Spring MVCs request以及session scope来存储OAuth2ClientContext实例。
在Spring Security中检索当前授权的客户端,需要@RegisteredOAuth2AuthorizedClient方法参数注解。Spring Security 把已授权的客户端存储在OAuth2AuthorizedClientRepository中。
2.1.3 Enhanced Client Resolution Spring Security OAuth通过Spring Boot properties配置单个客户端。
Spring Security使用ClientRegistrationRepository来表示客户端,客户端可以通过Spring Security DSL来提供或仍使用Spring Boot配置。
2.1.4 Simplified JWT Support Spring Security OAuth通过spring-security-jwt提供JWT支持。
Spring Security则依赖于Nimbus提供JWT支持。
2.1.5 示例 Examples Matrix Spring Security和Spring Security OAuth2都提供了如何配置客户端的示例:
2.2 登录 Login 2.2.1 方法变更 Changes In Approach Spring Security称呼此功能为OAuth 2.0 Login而Spring Security OAuth则称为SSO。
Spring Security OAuth的SSO支持通过添加@EnableOAuth2Sso注解开启。
Spring Security的OAuth 2.0 Login支持通过oauth2Login() DSL方法启用的。
2.3 资源服务器 Resource Server 2.3.1 方法变更 Changes In Approach Spring Security OAuth的资源服务器支持通过添加@EnableResourceServer注解开启。
Spring Security的资源服务器支持通过oauth2ResourceServer DSL方法启用的。
Spring Security OAuth为资源服务器提供了两种不同的DSL方法,通过扩展ResourceServerConfigurerAdapter来配置。
Spring Security通过Spring Security DSL提供相同的功能, 通过扩展WebSecurityConfigurerAdapter来配置。
Spring Security OAuth为具体授权规则指定了两个位置,第一个是通过ResourceServerConfigurerAdapter - 此处的任意规则都是为存在(present)的bearer token设定,第二个是通过WebSecurityConfigurerAdapter - 此处的任意规则都是为缺席(absent)的bearer token设定。
Spring Security则标明所有的授权规则都是通过一个或多个WebSecurityConfigurerAdapter配置而来的。
Spring Security OAuth提供了一个自定义的SpEL变量oauth2。为了基于scope去进行授权请求或方法, 可以编写一个表达式如access(“#oauth2.hasScope(‘scope’)”)。
Spring Security转换scope遵循授权命名规范。为了基于scope去进行授权请求或方法, 可以编写一个表达式如hasAuthority(“SCOPE_scope”)。
2.3.2 示例 Examples Matrix
2.3.3 未移植功能 Unported Features There are some features that we currently have no plans to port over.
In Spring Security OAuth, you can configure a UserDetailsService to look up a user that corresponds with the incoming bearer token. There are no plans for Spring Security’s Resource Server support to pick up a UserDetailsService. This is still simple in Spring Security, though, via the jwtAuthenticationConverter DSL method. Notably, one can return a BearerTokenAuthentication which takes an instance of OAuth2AuthenticatedPrincipal for a principal.
In Spring Security OAuth, you can assign an identifier to the resource server via the ResourceServerSecurityConfigurer#resourceId method. This configures the realm name used by the authentication entry point as well as adds audience validation. No such identifier is planned for Spring Security. However, audience validation and a custom realm name are both simple to achieve by configuring an OAuth2TokenValidator and AuthenticationEntryPoint respectively.
第三节 实战:实现单点登录
第四节 实战:APP平台第三方应用授权 首先在平台官网注册成为开发者。
然后注册应用信息,并搭建好项目。
OAuth时序图如下,通过OAuth来对接个人项目和APP平台,之后开发完毕后,只要保证提供一个稳定可用的线上版本即可。
OAuth时序图
官网文档提供了OAuth2.0的authorize接口、access token接口、获取用户信息接口。
怎么通过Spring Security配置OAuth2 Client?找了一下Spring官网Demo:OAuth-2.0-Migration-Guide ,直接按Demo来实现我们的需求。
代码就没必要再贴一遍了,了解OAuth2协议流程,对整个技术框架都会比较熟悉了,快速的实现需求后,再慢慢解决遇到的问题。
以下为部分配置文件内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # Spring-Security spring.security.oauth2.client.registration.cpdemo-client.provider=cpdemo spring.security.oauth2.client.registration.cpdemo-client.client-id=15764595791470995 spring.security.oauth2.client.registration.cpdemo-client.client-secret=trmCkkXvNTEtURIZjqj0yQ0A13PJ90 spring.security.oauth2.client.registration.cpdemo-client.authorization-grant-type=authorization_code spring.security.oauth2.client.registration.cpdemo-client.client-name=TEST spring.security.oauth2.client.registration.cpdemo-client.redirect-uri={baseUrl}/login/oauth2/code/{registrationId} spring.security.oauth2.client.registration.cpdemo-client.scope=get_user_info spring.security.oauth2.client.provider.cpdemo.authorization-uri=https: spring.security.oauth2.client.provider.cpdemo.token-uri=https: spring.security.oauth2.client.provider.cpdemo.user-info-uri=https: spring.security.oauth2.client.provider.cpdemo.user-name-attribute=user_name # 解决 Possible CSRF detected - state parameter was required but no state could be found 问题 server.servlet.session.cookie.name=OAUTH2CLIENTSESSION
第五节 问题记录:得到授权code后无法获取访问令牌 1.1 问题描述 正常访问http://localhost:7990/,向server发出授权请求,并重定向到一个PC接口需要用APP端扫码二维码,扫码成功后发生异常,返回/login?error页面提示错误msg:[authorization_request_not_found]
1.2 问题跟踪 开始调试代码,并记录访问流程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 --首先客户发起请求,收到rep重定向到/index http: Status Code: 302 Response: Location: http: --请求/index,收到rep重定向到/oauth2/authorization/cpdemo-client,并用Cookie保存session:OAUTH2CLIENTSESSION http: Status Code: 302 Response: Location: http: Set-Cookie: OAUTH2CLIENTSESSION=907AE53F4C3C1DA967944F7B89E295EA; Path=/; HttpOnly --请求/oauth2/authorization/cpdemo-client,携带Cookie,收到rep重定向到www.xxx.com的授权接口,注意此时state为WiSvFEChWOuaLw0GtwigPzmRx7ocBPRDGxZJMqzAnkQ%3D http: Status Code: 302 Request: Cookie: OAUTH2CLIENTSESSION=907AE53F4C3C1DA967944F7B89E295EA Response: Location: https: --请求www.cpdemo.com的授权接口,收到rep重定向到/pc/qrcode/index.html,并用保存Cookie:acw_tc,授权服务器检测到PC端访问,跳转到一个二维码界面 https: Status Code: 302 Response Location: https: Set-Cookie: acw_tc=76b20fec15776926419723186e2e77475066dc07c6ab3101e3cd340c96ff46;path=/;HttpOnly;Max-Age=2678401 --请求/pc/qrcode/index.html,携带Cookie,获取二维码界面 https: Status Code: 200 Request: Cookie: acw_tc=76b20fec15776926419723186e2e77475066dc07c6ab3101e3cd340c96ff46 --扫码登陆,并携带新的参数,注意此时state值为login https: Status Code: 200 Request: Cookie: acw_tc=76b20fec15776926419723186e2e77475066dc07c6ab3101e3cd340c96ff46 Playload: clientId: "xxx" redirectUri: "http://localhost:7990/login/oauth2/code/cpdemo-client" scope: "get_user_info" responseType: "code" state: "login" supportFreeLogin: "" --登陆成功,重定向到redirectUri:/login/oauth2/code/cpdemo-client,并携带授权code和state,请求失败,跳转error界面 http: Request: Cookie: OAUTH2CLIENTSESSION=907AE53F4C3C1DA967944F7B89E295EA
再看DEBUG日志
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 --本地客户端服务器收到code和state o.a.coyote.http11.Http11InputBuffer : Received [GET /login/oauth2/code/cpdemo-client?code=PYd42xxZiDccJH5K6Hmz1577674731&state=login HTTP/1.1 Host: localhost:7990 Connection: keep-alive Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0 ; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0 .3945 .88 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9 ,image/webp,image/apng,*
定位到异常发生的代码,部分源码如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public Authentication attemptAuthentication (HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap()); if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) { OAuth2Error oauth2Error = new OAuth2Error("invalid_request" ); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); } else { OAuth2AuthorizationRequest authorizationRequest = this .authorizationRequestRepository.removeAuthorizationRequest(request, response); if (authorizationRequest == null ) { OAuth2Error oauth2Error = new OAuth2Error("authorization_request_not_found" ); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); } ... } } ... }
继续跟踪代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public final class HttpSessionOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository <OAuth2AuthorizationRequest > { public OAuth2AuthorizationRequest removeAuthorizationRequest (HttpServletRequest request) { Assert.notNull(request, "request cannot be null" ); String stateParameter = this .getStateParameter(request); if (stateParameter == null ) { return null ; } else { Map<String, OAuth2AuthorizationRequest> authorizationRequests = this .getAuthorizationRequests(request); OAuth2AuthorizationRequest originalRequest = (OAuth2AuthorizationRequest)authorizationRequests.remove(stateParameter); if (!authorizationRequests.isEmpty()) { request.getSession().setAttribute(this .sessionAttributeName, authorizationRequests); } else { request.getSession().removeAttribute(this .sessionAttributeName); } return originalRequest; } } ... private String getStateParameter (HttpServletRequest request) { return request.getParameter("state" ); } private Map<String, OAuth2AuthorizationRequest> getAuthorizationRequests (HttpServletRequest request) { HttpSession session = request.getSession(false ); Map<String, OAuth2AuthorizationRequest> authorizationRequests = session == null ? null : (Map)session.getAttribute(this .sessionAttributeName); return (Map)(authorizationRequests == null ? new HashMap() : authorizationRequests); }
我们得到的session中有两个attribute:AUTHORIZATION_REQUEST和SPRING_SECURITY_SAVED_REQUEST,前者存储了一组映射(”WiSvFEChWOuaLw0GtwigPzmRx7ocBPRDGxZJMqzAnkQ=” -> req),后者则是默认Req:http://localhost:7990/index。
所以此处是根据state参数值来获取session中存储的信息,但我们知道在首次请求授权服务器时我们提供的state为:WiSvFEChWOuaLw0GtwigPzmRx7ocBPRDGxZJMqzAnkQ=,扫码登陆后又提交了state为:login,但这里需要的是旧的state参数。
所以要么授权服务器在扫码登陆时不要替换state为login,而是WiSv;要么授权服务器在扫码登陆后返回code和state时给旧值WiSv;要么我们客户端重构。
联系对方工程师后,回复:“把state写死试试 ”。虽然有点无语,但好吧。
1.3 问题处理 1.3.1 自定义Authorization_Code_Request 写死state,首先要自定义authorization code request,首先自定义OAuth2AuthorizationRequestResolver,覆盖state参数,代码如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 public class CustomAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { private final ClientRegistrationRepository clientRegistrationRepository; private final AntPathRequestMatcher authorizationRequestMatcher; private final StringKeyGenerator stateGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder()); private final StringKeyGenerator secureKeyGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96 ); public CustomAuthorizationRequestResolver ( ClientRegistrationRepository clientRegistrationRepository, String authorizationRequestBaseUri) { Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null" ); Assert.hasText(authorizationRequestBaseUri, "authorizationRequestBaseUri cannot be empty" ); this .clientRegistrationRepository = clientRegistrationRepository; this .authorizationRequestMatcher = new AntPathRequestMatcher(authorizationRequestBaseUri + "/{" + "registrationId" + "}" ); } @Override public OAuth2AuthorizationRequest resolve (HttpServletRequest request) { String registrationId = this .resolveRegistrationId(request); String redirectUriAction = this .getAction(request, "login" ); return this .resolve(request, registrationId, redirectUriAction); } @Override public OAuth2AuthorizationRequest resolve (HttpServletRequest request, String registrationId) { if (registrationId == null ) { return null ; } else { String redirectUriAction = this .getAction(request, "authorize" ); return this .resolve(request, registrationId, redirectUriAction); } } private String getAction (HttpServletRequest request, String defaultAction) { String action = request.getParameter("action" ); return action == null ? defaultAction : action; } private OAuth2AuthorizationRequest resolve (HttpServletRequest request, String registrationId, String redirectUriAction) { if (registrationId == null ) { return null ; } else { ClientRegistration clientRegistration = this .clientRegistrationRepository.findByRegistrationId(registrationId); if (clientRegistration == null ) { throw new IllegalArgumentException("Invalid Client Registration with Id: " + registrationId); } else { Map<String, Object> attributes = new HashMap(); attributes.put("registration_id" , clientRegistration.getRegistrationId()); OAuth2AuthorizationRequest.Builder builder; if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(clientRegistration.getAuthorizationGrantType())) { builder = OAuth2AuthorizationRequest.authorizationCode(); Map<String, Object> additionalParameters = new HashMap(); if (!CollectionUtils.isEmpty(clientRegistration.getScopes()) && clientRegistration.getScopes().contains("openid" )) { this .addNonceParameters(attributes, additionalParameters); } if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())) { this .addPkceParameters(attributes, additionalParameters); } builder.additionalParameters(additionalParameters); } else { if (!AuthorizationGrantType.IMPLICIT.equals(clientRegistration.getAuthorizationGrantType())) { throw new IllegalArgumentException("Invalid Authorization Grant Type (" + clientRegistration.getAuthorizationGrantType().getValue() + ") for Client Registration with Id: " + clientRegistration.getRegistrationId()); } builder = OAuth2AuthorizationRequest.implicit(); } String redirectUriStr = expandRedirectUri(request, clientRegistration, redirectUriAction); OAuth2AuthorizationRequest authorizationRequest = builder.clientId(clientRegistration.getClientId()).authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()).redirectUri(redirectUriStr).scopes(clientRegistration.getScopes()).state("login" ).attributes(attributes).build(); return customizeAuthorizationRequest(authorizationRequest); } } } private OAuth2AuthorizationRequest customizeAuthorizationRequest ( OAuth2AuthorizationRequest req) { Map<String,Object> extraParams = new HashMap<>(); System.out.println(req.getAdditionalParameters()); extraParams.putAll(req.getAdditionalParameters()); extraParams.put("state" , "login" ); return OAuth2AuthorizationRequest .from(req) .additionalParameters(extraParams) .build(); } private String resolveRegistrationId (HttpServletRequest request) { return this .authorizationRequestMatcher.matches(request) ? (String)this .authorizationRequestMatcher.matcher(request).getVariables().get("registrationId" ) : null ; } private static String expandRedirectUri (HttpServletRequest request, ClientRegistration clientRegistration, String action) { Map<String, String> uriVariables = new HashMap(); uriVariables.put("registrationId" , clientRegistration.getRegistrationId()); UriComponents uriComponents = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request)).replacePath(request.getContextPath()).replaceQuery((String)null ).fragment((String)null ).build(); String scheme = uriComponents.getScheme(); uriVariables.put("baseScheme" , scheme == null ? "" : scheme); String host = uriComponents.getHost(); uriVariables.put("baseHost" , host == null ? "" : host); int port = uriComponents.getPort(); uriVariables.put("basePort" , port == -1 ? "" : ":" + port); String path = uriComponents.getPath(); if (StringUtils.hasLength(path) && path.charAt(0 ) != '/' ) { path = '/' + path; } uriVariables.put("basePath" , path == null ? "" : path); uriVariables.put("baseUrl" , uriComponents.toUriString()); uriVariables.put("action" , action == null ? "" : action); return UriComponentsBuilder.fromUriString(clientRegistration.getRedirectUriTemplate()).buildAndExpand(uriVariables).toUriString(); } private void addNonceParameters (Map<String, Object> attributes, Map<String, Object> additionalParameters) { try { String nonce = this .secureKeyGenerator.generateKey(); String nonceHash = createHash(nonce); attributes.put("nonce" , nonce); additionalParameters.put("nonce" , nonceHash); } catch (NoSuchAlgorithmException var5) { ; } } private void addPkceParameters (Map<String, Object> attributes, Map<String, Object> additionalParameters) { String codeVerifier = this .secureKeyGenerator.generateKey(); attributes.put("code_verifier" , codeVerifier); try { String codeChallenge = createHash(codeVerifier); additionalParameters.put("code_challenge" , codeChallenge); additionalParameters.put("code_challenge_method" , "S256" ); } catch (NoSuchAlgorithmException var5) { additionalParameters.put("code_challenge" , codeVerifier); } } private static String createHash (String value) throws NoSuchAlgorithmException { MessageDigest md = MessageDigest.getInstance("SHA-256" ); byte [] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII)); return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); } }
在SecurityConfig中配置应用CustomAuthorizationRequestResolver。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.oauth2Login() .authorizationEndpoint() .authorizationRequestRepository(authorizationRequestRepository()) .authorizationRequestResolver(new CustomAuthorizationRequestResolver( clientRegistrationRepository(), "/oauth2/authorization" )) ... } @Bean public AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository () { return new HttpSessionOAuth2AuthorizationRequestRepository(); } private static List<String> clients = Arrays.asList("cpdemo-client" ); private static String CLIENT_PROPERTY_KEY = "spring.security.oauth2.client.registration." ; private static String CLIENT_PROVIDER_KEY = "spring.security.oauth2.client.provider." ; @Autowired private Environment env; public ClientRegistrationRepository clientRegistrationRepository () { List<ClientRegistration> registrations = clients.stream() .map(c -> getRegistration(c)) .filter(registration -> registration != null ) .collect(Collectors.toList()); return new InMemoryClientRegistrationRepository(registrations); } private ClientRegistration getRegistration (String client) { String clientId = env.getProperty(CLIENT_PROPERTY_KEY + client + ".client-id" ); if (clientId == null ) { return null ; } String clientSecret = env.getProperty(CLIENT_PROPERTY_KEY + client + ".client-secret" ); if (client.equals("cpdemo-client" )) { String provider = "cpdemo" ; ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(client); return builder.clientId(clientId) .clientSecret(clientSecret) .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUriTemplate(env.getProperty(CLIENT_PROPERTY_KEY + client + ".redirect-uri" )) .scope(env.getProperty(CLIENT_PROPERTY_KEY + client + ".scope" )) .authorizationUri(env.getProperty(CLIENT_PROVIDER_KEY + provider + ".authorization-uri" )) .tokenUri(env.getProperty(CLIENT_PROVIDER_KEY + provider + ".token-uri" )) .userInfoUri(env.getProperty(CLIENT_PROVIDER_KEY + provider + ".user-info-uri" )).build(); } return null ; }
执行后发现之前的问题解决,但发现授权平台提供的token请求和响应格式与标准不同,需要自定义修改。
1.3.2 自定义Access_Token_Request和Access_Token_Response 首先实现CustomRequestEntityConverter,为token请求增加参数client_id和client_secret。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class CustomRequestEntityConverter implements Converter <OAuth2AuthorizationCodeGrantRequest , RequestEntity <?>> { private static String CLIENT_PROPERTY_KEY = "spring.security.oauth2.client.registration.cpdemo-client." ; private Environment env; private OAuth2AuthorizationCodeGrantRequestEntityConverter defaultConverter; public CustomRequestEntityConverter (Environment env) { defaultConverter = new OAuth2AuthorizationCodeGrantRequestEntityConverter(); this .env = env; } @Override public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest req) { RequestEntity<?> entity = defaultConverter.convert(req); MultiValueMap<String, String> params = (MultiValueMap<String,String>) entity.getBody(); params.add("client_id" , env.getProperty(CLIENT_PROPERTY_KEY + "client-id" )); params.add("client_secret" , env.getProperty(CLIENT_PROPERTY_KEY + "client-secret" )); return new RequestEntity<>(params, entity.getHeaders(), entity.getMethod(), entity.getUrl()); } }
然后实现CustomTokenResponseConverter,为token响应配置参数格式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public class CustomTokenResponseConverter implements Converter <Map <String , String >, OAuth2AccessTokenResponse > { private static final Set<String> TOKEN_RESPONSE_PARAMETER_NAMES = Stream.of( OAuth2ParameterNames.ACCESS_TOKEN, OAuth2ParameterNames.TOKEN_TYPE, OAuth2ParameterNames.EXPIRES_IN, OAuth2ParameterNames.REFRESH_TOKEN, OAuth2ParameterNames.SCOPE).collect(Collectors.toSet()); @Override public OAuth2AccessTokenResponse convert (Map<String, String> tokenResponseParameters) { String accessToken = tokenResponseParameters.get(OAuth2ParameterNames.ACCESS_TOKEN); long expiresIn = Long.valueOf(tokenResponseParameters.get(OAuth2ParameterNames.EXPIRES_IN)); OAuth2AccessToken.TokenType accessTokenType = OAuth2AccessToken.TokenType.BEARER; Set<String> scopes = Collections.emptySet(); if (tokenResponseParameters.containsKey(OAuth2ParameterNames.SCOPE)) { String scope = tokenResponseParameters.get(OAuth2ParameterNames.SCOPE); scopes = Arrays.stream(StringUtils.delimitedListToStringArray(scope, "," )) .collect(Collectors.toSet()); } return OAuth2AccessTokenResponse.withToken(accessToken) .tokenType(accessTokenType) .expiresIn(expiresIn) .scopes(scopes) .build(); } }
最后修改SecurityConfig,增加token端点配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.oauth2Login() .authorizationEndpoint() .authorizationRequestRepository(authorizationRequestRepository()) .authorizationRequestResolver(new CustomAuthorizationRequestResolver( clientRegistrationRepository(), "/oauth2/authorization" )) .and() .tokenEndpoint() .accessTokenResponseClient(accessTokenResponseClient()) ... } @Bean public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient () { DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient(); accessTokenResponseClient.setRequestEntityConverter(new CustomRequestEntityConverter(env)); OAuth2AccessTokenResponseHttpMessageConverter tokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter(); tokenResponseHttpMessageConverter.setTokenResponseConverter(new CustomTokenResponseConverter()); RestTemplate restTemplate = new RestTemplate(Arrays.asList( new FormHttpMessageConverter(), tokenResponseHttpMessageConverter)); restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); accessTokenResponseClient.setRestOperations(restTemplate); return accessTokenResponseClient; } }
执行后发现之前的问题解决,但获取用户信息的响应格式需要自定义。
1.3.3 自定义User_Info_Response 创建CustomOAuth2User,对应user-info响应消息格式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @Data public class CustomOAuth2User implements OAuth2User { @JsonIgnore private List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("ROLE_USER" ); @JsonIgnore private Map<String, Object> attributes; @NonNull @JsonProperty(required = true,value = "campus_user_id") private String id; @NonNull @JsonProperty(required = true,value = "campus_id") private String userId; @JsonProperty(required = true,value = "tenant_code") private String tenantCode; @NonNull @JsonProperty(required = true,value = "user_name") private String name; @JsonProperty(required = true,value = "user_type") private String type; @JsonProperty(required = true,value = "gender") private String gender; ... @Override public Collection<? extends GrantedAuthority> getAuthorities() { return this .authorities; } @Override public String getName () { return this .name; } @Override public Map<String, Object> getAttributes () { if (this .attributes == null ) { this .attributes = new HashMap<>(); this .attributes.put("id" , this .id); this .attributes.put("userId" , this .userId); this .attributes.put("tenantCode" , this .tenantCode); this .attributes.put("name" , this .name); this .attributes.put("type" , this .type); this .attributes.put("gender" , this .gender); ... } return attributes; } }
修改SecurityConfig,增加customUserType配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.oauth2Login() .authorizationEndpoint() .authorizationRequestRepository(authorizationRequestRepository()) .authorizationRequestResolver(new CustomAuthorizationRequestResolver( clientRegistrationRepository(), "/oauth2/authorization" )) .and() .tokenEndpoint() .accessTokenResponseClient(accessTokenResponseClient()) .and() .userInfoEndpoint() .customUserType(CustomOAuth2User.class,"cpdemo-client" ); }
执行后发现get user info流程报错,如下,user-info的响应编码格式为octet-stream,但默认的转换器不支持。
1 no suitable HttpMessageConverter found for response type [XXX] and content type [application/octet-stream]
1.3.4 自定义User_Info_Request 自定义OAuth2UserService,为RestTemplate配置支持OCTET_STREAM(CustomOAuth2UserRequestEntityConverter此时还为OAuth2UserRequestEntityConverter)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 public class CustomUserService implements OAuth2UserService <OAuth2UserRequest , OAuth2User > { private final Map<String, Class<? extends OAuth2User>> customUserTypes; private Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter = new CustomOAuth2UserRequestEntityConverter(); private RestOperations restOperations; public CustomUserService (Map<String, Class<? extends OAuth2User>> customUserTypes) { Assert.notEmpty(customUserTypes, "customUserTypes cannot be empty" ); this .customUserTypes = Collections.unmodifiableMap(new LinkedHashMap(customUserTypes)); RestTemplate restTemplate = new RestTemplate(new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory())); List<ClientHttpRequestInterceptor> interceptors = new ArrayList<>(); interceptors.add(new LoggingRequestInterceptor()); restTemplate.setInterceptors(interceptors); MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); converter.setSupportedMediaTypes(Arrays.asList(new MediaType[]{MediaType.APPLICATION_JSON, MediaType.APPLICATION_OCTET_STREAM})); restTemplate.setMessageConverters(Arrays.asList(converter, new FormHttpMessageConverter())); this .restOperations = restTemplate; } @Override public OAuth2User loadUser (OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { Assert.notNull(userRequest, "userRequest cannot be null" ); String registrationId = userRequest.getClientRegistration().getRegistrationId(); Class customUserType; if ((customUserType = (Class)this .customUserTypes.get(registrationId)) == null ) { return null ; } else { System.out.println("此时access token :" + userRequest.getAccessToken().getTokenValue()); System.out.println("此时AdditionalParameters :" + userRequest.getAdditionalParameters().toString()); RequestEntity request = (RequestEntity)this .requestEntityConverter.convert(userRequest); ResponseEntity response; try { response = this .restOperations.exchange(request, customUserType); } catch (RestClientException var8) { OAuth2Error oauth2Error = new OAuth2Error("invalid_user_info_response" , "An error occurred while attempting to retrieve the UserInfo Resource: " + var8.getMessage(), (String)null ); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), var8); } OAuth2User oauth2User = (OAuth2User)response.getBody(); return oauth2User; } } public final void setRequestEntityConverter (Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter) { Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null" ); this .requestEntityConverter = requestEntityConverter; } public final void setRestOperations (RestOperations restOperations) { Assert.notNull(restOperations, "restOperations cannot be null" ); this .restOperations = restOperations; } }
通过实现ClientHttpRequestInterceptor接口来对RestTemplate打印日志。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public class LoggingRequestInterceptor implements ClientHttpRequestInterceptor { final static Logger log = LoggerFactory.getLogger(LoggingRequestInterceptor.class); @Override public ClientHttpResponse intercept (HttpRequest httpRequest, byte [] bytes, ClientHttpRequestExecution clientHttpRequestExecution) throws UnsupportedEncodingException,IOException { traceRequest(httpRequest,bytes); ClientHttpResponse response = clientHttpRequestExecution.execute(httpRequest,bytes); traceResponse(response); return response; } private void traceRequest (HttpRequest httpRequest, byte [] bytes) throws UnsupportedEncodingException { log.info("=============================request begin==============================" ); log.debug("URI : {}" ,httpRequest.getURI()); log.debug("Method : {}" ,httpRequest.getMethod()); log.debug("Headers : {}" ,httpRequest.getHeaders()); log.debug("Body : {}" ,new String(bytes,"UTF-8" )); log.info("=============================request end==============================" ); } private void traceResponse (ClientHttpResponse response) throws IOException { StringBuilder inputStringBuilder = new StringBuilder(); BufferedReader reader = new BufferedReader(new InputStreamReader(response.getBody(),"UTF-8" )); String line = reader.readLine(); while (line != null ){ inputStringBuilder.append(line); inputStringBuilder.append('\n' ); line = reader.readLine(); } log.info("=============================response begin==============================" ); log.debug("StatusCode : {}" ,response.getStatusCode()); log.debug("StatusText : {}" ,response.getStatusText()); log.debug("Headers : {}" ,response.getHeaders()); log.debug("Body : {}" ,inputStringBuilder.toString()); log.info("=============================response end==============================" ); } }
修改SecurityConfig,增加userService配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/" ,"/home" , "/login**" ,"/callback/" , "/webjars/**" , "/error**" , "/oauth2/authorization/**" ) .permitAll() .anyRequest() .authenticated(); Map<String,Class<? extends OAuth2User>> customUserTypes = new HashMap<>(); customUserTypes.put("cpdemo-client" ,CustomOAuth2User.class); http.oauth2Login() .authorizationEndpoint() .authorizationRequestRepository(authorizationRequestRepository()) .authorizationRequestResolver(new CustomAuthorizationRequestResolver( clientRegistrationRepository(), "/oauth2/authorization" )) .and() .tokenEndpoint() .accessTokenResponseClient(accessTokenResponseClient()) .and() .userInfoEndpoint() .userService(new CustomUserService(customUserTypes)) .customUserType(CustomOAuth2User.class,"cpdemo-client" ); } @Bean public AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository () { return new HttpSessionOAuth2AuthorizationRequestRepository(); }
执行后发现提示请求Token时未提供所需参数,根据接口文档发现授权服务器要求把授权令牌写入Get_Parm,查看源码可以发现默认Converter会根据请求类别把授权令牌放入HEADER或FORM_PARM。
自定义转换器CustomOAuth2UserRequestEntityConverter,强行将access_token拼入URL。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public class CustomOAuth2UserRequestEntityConverter implements Converter <OAuth2UserRequest , RequestEntity <?>> { private static final MediaType DEFAULT_CONTENT_TYPE = MediaType.valueOf("application/x-www-form-urlencoded;charset=UTF-8" ); public CustomOAuth2UserRequestEntityConverter () { } @Override public RequestEntity<?> convert(OAuth2UserRequest userRequest) { ClientRegistration clientRegistration = userRequest.getClientRegistration(); HttpMethod httpMethod = HttpMethod.GET; if (AuthenticationMethod.FORM.equals(clientRegistration.getProviderDetails().getUserInfoEndpoint().getAuthenticationMethod())) { httpMethod = HttpMethod.POST; } HttpHeaders headers = new HttpHeaders(); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); URI uri = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri() + "?access_token=" + userRequest.getAccessToken().getTokenValue()).build().toUri(); RequestEntity request; if (HttpMethod.POST.equals(httpMethod)) { headers.setContentType(DEFAULT_CONTENT_TYPE); MultiValueMap<String, String> formParameters = new LinkedMultiValueMap(); formParameters.add("access_token" , userRequest.getAccessToken().getTokenValue()); request = new RequestEntity(formParameters, headers, httpMethod, uri); } else { headers.setBearerAuth(userRequest.getAccessToken().getTokenValue()); request = new RequestEntity(headers, httpMethod, uri); } return request; } }
执行后发现问题解决,流程跑通。
参考博客和文章书籍等:
OAuth-2.0-Migration-Guide
spring-security-oauth
Spring Security OAuth2入门
深入理解Spring Cloud Security OAuth2及JWT
OAuth2实现单点登录SSO
Spring文档-26. WebClient
Spring文档-31.OAuth 2.0 Login
Spring文档中文翻译
Spring Boot OAuth 2.0 客户端
baeldung:Spring WebClient and OAuth2 Support
baeldung:Customizing Authorization and Token Requests with Spring Security 5.1 Client
github:spring-5-security-oauth
github:spring-security-oauth-5-2-migrate
Log your RestTemplate Request and Response without destroying the body
Spring Boot: Solving OAuth2 ERR_TOO_MANY_REDIRECTS [Snippet]
stackoverflow:Spring 5 Security OAuth2 Login Redirect Loop
stackoverflow:authorizationGrantType cannot be null in Spring Security 5 OAuth Client and Spring Boot 2.0
stackoverflow:Spring-boot Resttemplate response.body is null while interceptor clearly shows body
stackoverflow:Using Spring security oauth, using a custom OAuth provider, I get [authorization_request_not_found], should I handle the callback method myself? y-shows-b “Title”)
stackoverflow:Spring RestTemplate - how to enable full debugging/logging of requests/responses?
因博客主等未标明不可引用,若部分内容涉及侵权请及时告知,我会尽快修改和删除相关内容