Spring Authorization Server基于Spring Session的前后端分离实现

本章实现的效果

  1. 授权码模式下使用前后端分离的登录页面和授权确认页面。
  2. 设备码模式(Device Flow)下使用前后端分离的登录页面、授权确认页面、用户码(user_code)验证页面和用户码(user_code)验证成功页面。

实现步骤

  1. 添加Spring Session Data Redis依赖。
  2. 自定义登录成功、失败处理,在前后端分离时响应json,否则重定向。
  3. 自定义重定向至登录页面处理,在重定向至登录页面时将当前请求的请求路径挂在登录页面的target参数中,前端登录成功后直接跳转至target中挂的url。
  4. 自定义授权确认成功、失败处理,在前后端分离时响应json,否则重定向。
  5. 自定义获取设备码验证地址响应处理,前后端分离时直接返回前端的验证地址,否则重定向。
  6. 自定义校验设备码验证成功处理,在前后端分离时响应json,否则重定向。
  7. 授权接口中添加授权确认信息查询接口给分离前端的授权确认页面使用。
  8. 授权接口中添加设备码模式授权确认中转接口,如果是前后端分离则响应json,否则重定向。
  9. 在AuthorizationConfig中将自定义内容通过提供的配置入口添加配置。
  10. 开启两个过滤器链的CORS配置。
  11. 注入CorsConfigurationSource,实现跨域配置。
  12. 添加前端相关页面的实现。

编码

常量类在最后

1. 添加Spring Session Data Redis依赖。

xml 复制代码
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

2. 自定义登录成功、失败处理

在处理类内添加了前后端分离和不分离的适配,根据相关地址的配置自适应。

登录成功处理

com.example.authorization.handler包下添加LoginSuccessHandler类。

java 复制代码
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
    @SneakyThrows
    public 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类。

java 复制代码
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
    @SneakyThrows
    public 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类。

java 复制代码
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);
    }

    @Override
    public 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)时未登录并且设备码验证页面是前后端分离的那种则写回json
            Result<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类。

java 复制代码
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();

    @Override
    public 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是完整的地址,则响应json
            Result<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 encoded
        return uriBuilder.build(true).toUriString();

    }

}
授权确认失败处理

com.example.authorization.handler包下添加ConsentAuthenticationFailureHandler类。

java 复制代码
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 {

    @Override
    public 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: state
            message = 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类。

java 复制代码
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();

    @Override
    public 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 URI
        String 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_code
        String 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类。

java 复制代码
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 {

    @Override
    public 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中添加并修改。

java 复制代码
@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 approved
    Set<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中添加。

java 复制代码
@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配置在最后的附录中。

配置授权确认、获取设备码响应和设备码验证自定义处理,配置重定向至登录的自定义处理。
java 复制代码
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接口
java 复制代码
/**
 * 配置认证相关的过滤器链
 *
 * @param http spring security核心配置类
 * @return 过滤器链
 * @throws Exception 抛出
 */
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    // 开启CORS配置,配合下边的CorsConfigurationSource配置实现跨域配置
    http.cors(Customizer.withDefaults());
    // 禁用csrf
    http.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,将认证服务当做一个资源服务,解析请求头中的token
    http.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配置。

authorizationServerSecurityFilterChaindefaultSecurityFilterChain中添加以下配置。

java 复制代码
// 开启CORS配置,配合下边的CorsConfigurationSource配置实现跨域配置
http.cors(Customizer.withDefaults());
// 禁用csrf
http.csrf(AbstractHttpConfigurer::disable);

11. 注入CorsConfigurationSource,实现跨域配置。

java 复制代码
/**
 * 配置认证服务跨域过滤器
 *
 * @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");
    // 设置跨域访问可以携带cookie
    config.setAllowCredentials(true);
    source.registerCorsConfiguration("/**", config);
    return source;
}

12. 添加前端相关页面的实现。

这里只放一下登录页面的代码,其它相关内容请前往代码仓库查看。

Vue前端对接认证服务请查看下边两篇文章查看。

Spring Authorization Server入门 (十七) Vue项目使用授权码模式对接认证服务

Spring Authorization Server入门 (十八) Vue项目使用PKCE模式对接认证服务

登录页面

LoginView.vue

js 复制代码
<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.code
        captchaImage.value = result.data.imageData
        loginModel.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 = true
  loginModel.value.loginType = type
  loginSubmit(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>
    <img
      alt="Vue logo"
      class="logo"
      src="../../assets/logo.svg"
      width="125"
      height="125"
    />

    <div class="wrapper">
      <HelloWorld msg="统一认证平台" />
    </div>
  </header>

  <main>
    <n-card title="">
      <n-tabs
        default-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-input
                v-model:value="loginModel.username"
                placeholder="手机号 / 邮箱"
              />
            </n-form-item-row>
            <n-form-item-row label="密码">
              <n-input
                v-model:value="loginModel.password"
                type="password"
                show-password-on="mousedown"
                placeholder="密码"
              />
            </n-form-item-row>
            <n-form-item-row label="验证码">
              <n-input-group>
                <n-input
                  v-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-button
            type="info"
            :loading="loading"
            @click="submitLogin('passwordLogin')"
            block
            strong
          >
            登录
          </n-button>
        </n-tab-pane>
        <n-tab-pane name="signup" tab="短信登录">
          <n-form>
            <n-form-item-row label="手机号">
              <n-input
                v-model:value="loginModel.username"
                placeholder="手机号 / 邮箱"
              />
            </n-form-item-row>
            <n-form-item-row label="验证码">
              <n-input-group>
                <n-input
                  v-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-input
                  v-model:value="loginModel.password"
                  placeholder="请输入验证码"
                />
                <n-button
                  type="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-button
            type="info"
            :loading="loading"
            @click="submitLogin('smsCaptcha')"
            block
            strong
          >
            登录
          </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" />
        <img
          width="36"
          height="36"
          @click="thirdLogin('github')"
          src="../../assets/GitHub-Mark.png"
          class="icon_item"
        />
        <img
          width="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

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

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}`)}`
    // 移除入参中的key
    delete data.client_id
    delete 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',
    },
  })
}

测试

待续...

分析

待续...

附录

待续...

相关推荐
doiito3 分钟前
微服务资源优化新思路:Sablier + Podman 实现按需启动与自动休眠
后端·微服务·性能优化
接着奏乐接着舞。18 分钟前
【2026年7月最新】69道RAG面试题
前端·人工智能·后端·aigc·embedding·rag
HUMHSX21 分钟前
Vue 组件通信核心知识点梳理
javascript·vue.js·ecmascript
edwarddamon25 分钟前
Spring Cloud Gateway 全链路 traceId 传递方案
后端
神奇小汤圆26 分钟前
我们如何在 Elasticsearch 上构建一个持久 agent 记忆层,实现 0.89 召回率和零租户泄漏
后端
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(二十六):icon 组件封装
前端·vue.js·typescript
李明卫杭州1 小时前
Vue2 & Vue3 中 Router `props` 配置详解:彻底摆脱 `$route` 依赖
前端·vue.js·前端框架
掘金一周1 小时前
裁员了,公司很爽快,赔偿一分没少 | 沸点周刊 7.8
前端·人工智能·后端
神奇小汤圆1 小时前
MySQL 5.7 频繁被 Systemd 杀死:一次惊心动魄的故障排查之旅
后端
熊猫钓鱼>_>1 小时前
2026 前端的三场底层革命:当 Vue、React Native 和 Three.js 同时去掉中间层
前端·javascript·vue.js·react native·架构·webgl·three.js