本章实现的效果
- 授权码模式下使用前后端分离的登录页面和授权确认页面。
- 设备码模式(Device Flow)下使用前后端分离的登录页面、授权确认页面、用户码(user_code)验证页面和用户码(user_code)验证成功页面。
分析
在一年多以前我出了两篇前后端分离的文章,分别是《Spring Authorization Server入门 (十二) 实现授权码模式使用前后端分离的登录页面》和《Spring Authorization Server入门 (十五) 分离授权确认与设备码校验页面》,最开始我的想法就是在OAuth2登录前生成一个id,在OAuth2登录期间所有请求都携带着这个id,特别是前端提交登录时,后端根据这个id存储认证信息,然后等登录以后重定向会授权申请时授权申请也会带这个这个id,这样就可以获取到前端提交登录以后存储的认证信息,相当于前端、后端两条独立不相交的线通过这个id串联起来了,但是这样就会很麻烦,授权申请、授权确认等都需要带这个id,后来我观察到对于浏览器来说,访问认证服务相关端点时每次都是同一个Session,所以后来通过SessionId来代替前端生成的id,这样在请求时比前边方便很多,但是后端的一些自定义处理就不能出现一丝差错,错一点就达不到想要的效果;
再后来有大佬提议可以使用Spring Session
管理Session
,打通前端、后端Session
之间的壁垒,让他们直接可以互通,这样分离、不分离的唯一区别就是返回json和重定向的区别,那么在使用Spring Session的情况下仅需要自定义一些响应就差不多了,这种情况下出问题的概率就会很低了。
但是Spring Session
也是有一定限制的,它只能共享同一域名下不同子域名的Session
,前端在提交登录或在OAuth2登录流程中有其它需要和认证服务交互的地方(ajax\axios)时需要添加跨域请求携带cookie配置,例如Axios需要在初始化时添加withCredentials: true
配置使其在跨域请求时带上cookie,这样前后端之间的Session就只会有一个。
Spring Session
在只引入依赖时我测试时axios请求的ip、前端项目访问的ip保持一致就可以共享Session,如果在有域名代理的情况下如果不能共享Session,可以在application.yml
中添加配置指定域名server.servlet.session.cookie.domain: 这里是具体的顶级域名,类似(xxx.com)
,如果添加了该配置,那么配置的ip(域名)、axios请求的ip(域名)和访问前端项目的ip三者要保持一致。
实现步骤
- 添加Spring Session Data Redis依赖。
- 自定义登录成功、失败处理,在前后端分离时响应json,否则重定向。
- 自定义重定向至登录页面处理,在重定向至登录页面时将当前请求的请求路径挂在登录页面的target参数中,前端登录成功后直接跳转至target中挂的url。
- 自定义授权确认成功、失败处理,在前后端分离时响应json,否则重定向。
- 自定义获取设备码验证地址响应处理,前后端分离时直接返回前端的验证地址,否则重定向。
- 自定义校验设备码验证成功处理,在前后端分离时响应json,否则重定向。
- 授权接口中添加授权确认信息查询接口给分离前端的授权确认页面使用。
- 授权接口中添加设备码模式授权确认中转接口,如果是前后端分离则响应json,否则重定向。
- 在AuthorizationConfig中将自定义内容通过提供的配置入口添加配置。
- 开启两个过滤器链的CORS配置。
- 注入
CorsConfigurationSource
,实现跨域配置。 - 添加前端相关页面的实现。
编码
常量类在最后
1. 添加Spring Session Data Redis依赖。
<dependency><groupId>org.springframework.session</groupId><artifactId>spring-session-data-redis</artifactId>
</dependency>
2. 自定义登录成功、失败处理
在处理类内添加了前后端分离和不分离的适配,根据相关地址的配置自适应。
登录成功处理
在com.example.authorization.handler
包下添加LoginSuccessHandler
类。
package com.example.authorization.handler;import com.example.model.Result;
import com.example.util.JsonUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.util.UrlUtils;import java.io.IOException;
import java.nio.charset.StandardCharsets;/*** 登录成功处理类** @author vains*/
@RequiredArgsConstructor
public class LoginSuccessHandler implements AuthenticationSuccessHandler {private final String loginPageUri;private final AuthenticationSuccessHandler authenticationSuccessHandler = new SavedRequestAwareAuthenticationSuccessHandler();@Override@SneakyThrowspublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {// 如果是绝对路径(前后端分离)if (UrlUtils.isAbsoluteUrl(this.loginPageUri)) {Result<String> success = Result.success();response.setCharacterEncoding(StandardCharsets.UTF_8.name());response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.getWriter().write(JsonUtils.objectCovertToJson(success));response.getWriter().flush();} else {authenticationSuccessHandler.onAuthenticationSuccess(request, response, authentication);}}}
登录失败处理
在com.example.authorization.handler
包下添加LoginFailureHandler
类。
package com.example.authorization.handler;import com.example.model.Result;
import com.example.util.JsonUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.security.web.util.UrlUtils;import java.io.IOException;
import java.nio.charset.StandardCharsets;/*** 登录失败处理类** @author vains*/
@Slf4j
public class LoginFailureHandler implements AuthenticationFailureHandler {private final String loginPageUri;private final AuthenticationFailureHandler authenticationFailureHandler;public LoginFailureHandler(String loginPageUri) {this.loginPageUri = loginPageUri;String loginFailureUrl = this.loginPageUri + "?error";this.authenticationFailureHandler = new SimpleUrlAuthenticationFailureHandler(loginFailureUrl);}@Override@SneakyThrowspublic void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {log.debug("登录失败,原因:{}", exception.getMessage());// 如果是绝对路径(前后端分离)if (UrlUtils.isAbsoluteUrl(this.loginPageUri)) {log.debug("登录页面为独立的前端服务页面,写回json.");// 登录失败,写回401与具体的异常Result<String> success = Result.error(HttpStatus.UNAUTHORIZED.value(), exception.getMessage());response.setCharacterEncoding(StandardCharsets.UTF_8.name());response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.getWriter().write(JsonUtils.objectCovertToJson(success));response.getWriter().flush();} else {log.debug("登录页面为认证服务的相对路径,跳转至:{}", this.loginPageUri);authenticationFailureHandler.onAuthenticationFailure(request, response, exception);}}}
3. 自定义重定向至登录页面处理
在重定向至登录页面时将当前请求的请求路径挂在登录页面的target参数中,前端登录成功后直接跳转至target中挂的url。
在com.example.authorization.handler
包下添加LoginTargetAuthenticationEntryPoint
类。
package com.example.authorization.handler;import com.example.model.Result;
import com.example.util.JsonUtils;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.util.ObjectUtils;import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;import static com.example.constant.SecurityConstants.DEVICE_ACTIVATE_URI;/*** 重定向至登录处理** @author vains*/
@Slf4j
public class LoginTargetAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();/*** @param loginFormUrl URL where the login page can be found. Should either be* relative to the web-app context path (include a leading {@code /}) or an absolute* URL.*/public LoginTargetAuthenticationEntryPoint(String loginFormUrl) {super(loginFormUrl);}@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {String deviceVerificationUri = "/oauth2/device_verification";// 兼容设备码前后端分离if (request.getRequestURI().equals(deviceVerificationUri)&& request.getMethod().equals(HttpMethod.POST.name())&& UrlUtils.isAbsoluteUrl(DEVICE_ACTIVATE_URI)) {// 如果是请求验证设备激活码(user_code)时未登录并且设备码验证页面是前后端分离的那种则写回jsonResult<String> success = Result.error(HttpStatus.UNAUTHORIZED.value(), ("登录已失效,请重新打开设备提供的验证地址"));response.setCharacterEncoding(StandardCharsets.UTF_8.name());response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.getWriter().write(JsonUtils.objectCovertToJson(success));response.getWriter().flush();return;}// 获取登录表单的地址String loginForm = determineUrlToUseForThisRequest(request, response, authException);if (!UrlUtils.isAbsoluteUrl(loginForm)) {// 不是绝对路径调用父类方法处理super.commence(request, response, authException);return;}StringBuffer requestUrl = request.getRequestURL();if (!ObjectUtils.isEmpty(request.getQueryString())) {requestUrl.append("?").append(request.getQueryString());}// 2023-07-11添加逻辑:重定向地址添加nonce参数,该参数的值为sessionId// 绝对路径在重定向前添加target参数String targetParameter = URLEncoder.encode(requestUrl.toString(), StandardCharsets.UTF_8);String targetUrl = loginForm + "?target=" + targetParameter;log.debug("重定向至前后端分离的登录页面:{}", targetUrl);this.redirectStrategy.sendRedirect(request, response, targetUrl);}
}
4. 自定义授权确认成功、失败处理
授权确认成功处理
在com.example.authorization.handler
包下添加ConsentAuthorizationResponseHandler
类。
package com.example.authorization.handler;import com.example.model.Result;
import com.example.util.JsonUtils;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;import java.io.IOException;
import java.nio.charset.StandardCharsets;import static com.example.constant.SecurityConstants.CONSENT_PAGE_URI;
import static org.springframework.security.oauth2.core.OAuth2ErrorCodes.INVALID_REQUEST;/*** 授权确认前后端分离适配响应处理** @author vains*/
public class ConsentAuthorizationResponseHandler implements AuthenticationSuccessHandler {private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();@Overridepublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {// 获取将要重定向的回调地址String redirectUri = this.getAuthorizationResponseUri(authentication);if (request.getMethod().equals(HttpMethod.POST.name()) && UrlUtils.isAbsoluteUrl(CONSENT_PAGE_URI)) {// 如果是post请求并且CONSENT_PAGE_URI是完整的地址,则响应jsonResult<String> success = Result.success(redirectUri);response.setCharacterEncoding(StandardCharsets.UTF_8.name());response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.getWriter().write(JsonUtils.objectCovertToJson(success));response.getWriter().flush();return;}// 否则重定向至回调地址this.redirectStrategy.sendRedirect(request, response, redirectUri);}/*** 获取重定向的回调地址** @param authentication 认证信息* @return 地址*/private String getAuthorizationResponseUri(Authentication authentication) {OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication =(OAuth2AuthorizationCodeRequestAuthenticationToken) authentication;if (ObjectUtils.isEmpty(authorizationCodeRequestAuthentication.getRedirectUri())) {String authorizeUriError = "Redirect uri is not null";throw new OAuth2AuthorizationCodeRequestAuthenticationException(new OAuth2Error(INVALID_REQUEST, authorizeUriError, (null)), authorizationCodeRequestAuthentication);}if (authorizationCodeRequestAuthentication.getAuthorizationCode() == null) {String authorizeError = "AuthorizationCode is not null";throw new OAuth2AuthorizationCodeRequestAuthenticationException(new OAuth2Error(INVALID_REQUEST, authorizeError, (null)), authorizationCodeRequestAuthentication);}UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(authorizationCodeRequestAuthentication.getRedirectUri()).queryParam(OAuth2ParameterNames.CODE, authorizationCodeRequestAuthentication.getAuthorizationCode().getTokenValue());if (StringUtils.hasText(authorizationCodeRequestAuthentication.getState())) {uriBuilder.queryParam(OAuth2ParameterNames.STATE,UriUtils.encode(authorizationCodeRequestAuthentication.getState(), StandardCharsets.UTF_8));}// build(true) -> Components are explicitly encodedreturn uriBuilder.build(true).toUriString();}}
授权确认失败处理
在com.example.authorization.handler
包下添加ConsentAuthenticationFailureHandler
类。
package com.example.authorization.handler;import com.example.model.Result;
import com.example.util.JsonUtils;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.util.UrlUtils;import java.io.IOException;
import java.nio.charset.StandardCharsets;import static com.example.constant.SecurityConstants.CONSENT_PAGE_URI;/*** 授权确认失败处理** @author vains*/
public class ConsentAuthenticationFailureHandler implements AuthenticationFailureHandler {@Overridepublic void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {// 获取当前认证信息Authentication authentication = SecurityContextHolder.getContext().getAuthentication();// 获取具体的异常OAuth2AuthenticationException authenticationException = (OAuth2AuthenticationException) exception;OAuth2Error error = authenticationException.getError();// 异常信息String message;if (authentication == null) {message = "登录已失效";} else {// 第二次点击“拒绝”会因为之前取消时删除授权申请记录而找不到对应的数据,导致抛出 [invalid_request] OAuth 2.0 Parameter: statemessage = error.toString();}// 授权确认页面提交的请求,因为授权申请与授权确认提交公用一个过滤器,这里判断一下if (request.getMethod().equals(HttpMethod.POST.name()) && UrlUtils.isAbsoluteUrl(CONSENT_PAGE_URI)) {// 写回json异常Result<Object> result = Result.error(HttpStatus.BAD_REQUEST.value(), message);response.setCharacterEncoding(StandardCharsets.UTF_8.name());response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.getWriter().write(JsonUtils.objectCovertToJson(result));response.getWriter().flush();} else {// 在地址栏输入授权申请地址或设备码流程的验证地址错误(user_code错误)response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString());}}}
5. 自定义获取设备码验证地址响应处理
在com.example.authorization.handler
包下添加DeviceAuthorizationResponseHandler
类。
package com.example.authorization.handler;import com.example.constant.SecurityConstants;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
import org.springframework.security.oauth2.core.OAuth2UserCode;
import org.springframework.security.oauth2.core.endpoint.OAuth2DeviceAuthorizationResponse;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.http.converter.OAuth2DeviceAuthorizationResponseHttpMessageConverter;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceAuthorizationEndpointFilter;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.web.util.UriComponentsBuilder;import java.io.IOException;/*** 设备码认证成功响应* 参考{@link OAuth2DeviceAuthorizationEndpointFilter#sendDeviceAuthorizationResponse}实现** @author vains* @see org.springframework.security.web.authentication.AuthenticationSuccessHandler* @see org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceAuthorizationEndpointFilter*/
public class DeviceAuthorizationResponseHandler implements AuthenticationSuccessHandler {private final HttpMessageConverter<OAuth2DeviceAuthorizationResponse> deviceAuthorizationHttpResponseConverter =new OAuth2DeviceAuthorizationResponseHttpMessageConverter();@Overridepublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthentication =(OAuth2DeviceAuthorizationRequestAuthenticationToken) authentication;OAuth2DeviceCode deviceCode = deviceAuthorizationRequestAuthentication.getDeviceCode();OAuth2UserCode userCode = deviceAuthorizationRequestAuthentication.getUserCode();// Generate the fully-qualified verification URIString issuerUri = AuthorizationServerContextHolder.getContext().getIssuer();UriComponentsBuilder uriComponentsBuilder;if (UrlUtils.isAbsoluteUrl(SecurityConstants.DEVICE_ACTIVATE_URI)) {uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(SecurityConstants.DEVICE_ACTIVATE_URI);} else {uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(issuerUri).path(SecurityConstants.DEVICE_ACTIVATE_URI);}String verificationUri = uriComponentsBuilder.build().toUriString();// 拼接user_codeString verificationUriComplete = uriComponentsBuilder.queryParam(OAuth2ParameterNames.USER_CODE, userCode.getTokenValue()).build().toUriString();OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse =OAuth2DeviceAuthorizationResponse.with(deviceCode, userCode).verificationUri(verificationUri).verificationUriComplete(verificationUriComplete).build();ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);this.deviceAuthorizationHttpResponseConverter.write(deviceAuthorizationResponse, null, httpResponse);}}
6. 自定义校验设备码验证成功处理
在com.example.authorization.handler
包下添加DeviceVerificationResponseHandler
类。
package com.example.authorization.handler;import com.example.model.Result;
import com.example.util.JsonUtils;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.util.UrlUtils;import java.io.IOException;
import java.nio.charset.StandardCharsets;import static com.example.constant.SecurityConstants.DEVICE_ACTIVATED_URI;/*** 校验设备码成功响应类** @author vains*/
public class DeviceVerificationResponseHandler implements AuthenticationSuccessHandler {@Overridepublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {if (UrlUtils.isAbsoluteUrl(DEVICE_ACTIVATED_URI)) {// 写回json数据Result<Object> result = Result.success(DEVICE_ACTIVATED_URI);response.setCharacterEncoding(StandardCharsets.UTF_8.name());response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.getWriter().write(JsonUtils.objectCovertToJson(result));response.getWriter().flush();} else {response.sendRedirect(DEVICE_ACTIVATED_URI);}}
}
7. 授权接口中添加授权确认信息查询接口给分离前端的授权确认页面使用
在AuthorizationController
中添加并修改。
@GetMapping(value = "/oauth2/consent")
public String consent(Principal principal, Model model,@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,@RequestParam(OAuth2ParameterNames.SCOPE) String scope,@RequestParam(OAuth2ParameterNames.STATE) String state,@RequestParam(name = OAuth2ParameterNames.USER_CODE, required = false) String userCode) {// 获取consent页面所需的参数Map<String, Object> consentParameters = getConsentParameters(scope, state, clientId, userCode, principal);// 转至model中,让框架渲染页面consentParameters.forEach(model::addAttribute);return "consent";
}@ResponseBody
@GetMapping(value = "/oauth2/consent/parameters")
public Result<Map<String, Object>> consentParameters(Principal principal,@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,@RequestParam(OAuth2ParameterNames.SCOPE) String scope,@RequestParam(OAuth2ParameterNames.STATE) String state,@RequestParam(name = OAuth2ParameterNames.USER_CODE, required = false) String userCode) {// 获取consent页面所需的参数Map<String, Object> consentParameters = getConsentParameters(scope, state, clientId, userCode, principal);return Result.success(consentParameters);
}/*** 根据授权确认相关参数获取授权确认与未确认的scope相关参数** @param scope scope权限* @param state state* @param clientId 客户端id* @param userCode 设备码授权流程中的用户码* @param principal 当前认证信息* @return 页面所需数据*/
private Map<String, Object> getConsentParameters(String scope,String state,String clientId,String userCode,Principal principal) {// Remove scopes that were already approvedSet<String> scopesToApprove = new HashSet<>();Set<String> previouslyApprovedScopes = new HashSet<>();RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);if (registeredClient == null) {throw new RuntimeException("客户端不存在");}OAuth2AuthorizationConsent currentAuthorizationConsent =this.authorizationConsentService.findById(registeredClient.getId(), principal.getName());Set<String> authorizedScopes;if (currentAuthorizationConsent != null) {authorizedScopes = currentAuthorizationConsent.getScopes();} else {authorizedScopes = Collections.emptySet();}for (String requestedScope : StringUtils.delimitedListToStringArray(scope, " ")) {if (OidcScopes.OPENID.equals(requestedScope)) {continue;}if (authorizedScopes.contains(requestedScope)) {previouslyApprovedScopes.add(requestedScope);} else {scopesToApprove.add(requestedScope);}}Map<String, Object> parameters = new HashMap<>(7);parameters.put("clientId", registeredClient.getClientId());parameters.put("clientName", registeredClient.getClientName());parameters.put("state", state);parameters.put("scopes", withDescription(scopesToApprove));parameters.put("previouslyApprovedScopes", withDescription(previouslyApprovedScopes));parameters.put("principalName", principal.getName());parameters.put("userCode", userCode);if (StringUtils.hasText(userCode)) {parameters.put("requestURI", "/oauth2/device_verification");} else {parameters.put("requestURI", "/oauth2/authorize");}return parameters;
}
8. 授权接口中添加设备码模式授权确认中转接口
在AuthorizationController
中添加。
@SneakyThrows
@ResponseBody
@GetMapping(value = "/oauth2/consent/redirect")
public Result<String> consentRedirect(HttpSession session,HttpServletRequest request,HttpServletResponse response,@RequestParam(OAuth2ParameterNames.SCOPE) String scope,@RequestParam(OAuth2ParameterNames.STATE) String state,@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,@RequestParam(name = OAuth2ParameterNames.USER_CODE, required = false) String userCode) {// 携带当前请求参数与nonceId重定向至前端页面UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(SecurityConstants.CONSENT_PAGE_URI).queryParam(OAuth2ParameterNames.SCOPE, UriUtils.encode(scope, StandardCharsets.UTF_8)).queryParam(OAuth2ParameterNames.STATE, UriUtils.encode(state, StandardCharsets.UTF_8)).queryParam(OAuth2ParameterNames.CLIENT_ID, clientId).queryParam(OAuth2ParameterNames.USER_CODE, userCode);String uriString = uriBuilder.build(Boolean.TRUE).toUriString();if (ObjectUtils.isEmpty(userCode) || !UrlUtils.isAbsoluteUrl(SecurityConstants.DEVICE_ACTIVATE_URI)) {// 不是设备码模式或者设备码验证页面不是前后端分离的,无需返回json,直接重定向this.redirectStrategy.sendRedirect(request, response, uriString);return null;}// 兼容设备码,需响应JSON,由前端进行跳转return Result.success(uriString);
}
9. 在AuthorizationConfig中将自定义内容通过提供的配置入口添加配置。
这一步基本就是组装了,将上边的自定义内容加入配置,使其生效。
完整的AuthorizationConfig
配置在最后的附录中。
配置授权确认、获取设备码响应和设备码验证自定义处理,配置重定向至登录的自定义处理。
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)// 开启OpenID Connect 1.0协议相关端点.oidc(Customizer.withDefaults())// 设置自定义用户确认授权页.authorizationEndpoint(authorizationEndpoint ->authorizationEndpoint.consentPage(SecurityConstants.CONSENT_PAGE_URI).errorResponseHandler(new ConsentAuthenticationFailureHandler()).authorizationResponseHandler(new ConsentAuthorizationResponseHandler()))// 设置设备码用户验证url(自定义用户验证页).deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->deviceAuthorizationEndpoint.verificationUri(SecurityConstants.DEVICE_ACTIVATE_URI).deviceAuthorizationResponseHandler(new DeviceAuthorizationResponseHandler()))// 设置验证设备码用户确认页面.deviceVerificationEndpoint(deviceVerificationEndpoint ->// 设备码授权确认特殊处理,先重定向至后端服务,后端响应授权确认页面完整url给前端,前端跳转deviceVerificationEndpoint.consentPage(DEVICE_CONSENT_PAGE_URI).errorResponseHandler(new ConsentAuthenticationFailureHandler()).deviceVerificationResponseHandler(new DeviceVerificationResponseHandler()))
http// 当未登录时访问认证端点时重定向至login页面.exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(new LoginTargetAuthenticationEntryPoint(SecurityConstants.LOGIN_PAGE_URI),new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));
在认证相关的过滤器链中添加登录响应处理,放行/oauth2/consent/parameters
接口
/*** 配置认证相关的过滤器链** @param http spring security核心配置类* @return 过滤器链* @throws Exception 抛出*/
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {// 开启CORS配置,配合下边的CorsConfigurationSource配置实现跨域配置http.cors(Customizer.withDefaults());// 禁用csrfhttp.csrf(AbstractHttpConfigurer::disable);http.authorizeHttpRequests((authorize) -> authorize// 放行静态资源.requestMatchers("/assets/**", "/webjars/**", "/login", "/getCaptcha", "/getSmsCaptcha", "/error", "/oauth2/consent/parameters").permitAll().anyRequest().authenticated())// 指定登录页面.formLogin(formLogin ->formLogin.loginProcessingUrl("/login")// 登录成功和失败改为写回json,不重定向了.successHandler(new LoginSuccessHandler()).failureHandler(new LoginFailureHandler(SecurityConstants.LOGIN_PAGE_URI)));// 在UsernamePasswordAuthenticationFilter拦截器之前添加验证码校验拦截器,并拦截POST的登录接口
// http.addFilterBefore(new CaptchaAuthenticationFilter("/login"), UsernamePasswordAuthenticationFilter.class);// 添加BearerTokenAuthenticationFilter,将认证服务当做一个资源服务,解析请求头中的tokenhttp.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults()).accessDeniedHandler(SecurityUtils::exceptionHandler).authenticationEntryPoint(SecurityUtils::exceptionHandler));http// 当未登录时访问认证端点时重定向至login页面.exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(new LoginTargetAuthenticationEntryPoint(SecurityConstants.LOGIN_PAGE_URI),new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));return http.build();
}
10. 开启两个过滤器链的CORS配置。
在authorizationServerSecurityFilterChain
和defaultSecurityFilterChain
中添加以下配置。
// 开启CORS配置,配合下边的CorsConfigurationSource配置实现跨域配置
http.cors(Customizer.withDefaults());
// 禁用csrf
http.csrf(AbstractHttpConfigurer::disable);
11. 注入CorsConfigurationSource
,实现跨域配置。
/*** 配置认证服务跨域过滤器** @return CorsConfigurationSource 实例*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();CorsConfiguration config = new CorsConfiguration();config.addAllowedHeader("*");config.addAllowedMethod("*");// 设置允许跨域的域名,如果允许携带cookie的话,路径就不能写*号, *表示所有的域名都可以跨域访问config.addAllowedOrigin("http://127.0.0.1:5173");// 设置跨域访问可以携带cookieconfig.setAllowCredentials(true);source.registerCorsConfiguration("/**", config);return source;
}
12. 添加前端相关页面的实现。
这里只放一下登录页面的代码,其它相关内容请前往代码仓库查看。
Vue前端对接认证服务请查看下边两篇文章查看。
《Spring Authorization Server入门 (十七) Vue项目使用授权码模式对接认证服务》
《Spring Authorization Server入门 (十八) Vue项目使用PKCE模式对接认证服务》
登录页面
LoginView.vue
<script setup lang="ts">
import { ref } from 'vue'
import router from '../../router'
import { getQueryString } from '@/util/GlobalUtils'
import { type CountdownProps, createDiscreteApi } from 'naive-ui'
import { getImageCaptcha, getSmsCaptchaByPhone, loginSubmit } from '@/api/Login'const { message } = createDiscreteApi(['message'])// 登录按钮加载状态
const loading = ref(false)// 定义登录提交的对象
const loginModel = ref({code: '',username: 'admin',password: '123456',loginType: '',captchaId: '',
})// 图形验证码的base64数据
let captchaImage = ref('')
// 图形验证码的值
let captchaCode = ''
// 是否开始倒计时
const counterActive = ref(false)
// 是否显示三方登录
const showThirdLogin = ref(true)// 生成二维码响应数据
const getQrCodeInfo = ref({qrCodeId: '',imageData: '',
})// 是否自动提交授权确认(二维码登录自动提交)
const autoConsentKey: string = 'autoConsent'/*** 获取图形验证码*/
const getCaptcha = () => {getImageCaptcha().then((result: any) => {if (result.success) {captchaCode = result.data.codecaptchaImage.value = result.data.imageDataloginModel.value.captchaId = result.data.captchaId} else {message.warning(result.message)}}).catch((e: any) => {message.warning(`获取图形验证码失败:${e.message}`)})
}/*** 提交登录表单* @param type 登录类型,passwordLogin是密码模式,smsCaptcha短信登录*/
const submitLogin = (type: string) => {loading.value = trueloginModel.value.loginType = typeloginSubmit(loginModel.value).then((result: any) => {if (result.success) {// 移除自动提交缓存localStorage.removeItem(autoConsentKey)// message.info(`登录成功`)let target = getQueryString('target')if (target) {window.location.href = target} else {// 跳转到首页router.push({ path: '/' })}} else {message.warning(result.message)}}).catch((e: any) => {message.warning(`登录失败:${e.message}`)}).finally(() => {loading.value = false})
}/*** 获取短信验证码*/
const getSmsCaptcha = () => {if (!loginModel.value.username) {message.warning('请先输入手机号.')return}if (!loginModel.value.code) {message.warning('请先输入验证码.')return}if (loginModel.value.code !== captchaCode) {message.warning('验证码错误.')return}getSmsCaptchaByPhone({ phone: loginModel.value.username }).then((result: any) => {if (result.success) {message.info(`获取短信验证码成功,固定为:${result.data}`)counterActive.value = true} else {message.warning(result.message)}}).catch((e: any) => {message.warning(`获取短信验证码失败:${e.message}`)})
}/*** 切换时更新验证码* @param name tab的名字*/
const handleUpdateValue = (name: string) => {// 二维码登录时隐藏三方登录showThirdLogin.value = name !== 'qrcode'if (!showThirdLogin.value) {} else {getCaptcha()// 切换账号登录或短信认证登录时填充默认的手机号/账号if (name === 'signup') {// 短信认证登录时loginModel.value.username = '17683906001'loginModel.value.password = ''} else {loginModel.value.username = 'admin'loginModel.value.password = '123456'}}
}/*** 倒计时结束*/
const onFinish = () => {counterActive.value = false
}/*** 倒计时显示内容*/
const renderCountdown: CountdownProps['render'] = ({hours,minutes,seconds,
}) => {return `${seconds}`
}/*** 根据类型发起OAuth2授权申请* @param type 三方OAuth2登录提供商类型*/
const thirdLogin = (type: string) => {window.location.href = `${import.meta.env.VITE_OAUTH_ISSUER}/oauth2/authorization/${type}`
}getCaptcha()
</script><template><header><imgalt="Vue logo"class="logo"src="../../assets/logo.svg"width="125"height="125"/><div class="wrapper"><HelloWorld msg="统一认证平台" /></div></header><main><n-card title=""><n-tabsdefault-value="signin"size="large"justify-content="space-evenly"@update:value="handleUpdateValue"><n-tab-pane name="signin" tab="账号登录"><n-form><n-form-item-row label="用户名"><n-inputv-model:value="loginModel.username"placeholder="手机号 / 邮箱"/></n-form-item-row><n-form-item-row label="密码"><n-inputv-model:value="loginModel.password"type="password"show-password-on="mousedown"placeholder="密码"/></n-form-item-row><n-form-item-row label="验证码"><n-input-group><n-inputv-model:value="loginModel.code"placeholder="请输入验证码"/><n-image@click="getCaptcha"width="130"height="34":src="captchaImage"preview-disabled/></n-input-group></n-form-item-row></n-form><n-buttontype="info":loading="loading"@click="submitLogin('passwordLogin')"blockstrong>登录</n-button></n-tab-pane><n-tab-pane name="signup" tab="短信登录"><n-form><n-form-item-row label="手机号"><n-inputv-model:value="loginModel.username"placeholder="手机号 / 邮箱"/></n-form-item-row><n-form-item-row label="验证码"><n-input-group><n-inputv-model:value="loginModel.code"placeholder="请输入验证码"/><n-image@click="getCaptcha"width="130"height="34":src="captchaImage"preview-disabled/></n-input-group></n-form-item-row><n-form-item-row label="验证码"><n-input-group><n-inputv-model:value="loginModel.password"placeholder="请输入验证码"/><n-buttontype="info"@click="getSmsCaptcha"style="width: 130px":disabled="counterActive">获取验证码<span v-if="counterActive">(<n-countdown:render="renderCountdown":on-finish="onFinish":duration="59 * 1000":active="counterActive"/>)</span></n-button></n-input-group></n-form-item-row></n-form><n-buttontype="info":loading="loading"@click="submitLogin('smsCaptcha')"blockstrong>登录</n-button></n-tab-pane><!-- <n-tab-pane name="qrcode" tab="扫码登录" style="text-align: center">--><!-- <div style="margin: 5.305px">--><!-- <n-image width="300" :src="getQrCodeInfo.imageData" preview-disabled />--><!-- </div>--><!-- </n-tab-pane>--></n-tabs><n-divider style="font-size: 80%; color: #909399">{{ showThirdLogin ? '其它登录方式' : '使用app扫描二维码登录' }}</n-divider><div class="other_login_icon" v-if="showThirdLogin"><IconGitee :size="32" @click="thirdLogin('gitee')" class="icon_item" /><imgwidth="36"height="36"@click="thirdLogin('github')"src="../../assets/GitHub-Mark.png"class="icon_item"/><imgwidth="28"height="28"@click="thirdLogin('wechat')"src="../../assets/wechat_login.png"class="icon_item"/></div></n-card></main>
</template><style scoped>
.other_login_icon {display: flex;align-items: center;justify-content: center;gap: 0 10px;position: relative;margin-top: -5px;
}.icon_item {cursor: pointer;
}header {line-height: 1.5;
}.logo {display: block;margin: 0 auto 2rem;
}@media (min-width: 1024px) {header {display: flex;place-items: center;padding-right: calc(var(--section-gap) / 2);}.logo {margin: 0 2rem 0 0;}header .wrapper {display: flex;place-items: flex-start;flex-wrap: wrap;}
}
</style>
GlobalUtils.ts
/*** 根据参数name获取地址栏的参数* @param name 地址栏参数的key* @returns key对用的值*/
export function getQueryString(name: string) {const reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)', 'i')const r = window.location.search.substring(1).match(reg)if (r != null) {return decodeURIComponent(r[2])}return null
}
api/Login.ts
import { base64Str } from '@/util/pkce'
import loginRequest from '../util/http/LoginRequest'/*** 从认证服务获取AccessToken* @param data 获取token入参* @returns 返回AccessToken对象*/
export function getToken(data: any) {const headers: any = {'Content-Type': 'application/x-www-form-urlencoded',}if (data.client_secret) {// 设置客户端的basic认证headers.Authorization = `Basic ${base64Str(`${data.client_id}:${data.client_secret}`)}`// 移除入参中的keydelete data.client_iddelete data.client_secret}// 可以设置为AccessToken的类型return loginRequest.post<any>({url: '/oauth2/token',data,headers,})
}/*** 获取图片验证码* @returns 返回图片验证码信息*/
export function getImageCaptcha() {return loginRequest.get<any>({url: '/getCaptcha',})
}/*** 提交登录表单* @param data 登录表单数据* @returns 登录状态*/
export function loginSubmit(data: any) {return loginRequest.post<any>({url: '/login',data,headers: {'Content-Type': 'application/x-www-form-urlencoded',},})
}/*** 根据手机号获取短信验证码* @param params 手机号json,会被转为QueryString* @returns 登录状态*/
export function getSmsCaptchaByPhone(params: any) {return loginRequest.get<any>({url: '/getSmsCaptcha',params,})
}/*** 获取授权确认页面相关数据* @param queryString 查询参数,地址栏参数* @returns 授权确认页面相关数据*/
export function getConsentParameters(queryString: string) {return loginRequest.get<any>({url: `/oauth2/consent/parameters${queryString}`,})
}/*** 提交授权确认* @param data 客户端、scope等* @param requestUrl 请求地址(授权码与设备码授权提交不一样)* @returns 是否确认成功*/
export function submitApproveScope(data: any, requestUrl: string) {return loginRequest.post<any>({url: requestUrl,data,headers: {'Content-Type': 'application/x-www-form-urlencoded',},})
}/*** 验证设备码* @param data user_code,设备码* @returns 是否确认成功*/
export function deviceVerification(data: any) {return loginRequest.post<any>({url: `/oauth2/device_verification`,data,headers: {'Content-Type': 'application/x-www-form-urlencoded',},})
}
测试
测试时一定要注意,访问前端的ip与axios请求的ip保持一致!!!axios初始化时确保有withCredentials: true
配置!!!
测试时一定要注意,访问前端的ip与axios请求的ip保持一致!!!axios初始化时确保有withCredentials: true
配置!!!
测试时一定要注意,访问前端的ip与axios请求的ip保持一致!!!axios初始化时确保有withCredentials: true
配置!!!
后端配置的前端相关地址的ip也要保持一致!!!
后端配置的前端相关地址的ip也要保持一致!!!
后端配置的前端相关地址的ip也要保持一致!!!
例如我下边的动图,访问前端的地址是127.0.0.1,那么axios请求后端的ip则应该都是127.0.0.1!
如果测试时想直接通过授权申请地址走流程那也应该要与上边的注意项保持一致,如果登录页面在后端配置的是127.0.0.1:5173/login
,那么访问授权申请时就是127.0.0.1:8080/oauth2/authorize
- 访问授权申请
http://127.0.0.1:8080/oauth2/authorize?response_type=code&client_id=pkce-message-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A5173%2FPkceRedirect&scope=message.read&code_challenge=kfis_wJYpmCAPO-Ap1Sc6GXyz_x2dhhMsm9FOA7eEWY&code_challenge_method=S256
- 登录成功以后重定向回授权申请地址,授权申请端点生成授权码并重定向至回调地址
到此为止,前后端分离已经基于Spring Session实现。
链接地址
代码仓库(老)
代码仓库(新)
本章代码分支(新)
Spring Session文档
《Spring Authorization Server入门 (十二) 实现授权码模式使用前后端分离的登录页面》
《Spring Authorization Server入门 (十五) 分离授权确认与设备码校验页面》
《Spring Authorization Server入门 (十七) Vue项目使用授权码模式对接认证服务》
《Spring Authorization Server入门 (十八) Vue项目使用PKCE模式对接认证服务》
附录
SecurityConstants
package com.example.constant;/*** security 常量类** @author vains*/
public class SecurityConstants {/*** 登录方式——短信验证码*/public static final String SMS_LOGIN_TYPE = "smsCaptcha";/*** 登录方式——账号密码登录*/public static final String PASSWORD_LOGIN_TYPE = "passwordLogin";/*** 权限在token中的key*/public static final String AUTHORITIES_KEY = "authorities";/*** 自定义 grant type —— 短信验证码*/public static final String GRANT_TYPE_SMS_CODE = "urn:ietf:params:oauth:grant-type:sms_code";/*** 自定义 grant type —— 短信验证码 —— 手机号的key*/public static final String OAUTH_PARAMETER_NAME_PHONE = "phone";/*** 自定义 grant type —— 短信验证码 —— 短信验证码的key*/public static final String OAUTH_PARAMETER_NAME_SMS_CAPTCHA = "sms_captcha";/*** 登录方式入参名*/public static final String LOGIN_TYPE_NAME = "loginType";/*** 验证码id入参名*/public static final String CAPTCHA_ID_NAME = "captchaId";/*** 验证码值入参名*/public static final String CAPTCHA_CODE_NAME = "code";/*** 授权确认页面地址*/public static final String LOGIN_PAGE_URI = "http://127.0.0.1:5173/login";/*** 授权确认页面地址*/public static final String DEVICE_ACTIVATED_URI = "http://127.0.0.1:5173/activated";/*** 授权确认页面地址*/public static final String DEVICE_ACTIVATE_URI = "http://127.0.0.1:5173/activate";/*** 授权确认页面地址*/public static final String CONSENT_PAGE_URI = "http://127.0.0.1:5173/consent";
}
AuthorizationConfig
package com.example.config;import com.example.authorization.DeviceClientAuthenticationConverter;
import com.example.authorization.DeviceClientAuthenticationProvider;
import com.example.authorization.handler.*;
import com.example.authorization.sms.SmsCaptchaGrantAuthenticationConverter;
import com.example.authorization.sms.SmsCaptchaGrantAuthenticationProvider;
import com.example.constant.SecurityConstants;
import com.example.util.SecurityUtils;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.*;
import java.util.stream.Collectors;/*** 认证配置* {@link EnableMethodSecurity} 开启全局方法认证,启用JSR250注解支持,启用注解 {@link Secured} 支持,* 在Spring Security 6.0版本中将@Configuration注解从@EnableWebSecurity, @EnableMethodSecurity, @EnableGlobalMethodSecurity* 和 @EnableGlobalAuthentication 中移除,使用这些注解需手动添加 @Configuration 注解* {@link EnableWebSecurity} 注解有两个作用:* 1. 加载了WebSecurityConfiguration配置类, 配置安全认证策略。* 2. 加载了AuthenticationConfiguration, 配置了认证信息。** @author vains*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true)
public class AuthorizationConfig {private static final String DEVICE_CONSENT_PAGE_URI = "/oauth2/consent/redirect";/*** 配置端点的过滤器链** @param http spring security核心配置类* @return 过滤器链* @throws Exception 抛出*/@Beanpublic SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,RegisteredClientRepository registeredClientRepository,AuthorizationServerSettings authorizationServerSettings) throws Exception {// 配置默认的设置,忽略认证端点的csrf校验OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);// 开启CORS配置,配合下边的CorsConfigurationSource配置实现跨域配置http.cors(Customizer.withDefaults());// 禁用csrfhttp.csrf(AbstractHttpConfigurer::disable);// 新建设备码converter和providerDeviceClientAuthenticationConverter deviceClientAuthenticationConverter =new DeviceClientAuthenticationConverter(authorizationServerSettings.getDeviceAuthorizationEndpoint());DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =new DeviceClientAuthenticationProvider(registeredClientRepository);http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)// 开启OpenID Connect 1.0协议相关端点.oidc(Customizer.withDefaults())// 设置自定义用户确认授权页.authorizationEndpoint(authorizationEndpoint ->authorizationEndpoint.consentPage(SecurityConstants.CONSENT_PAGE_URI).errorResponseHandler(new ConsentAuthenticationFailureHandler()).authorizationResponseHandler(new ConsentAuthorizationResponseHandler()))// 设置设备码用户验证url(自定义用户验证页).deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->deviceAuthorizationEndpoint.verificationUri(SecurityConstants.DEVICE_ACTIVATE_URI).deviceAuthorizationResponseHandler(new DeviceAuthorizationResponseHandler()))// 设置验证设备码用户确认页面.deviceVerificationEndpoint(deviceVerificationEndpoint ->// 设备码授权确认特殊处理,先重定向至后端服务,后端响应授权确认页面完整url给前端,前端跳转deviceVerificationEndpoint.consentPage(DEVICE_CONSENT_PAGE_URI).errorResponseHandler(new ConsentAuthenticationFailureHandler()).deviceVerificationResponseHandler(new DeviceVerificationResponseHandler())).clientAuthentication(clientAuthentication ->// 客户端认证添加设备码的converter和providerclientAuthentication.authenticationConverter(deviceClientAuthenticationConverter).authenticationProvider(deviceClientAuthenticationProvider));http// 当未登录时访问认证端点时重定向至login页面.exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(new LoginTargetAuthenticationEntryPoint(SecurityConstants.LOGIN_PAGE_URI),new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))// 处理使用access token访问用户信息端点和客户端注册端点.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults()));// 自定义短信认证登录转换器SmsCaptchaGrantAuthenticationConverter converter = new SmsCaptchaGrantAuthenticationConverter();// 自定义短信认证登录认证提供SmsCaptchaGrantAuthenticationProvider provider = new SmsCaptchaGrantAuthenticationProvider();http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)// 让认证服务器元数据中有自定义的认证方式.authorizationServerMetadataEndpoint(metadata -> metadata.authorizationServerMetadataCustomizer(customizer -> customizer.grantType(SecurityConstants.GRANT_TYPE_SMS_CODE)))// 添加自定义grant_type——短信认证登录.tokenEndpoint(tokenEndpoint -> tokenEndpoint.accessTokenRequestConverter(converter).authenticationProvider(provider));DefaultSecurityFilterChain build = http.build();// 从框架中获取provider中所需的beanOAuth2TokenGenerator<?> tokenGenerator = http.getSharedObject(OAuth2TokenGenerator.class);AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);OAuth2AuthorizationService authorizationService = http.getSharedObject(OAuth2AuthorizationService.class);// 以上三个bean在build()方法之后调用是因为调用build方法时框架会尝试获取这些类,// 如果获取不到则初始化一个实例放入SharedObject中,所以要在build方法调用之后获取// 在通过set方法设置进provider中,但是如果在build方法之后调用authenticationProvider(provider)// 框架会提示unsupported_grant_type,因为已经初始化完了,在添加就不会生效了provider.setTokenGenerator(tokenGenerator);provider.setAuthorizationService(authorizationService);provider.setAuthenticationManager(authenticationManager);return build;}/*** 配置认证相关的过滤器链** @param http spring security核心配置类* @return 过滤器链* @throws Exception 抛出*/@Beanpublic SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {// 开启CORS配置,配合下边的CorsConfigurationSource配置实现跨域配置http.cors(Customizer.withDefaults());// 禁用csrfhttp.csrf(AbstractHttpConfigurer::disable);http.authorizeHttpRequests((authorize) -> authorize// 放行静态资源.requestMatchers("/assets/**", "/webjars/**", "/login", "/getCaptcha", "/getSmsCaptcha", "/error", "/oauth2/consent/parameters").permitAll().anyRequest().authenticated())// 指定登录页面.formLogin(formLogin ->formLogin.loginProcessingUrl("/login")// 登录成功和失败改为写回json,不重定向了.successHandler(new LoginSuccessHandler(SecurityConstants.LOGIN_PAGE_URI)).failureHandler(new LoginFailureHandler(SecurityConstants.LOGIN_PAGE_URI)));// 在UsernamePasswordAuthenticationFilter拦截器之前添加验证码校验拦截器,并拦截POST的登录接口
// http.addFilterBefore(new CaptchaAuthenticationFilter("/login"), UsernamePasswordAuthenticationFilter.class);// 添加BearerTokenAuthenticationFilter,将认证服务当做一个资源服务,解析请求头中的tokenhttp.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults()).accessDeniedHandler(SecurityUtils::exceptionHandler).authenticationEntryPoint(SecurityUtils::exceptionHandler));http// 当未登录时访问认证端点时重定向至login页面.exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(new LoginTargetAuthenticationEntryPoint(SecurityConstants.LOGIN_PAGE_URI),new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));return http.build();}/*** 配置密码解析器,使用BCrypt的方式对密码进行加密和验证** @return BCryptPasswordEncoder*/@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}/*** 配置客户端Repository** @param jdbcTemplate db 数据源信息* @param passwordEncoder 密码解析器* @return 基于数据库的repository*/@Beanpublic RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) {// 基于db存储客户端,还有一个基于内存的实现 InMemoryRegisteredClientRepositoryJdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);RegisteredClient.Builder registeredClientBuilder = RegisteredClient.withId(UUID.randomUUID().toString())// 客户端id.clientId("messaging-client")// 客户端秘钥,使用密码解析器加密.clientSecret(passwordEncoder.encode("123456"))// 客户端认证方式,基于请求头的认证.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)// 配置资源服务器使用该客户端获取授权时支持的方式.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN).authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)// 授权码模式回调地址,oauth2.1已改为精准匹配,不能只设置域名,并且屏蔽了localhost,本机使用127.0.0.1访问.redirectUri("http://127.0.0.1:5173/OAuth2Redirect").redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc").redirectUri("https://www.baidu.com")// 该客户端的授权范围,OPENID与PROFILE是IdToken的scope,获取授权时请求OPENID的scope时认证服务会返回IdToken.scope(OidcScopes.OPENID).scope(OidcScopes.PROFILE)// 自定scope.scope("message.read").scope("message.write")// 客户端设置,设置用户需要确认授权.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build());// 初始化客户端RegisteredClient repositoryByClientId = registeredClientRepository.findByClientId(registeredClientBuilder.build().getClientId());if (repositoryByClientId != null) {registeredClientBuilder.id(repositoryByClientId.getId());registeredClientRepository.save(registeredClientBuilder.build());}// 设备码授权客户端RegisteredClient.Builder deviceClientBuilder = RegisteredClient.withId(UUID.randomUUID().toString()).clientId("device-message-client")// 公共客户端.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)// 设备码授权.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)// 自定scope.scope("message.read").scope("message.write");RegisteredClient byClientId = registeredClientRepository.findByClientId(deviceClientBuilder.build().getClientId());if (byClientId != null) {deviceClientBuilder.id(byClientId.getId());registeredClientRepository.save(deviceClientBuilder.build());}// PKCE客户端RegisteredClient.Builder pkceClientBuilder = RegisteredClient.withId(UUID.randomUUID().toString()).clientId("pkce-message-client")// 公共客户端.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)// 授权码模式,因为是扩展授权码流程,所以流程还是授权码的流程,改变的只是参数.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)// 授权码模式回调地址,oauth2.1已改为精准匹配,不能只设置域名,并且屏蔽了localhost,本机使用127.0.0.1访问.redirectUri("http://127.0.0.1:5173/PkceRedirect").redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc").clientSettings(ClientSettings.builder().requireProofKey(Boolean.TRUE).build())// 自定scope.scope("message.read").scope("message.write");RegisteredClient findPkceClient = registeredClientRepository.findByClientId(pkceClientBuilder.build().getClientId());if (findPkceClient != null) {pkceClientBuilder.id(findPkceClient.getId());registeredClientRepository.save(pkceClientBuilder.build());}return registeredClientRepository;}/*** 配置基于db的oauth2的授权管理服务** @param jdbcTemplate db数据源信息* @param registeredClientRepository 上边注入的客户端repository* @return JdbcOAuth2AuthorizationService*/@Beanpublic OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {// 基于db的oauth2认证服务,还有一个基于内存的服务实现InMemoryOAuth2AuthorizationServicereturn new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);}/*** 配置基于db的授权确认管理服务** @param jdbcTemplate db数据源信息* @param registeredClientRepository 客户端repository* @return JdbcOAuth2AuthorizationConsentService*/@Beanpublic OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {// 基于db的授权确认管理服务,还有一个基于内存的服务实现InMemoryOAuth2AuthorizationConsentServicereturn new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);}/*** 配置jwk源,使用非对称加密,公开用于检索匹配指定选择器的JWK的方法** @return JWKSource*/@Beanpublic JWKSource<SecurityContext> jwkSource() {KeyPair keyPair = generateRsaKey();RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();RSAKey rsaKey = new RSAKey.Builder(publicKey).privateKey(privateKey).keyID(UUID.randomUUID().toString()).build();JWKSet jwkSet = new JWKSet(rsaKey);return new ImmutableJWKSet<>(jwkSet);}/*** 生成rsa密钥对,提供给jwk** @return 密钥对*/private static KeyPair generateRsaKey() {KeyPair keyPair;try {KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");keyPairGenerator.initialize(2048);keyPair = keyPairGenerator.generateKeyPair();} catch (Exception ex) {throw new IllegalStateException(ex);}return keyPair;}/*** 配置jwt解析器** @param jwkSource jwk源* @return JwtDecoder*/@Beanpublic JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);}/*** 添加认证服务器配置,设置jwt签发者、默认端点请求地址等** @return AuthorizationServerSettings*/@Beanpublic AuthorizationServerSettings authorizationServerSettings() {return AuthorizationServerSettings.builder()/*设置token签发地址(http(s)://{ip}:{port}/context-path, http(s)://domain.com/context-path)如果需要通过ip访问这里就是ip,如果是有域名映射就填域名,通过什么方式访问该服务这里就填什么*/.issuer("http://172.20.10.3:8080").build();}/*** 自定义jwt,将权限信息放至jwt中** @return OAuth2TokenCustomizer的实例*/@Beanpublic OAuth2TokenCustomizer<JwtEncodingContext> oAuth2TokenCustomizer() {return context -> {// 检查登录用户信息是不是UserDetails,排除掉没有用户参与的流程if (context.getPrincipal().getPrincipal() instanceof UserDetails user) {// 获取申请的scopesSet<String> scopes = context.getAuthorizedScopes();// 获取用户的权限Collection<? extends GrantedAuthority> authorities = user.getAuthorities();// 提取权限并转为字符串Set<String> authoritySet = Optional.ofNullable(authorities).orElse(Collections.emptyList()).stream()// 获取权限字符串.map(GrantedAuthority::getAuthority)// 去重.collect(Collectors.toSet());// 合并scope与用户信息authoritySet.addAll(scopes);JwtClaimsSet.Builder claims = context.getClaims();// 将权限信息放入jwt的claims中(也可以生成一个以指定字符分割的字符串放入)claims.claim("authorities", authoritySet);// 放入其它自定内容// 角色、头像...}};}/*** 自定义jwt解析器,设置解析出来的权限信息的前缀与在jwt中的key** @return jwt解析器 JwtAuthenticationConverter*/@Beanpublic JwtAuthenticationConverter jwtAuthenticationConverter() {JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();// 设置解析权限信息的前缀,设置为空是去掉前缀grantedAuthoritiesConverter.setAuthorityPrefix("");// 设置权限信息在jwt claims中的keygrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);return jwtAuthenticationConverter;}/*** 配置认证服务跨域过滤器** @return CorsConfigurationSource 实例*/@Beanpublic CorsConfigurationSource corsConfigurationSource() {UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();CorsConfiguration config = new CorsConfiguration();config.addAllowedHeader("*");config.addAllowedMethod("*");// 设置允许跨域的域名,如果允许携带cookie的话,路径就不能写*号, *表示所有的域名都可以跨域访问config.addAllowedOrigin("http://127.0.0.1:5173");// 设置跨域访问可以携带cookieconfig.setAllowCredentials(true);source.registerCorsConfiguration("/**", config);return source;}}
AuthorizationController
package com.example.controller;import com.example.constant.SecurityConstants;
import com.example.model.Result;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.WebAttributes;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.*;import static com.example.constant.SecurityConstants.CONSENT_PAGE_URI;/*** 认证服务器相关自定接口** @author vains*/
@Controller
@RequiredArgsConstructor
public class AuthorizationController {private final RegisteredClientRepository registeredClientRepository;private final OAuth2AuthorizationConsentService authorizationConsentService;private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();@GetMapping("/activate")public String activate(@RequestParam(value = "user_code", required = false) String userCode) {if (userCode != null) {return "redirect:/oauth2/device_verification?user_code=" + userCode;}return "device-activate";}@GetMapping("/activated")public String activated() {return "device-activated";}@GetMapping(value = "/", params = "success")public String success() {return "device-activated";}@GetMapping("/login")public String login(Model model, HttpSession session) {Object attribute = session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);if (attribute instanceof AuthenticationException exception) {model.addAttribute("error", exception.getMessage());}return "login";}@ResponseBody@GetMapping("/user")public Map<String,Object> user(Principal principal) {if (!(principal instanceof JwtAuthenticationToken token)) {return Collections.emptyMap();}return token.getToken().getClaims();}@GetMapping(value = "/oauth2/consent")public String consent(Principal principal, Model model,@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,@RequestParam(OAuth2ParameterNames.SCOPE) String scope,@RequestParam(OAuth2ParameterNames.STATE) String state,@RequestParam(name = OAuth2ParameterNames.USER_CODE, required = false) String userCode) {// 获取consent页面所需的参数Map<String, Object> consentParameters = getConsentParameters(scope, state, clientId, userCode, principal);// 转至model中,让框架渲染页面consentParameters.forEach(model::addAttribute);return "consent";}@ResponseBody@GetMapping(value = "/oauth2/consent/parameters")public Result<Map<String, Object>> consentParameters(Principal principal,@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,@RequestParam(OAuth2ParameterNames.SCOPE) String scope,@RequestParam(OAuth2ParameterNames.STATE) String state,@RequestParam(name = OAuth2ParameterNames.USER_CODE, required = false) String userCode) {// 获取consent页面所需的参数Map<String, Object> consentParameters = getConsentParameters(scope, state, clientId, userCode, principal);return Result.success(consentParameters);}@SneakyThrows@ResponseBody@GetMapping(value = "/oauth2/consent/redirect")public Result<String> consentRedirect(HttpSession session,HttpServletRequest request,HttpServletResponse response,@RequestParam(OAuth2ParameterNames.SCOPE) String scope,@RequestParam(OAuth2ParameterNames.STATE) String state,@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,@RequestParam(name = OAuth2ParameterNames.USER_CODE, required = false) String userCode) {// 携带当前请求参数与nonceId重定向至前端页面UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(SecurityConstants.CONSENT_PAGE_URI).queryParam(OAuth2ParameterNames.SCOPE, UriUtils.encode(scope, StandardCharsets.UTF_8)).queryParam(OAuth2ParameterNames.STATE, UriUtils.encode(state, StandardCharsets.UTF_8)).queryParam(OAuth2ParameterNames.CLIENT_ID, clientId).queryParam(OAuth2ParameterNames.USER_CODE, userCode);String uriString = uriBuilder.build(Boolean.TRUE).toUriString();if (ObjectUtils.isEmpty(userCode) || !UrlUtils.isAbsoluteUrl(SecurityConstants.DEVICE_ACTIVATE_URI)) {// 不是设备码模式或者设备码验证页面不是前后端分离的,无需返回json,直接重定向this.redirectStrategy.sendRedirect(request, response, uriString);return null;}// 兼容设备码,需响应JSON,由前端进行跳转return Result.success(uriString);}/*** 根据授权确认相关参数获取授权确认与未确认的scope相关参数** @param scope scope权限* @param state state* @param clientId 客户端id* @param userCode 设备码授权流程中的用户码* @param principal 当前认证信息* @return 页面所需数据*/private Map<String, Object> getConsentParameters(String scope,String state,String clientId,String userCode,Principal principal) {// Remove scopes that were already approvedSet<String> scopesToApprove = new HashSet<>();Set<String> previouslyApprovedScopes = new HashSet<>();RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);if (registeredClient == null) {throw new RuntimeException("客户端不存在");}OAuth2AuthorizationConsent currentAuthorizationConsent =this.authorizationConsentService.findById(registeredClient.getId(), principal.getName());Set<String> authorizedScopes;if (currentAuthorizationConsent != null) {authorizedScopes = currentAuthorizationConsent.getScopes();} else {authorizedScopes = Collections.emptySet();}for (String requestedScope : StringUtils.delimitedListToStringArray(scope, " ")) {if (OidcScopes.OPENID.equals(requestedScope)) {continue;}if (authorizedScopes.contains(requestedScope)) {previouslyApprovedScopes.add(requestedScope);} else {scopesToApprove.add(requestedScope);}}Map<String, Object> parameters = new HashMap<>(7);parameters.put("clientId", registeredClient.getClientId());parameters.put("clientName", registeredClient.getClientName());parameters.put("state", state);parameters.put("scopes", withDescription(scopesToApprove));parameters.put("previouslyApprovedScopes", withDescription(previouslyApprovedScopes));parameters.put("principalName", principal.getName());parameters.put("userCode", userCode);if (StringUtils.hasText(userCode)) {parameters.put("requestURI", "/oauth2/device_verification");} else {parameters.put("requestURI", "/oauth2/authorize");}return parameters;}private static Set<ScopeWithDescription> withDescription(Set<String> scopes) {Set<ScopeWithDescription> scopeWithDescriptions = new HashSet<>();for (String scope : scopes) {scopeWithDescriptions.add(new ScopeWithDescription(scope));}return scopeWithDescriptions;}@Datapublic static class ScopeWithDescription {private static final String DEFAULT_DESCRIPTION = "UNKNOWN SCOPE - We cannot provide information about this permission, use caution when granting this.";private static final Map<String, String> scopeDescriptions = new HashMap<>();static {scopeDescriptions.put(OidcScopes.PROFILE,"This application will be able to read your profile information.");scopeDescriptions.put("message.read","This application will be able to read your message.");scopeDescriptions.put("message.write","This application will be able to add new messages. It will also be able to edit and delete existing messages.");scopeDescriptions.put("other.scope","This is another scope example of a scope description.");}public final String scope;public final String description;ScopeWithDescription(String scope) {this.scope = scope;this.description = scopeDescriptions.getOrDefault(scope, DEFAULT_DESCRIPTION);}}}