Oauth2.0 授权码模式认证流程

以授权码模式登录为例,源码讲述整个登录过程,授权码登录分两步,分别是获取code码,和获取token两步 原图地址 下面是对这些过程的简要介绍,清晰说明每个步骤的核心功能:

1. 客户端获取授权码 (code)

  • 核心功能:启动OAuth2授权流程
  • 操作 :用户访问授权URL https://auth-server/oauth/authorize?response_type=code&client_id=xx&redirect_uri=xx
  • 目的:通过授权服务器获取访问资源的临时凭证

2. FilterSecurityInterceptor#doFilter()调用invoke()

  • 安全机制:Spring Security过滤器链的核心拦截器
  • 作用:检查当前请求是否具有访问目标资源的权限
  • 流程:触发安全决策流程
java 复制代码
/**
 * @param filterInvocation 过滤器调用对象,包含请求、响应和过滤器链信息
 * @throws IOException      如果在处理请求或响应时发生 I/O 错误
 * @throws ServletException 如果在处理请求时发生 Servlet 异常
 */
public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
    // 检查该请求是否已经应用过此过滤器,并且配置了每个请求只进行一次安全检查
    if (isApplied(filterInvocation) && this.observeOncePerRequest) {
        // 如果该请求已经经过此过滤器的处理,并且用户希望每个请求只进行一次安全检查,
        // 则不再次进行安全检查,直接将请求传递给后续的过滤器链继续处理
        filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
        return;
    }
    // 如果这是该请求第一次被此过滤器处理,且配置了每个请求只进行一次安全检查
    if (filterInvocation.getRequest() != null && this.observeOncePerRequest) {
        // 标记该请求已经经过此过滤器的处理,避免后续重复进行安全检查
        filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
    }
    // 调用父类的 beforeInvocation 方法进行前置的安全检查,获取拦截器状态令牌
    // 该方法会根据配置的安全元数据和访问决策管理器来判断是否允许访问
    InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
    try {
        // 如果前置安全检查通过,将请求传递给后续的过滤器链继续处理
        filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
    } finally {
        // 在请求处理完成后,无论是否发生异常,都调用父类的 finallyInvocation 方法
        // 该方法用于进行一些清理和资源释放的操作
        super.finallyInvocation(token);
    }
    // 调用父类的 afterInvocation 方法进行后置处理
    // 此方法可以用于记录日志、更新安全上下文等操作
    super.afterInvocation(token, null);
}

3. AbstractSecurityInterceptor#beforeInvocation()

  • 权限决策:执行前置权限检查
  • 关键操作 :调用attemptAuthorization()方法
  • 目的:验证当前认证主体是否有权限访问目标资源
java 复制代码
/**
 * @param object 要进行安全检查的对象,通常是一个请求相关的对象
 * @return 拦截器状态令牌,用于后续的清理和状态恢复操作
 */
protected InterceptorStatusToken beforeInvocation(Object object) {
    //省略
    // 如果需要,进行身份验证操作,并返回经过身份验证的认证对象
    Authentication authenticated = authenticateIfRequired();
    // 尝试对请求对象进行授权检查
    // 如果授权失败,会抛出相应的异常
    attemptAuthorization(object, attributes, authenticated);
    //省略
}
/**
 * @return 经过身份验证的认证对象
 */
private Authentication authenticateIfRequired() {
    //省略
    // 如果需要重新认证,调用认证管理器对当前的认证对象进行认证
    authentication = this.authenticationManager.authenticate(authentication);
    // 返回重新认证后的认证对象
    //省略
    return authentication;
}
​
/**
 * @param object        要进行授权检查的对象,通常是一个请求或资源
 * @param attributes    与该对象关联的配置属性集合,这些属性定义了访问该对象所需的权限规则
 * @param authenticated 已认证的用户身份信息
 */
private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
                                  Authentication authenticated) {
    try {
        // 调用访问决策管理器的 decide 方法,根据已认证的用户信息、要访问的对象以及配置的属性进行授权决策
        // 访问决策管理器会根据具体的实现逻辑判断用户是否有权限访问该对象
        this.accessDecisionManager.decide(authenticated, object, attributes);
    }
    catch (AccessDeniedException ex) {
        // 如果访问决策管理器抛出 AccessDeniedException 异常,说明授权失败
        // 发布授权失败事件,通知系统授权操作未能成功,事件中包含了访问的对象、配置属性、用户身份信息以及异常信息
        publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, ex));
        // 重新抛出 AccessDeniedException 异常,以便后续的异常处理逻辑可以捕获并处理该异常
        throw ex;
    }
}

4. AffirmativeBased#decide()抛出AccessDeniedException

  • 投票决策:基于投票的访问控制决策
  • 结果:当拒绝票数 > 0 时抛出异常
  • 意义:表示当前请求未经授权
java 复制代码
/**
 * @param authentication   表示当前用户的认证信息,包含用户的身份和权限等信息。
 * @param object           表示要访问的目标对象,比如一个请求、一个方法调用等。
 * @param configAttributes 与要访问对象相关的配置属性集合,这些属性定义了访问该对象所需满足的条件。
 * @throws AccessDeniedException 如果最终判定用户没有权限访问该对象,则抛出此异常。
 */
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
        throws AccessDeniedException {
    // 用于记录投票结果为拒绝访问的投票器数量
    int deny = 0;
    // 遍历所有配置的访问决策投票器
    for (AccessDecisionVoter voter : getDecisionVoters()) {
        // 调用每个投票器的 vote 方法进行投票,获取投票结果
        int result = voter.vote(authentication, object, configAttributes);
        // 根据投票结果进行不同的处理
        switch (result) {
            // 如果投票结果为允许访问(ACCESS_GRANTED),则直接返回,表示用户有权限访问该对象
            case AccessDecisionVoter.ACCESS_GRANTED:
                return;
            // 如果投票结果为拒绝访问(ACCESS_DENIED),则拒绝访问的投票器数量加 1
            case AccessDecisionVoter.ACCESS_DENIED:
                deny++;
                break;
            // 如果投票结果为弃权(其他情况),则不做特殊处理,继续下一个投票器的投票
            default:
                break;
        }
    }
    // 如果有任何一个投票器的投票结果为拒绝访问(deny > 0),则抛出 AccessDeniedException 异常,表示用户没有权限访问该对象
    if (deny > 0) {
        throw new AccessDeniedException(
                this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
    }
    // 当执行到这里时,说明所有投票器都弃权了(没有投票为允许或拒绝)
    // 调用 checkAllowIfAllAbstainDecisions 方法来处理所有投票器都弃权的情况
    checkAllowIfAllAbstainDecisions();
}

5. ExceptionTranslationFilter处理异常

  • 异常处理链

    1. handleSpringSecurityException():捕获安全异常
    2. handleAccessDeniedException():处理访问拒绝异常
    3. sendStartAuthentication():触发认证流程
  • 目的:将未认证请求重定向到认证流程

java 复制代码
/**
 * @param request  当前的 HTTP 请求对象,包含客户端发送的请求信息。
 * @param response 当前的 HTTP 响应对象,用于向客户端发送响应信息。
 * @param chain    过滤器链对象,可用于将请求传递给后续的过滤器或 Servlet。
 * @param reason   身份验证异常对象,包含身份验证失败的原因信息。
 * @throws ServletException 如果在处理 Servlet 相关操作时发生异常。
 * @throws IOException      如果在进行 I/O 操作(如读写请求或响应)时发生异常。
 */
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                       AuthenticationException reason) throws ServletException, IOException {
    // SEC-112: 清除 SecurityContextHolder 中的认证信息,因为现有的认证信息不再被认为是有效的
    // 创建一个新的空安全上下文
    SecurityContext context = SecurityContextHolder.createEmptyContext();
    // 将新的空安全上下文设置到 SecurityContextHolder 中,以清除之前可能存在的认证信息
    SecurityContextHolder.setContext(context);
​
    // 保存当前的请求信息,以便在身份验证成功后可以恢复到当前请求
    // 例如,用户在未登录状态下访问了一个受保护的页面,登录成功后可以直接回到该页面
    this.requestCache.saveRequest(request, response);
​
    // 调用认证入口点的 commence 方法,开始身份验证流程
    // 认证入口点负责向客户端发送身份验证挑战,例如重定向到登录页面
    this.authenticationEntryPoint.commence(request, response, reason);
}

6. DelegatingAuthenticationEntryPoint#commence()

  • 认证入口:认证入口点的代理
  • 作用:根据请求类型选择合适的认证方式
  • 本例选择:Basic认证
java 复制代码
/**
 * 该方法用于根据请求匹配情况,选择合适的认证入口点(AuthenticationEntryPoint)来处理身份验证异常,
 * 并启动身份验证流程。它会遍历所有配置的请求匹配器(RequestMatcher),
 * 若找到匹配的请求,则使用对应的认证入口点;若未找到匹配的请求,则使用默认的认证入口点。
 *
 * @param request        当前的 HTTP 请求对象,包含客户端发送的请求信息。
 * @param response       当前的 HTTP 响应对象,用于向客户端发送响应信息。
 * @param authException  身份验证异常对象,包含身份验证失败的原因信息。
 * @throws IOException      如果在进行 I/O 操作(如读写请求或响应)时发生异常。
 * @throws ServletException 如果在处理 Servlet 相关操作时发生异常。
 */
public void commence(HttpServletRequest request, HttpServletResponse response,
                     AuthenticationException authException) throws IOException, ServletException {
    // 遍历所有配置的请求匹配器和对应的认证入口点的映射
    for (RequestMatcher requestMatcher : this.entryPoints.keySet()) {
        // 记录调试日志,表明正在尝试使用当前的请求匹配器进行匹配
        logger.debug(LogMessage.format("Trying to match using %s", requestMatcher));
        // 检查当前请求是否与该请求匹配器相匹配
        if (requestMatcher.matches(request)) {
            // 如果匹配成功,从映射中获取对应的认证入口点
            AuthenticationEntryPoint entryPoint = this.entryPoints.get(requestMatcher);
            // 记录调试日志,表明已找到匹配的认证入口点,即将执行该入口点的 commence 方法
            logger.debug(LogMessage.format("Match found! Executing %s", entryPoint));
            // 调用匹配到的认证入口点的 commence 方法,启动身份验证流程
            entryPoint.commence(request, response, authException);
            // 匹配并处理完成后,直接返回,不再继续遍历其他请求匹配器
            return;
        }
    }
    // 若遍历完所有请求匹配器都未找到匹配的,记录调试日志,表明使用默认的认证入口点
    logger.debug(LogMessage.format("No match found. Using default entry point %s", this.defaultEntryPoint));
    // 没有找到匹配的认证入口点,使用默认的认证入口点来处理身份验证异常,启动身份验证流程
    this.defaultEntryPoint.commence(request, response, authException);
}

7. BasicAuthenticationEntryPoint#commence()

  • 基本认证响应

    • 设置HTTP状态码401 (Unauthorized)
    • 添加Header:WWW-Authenticate: Basic realm="xxx"
  • 效果:要求客户端提供用户名/密码凭证

8. 客户端输入账号密码

  • 用户交互:用户提供认证凭证

  • 技术实现

    • 浏览器弹出认证对话框
    • 或API请求携带Authorization: Basic base64(username:password)

9. BasicAuthenticationFilter#doFilterInternal()

  • 凭证提取

    • 拦截请求
    • 解析Authorization
    • 解码Base64凭证
  • 输出 :生成未认证的UsernamePasswordAuthenticationToken

10. ProviderManager#authenticate()

  • 认证调度:认证管理器

  • 流程

    • 遍历已配置的AuthenticationProvider
    • 找到支持UsernamePasswordAuthenticationToken的提供者
    • 本例选择:PasswordAuthenticationProvider

11. PasswordAuthenticationProvider认证(重写了)

  • 认证执行

    1. retrieveUser():加载用户详情
    2. additionalAuthenticationChecks():验证密码
  • 结果

    • 成功:返回完全认证的Authentication对象
    • 失败:抛出BadCredentialsException

13. AuthorizationEndpoint#authorize()

  • 授权处理:OAuth2授权端点控制器

  • 流程

    • 验证客户端信息
    • 检查用户是否已授权
    • 生成授权码

14. AuthorizationCodeServices#createAuthorizationCode()

  • 授权码生成

    • 创建唯一的授权码 (如:5F8e9C0d-3a7b-4f9d-8c1e-6f3a9b8d7c2e)
    • 将授权码与用户/客户端信息关联存储
  • 存储 :通常使用内存或Redis存储{code: user_info}映射

15. 携带code重定向

  • 流程完成

    • 重定向到redirect_uri?code=生成的授权码
    • 示例:https://client.com/callback?code=5F8e9C0d-3a7b-4f9d-8c1e-6f3a9b8d7c2e
  • 后续:客户端用code交换access_token

1.2.2 获取token流程

原图

以下是基于OAuth2授权码模式中客户端使用授权码获取访问令牌的完整时序图,包含了关键步骤和逻辑补充:

关键步骤源码解析(带注释)

1. TokenEndpoint#postAccessToken()
java 复制代码
@RequestMapping(value = "/oauth/token", method = RequestMethod.POST)
public ResponseEntity<OAuth2AccessToken> postAccessToken(
        Principal principal, @RequestParam Map<String, String> parameters) {
    
    // 验证客户端身份
    String clientId = getClientId(principal);
    ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
    
    // 创建令牌请求
    TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);
    
    // 验证授权类型
    if (StringUtils.hasText(parameters.get("grant_type"))) {
        tokenRequest.setGrantType(parameters.get("grant_type"));
    }
    
    // 验证scope范围
    if (parameters.containsKey("scope")) {
        tokenRequest.setScope(StringUtils.commaDelimitedListToSet(parameters.get("scope")));
    }
    
    // 委托给TokenGranter颁发令牌
    OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
    
    return getResponse(token);
}
2. AbstractTokenGranter#grant()
java 复制代码
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
    // 检查授权类型是否匹配
    if (!this.grantType.equals(grantType)) {
        return null;
    }
    
    // 获取客户端ID
    String clientId = tokenRequest.getClientId();
    
    // 加载客户端详情
    ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
    
    // 验证授权类型
    validateGrantType(grantType, client);
    
    // 记录令牌发放日志
    logger.debug("Getting access token for: " + clientId);
    
    // 获取OAuth2认证对象(由子类实现)
    return getAccessToken(client, tokenRequest);
}
3. AuthorizationCodeTokenGranter#getOAuth2Authentication()
java 复制代码
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
    // 从请求参数获取授权码
    String authorizationCode = tokenRequest.getRequestParameters().get("code");
    // 获取重定向URI
    String redirectUri = tokenRequest.getRequestParameters().get("redirect_uri");
    
    if (authorizationCode == null) {
        throw new InvalidRequestException("An authorization code must be supplied");
    }
    
    // 消费授权码(验证并删除)
    OAuth2Authentication storedAuth = authorizationCodeServices.consumeAuthorizationCode(authorizationCode);
    
    // 验证重定向URI
    if (redirectUri != null && !redirectUri.equals(storedAuth.getAuthorizationRequest().getRedirectUri())) {
        throw new RedirectMismatchException("Redirect URI mismatch");
    }
    
    // 验证客户端ID
    String clientId = storedAuth.getAuthorizationRequest().getClientId();
    if (clientId == null || !clientId.equals(client.getClientId())) {
        throw new InvalidClientException("Client ID mismatch");
    }
    
    return storedAuth;
}
4. AuthorizationCodeServices#consumeAuthorizationCode()
java 复制代码
public OAuth2Authentication consumeAuthorizationCode(String code) throws InvalidGrantException {
    // 从存储中移除授权码
    OAuth2Authentication auth = this.authorizationCodeStore.remove(code);
    
    if (auth == null) {
        // 授权码不存在或已使用
        throw new InvalidGrantException("Invalid authorization code: " + code);
    }
    
    return auth;
}
5. DefaultTokenServices#createAccessToken()
java 复制代码
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) {
    // 检查是否已存在有效令牌
    OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
    if (existingAccessToken != null) {
        if (hasTokenExpired(existingAccessToken)) {
            // 令牌过期时删除关联的刷新令牌
            if (existingAccessToken.getRefreshToken() != null) {
                tokenStore.removeRefreshToken(existingAccessToken.getRefreshToken());
            }
            tokenStore.removeAccessToken(existingAccessToken);
        } else {
            // 返回现有有效令牌
            return existingAccessToken;
        }
    }
    
    // 创建新的访问令牌
    OAuth2AccessToken accessToken = createAccessToken(authentication, existingAccessToken);
    tokenStore.storeAccessToken(accessToken, authentication);
    
    // 创建/更新刷新令牌
    OAuth2RefreshToken refreshToken = accessToken.getRefreshToken();
    if (refreshToken != null) {
        tokenStore.storeRefreshToken(refreshToken, authentication);
    }
    
    return accessToken;
}
​
private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2AccessToken existingToken) {
    // 使用UUID生成令牌值
    String tokenValue = UUID.randomUUID().toString();
    
    // 创建基础令牌
    DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(tokenValue);
    
    // 设置有效期
    int validitySeconds = getAccessTokenValiditySeconds(authentication.getAuthorizationRequest());
    if (validitySeconds > 0) {
        token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
    }
    
    // 设置刷新令牌
    if (existingToken != null && existingToken.getRefreshToken() != null) {
        token.setRefreshToken(existingToken.getRefreshToken());
    } else {
        token.setRefreshToken(createRefreshToken(authentication));
    }
    
    // 应用令牌增强器
    return tokenEnhancer != null ? tokenEnhancer.enhance(token, authentication) : token;
}
相关推荐
tangdou3690986554 小时前
可怕!我的Nodejs系统因为日志打印了Error 对象就崩溃了😱 Node.js System Crashed Because of Logging
前端·javascript·后端
BlackQid4 小时前
深入理解指针Part4——字符、数组与函数指针变量
c++·后端
Postkarte不想说话4 小时前
FreeBSD配置Jails
后端
但求无bug4 小时前
Java中计算两个日期的相差时间
后端
小傅哥4 小时前
新项目完结,Ai Agent 智能体、拖拉拽编排!
前端·后端
廖广杰4 小时前
java虚拟机-如何通过GC日志判断晋升失败(Promotion Failed)
后端
自由的疯4 小时前
优雅的代码java
java·后端·面试
gensue4 小时前
【征文计划】深度解析Rokid UXR 2.0 SDK:Unity开发者的空间计算开发利器
后端
武子康4 小时前
大数据-124 - Flink State:Keyed State、Operator State KeyGroups 工作原理 案例解析
大数据·后端·flink