Skip to content

OAuth2.0 + CAS 统一认证集成:从 Filter 鉴权到 Token 管理的完整方案

作者: 必码 | bima.cc


前言

在企业级软件开发领域,统一认证(Unified Authentication)一直是一个核心且不可回避的基础设施问题。随着微服务架构的普及和企业数字化转型的深入,一个组织内部往往会同时运行数十乃至上百个独立的应用系统——从传统的 OA 办公系统、ERP 企业资源计划系统,到现代化的 CRM 客户关系管理系统、BI 商业智能平台,再到各类移动端应用和小程序。每一个系统都需要对用户进行身份认证和权限控制,如果每个系统都独立维护一套用户体系和认证逻辑,不仅会造成严重的用户体验割裂(用户需要记住多套账号密码),更会给 IT 运维团队带来难以承受的管理负担和安全风险。

统一认证的核心目标是实现"一次登录,处处通行"——即单点登录(Single Sign-On,SSO)。用户只需要在一个统一的认证中心完成一次身份验证,就可以无缝访问所有被授权的应用系统,无需重复登录。这不仅是用户体验的提升,更是企业信息安全治理的基础设施。

在众多 SSO 解决方案中,CAS(Central Authentication Service)作为 Apereo 基金会旗下的开源项目,凭借其成熟稳定的架构、丰富的协议支持(CAS Protocol、SAML、OAuth2.0、OpenID Connect)以及庞大的社区生态,一直是企业级 SSO 方案的首选之一。而 OAuth2.0 作为当今互联网最广泛使用的授权框架,其授权码模式(Authorization Code Grant)更是被业界公认为最安全的 OAuth2.0 授权方式,特别适合用于 Web 应用和服务端应用之间的认证集成。

本文基于 smart-scaffold 企业级快速开发脚手架项目中的实际集成经验,从零开始、由浅入深地完整解析 OAuth2.0 + CAS 统一认证集成的技术方案。我们将从需求分析出发,深入 OAuth2.0 授权码模式的每一个技术细节,然后逐步剖析 OAuthFilter 鉴权过滤器、TokenRequestWrapper 请求包装器、FilterConfig 过滤器配置等核心组件的设计与实现,最后覆盖 CAS Server 端配置和安全最佳实践。

无论你是一名正在规划企业统一认证方案的技术架构师,还是一名需要将现有系统接入 CAS SSO 的后端开发工程师,抑或是一名对 OAuth2.0 协议实现细节感兴趣的技术爱好者,本文都将为你提供一份详尽、可操作、可直接落地参考的技术指南。


第一章 统一认证需求分析

1.1 企业级应用认证统一化背景

在企业 IT 建设的早期阶段,各个业务系统通常是独立建设、独立部署的。每个系统都有自己的用户表、密码策略、登录页面和会话管理机制。这种"烟囱式"的建设模式在业务初期看似高效,但随着系统数量的增长和业务复杂度的提升,一系列严峻的问题逐渐暴露出来。

第一,用户体验严重割裂。 一个企业员工在日常工作中可能需要同时使用 5-10 个不同的业务系统。如果每个系统都有独立的账号密码,用户不仅需要记忆大量的凭证信息,还需要频繁地在不同系统之间切换登录。根据行业调研数据,一个大型企业中,IT 帮助台收到的工单中有超过 30% 与密码重置相关——这不仅是 IT 资源的巨大浪费,更是员工生产力的严重损耗。

第二,安全管理存在重大隐患。 当用户需要在多个系统中维护多套密码时,出于"人性"的考虑,往往会选择在所有系统中使用相同的密码,或者将密码写在便签纸上贴在显示器旁边。这种行为使得整个企业的安全防线形同虚设——一旦某一个系统的密码被泄露,攻击者就可以利用相同的凭证尝试登录其他所有系统,形成所谓的"横向移动"攻击。

第三,运维管理成本高昂。 当一个员工入职时,IT 管理员需要在所有相关系统中为其创建账号;当员工调岗时,需要在多个系统中调整其权限;当员工离职时,需要在所有系统中及时注销其账号。任何一个环节的遗漏都可能导致安全漏洞。在缺乏统一认证体系的企业中,"僵尸账号"(离职员工仍然可以访问的系统账号)是一个普遍存在的安全隐患。

第四,合规审计面临挑战。 随着《网络安全法》、《数据安全法》、《个人信息保护法》等法律法规的实施,企业面临着越来越严格的合规要求。统一的认证审计日志是满足合规要求的基础设施之一——它能够清晰地记录"谁在什么时间从什么位置访问了什么系统"。但在分散的认证体系下,汇总和分析这些审计日志几乎是一项不可能完成的任务。

第五,新系统接入成本高。 每次开发新的业务系统时,开发团队都需要从零开始实现用户管理、密码加密、会话管理等基础功能。这不仅浪费了大量的开发资源,而且由于各团队的实现水平参差不齐,很容易引入安全漏洞。

正是基于以上痛点,企业级应用认证统一化成为了 IT 架构演进中的一个必然趋势。统一认证体系的核心价值可以概括为以下几点:

维度分散认证统一认证
用户体验多次登录,体验割裂一次登录,处处通行
安全管理密码策略不一致,风险高统一策略,集中管控
运维成本每个系统独立维护集中管理,自动化流程
合规审计日志分散,难以汇总统一日志,全景审计
开发效率重复造轮子标准化接入,快速集成

1.2 CAS 作为 OAuth2.0 授权服务器

CAS(Central Authentication Service)最初由耶鲁大学开发,后来捐赠给 Apereo 基金会,是目前最成熟、最广泛使用的开源 SSO 解决方案之一。CAS 的核心设计理念是"认证集中化,授权分布化"——所有的身份验证都在 CAS Server 上统一完成,而各个业务应用(CAS Client)只需要信任 CAS Server 的认证结果即可。

在协议支持方面,CAS 经历了从最初的 CAS Protocol v1/v2/v3 到 SAML 1.1/2.0,再到 OAuth2.0 和 OpenID Connect 的演进。这种多协议支持的能力使得 CAS 不仅能够服务于传统的 Web 应用,还能够满足现代移动应用、单页应用(SPA)和微服务架构的认证需求。

选择 CAS 作为 OAuth2.0 授权服务器有以下几个关键优势:

(1)协议兼容性强。 CAS 同时支持 CAS Protocol、OAuth2.0 和 OpenID Connect,这意味着你可以在同一个 CAS Server 上同时服务于不同类型的客户端。例如,传统的 Java Web 应用可以使用 CAS Protocol 进行 SSO,而移动端应用和第三方系统可以通过 OAuth2.0 进行授权接入。

(2)多因素认证(MFA)支持。 CAS 内置了对多种第二因素认证方式的支持,包括 TOTP(基于时间的一次性密码)、SMS 短信验证码、邮件验证码、Duo Security、YubiKey 等。在安全要求较高的场景下,可以灵活地配置 MFA 策略。

(3)丰富的身份源集成。 CAS 支持从多种后端数据源进行用户认证,包括 LDAP/Active Directory、数据库(JDBC)、JSON 文件、REST API、Shiro、Spring Security 等。这意味着 CAS 可以无缝地对接企业现有的用户目录,无需进行大规模的用户数据迁移。

(4)可扩展的授权策略。 CAS 支持基于属性的访问控制(ABAC)、基于角色的访问控制(RBAC)以及自定义的授权策略。你可以根据不同的应用配置不同的授权规则,实现精细化的权限控制。

(5)高可用和集群部署。 CAS 支持通过 Hazelcast、Redis、Memcached 等分布式缓存实现 Ticket Registry 的集群共享,支持通过 Tomcat Cluster、Nginx 负载均衡等方式实现水平扩展,满足企业级的高可用要求。

在 smart-scaffold 项目中,我们选择 CAS 作为 OAuth2.0 授权服务器,主要基于以下考虑:

  • 项目已有 CAS Server 部署,且运行稳定
  • 需要同时支持 Web SSO 和 API OAuth2.0 认证
  • 需要与现有的 LDAP 用户目录集成
  • 未来需要支持移动端应用的 OAuth2.0 接入

1.3 多应用单点登录(SSO)架构

单点登录(Single Sign-On,SSO)是统一认证体系最核心的功能。在 CAS + OAuth2.0 的架构下,SSO 的实现原理如下:

SSO 的核心机制:TGT(Ticket Granting Ticket)

当用户第一次通过 CAS Server 完成身份认证后,CAS Server 会在自己的服务端创建一个 TGT(Ticket Granting Ticket),并将 TGT 的 ID 以 Cookie 的形式(默认名称为 TGC,即 Ticket Granting Cookie)写入用户的浏览器。这个 Cookie 的作用域设置为 CAS Server 的域名。

当用户访问第二个应用时,该应用会将用户重定向到 CAS Server。此时,CAS Server 会检查浏览器中是否携带了有效的 TGC Cookie。如果存在且有效,CAS Server 就会直接为该应用签发一个 Service Ticket(ST),无需用户再次输入用户名和密码——这就是"单点登录"的本质。

┌──────────┐     ┌──────────┐     ┌──────────┐
│  浏览器   │     │ CAS Server│     │ 应用系统  │
└────┬─────┘     └────┬─────┘     └────┬─────┘
     │  1.访问应用A    │                │
     │───────────────>│                │
     │  2.重定向到CAS  │                │
     │<───────────────│                │
     │  3.显示登录页   │                │
     │───────────────>│                │
     │  4.提交凭证     │                │
     │───────────────>│                │
     │  5.认证成功     │                │
     │  (创建TGT+TGC) │                │
     │<───────────────│                │
     │  6.重定向回应用A(携带ST)         │
     │<───────────────│                │
     │  7.验证ST       │                │
     │────────────────────────────────>│
     │  8.返回用户信息 │                │
     │<────────────────────────────────│
     │                │                │
     │  === 访问应用B ===              │
     │  9.访问应用B    │                │
     │────────────────────────────────>│
     │ 10.重定向到CAS  │                │
     │<────────────────────────────────│
     │ 11.携带TGC访问CAS│               │
     │───────────────>│                │
     │ 12.验证TGC有效  │                │
     │ (无需再次登录)  │                │
     │ 13.重定向回应用B(携带新ST)       │
     │<───────────────│                │
     │ 14.验证ST       │                │
     │────────────────────────────────>│
     │ 15.返回用户信息 │                │
     │<────────────────────────────────│

多应用场景下的架构设计要点:

在 smart-scaffold 项目中,我们的 SSO 架构遵循以下设计原则:

  1. CAS Server 独立部署: CAS Server 作为独立的认证服务部署,不与任何业务应用耦合。这使得 CAS Server 可以独立升级、独立扩容,不影响业务系统的正常运行。

  2. 所有应用共享同一个 CAS Server: 无论是前端渲染的 Web 应用,还是纯 API 服务,都统一接入同一个 CAS Server。这确保了用户只需要一次登录就能访问所有系统。

  3. OAuth2.0 作为统一协议: 虽然传统 CAS Protocol 也能实现 SSO,但我们选择 OAuth2.0 作为统一协议,原因是 OAuth2.0 是业界标准协议,具有更好的生态兼容性,特别适合 API 认证场景。

  4. Token 中继(Token Relay): 在微服务架构下,前端应用获取的 Access Token 可以通过 HTTP Header 在服务间传递,实现服务到服务的认证。这避免了每个微服务都需要直接与 CAS Server 交互的性能开销。

1.4 认证与授权分离的设计哲学

在软件架构设计中,"认证"(Authentication)和"授权"(Authorization)是两个经常被混淆但本质不同的概念。理解这两个概念的区别,是设计统一认证体系的基础。

认证(Authentication)回答的是"你是谁"的问题。 它是验证用户身份的过程——用户声称自己是张三,认证系统需要验证这个声称是否属实。常见的认证方式包括:用户名+密码、数字证书、生物识别(指纹、人脸)、多因素认证(MFA)等。

授权(Authorization)回答的是"你能做什么"的问题。 它是在确认用户身份之后,决定该用户是否有权限执行某个操作的过程。例如,张三可以查看销售报表,但不能修改系统配置。常见的授权模型包括:ACL(访问控制列表)、RBAC(基于角色的访问控制)、ABAC(基于属性的访问控制)等。

在 CAS + OAuth2.0 的架构下,认证与授权的分离体现在以下层面:

(1)CAS Server 只负责认证,不负责业务授权。

CAS Server 的职责是验证用户的身份(你是谁),并签发 Token 作为身份凭证。CAS Server 不关心用户在具体业务应用中拥有什么权限——这些授权逻辑由各个业务应用自行管理。

这种设计的好处是显而易见的:

  • CAS Server 保持简洁,只关注认证这一件事
  • 各业务应用可以灵活地定义自己的权限模型
  • 权限变更不需要修改 CAS Server 的配置
  • 不同应用可以有不同的授权策略(例如,OA 系统用 RBAC,数据平台用 ABAC)

(2)OAuth2.0 Token 携带身份信息,不携带权限信息。

Access Token 的核心作用是证明"持有此 Token 的人已经通过了身份认证"。Token 中通常包含用户的基本身份信息(如 userId、userName),但不包含具体的业务权限信息。业务应用在收到请求后,需要根据 Token 中的用户身份信息,查询自己的权限系统来决定是否授权。

当然,在 OAuth2.0 中可以通过 Scope 机制传递一些粗粒度的权限信息(如 readwriteadmin),但这些 Scope 是由 OAuth2.0 授权服务器定义的通用权限,不等同于业务系统中的细粒度权限。

(3)业务应用内部实现细粒度授权。

在 smart-scaffold 项目中,我们推荐在业务应用内部使用以下方式实现授权:

java
// 在 Controller 或 Service 中获取当前用户信息
String userId = (String) request.getAttribute("userId");
String userName = (String) request.getAttribute("userName");

// 查询用户的权限列表
List<String> permissions = permissionService.getUserPermissions(userId);

// 进行权限校验
if (!permissions.contains("order:view")) {
    throw new AccessDeniedException("无权查看订单信息");
}

或者使用注解式权限控制:

java
@RequirePermission("order:view")
@GetMapping("/orders")
public Result<List<Order>> listOrders() {
    // ...
}

这种认证与授权分离的设计哲学,使得整个系统的架构更加清晰、职责更加明确、扩展更加灵活。

1.5 smart-scaffold 项目的认证架构总览

在正式进入技术细节之前,让我们先从宏观层面了解 smart-scaffold 项目中 OAuth2.0 + CAS 统一认证的整体架构。

┌─────────────────────────────────────────────────────────────────┐
│                     smart-scaffold 认证架构                      │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐         │
│  │  Web 应用 A  │    │  Web 应用 B  │    │  移动端 App  │         │
│  │(前端渲染模式) │    │ (API 模式)   │    │ (API 模式)   │         │
│  └──────┬──────┘    └──────┬──────┘    └──────┬──────┘         │
│         │                  │                  │                 │
│         ▼                  ▼                  ▼                 │
│  ┌──────────────────────────────────────────────────┐          │
│  │              OAuthFilter 鉴权过滤器                │          │
│  │  ┌────────────────────────────────────────────┐  │          │
│  │  │ Token 获取: Header / Parameter / Session   │  │          │
│  │  │ 白名单匹配: 静态资源 / 登录页 / 回调端点    │  │          │
│  │  │ 模式识别: 前端模式 -> 302 / API模式 -> 403  │  │          │
│  │  └────────────────────────────────────────────┘  │          │
│  └──────────────────────┬───────────────────────────┘          │
│                         │                                      │
│                         ▼                                      │
│  ┌──────────────────────────────────────────────────┐          │
│  │           TokenRequestWrapper 请求包装器            │          │
│  │  ┌────────────────────────────────────────────┐  │          │
│  │  │ 注入用户信息: userId / userName / ...       │  │          │
│  │  │ request.setAttribute() 供下游使用           │  │          │
│  │  └────────────────────────────────────────────┘  │          │
│  └──────────────────────┬───────────────────────────┘          │
│                         │                                      │
│                         ▼                                      │
│  ┌──────────────────────────────────────────────────┐          │
│  │              业务 Controller / Service            │          │
│  │  ┌────────────────────────────────────────────┐  │          │
│  │  │ request.getAttribute("userId")              │  │          │
│  │  │ request.getAttribute("userName")            │  │          │
│  │  │ 业务逻辑处理 + 权限校验                      │  │          │
│  │  └────────────────────────────────────────────┘  │          │
│  └──────────────────────────────────────────────────┘          │
│                                                                 │
│  ┌──────────────────────────────────────────────────┐          │
│  │                  CAS Server (OAuth2.0)            │          │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────────┐  │          │
│  │  │ 登录页面  │  │ Token签发 │  │ 用户信息接口  │  │          │
│  │  └──────────┘  └──────────┘  └──────────────┘  │          │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────────┐  │          │
│  │  │ TGT管理   │  │ 服务注册  │  │ LDAP/DB认证  │  │          │
│  │  └──────────┘  └──────────┘  └──────────────┘  │          │
│  └──────────────────────────────────────────────────┘          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

从架构图中可以看出,smart-scaffold 的认证体系由以下几个核心组件构成:

  1. OAuthFilter:作为 Servlet Filter 部署在每个业务应用中,负责拦截所有 HTTP 请求,从请求中提取 Token,验证 Token 的有效性,并根据验证结果决定是放行请求还是拒绝访问。

  2. TokenRequestWrapper:继承自 HttpServletRequestWrapper,在 Token 验证通过后,将用户信息注入到请求属性中,使得下游的 Controller 和 Service 可以方便地获取当前登录用户的信息。

  3. FilterConfig:负责 OAuthFilter 的注册和配置,包括 URL Pattern、白名单、初始化参数等。

  4. CAS Server:作为 OAuth2.0 授权服务器,负责用户认证、Token 签发和用户信息查询。

在接下来的章节中,我们将逐一深入解析这些核心组件的设计与实现。


第二章 OAuth2.0 授权码模式完整流程

2.1 OAuth2.0 协议概述与四种授权模式

OAuth2.0 是由 IETF(互联网工程任务组)在 RFC 6749 中定义的授权框架,它允许第三方应用在资源所有者(用户)的授权下,代表资源所有者访问其存储在资源服务器上的受保护资源。OAuth2.0 的核心设计思想是引入一个"授权层"——客户端(应用)不需要直接获取用户的凭证(用户名和密码),而是通过一个授权服务器颁发的 Token 来访问受保护资源。

OAuth2.0 定义了四种授权模式(Grant Types),每种模式适用于不同的应用场景:

(1)授权码模式(Authorization Code Grant)

这是最完整、最安全的 OAuth2.0 授权模式,适用于有服务端的 Web 应用。它的核心特点是引入了一个"授权码"(Authorization Code)作为中间凭证——客户端先用授权码换取 Access Token,而授权码只能使用一次,且需要配合客户端密钥(Client Secret)才能换取 Token。这种"两步走"的设计有效防止了 Token 被截获的风险。

(2)隐式授权模式(Implicit Grant)

适用于纯前端应用(如单页应用 SPA),浏览器直接获取 Access Token,无需授权码中间步骤。由于 Token 直接暴露在浏览器 URL 中(通过 Fragment #),安全性较低,在 OAuth2.1 中已被废弃。

(3)资源所有者密码凭证模式(Resource Owner Password Credentials Grant)

适用于高度信任的第一方应用。用户直接将用户名和密码提供给客户端,客户端用这些凭证换取 Token。这种模式要求用户完全信任客户端应用,因此只适用于官方应用,不适用于第三方应用。

(4)客户端凭证模式(Client Credentials Grant)

适用于服务器到服务器(M2M)的通信场景,不涉及用户参与。客户端使用自己的 Client ID 和 Client Secret 直接获取 Token,用于访问与用户无关的资源。

授权模式适用场景安全级别是否需要 Client Secret
授权码模式Web 应用(有服务端)
隐式授权模式纯前端 SPA
密码凭证模式第一方可信应用
客户端凭证模式服务器间通信

2.2 为什么选择授权码模式(Authorization Code)

在 smart-scaffold 项目中,我们选择 OAuth2.0 授权码模式作为核心认证方式,原因如下:

安全性最高。 授权码模式是唯一一种 Access Token 不会经过用户浏览器的模式。授权码通过浏览器重定向传递(前端可见),但授权码本身不是 Token,它只是一个一次性的中间凭证。真正的 Token 交换发生在服务端到服务端之间(通过后端 HTTP 请求),用户浏览器无法获取到 Access Token。这种设计从根本上杜绝了 Token 在浏览器端泄露的风险。

支持 Token 刷新。 授权码模式在换取 Access Token 的同时,还会返回一个 Refresh Token。当 Access Token 过期后,客户端可以使用 Refresh Token 静默地获取新的 Access Token,无需用户重新登录。这为长期运行的会话提供了良好的支持。

审计追踪完善。 由于整个认证流程涉及多次服务端交互(授权码签发、Token 换取、用户信息查询),CAS Server 可以完整地记录每一步操作,形成完善的审计日志。

生态兼容性好。 授权码模式是 OAuth2.0 中最标准、最广泛支持的授权模式。几乎所有的 OAuth2.0 授权服务器和客户端库都支持这种模式。选择授权码模式意味着更好的互操作性和更丰富的工具链支持。

支持 PKCE 扩展。 授权码模式可以结合 PKCE(Proof Key for Code Exchange,RFC 7636)扩展,在不使用 Client Secret 的情况下也能保证安全性。这对于移动端应用和单页应用尤为重要——这些场景下无法安全地存储 Client Secret。

2.3 授权码模式完整时序解析

在深入代码实现之前,让我们先通过一个完整的时序图来理解 OAuth2.0 授权码模式的六个核心步骤。理解这个流程是后续所有技术实现的基础。

  用户/浏览器          客户端应用           CAS Server          用户信息源
      │                  │                    │                    │
      │ 1.访问受保护资源  │                    │                    │
      │─────────────────>│                    │                    │
      │                  │                    │                    │
      │ 2.302重定向到CAS  │                    │                    │
      │<─────────────────│                    │                    │
      │   (携带client_id,                     │                    │
      │    redirect_uri,                      │                    │
      │    response_type=code,                │                    │
      │    scope, state)                      │                    │
      │                  │                    │                    │
      │ 3.显示CAS登录页面 │                    │                    │
      │─────────────────────────────────────>│                    │
      │                  │                    │                    │
      │ 4.输入用户名密码  │                    │                    │
      │─────────────────────────────────────>│                    │
      │                  │                    │ 5.验证用户凭证      │
      │                  │                    │───────────────────>│
      │                  │                    │ 6.验证结果          │
      │                  │                    │<───────────────────│
      │                  │                    │                    │
      │                  │                    │ 7.创建授权码(Code)  │
      │                  │                    │                    │
      │ 8.302重定向回客户端│                    │                    │
      │<─────────────────────────────────────│                    │
      │   (携带code, state)                   │                    │
      │                  │                    │                    │
      │─────────────────>│                    │                    │
      │                  │ 9.用授权码换Token   │                    │
      │                  │  (POST /oauth2.0/accessToken)           │
      │                  │  (携带code, client_id,                  │
      │                  │   client_secret, redirect_uri)         │
      │                  │───────────────────>│                    │
      │                  │                    │                    │
      │                  │ 10.返回Token       │                    │
      │                  │  (access_token,    │                    │
      │                  │   refresh_token,   │                    │
      │                  │   expires_in,      │                    │
      │                  │   token_type)      │                    │
      │                  │<───────────────────│                    │
      │                  │                    │                    │
      │                  │ 11.用Token获取用户信息│                   │
      │                  │  (GET /oauth2.0/profile)                │
      │                  │  (Header: Authorization: Bearer xxx)   │
      │                  │───────────────────>│                    │
      │                  │                    │                    │
      │                  │ 12.返回用户信息     │                    │
      │                  │  (id, name, email, │                    │
      │                  │   attributes...)   │                    │
      │                  │<───────────────────│                    │
      │                  │                    │                    │
      │ 13.返回受保护资源 │                    │                    │
      │<─────────────────│                    │                    │
      │                  │                    │                    │

2.4 步骤一:用户访问客户端应用

当用户在浏览器中输入业务应用的 URL(例如 https://app.example.com/dashboard)时,请求首先到达业务应用的服务器。在 smart-scaffold 项目中,这个请求会被 OAuthFilter 拦截。

OAuthFilter 的第一个任务就是检查当前请求是否需要鉴权。它会依次检查:

  1. 请求的 URI 是否在白名单中? 如果是(例如静态资源、登录页、健康检查端点等),直接放行,不做任何鉴权处理。
  2. 请求中是否携带了有效的 Token? OAuthFilter 会按照优先级依次从以下三个位置查找 Token:
    • HTTP 请求头 Authorization: Bearer <token>
    • URL 参数 ?access_token=<token>
    • HTTP Session 中存储的 Token
  3. 如果找到了 Token,是否有效? OAuthFilter 会调用 CAS Server 的 Token 验证接口来验证 Token 的有效性。

如果以上检查都未通过(即请求不在白名单中,且没有携带有效 Token),OAuthFilter 会根据当前的模式(前端模式或 API 模式)做出不同的响应:

  • 前端模式: 生成 OAuth2.0 授权 URL,将用户重定向到 CAS Server 的登录页面。
  • API 模式: 直接返回 HTTP 403 Forbidden 的 JSON 响应。

以下是一个简化的代码示例,展示了 OAuthFilter 在这一步的核心逻辑:

java
@Override
public void doFilter(ServletRequest request, ServletResponse response,
                     FilterChain chain) throws IOException, ServletException {

    HttpServletRequest httpRequest = (HttpServletRequest) request;
    HttpServletResponse httpResponse = (HttpServletResponse) response;

    String requestUri = httpRequest.getRequestURI();

    // 1. 检查是否在白名单中
    if (isWhiteListed(requestUri)) {
        chain.doFilter(request, response);
        return;
    }

    // 2. 尝试获取 Token
    String accessToken = getTokenFromRequest(httpRequest);

    if (accessToken == null || !validateToken(accessToken)) {
        // 3. 无有效 Token,根据模式处理
        handleUnauthorized(httpRequest, httpResponse);
        return;
    }

    // 4. Token 有效,获取用户信息并包装请求
    UserInfo userInfo = getUserInfo(accessToken);
    TokenRequestWrapper wrappedRequest = new TokenRequestWrapper(httpRequest, userInfo);

    chain.doFilter(wrappedRequest, response);
}

2.5 步骤二:重定向到 CAS 登录页面

在前端模式下,当 OAuthFilter 判断当前请求未携带有效 Token 时,会构造一个 OAuth2.0 授权请求 URL,并通过 HTTP 302 重定向将用户浏览器导航到 CAS Server 的登录页面。

OAuth2.0 授权请求 URL 的格式如下:

https://cas.example.com/cas/oauth2.0/authorize?
    client_id={client_id}
  & redirect_uri={redirect_uri}
  & response_type=code
  & scope={scope}
  & state={state}

各参数的含义如下:

参数必填说明
client_id客户端应用在 CAS Server 上注册的唯一标识符
redirect_uri授权完成后回调的 URL,必须与注册时配置的回调地址一致
response_type固定为 code,表示使用授权码模式
scope请求的权限范围,如 openid profile email
state推荐随机生成的字符串,用于防止 CSRF 攻击

state 参数的安全作用:

state 参数是一个由客户端生成的随机字符串,它会在整个授权流程中"往返传递"——客户端生成 state,将其附加到授权请求 URL 中;CAS Server 在回调时会将同一个 state 原样返回;客户端收到回调后,需要验证返回的 state 是否与自己发送的一致。如果不一致,说明可能发生了 CSRF 攻击,应该拒绝该请求。

java
/**
 * 生成 OAuth2.0 授权 URL
 */
public String buildAuthorizationUrl(HttpServletRequest request) {
    String casServerUrl = config.getCasServerUrl();
    String clientId = config.getClientId();
    String redirectUri = buildRedirectUri(request);

    // 生成 state 参数(防 CSRF)
    String state = generateSecureRandom();

    // 将 state 存入 Session,用于后续验证
    request.getSession().setAttribute(OAUTH_STATE_KEY, state);

    // 记录原始请求 URL,登录成功后跳回
    String originalUrl = request.getRequestURL().toString() +
                         (request.getQueryString() != null ? "?" + request.getQueryString() : "");
    request.getSession().setAttribute(ORIGINAL_URL_KEY, originalUrl);

    StringBuilder url = new StringBuilder(casServerUrl);
    url.append("/oauth2.0/authorize?")
       .append("client_id=").append(URLEncoder.encode(clientId, "UTF-8"))
       .append("&redirect_uri=").append(URLEncoder.encode(redirectUri, "UTF-8"))
       .append("&response_type=code")
       .append("&scope=").append(URLEncoder.encode(config.getScope(), "UTF-8"))
       .append("&state=").append(URLEncoder.encode(state, "UTF-8"));

    return url.toString();
}

/**
 * 生成安全的随机字符串(用于 state 参数)
 */
private String generateSecureRandom() {
    SecureRandom random = new SecureRandom();
    byte[] bytes = new byte[32];
    random.nextBytes(bytes);
    return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

redirect_uri 的构建策略:

redirect_uri 是 CAS Server 在用户授权完成后回调的地址。在实际项目中,redirect_uri 通常指向客户端应用中的一个专用端点(例如 /oauth2/callback),该端点负责接收授权码并完成后续的 Token 换取流程。

需要注意的是,redirect_uri 必须与在 CAS Server 上注册客户端时配置的回调地址完全一致(包括协议、域名、端口和路径),否则 CAS Server 会拒绝该授权请求。这是 OAuth2.0 的安全机制之一,用于防止授权码被劫持到恶意网站。

2.6 步骤三:用户认证授权

当用户的浏览器被重定向到 CAS Server 的登录页面后,CAS Server 会执行以下操作:

(1)检查 TGT Cookie(实现 SSO 的关键)

CAS Server 首先会检查浏览器中是否携带了有效的 TGC(Ticket Granting Cookie)。如果存在且有效,说明用户已经在 CAS Server 上完成过登录,CAS Server 可以直接跳过登录步骤,为该应用签发授权码。这就是 CAS SSO 的核心机制——"一次登录,处处通行"。

(2)显示登录页面

如果不存在有效的 TGC Cookie,CAS Server 会显示一个登录页面,要求用户输入用户名和密码。CAS 的登录页面可以高度自定义,支持集成多因素认证(MFA)、验证码、记住我等功能。

(3)验证用户凭证

用户提交登录表单后,CAS Server 会使用配置的认证处理器(Authentication Handler)来验证用户的凭证。在 smart-scaffold 项目中,我们使用 LDAP 作为认证源,CAS Server 通过 LDAP 协议验证用户名和密码的正确性。

CAS Server 的认证流程支持多种认证策略:

  • Required: 必须通过认证才能继续
  • Optional: 认证失败也可以继续(适用于多认证源的场景)
  • Sufficient: 只要通过一个认证源即可
  • Groovy 脚本: 通过自定义 Groovy 脚本实现复杂的认证逻辑

(4)用户授权确认

在某些安全要求较高的场景下,CAS Server 会在用户认证成功后显示一个"授权确认"页面,告知用户客户端应用请求了哪些权限(Scope),并要求用户明确确认是否授权。用户确认后,CAS Server 才会签发授权码。

在 smart-scaffold 项目中,由于所有客户端应用都是内部可信应用,我们配置 CAS Server 跳过授权确认步骤(通过 bypassApprovalPrompt 配置),以提供更流畅的用户体验。

2.7 步骤四:CAS 回调返回授权码

用户认证成功(并确认授权)后,CAS Server 会生成一个授权码(Authorization Code),并通过 HTTP 302 重定向将用户浏览器导航回客户端应用预先配置的 redirect_uri。授权码会作为 URL 查询参数附加在回调 URL 中。

回调 URL 的格式如下:

https://app.example.com/oauth2/callback?
    code=SplxlOBeZQQYbYS6WxSbIA
  & state=xyz123abc456

授权码的安全特性:

授权码具有以下安全特性,这些特性是整个 OAuth2.0 授权码模式安全性的基础:

  1. 一次性使用: 每个授权码只能使用一次。一旦用它换取了 Access Token,该授权码就会立即失效。即使攻击者截获了授权码,只要客户端先使用了它,攻击者就无法再次使用。

  2. 短有效期: 授权码的有效期通常非常短(默认为 30 秒,CAS Server 可配置)。这大大缩小了授权码被截获后被利用的时间窗口。

  3. 绑定客户端: 授权码与特定的 client_idredirect_uri 绑定。即使攻击者截获了授权码,也无法用它从其他客户端或重定向到其他地址来换取 Token。

  4. 需要 Client Secret: 用授权码换取 Token 时,需要提供客户端密钥(Client Secret)。由于 Client Secret 只存储在服务端,不会暴露给浏览器,因此即使攻击者截获了授权码,没有 Client Secret 也无法换取 Token。

客户端应用在接收到回调请求后,需要执行以下操作:

java
/**
 * OAuth2.0 回调端点 - 接收授权码
 */
@GetMapping("/oauth2/callback")
public String handleCallback(
        @RequestParam("code") String code,
        @RequestParam("state") String state,
        HttpServletRequest request) {

    // 1. 验证 state 参数(防 CSRF)
    String sessionState = (String) request.getSession().getAttribute(OAUTH_STATE_KEY);
    if (sessionState == null || !sessionState.equals(state)) {
        throw new SecurityException("State 参数验证失败,可能存在 CSRF 攻击");
    }
    request.getSession().removeAttribute(OAUTH_STATE_KEY);

    // 2. 用授权码换取 Access Token
    OAuthTokenResponse tokenResponse = exchangeCodeForToken(code, request);

    // 3. 用 Access Token 获取用户信息
    UserInfo userInfo = getUserInfo(tokenResponse.getAccessToken());

    // 4. 将 Token 和用户信息存入 Session
    request.getSession().setAttribute(ACCESS_TOKEN_KEY, tokenResponse.getAccessToken());
    request.getSession().setAttribute(USER_INFO_KEY, userInfo);

    // 5. 获取原始请求 URL,重定向回去
    String originalUrl = (String) request.getSession().getAttribute(ORIGINAL_URL_KEY);
    if (originalUrl != null) {
        request.getSession().removeAttribute(ORIGINAL_URL_KEY);
        return "redirect:" + originalUrl;
    }

    return "redirect:/";
}

2.8 步骤五:客户端用授权码换取 Access Token

这是整个 OAuth2.0 授权码模式中最关键的安全步骤。客户端应用(服务端)使用授权码向 CAS Server 发起一个 POST 请求,换取 Access Token。

请求格式:

POST /cas/oauth2.0/accessToken HTTP/1.1
Host: cas.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
  & code=SplxlOBeZQQYbYS6WxSbIA
  & redirect_uri=https://app.example.com/oauth2/callback
  & client_id=smart-scaffold-app
  & client_secret=your-client-secret

参数说明:

参数说明
grant_type固定为 authorization_code
code上一步获取的授权码
redirect_uri必须与获取授权码时使用的 redirect_uri 完全一致
client_id客户端标识符
client_secret客户端密钥

响应格式(CAS 默认使用 JSON):

json
{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
    "token_type": "Bearer",
    "expires_in": 7200,
    "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
    "scope": "openid profile email"
}

响应参数说明:

参数说明
access_token访问令牌,用于后续的 API 调用
token_type令牌类型,通常为 Bearer
expires_inAccess Token 的有效期(秒)
refresh_token刷新令牌,用于在 Access Token 过期后获取新的 Token
scope实际授予的权限范围

Java 实现代码:

java
/**
 * 用授权码换取 Access Token
 */
public OAuthTokenResponse exchangeCodeForToken(String code, HttpServletRequest request) {

    String tokenUrl = casServerUrl + "/oauth2.0/accessToken";
    String redirectUri = buildRedirectUri(request);

    // 构建请求参数
    Map<String, String> params = new LinkedHashMap<>();
    params.put("grant_type", "authorization_code");
    params.put("code", code);
    params.put("redirect_uri", redirectUri);
    params.put("client_id", clientId);
    params.put("client_secret", clientSecret);

    // 发送 POST 请求
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    HttpEntity<String> entity = new HttpEntity<>(buildFormBody(params), headers);

    ResponseEntity<String> response = restTemplate.exchange(
        tokenUrl,
        HttpMethod.POST,
        entity,
        String.class
    );

    if (response.getStatusCode() != HttpStatus.OK) {
        throw new RuntimeException("获取 Access Token 失败: " + response.getBody());
    }

    // 解析响应
    return parseTokenResponse(response.getBody());
}

/**
 * 解析 Token 响应
 */
private OAuthTokenResponse parseTokenResponse(String responseBody) {
    JSONObject json = JSON.parseObject(responseBody);

    OAuthTokenResponse tokenResponse = new OAuthTokenResponse();
    tokenResponse.setAccessToken(json.getString("access_token"));
    tokenResponse.setTokenType(json.getString("token_type"));
    tokenResponse.setExpiresIn(json.getLongValue("expires_in"));
    tokenResponse.setRefreshToken(json.getString("refresh_token"));
    tokenResponse.setScope(json.getString("scope"));

    return tokenResponse;
}

关于 Token 的存储策略:

在 smart-scaffold 项目中,我们推荐以下 Token 存储策略:

  1. Access Token 存储在服务端 Session 中。 这样可以避免 Token 暴露给浏览器端(前端 JavaScript 无法访问 HttpSession 中的数据),同时也可以在 Session 销毁时自动清理 Token。

  2. Refresh Token 存储在服务端持久化存储中(如数据库或 Redis)。 这样即使应用重启,也可以使用 Refresh Token 获取新的 Access Token,避免用户被强制登出。

  3. 不要将 Token 存储在浏览器的 localStorage 中。 localStorage 容易受到 XSS 攻击的影响,一旦攻击者在页面中注入恶意脚本,就可以读取 localStorage 中的 Token。

2.9 步骤六:客户端用 Access Token 获取用户信息

获取 Access Token 后,客户端应用需要使用它来从 CAS Server 获取当前用户的详细信息。CAS Server 提供了一个标准的用户信息接口(Profile Endpoint)。

请求格式:

GET /cas/oauth2.0/profile HTTP/1.1
Host: cas.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

响应格式:

json
{
    "id": "zhangsan",
    "attributes": {
        "cn": "张三",
        "mail": "zhangsan@example.com",
        "department": "技术部",
        "title": "高级工程师",
        "employeeNumber": "EMP001"
    },
    "client_id": "smart-scaffold-app"
}

响应字段说明:

字段说明
id用户唯一标识符(通常是用户名或 LDAP DN)
attributes用户属性集合,包含用户的详细信息
attributes.cn用户的通用名称(Common Name)
attributes.mail用户的邮箱地址
client_id发起请求的客户端标识符

Java 实现代码:

java
/**
 * 使用 Access Token 获取用户信息
 */
public UserInfo getUserInfo(String accessToken) {

    String profileUrl = casServerUrl + "/oauth2.0/profile";

    HttpHeaders headers = new HttpHeaders();
    headers.set("Authorization", "Bearer " + accessToken);

    HttpEntity<String> entity = new HttpEntity<>(headers);

    ResponseEntity<String> response = restTemplate.exchange(
        profileUrl,
        HttpMethod.GET,
        entity,
        String.class
    );

    if (response.getStatusCode() != HttpStatus.OK) {
        throw new RuntimeException("获取用户信息失败: " + response.getBody());
    }

    return parseUserInfo(response.getBody());
}

/**
 * 解析用户信息
 */
private UserInfo parseUserInfo(String responseBody) {
    JSONObject json = JSON.parseObject(responseBody);

    UserInfo userInfo = new UserInfo();
    userInfo.setUserId(json.getString("id"));

    JSONObject attributes = json.getJSONObject("attributes");
    if (attributes != null) {
        userInfo.setUserName(attributes.getString("cn"));
        userInfo.setEmail(attributes.getString("mail"));
        userInfo.setDepartment(attributes.getString("department"));
        userInfo.setTitle(attributes.getString("title"));
        userInfo.setEmployeeNumber(attributes.getString("employeeNumber"));
    }

    return userInfo;
}

用户信息缓存策略:

由于每次 HTTP 请求都需要验证 Token 并获取用户信息,如果每次都调用 CAS Server 的接口,会产生大量的网络开销。在 smart-scaffold 项目中,我们采用了以下缓存策略:

  1. 本地缓存(Caffeine/Guava Cache): 在应用内存中缓存用户信息,设置合理的过期时间(如 5 分钟)。当 Token 验证通过后,先从缓存中查找用户信息;如果缓存命中,直接使用缓存数据;如果缓存未命中,再调用 CAS Server 接口获取。

  2. Token 绑定缓存: 缓存的 Key 使用 Access Token 的哈希值,确保不同的 Token 不会互相干扰。

  3. 主动失效: 当用户登出时,清除对应的缓存条目。

java
/**
 * 带缓存的用户信息获取
 */
private final Cache<String, UserInfo> userInfoCache = Caffeine.newBuilder()
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .maximumSize(10000)
    .build();

public UserInfo getUserInfoWithCache(String accessToken) {
    String cacheKey = DigestUtils.md5Hex(accessToken);

    UserInfo userInfo = userInfoCache.getIfPresent(cacheKey);
    if (userInfo != null) {
        return userInfo;
    }

    userInfo = getUserInfo(accessToken);
    userInfoCache.put(cacheKey, userInfo);

    return userInfo;
}

2.10 完整流程代码示例

为了帮助读者建立完整的认知,下面我们给出一个简化但完整的 OAuth2.0 回调控制器实现,它串联了授权码换取 Token、Token 获取用户信息、会话管理等所有步骤:

java
/**
 * OAuth2.0 回调控制器
 * 负责处理 CAS Server 的授权回调
 */
@Controller
@RequestMapping("/oauth2")
public class OAuth2CallbackController {

    @Autowired
    private OAuth2Service oauth2Service;

    @Autowired
    private OAuth2Config oauth2Config;

    private static final String OAUTH_STATE_KEY = "oauth_state";
    private static final String ORIGINAL_URL_KEY = "original_request_url";
    private static final String ACCESS_TOKEN_KEY = "access_token";
    private static final String REFRESH_TOKEN_KEY = "refresh_token";

    /**
     * OAuth2.0 授权回调端点
     * CAS Server 在用户授权成功后会回调此端点
     */
    @GetMapping("/callback")
    public String handleCallback(
            @RequestParam(value = "code", required = false) String code,
            @RequestParam(value = "state", required = false) String state,
            @RequestParam(value = "error", required = false) String error,
            HttpServletRequest request,
            HttpServletResponse response) {

        // 处理授权错误
        if (error != null) {
            log.error("OAuth2.0 授权失败: error={}, state={}", error, state);
            return "redirect:/error?message=" + URLEncoder.encode("授权失败: " + error, "UTF-8");
        }

        // 验证 state 参数(防 CSRF)
        String sessionState = (String) request.getSession().getAttribute(OAUTH_STATE_KEY);
        if (sessionState == null || !sessionState.equals(state)) {
            log.error("State 验证失败: sessionState={}, receivedState={}", sessionState, state);
            return "redirect:/error?message=" + URLEncoder.encode("安全验证失败", "UTF-8");
        }
        request.getSession().removeAttribute(OAUTH_STATE_KEY);

        try {
            // 步骤一:用授权码换取 Token
            OAuthTokenResponse tokenResponse = oauth2Service.exchangeCodeForToken(code, request);

            // 步骤二:用 Access Token 获取用户信息
            UserInfo userInfo = oauth2Service.getUserInfo(tokenResponse.getAccessToken());

            // 步骤三:将 Token 和用户信息存入 Session
            HttpSession session = request.getSession();
            session.setAttribute(ACCESS_TOKEN_KEY, tokenResponse.getAccessToken());
            session.setAttribute(REFRESH_TOKEN_KEY, tokenResponse.getRefreshToken());
            session.setAttribute("userInfo", userInfo);

            log.info("用户登录成功: userId={}, userName={}", userInfo.getUserId(), userInfo.getUserName());

            // 步骤四:重定向到原始请求页面
            String originalUrl = (String) session.getAttribute(ORIGINAL_URL_KEY);
            if (originalUrl != null) {
                session.removeAttribute(ORIGINAL_URL_KEY);
                return "redirect:" + originalUrl;
            }

            return "redirect:/";

        } catch (Exception e) {
            log.error("OAuth2.0 Token 换取失败", e);
            return "redirect:/error?message=" + URLEncoder.encode("登录处理失败", "UTF-8");
        }
    }

    /**
     * 登出端点
     */
    @GetMapping("/logout")
    public String logout(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }
        // 重定向到 CAS Server 的登出页面
        return "redirect:" + oauth2Config.getCasServerUrl() + "/logout";
    }
}

第三章 OAuthFilter 鉴权过滤器设计

3.1 Servlet Filter 机制回顾

在深入 OAuthFilter 的设计之前,我们先回顾一下 Java Servlet Filter 的基本机制。Filter 是 Java Servlet 规范中定义的一种组件,它可以在请求到达 Servlet(或 Controller)之前和响应返回客户端之后进行拦截处理。Filter 的核心价值在于它提供了一种"非侵入式"的方式来为 Web 应用添加横切关注点(Cross-cutting Concerns),如认证、日志、编码转换、压缩等。

Filter 的生命周期:

  1. 初始化(init): Web 容器在启动时调用 Filter 的 init(FilterConfig) 方法,传入 FilterConfig 对象。Filter 可以在此方法中读取初始化参数、初始化资源。
  2. 过滤(doFilter): 每个匹配的请求都会调用 doFilter(ServletRequest, ServletResponse, FilterChain) 方法。Filter 在此方法中执行过滤逻辑,并通过调用 FilterChain.doFilter() 将请求传递给下一个 Filter 或目标 Servlet。
  3. 销毁(destroy): Web 容器在关闭时调用 Filter 的 destroy() 方法,Filter 可以在此方法中释放资源。

Filter 链(Filter Chain):

在一个 Web 应用中,可以配置多个 Filter,它们形成一个"责任链"。当请求到达时,按照配置的顺序依次经过每个 Filter。每个 Filter 都有权决定是否将请求传递给链中的下一个组件。

HTTP Request
    |
    v
+-------------+
|  Filter 1    |  (编码过滤器 CharacterEncodingFilter)
|  doFilter()  |
+------+------+
       | chain.doFilter()
       v
+-------------+
|  Filter 2    |  (CORS 过滤器 CorsFilter)
|  doFilter()  |
+------+------+
       | chain.doFilter()
       v
+-------------+
|  OAuthFilter |  (OAuth2.0 鉴权过滤器 - 本文核心)
|  doFilter()  |
+------+------+
       | chain.doFilter()
       v
+-------------+
|  Filter 4    |  (日志过滤器 LoggingFilter)
|  doFilter()  |
+------+------+
       | chain.doFilter()
       v
+-------------+
|  Dispatcher  |  (前端控制器 DispatcherServlet)
|  Servlet     |
+------+------+
       |
       v
  Controller

Filter 与 Interceptor 的区别:

在 Spring MVC 中,除了 Servlet Filter,还有 HandlerInterceptor。两者虽然都能实现请求拦截,但在作用范围和功能上有重要区别:

特性Servlet FilterHandlerInterceptor
规范Java Servlet 规范Spring MVC 规范
作用范围所有请求(包括静态资源)仅 Controller 请求
执行时机DispatcherServlet 之前Controller 方法前后
可访问对象ServletRequest/ResponseHttpServletRequest/Response, Handler
依赖注入需要额外配置支持 Spring 依赖注入

在 smart-scaffold 项目中,我们选择使用 Servlet Filter 而非 Spring Interceptor 来实现 OAuth2.0 鉴权,原因如下:

  1. 更早的拦截时机: Filter 在 DispatcherServlet 之前执行,可以在请求到达 Spring MVC 之前就完成鉴权,避免未授权的请求占用 Spring MVC 的处理资源。
  2. 更广的覆盖范围: Filter 可以拦截所有请求,包括静态资源请求和错误页面请求,而 Interceptor 只能拦截 Controller 请求。
  3. 框架无关性: Filter 是 Java Servlet 规范的标准组件,不依赖 Spring 框架。这使得 OAuthFilter 可以在非 Spring 环境下复用。

3.2 OAuthFilter 整体架构设计

OAuthFilter 是 smart-scaffold 统一认证体系的核心组件。它的设计遵循"单一职责"原则,专注于 OAuth2.0 Token 的验证和用户身份的注入,不涉及任何业务逻辑。

OAuthFilter 的核心职责:

  1. Token 提取: 从 HTTP 请求中提取 Access Token(支持 Header、Parameter、Session 三种方式)。
  2. Token 验证: 调用 CAS Server 的接口验证 Token 的有效性。
  3. 白名单匹配: 判断当前请求是否需要鉴权(白名单内的请求直接放行)。
  4. 用户信息注入: Token 验证通过后,获取用户信息并注入到请求属性中。
  5. 未授权处理: Token 无效或不存在时,根据模式(前端/API)做出不同的响应。

3.3 Token 获取的三种方式

在实际的企业应用环境中,不同的客户端可能以不同的方式携带 Access Token。OAuthFilter 需要支持多种 Token 传递方式,以适配不同的使用场景。

方式一:HTTP Header(推荐方式)

这是 OAuth2.0 规范推荐的标准方式。客户端在 HTTP 请求头中添加 Authorization 字段,值为 Bearer <token>

GET /api/users HTTP/1.1
Host: app.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

优点:

  • 符合 OAuth2.0 标准,兼容性最好
  • Token 不会出现在 URL 中,不会被浏览器历史记录、服务器访问日志等记录
  • 适合 RESTful API 调用场景

提取代码:

java
private String getTokenFromHeader(HttpServletRequest request) {
    String authHeader = request.getHeader("Authorization");
    if (authHeader != null && authHeader.startsWith("Bearer ")) {
        return authHeader.substring(7).trim();
    }
    return null;
}

方式二:URL Parameter(兼容方式)

客户端将 Access Token 作为 URL 查询参数传递。

GET /api/users?access_token=eyJhbGciOiJSUzI1NiIs... HTTP/1.1

优点: 实现简单,适合简单的 AJAX 请求和测试场景。 缺点: Token 会出现在 URL 中,可能被浏览器历史记录、代理服务器日志、Referer 头等泄露。

java
private String getTokenFromParameter(HttpServletRequest request) {
    return request.getParameter("access_token");
}

方式三:HTTP Session(前端渲染模式)

在传统的服务端渲染模式(如 JSP、Thymeleaf)下,Access Token 存储在服务端的 HttpSession 中。浏览器通过 Session Cookie(JSESSIONID)来标识会话,OAuthFilter 通过 Session 获取 Token。

优点: Token 完全存储在服务端,不暴露给浏览器;用户体验好,无需前端代码管理 Token。 缺点: 依赖服务端 Session,不适合无状态的 API 服务;在分布式环境下需要 Session 共享。

java
private String getTokenFromSession(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session != null) {
        return (String) session.getAttribute("access_token");
    }
    return null;
}

三种方式的优先级策略(Header > Parameter > Session):

java
public String extractToken(HttpServletRequest request) {
    // 1. 优先从 Header 获取(最安全,标准方式)
    String token = getTokenFromHeader(request);
    if (token != null && !token.isEmpty()) {
        return token;
    }

    // 2. 其次从 Parameter 获取(兼容方式)
    token = getTokenFromParameter(request);
    if (token != null && !token.isEmpty()) {
        return token;
    }

    // 3. 最后从 Session 获取(前端渲染模式)
    token = getTokenFromSession(request);
    if (token != null && !token.isEmpty()) {
        return token;
    }

    return null;
}

3.4 免鉴权 URI 白名单配置

并非所有的 HTTP 请求都需要经过 OAuth2.0 鉴权。例如,静态资源(CSS、JS、图片)、登录页面、健康检查端点、公开 API 等应该直接放行。OAuthFilter 通过白名单机制来管理这些不需要鉴权的 URI。

白名单支持以下匹配模式:

  1. 精确匹配: URI 与白名单条目完全一致时放行。例如 /login/health
  2. 前缀匹配: URI 以白名单条目开头时放行。例如 /static/** 匹配所有 /static/ 下的资源。
  3. 后缀匹配: URI 以指定后缀结尾时放行。例如 *.css*.js*.png
  4. 正则匹配: 使用正则表达式进行匹配,提供最灵活的匹配能力。

白名单配置示例(简化版):

properties
# OAuth2.0 白名单配置
oauth.whitelist.enabled=true

# 精确匹配的 URI
oauth.whitelist.exact=/login,/logout,/health,/favicon.ico,/oauth2/callback

# 前缀匹配的 URI
oauth.whitelist.prefixes=/static/,/public/,/layui/,/webjars/

# 后缀匹配(静态资源)
oauth.whitelist.suffixes=.css,.js,.png,.jpg,.jpeg,.gif,.ico,.woff,.woff2,.ttf,.eot,.svg,.map

# 正则匹配
oauth.whitelist.regex=/api/public/.*

白名单匹配器实现:

java
public class WhiteListMatcher {

    private Set<String> exactMatches = new HashSet<>();
    private List<String> prefixMatches = new ArrayList<>();
    private List<String> suffixMatches = new ArrayList<>();
    private List<Pattern> regexPatterns = new ArrayList<>();

    public boolean isWhiteListed(String uri) {
        if (uri == null || uri.isEmpty()) {
            return false;
        }

        // 1. 精确匹配
        if (exactMatches.contains(uri)) {
            return true;
        }

        // 2. 前缀匹配
        for (String prefix : prefixMatches) {
            if (uri.startsWith(prefix)) {
                return true;
            }
        }

        // 3. 后缀匹配
        for (String suffix : suffixMatches) {
            if (uri.endsWith(suffix)) {
                return true;
            }
        }

        // 4. 正则匹配
        for (Pattern pattern : regexPatterns) {
            if (pattern.matcher(uri).matches()) {
                return true;
            }
        }

        return false;
    }
}

3.5 静态资源放行策略

在 Web 应用中,静态资源(CSS、JavaScript、图片、字体等)通常不需要经过鉴权。如果 OAuthFilter 对静态资源请求也进行 Token 验证,不仅会带来不必要的性能开销,还可能导致页面样式和脚本加载失败(因为静态资源请求没有携带 Token)。

在 smart-scaffold 项目中,我们采用"后缀匹配 + 前缀匹配"的组合策略来放行静态资源:

java
private boolean isStaticResource(String uri) {
    String[] staticSuffixes = {
        ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico",
        ".woff", ".woff2", ".ttf", ".eot", ".svg", ".map",
        ".html", ".htm", ".json", ".xml"
    };

    for (String suffix : staticSuffixes) {
        if (uri.endsWith(suffix)) {
            return true;
        }
    }

    String[] staticPrefixes = {
        "/static/", "/resources/", "/public/", "/assets/",
        "/layui/", "/webjars/", "/META-INF/resources/"
    };

    for (String prefix : staticPrefixes) {
        if (uri.startsWith(prefix)) {
            return true;
        }
    }

    return false;
}

LayUI 等前端框架的特殊处理:

LayUI 是一款流行的前端 UI 框架,在企业级 Java Web 应用中被广泛使用。LayUI 的资源文件通常部署在 /layui/ 路径下,包括 CSS 样式文件、JavaScript 脚本文件和字体文件。这些资源文件必须在 OAuthFilter 中放行,否则 LayUI 的页面将无法正常渲染。

注意事项:

  1. 不要过度放行: 白名单应该遵循"最小权限"原则,只放行确实不需要鉴权的资源。
  2. 注意路径规范化: 在匹配白名单之前,应该对 URI 进行规范化处理,防止攻击者通过路径遍历(如 /static/../admin/users)绕过白名单。
  3. 考虑 Context Path: 如果应用部署在非根路径下(如 /myapp),白名单匹配时需要考虑 Context Path 的影响。

3.6 登录页与 CAS 回调端点放行

除了静态资源,以下 URI 也必须在 OAuthFilter 的白名单中放行:

(1)登录页面(/login) - 登录页面是用户输入凭证的入口,它本身不应该需要鉴权。

(2)OAuth2.0 回调端点(/oauth2/callback) - CAS Server 在用户授权成功后回调的端点,此时用户还没有获取到 Token。

(3)登出端点(/logout) - 用于清除用户的会话信息并重定向到 CAS Server 的登出页面。

(4)错误页面(/error) - OAuth2.0 授权过程中发生错误时的重定向目标。

(5)健康检查端点(/health, /info) - 由监控系统调用,用于检测应用是否正常运行。

properties
oauth.whitelist.exact=\
  /login,\
  /logout,\
  /oauth2/callback,\
  /error,\
  /health,\
  /info,\
  /favicon.ico

3.7 OAuthFilter 完整实现代码

下面给出 OAuthFilter 的完整实现代码(教学简化版):

java
/**
 * OAuth2.0 鉴权过滤器
 *
 * 职责:
 * 1. 拦截所有 HTTP 请求
 * 2. 从请求中提取 Access Token
 * 3. 验证 Token 的有效性
 * 4. 将用户信息注入到请求属性中
 * 5. 处理未授权的请求
 *
 * @author smart-scaffold
 */
public class OAuthFilter implements Filter {

    private static final Logger log = LoggerFactory.getLogger(OAuthFilter.class);

    private OAuth2Config oauth2Config;
    private OAuth2Service oauth2Service;
    private WhiteListMatcher whiteListMatcher;
    private Cache<String, UserInfo> userInfoCache;

    private static final String BEARER_PREFIX = "Bearer ";
    private static final String SESSION_TOKEN_KEY = "access_token";
    private static final String SESSION_STATE_KEY = "oauth_state";
    private static final String SESSION_ORIGINAL_URL_KEY = "original_request_url";

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("OAuthFilter 初始化开始...");

        ServletContext servletContext = filterConfig.getServletContext();
        WebApplicationContext appContext = WebApplicationContextUtils
            .getRequiredWebApplicationContext(servletContext);

        this.oauth2Config = appContext.getBean(OAuth2Config.class);
        this.oauth2Service = appContext.getBean(OAuth2Service.class);

        this.whiteListMatcher = new WhiteListMatcher();
        this.whiteListMatcher.initFromConfig(oauth2Config);

        this.userInfoCache = Caffeine.newBuilder()
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .maximumSize(10000)
            .build();

        log.info("OAuthFilter 初始化完成");
        log.info("CAS Server URL: {}", oauth2Config.getCasServerUrl());
        log.info("Client ID: {}", oauth2Config.getClientId());
    }

    @Override
    public void doFilter(ServletRequest servletRequest,
                         ServletResponse servletResponse,
                         FilterChain filterChain)
            throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String requestUri = request.getRequestURI();
        log.debug("OAuthFilter 拦截请求: {}", requestUri);

        // 第一步:白名单检查
        if (whiteListMatcher.isWhiteListed(requestUri)) {
            log.debug("请求 {} 在白名单中,直接放行", requestUri);
            filterChain.doFilter(request, response);
            return;
        }

        // 第二步:提取 Token
        String accessToken = extractToken(request);

        if (accessToken == null || accessToken.isEmpty()) {
            log.debug("未获取到 Token,处理未授权请求");
            handleUnauthorized(request, response);
            return;
        }

        // 第三步:验证 Token 并获取用户信息
        UserInfo userInfo;
        try {
            userInfo = getUserInfoWithCache(accessToken);
            if (userInfo == null) {
                log.warn("Token 验证失败或用户信息获取失败");
                handleUnauthorized(request, response);
                return;
            }
        } catch (Exception e) {
            log.error("Token 验证异常", e);
            handleUnauthorized(request, response);
            return;
        }

        // 第四步:包装请求,注入用户信息
        log.debug("Token 验证通过,用户: {} ({})", userInfo.getUserName(), userInfo.getUserId());
        TokenRequestWrapper wrappedRequest = new TokenRequestWrapper(request, userInfo);

        // 第五步:传递给下一个 Filter 或 Servlet
        filterChain.doFilter(wrappedRequest, response);
    }

    @Override
    public void destroy() {
        log.info("OAuthFilter 销毁");
        if (userInfoCache != null) {
            userInfoCache.cleanUp();
        }
    }

    private String extractToken(HttpServletRequest request) {
        // 1. 从 Authorization Header 获取
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
            String token = authHeader.substring(BEARER_PREFIX.length()).trim();
            if (!token.isEmpty()) {
                return token;
            }
        }

        // 2. 从 URL Parameter 获取
        String paramToken = request.getParameter("access_token");
        if (paramToken != null && !paramToken.isEmpty()) {
            return paramToken;
        }

        // 3. 从 Session 获取
        HttpSession session = request.getSession(false);
        if (session != null) {
            String sessionToken = (String) session.getAttribute(SESSION_TOKEN_KEY);
            if (sessionToken != null && !sessionToken.isEmpty()) {
                return sessionToken;
            }
        }

        return null;
    }

    private UserInfo getUserInfoWithCache(String accessToken) {
        String cacheKey = DigestUtils.md5Hex(accessToken);
        UserInfo cached = userInfoCache.getIfPresent(cacheKey);
        if (cached != null) {
            return cached;
        }
        UserInfo userInfo = oauth2Service.getUserInfo(accessToken);
        if (userInfo != null) {
            userInfoCache.put(cacheKey, userInfo);
        }
        return userInfo;
    }

    private void handleUnauthorized(HttpServletRequest request,
                                     HttpServletResponse response) throws IOException {
        if (isApiRequest(request)) {
            handleApiUnauthorized(response);
        } else {
            handleWebUnauthorized(request, response);
        }
    }

    private boolean isApiRequest(HttpServletRequest request) {
        String uri = request.getRequestURI();
        String contextPath = request.getContextPath();
        String relativeUri = uri.substring(contextPath.length());

        if (relativeUri.startsWith("/api/")) {
            return true;
        }

        String accept = request.getHeader("Accept");
        if (accept != null && accept.contains("application/json")) {
            return true;
        }

        String requestedWith = request.getHeader("X-Requested-With");
        if ("XMLHttpRequest".equals(requestedWith)) {
            return true;
        }

        return false;
    }

    private void handleWebUnauthorized(HttpServletRequest request,
                                       HttpServletResponse response) throws IOException {
        String originalUrl = request.getRequestURL().toString();
        if (request.getQueryString() != null) {
            originalUrl += "?" + request.getQueryString();
        }

        HttpSession session = request.getSession(true);
        session.setAttribute(SESSION_ORIGINAL_URL_KEY, originalUrl);

        String state = generateSecureRandom();
        session.setAttribute(SESSION_STATE_KEY, state);

        String authUrl = buildAuthorizationUrl(request, state);
        log.debug("重定向到 CAS 登录页面: {}", authUrl);
        response.sendRedirect(authUrl);
    }

    private void handleApiUnauthorized(HttpServletResponse response) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("code", 403);
        result.put("message", "未授权访问,请先登录");
        result.put("timestamp", System.currentTimeMillis());

        response.getWriter().write(JSON.toJSONString(result));
    }

    private String buildAuthorizationUrl(HttpServletRequest request, String state) {
        StringBuilder url = new StringBuilder();
        url.append(oauth2Config.getCasServerUrl())
           .append("/oauth2.0/authorize?")
           .append("client_id=").append(urlEncode(oauth2Config.getClientId()))
           .append("&redirect_uri=").append(urlEncode(buildRedirectUri(request)))
           .append("&response_type=code")
           .append("&scope=").append(urlEncode(oauth2Config.getScope()))
           .append("&state=").append(urlEncode(state));
        return url.toString();
    }

    private String buildRedirectUri(HttpServletRequest request) {
        StringBuilder uri = new StringBuilder();
        uri.append(request.getScheme()).append("://").append(request.getServerName());
        if (request.getServerPort() != 80 && request.getServerPort() != 443) {
            uri.append(":").append(request.getServerPort());
        }
        uri.append(request.getContextPath()).append("/oauth2/callback");
        return uri.toString();
    }

    private String generateSecureRandom() {
        SecureRandom random = new SecureRandom();
        byte[] bytes = new byte[32];
        random.nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }

    private String urlEncode(String value) {
        try {
            return URLEncoder.encode(value, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }
}

3.8 过滤器链的执行流程分析

让我们通过三个具体的请求场景来分析 OAuthFilter 在整个过滤器链中的执行流程。

场景一:用户首次访问受保护页面(前端模式)

1. 用户在浏览器中访问 https://app.example.com/dashboard
2. 请求到达 OAuthFilter
3. OAuthFilter 检查白名单 -> /dashboard 不在白名单中
4. OAuthFilter 提取 Token -> 未找到 Token
5. OAuthFilter 判断请求类型 -> 非API请求(前端模式)
6. OAuthFilter 保存原始URL到Session
7. OAuthFilter 生成state参数并存入Session
8. OAuthFilter 构建CAS授权URL
9. OAuthFilter 返回302重定向到CAS登录页面
10. 浏览器跟随重定向,显示CAS登录页面
11. 用户输入用户名密码,提交登录
12. CAS Server 验证凭证,创建TGT
13. CAS Server 生成授权码,302重定向回 /oauth2/callback?code=xxx&state=yyy
14. 浏览器访问 /oauth2/callback(OAuthFilter白名单,直接放行)
15. OAuth2CallbackController 接收授权码
16. 用授权码换取Access Token
17. 用Access Token获取用户信息
18. 将Token和用户信息存入Session
19. 重定向回原始URL /dashboard
20. 浏览器再次访问 /dashboard
21. OAuthFilter 从Session中获取Token -> 有效
22. OAuthFilter 获取用户信息 -> 成功
23. OAuthFilter 创建 TokenRequestWrapper,注入用户信息
24. Controller 处理请求,通过 request.getAttribute("userId") 获取用户信息
25. 返回页面内容

场景二:API 请求携带 Token(API 模式)

1. 前端AJAX请求 GET /api/users,Header: Authorization: Bearer xxx
2. OAuthFilter 检查白名单 -> /api/users 不在白名单中
3. OAuthFilter 提取 Token -> 从Header中获取到Token
4. OAuthFilter 验证Token -> 有效
5. OAuthFilter 获取用户信息 -> 成功(优先从缓存获取)
6. OAuthFilter 创建 TokenRequestWrapper,注入用户信息
7. Controller 处理请求,返回用户列表JSON

场景三:API 请求未携带 Token(API 模式)

1. 前端AJAX请求 GET /api/users,无Token
2. OAuthFilter 检查白名单 -> /api/users 不在白名单中
3. OAuthFilter 提取 Token -> 未找到Token
4. OAuthFilter 判断请求类型 -> API请求(路径以/api/开头)
5. OAuthFilter 返回 HTTP 403 JSON: {"code": 403, "message": "未授权访问"}

第四章 TokenRequestWrapper 请求包装器

4.1 HttpServletRequestWrapper 原理

HttpServletRequestWrapper 是 Java Servlet 规范提供的一个装饰器(Decorator)类,它实现了 HttpServletRequest 接口,并将所有方法调用委托给被包装的原始 HttpServletRequest 对象。开发者可以通过继承 HttpServletRequestWrapper 并重写特定方法来"修改"请求的行为,而不需要修改原始请求对象。

这种设计模式在 Servlet 规范中被广泛使用,它的核心优势在于:

  1. 不可变性: 原始的 HttpServletRequest 对象由 Servlet 容器创建和管理,应用代码不应该直接修改它。通过 Wrapper 模式,我们可以在不修改原始对象的前提下"扩展"请求的功能。

  2. 透明性: Wrapper 对象实现了与原始对象相同的接口,对于下游的 Filter、Servlet 来说,使用 Wrapper 和使用原始请求没有任何区别。

  3. 链式包装: 多个 Wrapper 可以嵌套使用,每个 Wrapper 负责不同的功能增强。

4.2 TokenRequestWrapper 设计目标

TokenRequestWrapper 的设计目标是:在 OAuthFilter 验证 Token 成功后,将用户信息(如 userId、userName、email 等)注入到请求对象中,使得下游的 Controller 和 Service 可以通过 request.getAttribute() 方便地获取当前登录用户的信息。

设计目标:

  1. 透明注入: 下游代码不需要知道 TokenRequestWrapper 的存在,只需要调用 request.getAttribute("userId") 就能获取用户ID。
  2. 属性隔离: 用户信息属性与原始请求中的属性不冲突。如果原始请求中已经存在同名属性,用户信息属性优先返回。
  3. 只读安全: 用户信息属性是只读的,下游代码不能通过 setAttribute() 修改用户信息。
  4. 类型安全: 提供便捷的类型转换方法,避免下游代码频繁进行类型转换。

4.3 用户信息注入机制

TokenRequestWrapper 通过重写 getAttribute() 方法来实现用户信息的注入。当下游代码调用 request.getAttribute("userId") 时,TokenRequestWrapper 会先检查是否是预定义的用户信息属性,如果是,返回注入的值;否则,委托给原始请求对象。

预定义的用户信息属性:

属性名类型说明
userIdString用户唯一标识符
userNameString用户名称
emailString用户邮箱
departmentString用户所属部门
titleString用户职位
employeeNumberString员工编号
userInfoUserInfo完整的用户信息对象
java
@Override
public Object getAttribute(String name) {
    if (userInfo == null) {
        return super.getAttribute(name);
    }

    switch (name) {
        case "userId":
            return userInfo.getUserId();
        case "userName":
            return userInfo.getUserName();
        case "email":
            return userInfo.getEmail();
        case "department":
            return userInfo.getDepartment();
        case "title":
            return userInfo.getTitle();
        case "employeeNumber":
            return userInfo.getEmployeeNumber();
        case "userInfo":
            return userInfo;
        default:
            return super.getAttribute(name);
    }
}

4.4 Controller 层获取用户信息的方式

在 TokenRequestWrapper 注入用户信息后,Controller 层可以通过多种方式获取当前登录用户的信息。

方式一:通过 HttpServletRequest 获取(最基础)

java
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping
    public Result<List<Order>> listOrders(HttpServletRequest request) {
        String userId = (String) request.getAttribute("userId");
        String userName = (String) request.getAttribute("userName");
        List<Order> orders = orderService.getOrdersByUserId(userId);
        return Result.success(orders);
    }
}

方式二:通过工具类获取(推荐)

java
/**
 * 用户信息工具类
 */
public final class UserContext {

    private UserContext() {}

    public static String getUserId(HttpServletRequest request) {
        return (String) request.getAttribute("userId");
    }

    public static String getUserName(HttpServletRequest request) {
        return (String) request.getAttribute("userName");
    }

    public static UserInfo getUserInfo(HttpServletRequest request) {
        return (UserInfo) request.getAttribute("userInfo");
    }

    public static String requireUserId(HttpServletRequest request) {
        String userId = getUserId(request);
        if (userId == null || userId.isEmpty()) {
            throw new UnauthorizedException("用户未登录");
        }
        return userId;
    }
}

使用工具类后,Controller 代码更加简洁:

java
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping
    public Result<List<Order>> listOrders(HttpServletRequest request) {
        String userId = UserContext.requireUserId(request);
        List<Order> orders = orderService.getOrdersByUserId(userId);
        return Result.success(orders);
    }

    @PostMapping
    public Result<Order> createOrder(@RequestBody OrderCreateDTO dto,
                                      HttpServletRequest request) {
        String userId = UserContext.requireUserId(request);
        dto.setCreatorId(userId);
        dto.setCreatorName(UserContext.getUserName(request));
        Order order = orderService.createOrder(dto);
        return Result.success(order);
    }
}

方式三:通过方法参数解析器获取(高级)

在 Spring MVC 中,可以自定义 HandlerMethodArgumentResolver,将用户信息直接注入到 Controller 方法参数中:

java
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
    boolean required() default true;
}

public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class)
            && parameter.getParameterType().isAssignableFrom(UserInfo.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        UserInfo userInfo = UserContext.getUserInfo(request);

        CurrentUser annotation = parameter.getParameterAnnotation(CurrentUser.class);
        if (annotation.required() && userInfo == null) {
            throw new UnauthorizedException("用户未登录");
        }
        return userInfo;
    }
}

使用注解方式后,Controller 代码更加优雅:

java
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping
    public Result<List<Order>> listOrders(@CurrentUser UserInfo user) {
        List<Order> orders = orderService.getOrdersByUserId(user.getUserId());
        return Result.success(orders);
    }
}

4.5 线程安全考量与最佳实践

在 Servlet 容器中,每个 HTTP 请求通常由一个独立的线程处理。Servlet 规范保证同一个 HttpServletRequest 对象在同一个线程内使用,不会出现多线程并发访问同一个请求对象的情况。因此,TokenRequestWrapper 本身不存在线程安全问题。

但是,在以下场景中需要注意线程安全:

(1)避免在 TokenRequestWrapper 中存储可变状态

TokenRequestWrapper 应该是不可变的(Immutable)。一旦创建,其中的用户信息就不应该被修改。如果需要在请求处理过程中传递额外的信息,应该使用 request.setAttribute() 方法。

java
// 错误做法
public class BadWrapper extends HttpServletRequestWrapper {
    private Map<String, Object> extraAttributes = new HashMap<>(); // 线程不安全
}

// 正确做法
request.setAttribute("extraData", someObject);

(2)用户信息缓存使用 Token 哈希作为 Key

在 OAuthFilter 中,我们使用 Caffeine 缓存来缓存用户信息。缓存的 Key 使用 Access Token 的 MD5 哈希值,而不是 Token 本身(因为 Token 可能很长,作为 Key 效率低)。

(3)Service 层不要依赖 Request 对象

在 Service 层中,不应该直接依赖 HttpServletRequest 对象来获取用户信息。正确的做法是在 Controller 层获取用户信息,然后作为方法参数传递给 Service 层:

java
// 错误做法
@Service
public class OrderService {
    public void createOrder(OrderDTO dto) {
        HttpServletRequest request = ((ServletRequestAttributes)
            RequestContextHolder.currentRequestAttributes()).getRequest();
        String userId = (String) request.getAttribute("userId");
    }
}

// 正确做法
@Service
public class OrderService {
    public void createOrder(OrderDTO dto, String userId, String userName) {
        dto.setCreatorId(userId);
        dto.setCreatorName(userName);
    }
}

4.6 TokenRequestWrapper 完整实现

java
/**
 * Token 请求包装器
 *
 * 在 OAuthFilter 验证 Token 成功后,使用此类包装原始请求,
 * 将用户信息注入到请求属性中,供下游 Controller 和 Service 使用。
 *
 * @author smart-scaffold
 */
public class TokenRequestWrapper extends HttpServletRequestWrapper {

    private final UserInfo userInfo;

    public static final String ATTR_USER_ID = "userId";
    public static final String ATTR_USER_NAME = "userName";
    public static final String ATTR_EMAIL = "email";
    public static final String ATTR_DEPARTMENT = "department";
    public static final String ATTR_TITLE = "title";
    public static final String ATTR_EMPLOYEE_NUMBER = "employeeNumber";
    public static final String ATTR_USER_INFO = "userInfo";

    public TokenRequestWrapper(HttpServletRequest request, UserInfo userInfo) {
        super(request);
        this.userInfo = userInfo;
    }

    public UserInfo getUserInfo() {
        return this.userInfo;
    }

    @Override
    public Object getAttribute(String name) {
        if (userInfo == null) {
            return super.getAttribute(name);
        }

        switch (name) {
            case ATTR_USER_ID:
                return userInfo.getUserId();
            case ATTR_USER_NAME:
                return userInfo.getUserName();
            case ATTR_EMAIL:
                return userInfo.getEmail();
            case ATTR_DEPARTMENT:
                return userInfo.getDepartment();
            case ATTR_TITLE:
                return userInfo.getTitle();
            case ATTR_EMPLOYEE_NUMBER:
                return userInfo.getEmployeeNumber();
            case ATTR_USER_INFO:
                return userInfo;
            default:
                return super.getAttribute(name);
        }
    }

    public String getUserId() {
        return userInfo != null ? userInfo.getUserId() : null;
    }

    public String getUserName() {
        return userInfo != null ? userInfo.getUserName() : null;
    }

    public String getEmail() {
        return userInfo != null ? userInfo.getEmail() : null;
    }

    @Override
    public Enumeration<String> getAttributeNames() {
        Set<String> names = new LinkedHashSet<>();

        Enumeration<String> originalNames = super.getAttributeNames();
        while (originalNames.hasMoreElements()) {
            names.add(originalNames.nextElement());
        }

        if (userInfo != null) {
            names.add(ATTR_USER_ID);
            names.add(ATTR_USER_NAME);
            names.add(ATTR_EMAIL);
            names.add(ATTR_DEPARTMENT);
            names.add(ATTR_TITLE);
            names.add(ATTR_EMPLOYEE_NUMBER);
            names.add(ATTR_USER_INFO);
        }

        return Collections.enumeration(names);
    }
}

UserInfo 数据模型:

java
public class UserInfo implements Serializable {

    private static final long serialVersionUID = 1L;

    private String userId;
    private String userName;
    private String email;
    private String department;
    private String title;
    private String employeeNumber;
    private Map<String, Object> extraAttributes;

    // Getter / Setter 省略...

    @Override
    public String toString() {
        return "UserInfo{" +
                "userId='" + userId + '\'' +
                ", userName='" + userName + '\'' +
                ", email='" + email + '\'' +
                ", department='" + department + '\'' +
                '}';
    }
}

第五章 FilterConfig 过滤器配置

5.1 Filter 注册方式对比

在 Java Web 应用中,Filter 的注册方式主要有以下三种:

方式一:web.xml 配置(传统方式)

xml
<filter>
    <filter-name>oauthFilter</filter-name>
    <filter-class>com.example.filter.OAuthFilter</filter-class>
    <init-param>
        <param-name>casServerUrl</param-name>
        <param-value>https://cas.example.com/cas</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>oauthFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

方式二:Servlet 3.0 注解方式

java
@WebFilter(urlPatterns = "/*",
    initParams = {
        @WebInitParam(name = "casServerUrl", value = "https://cas.example.com/cas")
    })
public class OAuthFilter implements Filter { }

方式三:Spring Boot 编程式注册(推荐)

java
@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<OAuthFilter> oauthFilterRegistration() {
        FilterRegistrationBean<OAuthFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new OAuthFilter());
        registration.addUrlPatterns("/*");
        registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
        registration.setName("oauthFilter");
        return registration;
    }
}
特性web.xml@WebFilterFilterRegistrationBean
框架无关否(依赖 Spring Boot)
依赖注入
顺序控制配置顺序不支持setOrder()
URL Pattern支持支持支持
灵活性

5.2 URL Pattern 配置策略

在 smart-scaffold 项目中,我们使用 /* 拦截所有请求,然后在 OAuthFilter 内部通过白名单机制来放行不需要鉴权的请求。这种策略的好处是配置简单、集中管理、灵活可控。

5.3 过滤器顺序控制

Filter 的执行顺序非常重要。在 smart-scaffold 项目中,推荐顺序如下:

1. CharacterEncodingFilter     (编码过滤器)
2. CorsFilter                  (跨域过滤器 - 必须在 OAuthFilter 之前)
3. OAuthFilter                 (鉴权过滤器 - 本文核心)
4. RequestLoggingFilter        (日志过滤器)

为什么 OAuthFilter 要在 CORS Filter 之后?

如果 OAuthFilter 在 CORS Filter 之前执行,浏览器发出的跨域预检请求(OPTIONS)也会被 OAuthFilter 拦截。由于 OPTIONS 请求不会携带 Authorization Header,OAuthFilter 会将其视为未授权请求,导致 CORS 预检失败。

5.4 初始化参数配置

在 Spring Boot 环境下,我们推荐使用 @ConfigurationProperties 来管理配置:

java
@ConfigurationProperties(prefix = "oauth2")
public class OAuth2Config {

    private String casServerUrl;
    private String clientId;
    private String clientSecret;
    private String scope = "openid profile email";
    private String redirectUri;
    private String tokenEndpoint = "/oauth2.0/accessToken";
    private String profileEndpoint = "/oauth2.0/profile";
    private String authorizeEndpoint = "/oauth2.0/authorize";
    private WhitelistConfig whitelist = new WhitelistConfig();
    private boolean enabled = true;

    // Getter / Setter 省略...

    public static class WhitelistConfig {
        private String exact;
        private String prefixes;
        private String suffixes;
        private String regex;
        // Getter / Setter 省略...
    }
}

application.yml 配置示例:

yaml
oauth2:
  enabled: true
  cas-server-url: https://cas.example.com/cas
  client-id: smart-scaffold-app
  client-secret: ${OAUTH_CLIENT_SECRET:default-secret}
  scope: openid profile email
  token-endpoint: /oauth2.0/accessToken
  profile-endpoint: /oauth2.0/profile
  authorize-endpoint: /oauth2.0/authorize
  whitelist:
    exact: /login,/logout,/oauth2/callback,/error,/health,/info,/favicon.ico
    prefixes: /static/,/public/,/layui/,/webjars/,/assets/
    suffixes: .css,.js,.png,.jpg,.jpeg,.gif,.ico,.woff,.woff2,.ttf,.eot,.svg,.map
    regex: /api/public/.*

5.5 FilterConfig 完整配置示例

java
@Configuration
@EnableConfigurationProperties(OAuth2Config.class)
public class FilterConfig {

    private static final Logger log = LoggerFactory.getLogger(FilterConfig.class);

    @Autowired
    private OAuth2Config oauth2Config;

    @Bean
    public FilterRegistrationBean<OAuthFilter> oauthFilterRegistration() {
        log.info("注册 OAuthFilter...");
        log.info("  CAS Server URL: {}", oauth2Config.getCasServerUrl());
        log.info("  Client ID: {}", oauth2Config.getClientId());

        FilterRegistrationBean<OAuthFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new OAuthFilter());
        registration.addUrlPatterns("/*");
        registration.setName("oauthFilter");
        registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);

        Map<String, String> initParams = new LinkedHashMap<>();
        initParams.put("casServerUrl", oauth2Config.getCasServerUrl());
        initParams.put("clientId", oauth2Config.getClientId());
        initParams.put("clientSecret", oauth2Config.getClientSecret());
        initParams.put("scope", oauth2Config.getScope());
        initParams.put("enabled", String.valueOf(oauth2Config.isEnabled()));
        registration.setInitParameters(initParams);

        return registration;
    }

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE + 1)
    public FilterRegistrationBean<CorsFilter> corsFilterRegistration() {
        FilterRegistrationBean<CorsFilter> registration = new FilterRegistrationBean<>();

        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOriginPattern("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);

        registration.setFilter(new CorsFilter(source));
        registration.addUrlPatterns("/*");
        registration.setName("corsFilter");
        registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);

        return registration;
    }
}

5.6 Spring Boot 环境下的 Filter 注册

在 Spring Boot 环境下,除了使用 FilterRegistrationBean,还可以直接将 Filter 声明为 Spring Bean。但这种方式有一个重要的限制:Spring Boot 自动注册的 Filter 默认拦截所有请求(/*),且无法通过 @Order 注解精确控制与其他 Filter 的相对顺序。

因此,在 smart-scaffold 项目中,我们推荐使用 FilterRegistrationBean 方式,因为它提供了更精细的控制能力。


第六章 前端模式 vs API 模式

6.1 两种模式的场景分析

在实际的企业应用中,通常存在两种截然不同的请求类型:

前端渲染请求(Web Mode):

  • 用户在浏览器中直接访问页面 URL
  • 期望的行为是:如果未登录,重定向到 CAS 登录页面;登录成功后,自动跳回原始页面
  • 典型场景:用户在地址栏输入 URL、点击页面中的链接、浏览器前进/后退

API 请求(API Mode):

  • 前端 JavaScript 通过 AJAX/Fetch 发起的 API 调用
  • 期望的行为是:如果未登录,返回 HTTP 403 JSON 响应,由前端 JavaScript 决定如何处理
  • 典型场景:Vue/React 单页应用的数据请求、移动端 App 的接口调用
特性前端模式API 模式
请求方式浏览器地址栏/链接AJAX/Fetch
期望响应302 重定向到登录页403 JSON
Content-Typetext/htmlapplication/json
错误处理浏览器自动跟随重定向前端 JS 处理
Token 来源SessionHeader (Authorization)

6.2 前端模式:重定向到登录页

在前端模式下,当 OAuthFilter 判断当前请求未携带有效 Token 时,会执行以下操作:

  1. 保存原始请求 URL 到 Session
  2. 生成 state 参数(防 CSRF)
  3. 构建 OAuth2.0 授权 URL
  4. 发送 302 重定向 到 CAS Server 登录页面
java
private void handleWebUnauthorized(HttpServletRequest request,
                                   HttpServletResponse response) throws IOException {
    String originalUrl = buildFullUrl(request);
    HttpSession session = request.getSession(true);
    session.setAttribute(SESSION_ORIGINAL_URL_KEY, originalUrl);

    String state = generateSecureRandom();
    session.setAttribute(SESSION_STATE_KEY, state);

    String authUrl = buildAuthorizationUrl(request, state);
    response.sendRedirect(authUrl);
}

6.3 API 模式:返回 403 JSON

在 API 模式下,OAuthFilter 直接返回 HTTP 403 Forbidden 的 JSON 响应:

java
private void handleApiUnauthorized(HttpServletResponse response) throws IOException {
    response.setStatus(HttpServletResponse.SC_FORBIDDEN);
    response.setContentType("application/json;charset=UTF-8");
    response.setHeader("Cache-Control", "no-store");
    response.setHeader("Pragma", "no-cache");

    Map<String, Object> result = new LinkedHashMap<>();
    result.put("code", HttpServletResponse.SC_FORBIDDEN);
    result.put("message", "访问被拒绝:未提供有效的认证凭证");
    result.put("timestamp", System.currentTimeMillis());
    result.put("path", "/api/...");

    response.getWriter().write(JSON.toJSONString(result));
}

前端 AJAX 请求的 403 处理示例(使用 Axios):

javascript
axios.interceptors.response.use(
    function (response) { return response; },
    function (error) {
        if (error.response && error.response.status === 403) {
            window.location.href = '/login';
        }
        return Promise.reject(error);
    }
);

6.4 配置切换策略

在 smart-scaffold 项目中,前端模式和 API 模式的切换不是通过全局配置来控制的,而是由 OAuthFilter 根据每个请求的特征自动判断的。这种"自动识别"的策略使得同一个应用可以同时支持两种模式。

java
private boolean isApiRequest(HttpServletRequest request) {
    String uri = request.getRequestURI();
    String contextPath = request.getContextPath();
    String relativeUri = uri.substring(contextPath.length());

    // 优先级1:路径前缀
    if (relativeUri.startsWith("/api/")) {
        return true;
    }

    // 优先级2:Accept 头
    String accept = request.getHeader("Accept");
    if (accept != null && accept.contains("application/json")) {
        return true;
    }

    // 优先级3:X-Requested-With 头
    String requestedWith = request.getHeader("X-Requested-With");
    if ("XMLHttpRequest".equals(requestedWith)) {
        return true;
    }

    // 优先级4:Content-Type 头
    String contentType = request.getContentType();
    if (contentType != null && contentType.contains("application/json")) {
        return true;
    }

    return false;
}

6.5 混合模式:同时支持两种模式

在实际项目中,一个应用往往需要同时支持前端模式和 API 模式。例如:

  • 用户在浏览器中访问 /dashboard 页面(前端模式)
  • 页面加载后,JavaScript 发起 GET /api/dashboard/data 获取数据(API 模式)
  • 用户点击页面中的链接,访问 /orders 页面(前端模式)
  • 页面中的表格通过 GET /api/orders 获取数据(API 模式)

smart-scaffold 的 OAuthFilter 天然支持这种混合模式——它根据每个请求的特征自动判断应该使用哪种模式,无需任何额外配置。

6.6 模式识别的实现细节

在实际实现中,模式识别需要考虑一些边界情况和安全考量:

(1)OPTIONS 预检请求的处理

浏览器在发起跨域 AJAX 请求之前,会先发送一个 OPTIONS 预检请求。OPTIONS 请求不会携带 Authorization Header,因此 OAuthFilter 不应该对 OPTIONS 请求进行鉴权。

java
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
    filterChain.doFilter(request, response);
    return;
}

(2)路径规范化的安全考量

攻击者可能通过路径规范化绕过 API 模式的检测。例如 /api/../dashboard 可能绕过 /api/ 前缀检测。

java
private String normalizeAndValidateUri(String uri) {
    if (uri == null) return "";
    try {
        uri = URLDecoder.decode(uri, "UTF-8");
    } catch (Exception e) { }
    while (uri.contains("/../")) {
        uri = uri.replace("/../", "/");
    }
    while (uri.contains("/./")) {
        uri = uri.replace("/./", "/");
    }
    return uri;
}

(3)自定义请求头的模式提示

在某些复杂场景下,可以通过自定义请求头来显式指定请求模式:

java
private static final String MODE_HEADER = "X-Auth-Mode";

private AuthMode detectAuthMode(HttpServletRequest request) {
    String modeHeader = request.getHeader(MODE_HEADER);
    if ("api".equalsIgnoreCase(modeHeader)) {
        return AuthMode.API;
    }
    if ("web".equalsIgnoreCase(modeHeader)) {
        return AuthMode.WEB;
    }
    if (isApiRequest(request)) {
        return AuthMode.API;
    }
    return AuthMode.WEB;
}

public enum AuthMode {
    WEB,    // 前端模式:重定向到登录页
    API     // API 模式:返回 403 JSON
}

第七章 CAS Server 端配置

7.1 CAS Server 架构概述

CAS Server 是整个统一认证体系的核心组件。它是一个基于 Java 的 Web 应用,通常部署在 Tomcat、Jetty 等 Servlet 容器中。CAS Server 的核心架构包括以下几个模块:

  • Web Layer: 登录页面、授权确认页面、错误页面
  • Protocol Layer: CAS Protocol、OAuth2.0、OpenID Connect
  • Authentication Layer: LDAP/AD、JDBC、REST API 等认证源
  • Ticket Management: TGT、ST、OAuth Token 的管理
  • Service Registry: 客户端服务的注册和管理

7.2 OAuth2.0 服务注册

在 CAS Server 中,每个需要接入 OAuth2.0 的客户端应用都必须先进行服务注册。CAS Server 支持多种服务注册方式:JSON 文件注册(开发/测试)、数据库注册(生产)、JMX 动态注册。

JSON 文件注册示例(教学简化版):

json
{
  "@class": "org.apereo.cas.services.OAuthRegisteredService",
  "serviceId": "^https://app\\.example\\.com/.*",
  "name": "Smart Scaffold Application",
  "id": 10000001,
  "description": "smart-scaffold 企业级快速开发脚手架",
  "clientId": "smart-scaffold-app",
  "clientSecret": "{noop}your-client-secret-here",
  "supportedGrantTypes": [
    "java.util.HashSet",
    ["authorization_code", "refresh_token"]
  ],
  "supportedResponseTypes": [
    "java.util.HashSet",
    ["code"]
  ],
  "scopes": [
    "java.util.HashSet",
    ["openid", "profile", "email"]
  ],
  "bypassApprovalPrompt": true,
  "evaluationOrder": 100,
  "accessStrategy": {
    "@class": "org.apereo.cas.services.DefaultRegisteredServiceAccessStrategy",
    "enabled": true,
    "unauthorizedRedirectUrl": "https://app.example.com/login"
  },
  "attributeReleasePolicy": {
    "@class": "org.apereo.cas.services.ReturnAllAttributeReleasePolicy"
  }
}

关键字段说明:

字段说明
serviceId服务匹配模式(正则表达式),CAS 使用此字段匹配回调 URL
name服务名称(便于管理)
id服务唯一标识(数字)
clientIdOAuth2.0 客户端 ID
clientSecretOAuth2.0 客户端密钥(支持加密)
supportedGrantTypes支持的授权类型
bypassApprovalPrompt是否跳过授权确认页面
attributeReleasePolicy属性释放策略(控制返回哪些用户属性)

7.3 回调 URL 配置

回调 URL(redirect_uri)是 OAuth2.0 安全机制的重要组成部分。CAS Server 会严格验证回调 URL,确保授权码只能被发送到预先注册的可信地址。

回调 URL 的安全验证规则:

  1. 必须完全匹配注册的 serviceId 模式
  2. 必须使用 HTTPS(生产环境)
  3. 不允许使用 IP 地址(防止 DNS 重绑定攻击)

多环境回调 URL 配置:

json
{
  "serviceId": "^https://(localhost|127\\.0\\.0\\.1|dev\\.example\\.com|app\\.example\\.com)(:\\d+)?/.*",
  "name": "Smart Scaffold Application (All Environments)"
}

注意事项:

  • 回调 URL 的正则表达式需要仔细编写,避免过于宽松导致安全漏洞
  • 不要使用 .* 作为 serviceId
  • 在生产环境中,应该精确匹配域名和端口

7.4 Token 有效期配置

CAS Server 允许对各种 Ticket 和 Token 的有效期进行精细配置:

properties
# TGT 有效期(用户登录凭证)
cas.ticket.tgt.primary.maxTimeToLiveInSeconds=7200
cas.ticket.tgt.primary.timeToKillInSeconds=3600

# ST 有效期(服务票据)
cas.ticket.st.timeToKillInSeconds=10

# OAuth2.0 授权码有效期
cas.ticket.registry.oauth.timeToKillInSeconds=30

# OAuth2.0 Access Token 有效期
cas.ticket.registry.accessToken.timeToKillInSeconds=7200

# OAuth2.0 Refresh Token 有效期
cas.ticket.registry.refreshToken.timeToKillInSeconds=2592000

有效期配置建议:

Token/Ticket 类型开发环境测试环境生产环境
TGT8 小时4 小时2 小时
ST30 秒10 秒10 秒
授权码60 秒30 秒30 秒
Access Token8 小时4 小时2 小时
Refresh Token90 天30 天7 天

7.5 用户信息返回格式

CAS Server 的 OAuth2.0 用户信息接口(Profile Endpoint)默认返回 JSON 格式的用户信息:

json
{
    "id": "zhangsan",
    "attributes": {
        "cn": ["张三"],
        "mail": ["zhangsan@example.com"],
        "department": ["技术部"],
        "title": ["高级工程师"],
        "employeeNumber": ["EMP001"]
    },
    "client_id": "smart-scaffold-app"
}

注意: CAS 默认将属性值返回为数组格式(即使只有一个值)。这是因为 LDAP 中一个属性可能有多个值。

属性释放策略配置:

策略说明
ReturnAllAttributeReleasePolicy返回所有用户属性(仅用于开发/测试)
ReturnAllowedAttributeReleasePolicy只返回指定的属性(推荐用于生产)
DenyAllAttributeReleasePolicy不返回任何属性
MappedAttributeReleasePolicy属性名映射(CAS 属性名 -> 客户端属性名)

生产环境推荐配置(只返回指定属性):

json
{
  "attributeReleasePolicy": {
    "@class": "org.apereo.cas.services.ReturnAllowedAttributeReleasePolicy",
    "allowedAttributes": [
      "cn", "mail", "department", "title", "employeeNumber"
    ]
  }
}

7.6 CAS Server 完整配置示例

下面给出 CAS Server 的完整配置示例(基于 cas.properties):

properties
# ========================================
# CAS Server 基础配置
# ========================================
cas.server.name=https://cas.example.com
cas.server.prefix=${cas.server.name}/cas

# ========================================
# Ticket Registry 配置
# 生产环境建议使用 Hazelcast 或 Redis
# ========================================
cas.ticket.registry.core.enable-locking=false

# ========================================
# TGT 有效期配置
# ========================================
cas.ticket.tgt.primary.maxTimeToLiveInSeconds=7200
cas.ticket.tgt.primary.timeToKillInSeconds=3600

# ========================================
# ST 有效期配置
# ========================================
cas.ticket.st.timeToKillInSeconds=10

# ========================================
# OAuth2.0 配置
# ========================================
cas.ticket.registry.oauth.timeToKillInSeconds=30
cas.ticket.registry.accessToken.timeToKillInSeconds=7200
cas.ticket.registry.refreshToken.timeToKillInSeconds=2592000

# ========================================
# LDAP 认证配置
# ========================================
cas.authn.ldap[0].type=AUTHENTICATED
cas.authn.ldap[0].ldap-url=ldap://ldap.example.com:389
cas.authn.ldap[0].base-dn=dc=example,dc=com
cas.authn.ldap[0].search-filter=(&(objectClass=person)(uid={user}))
cas.authn.ldap[0].bind-dn=cn=cas,ou=services,dc=example,dc=com
cas.authn.ldap[0].bind-password=${LDAP_BIND_PASSWORD}
cas.authn.ldap[0].follow-refs=true

第八章 安全最佳实践

8.1 Token 安全存储策略

Token 的安全存储是 OAuth2.0 集成中最关键的安全环节之一。不同的 Token 类型有不同的安全存储要求。

Access Token 存储策略:

  1. 服务端 Session 存储(推荐用于传统 Web 应用): Access Token 存储在服务端的 HttpSession 中,浏览器只持有 Session Cookie(JSESSIONID)。这种方式下,Access Token 永远不会暴露给浏览器端(包括 JavaScript)。

  2. 内存变量存储(推荐用于 SPA 和移动端): Access Token 存储在 JavaScript 的内存变量中(而不是 localStorage 或 sessionStorage)。当页面刷新或关闭时,Token 会自动清除,减少了 Token 被窃取的风险。

  3. HttpOnly Cookie 存储(推荐替代方案): 将 Access Token 存储在 HttpOnly、Secure、SameSite 的 Cookie 中。HttpOnly 属性防止 JavaScript 访问 Cookie,Secure 属性确保 Cookie 只通过 HTTPS 传输,SameSite 属性防止 CSRF 攻击。

Refresh Token 存储策略:

  1. 服务端持久化存储(推荐): Refresh Token 应该存储在服务端的数据库或 Redis 中,而不是发送给浏览器。当 Access Token 过期时,服务端可以使用 Refresh Token 静默地获取新的 Access Token。

  2. 加密存储: 如果 Refresh Token 必须存储在数据库中,应该使用强加密算法(如 AES-256)进行加密存储,防止数据库泄露导致 Token 被窃取。

  3. 绑定用户和设备: Refresh Token 应该与用户 ID 和设备指纹绑定,防止 Token 被转移到其他设备上使用。

绝对不要做的事情:

  1. 不要将 Token 存储在 localStorage 中: localStorage 容易受到 XSS 攻击的影响,一旦攻击者在页面中注入恶意脚本,就可以读取 localStorage 中的所有数据。

  2. 不要将 Token 暴露在 URL 中: URL 会被浏览器历史记录、代理服务器日志、Referer 头等记录,Token 很容易被泄露。

  3. 不要在客户端代码中硬编码 Client Secret: Client Secret 只应该在服务端使用,永远不应该暴露给浏览器端。

java
/**
 * Token 安全存储工具类
 */
public class TokenStorageUtil {

    /**
     * 安全地存储 Access Token 到 Session
     */
    public static void storeAccessToken(HttpSession session, String accessToken) {
        session.setAttribute("access_token", accessToken);
    }

    /**
     * 安全地从 Session 获取 Access Token
     */
    public static String getAccessToken(HttpSession session) {
        return (String) session.getAttribute("access_token");
    }

    /**
     * 安全地清除 Session 中的 Token
     */
    public static void clearToken(HttpSession session) {
        session.removeAttribute("access_token");
        session.removeAttribute("refresh_token");
        session.removeAttribute("userInfo");
    }

    /**
     * 加密存储 Refresh Token 到数据库
     */
    public static String encryptRefreshToken(String refreshToken, String encryptionKey) {
        // 使用 AES-256 加密
        return AesUtils.encrypt(refreshToken, encryptionKey);
    }

    /**
     * 解密 Refresh Token
     */
    public static String decryptRefreshToken(String encryptedToken, String encryptionKey) {
        return AesUtils.decrypt(encryptedToken, encryptionKey);
    }
}

8.2 HTTPS 传输强制

OAuth2.0 协议中的所有通信(包括授权请求、Token 换取、用户信息获取)都必须通过 HTTPS 进行传输。HTTP 明文传输会导致 Token、用户凭证等敏感信息被网络中间人截获。

HTTPS 强制措施:

(1)CAS Server 端强制 HTTPS

properties
# CAS Server 强制 HTTPS
cas.server.http.enabled=false
cas.server.ssl.enabled=true

# 强制所有请求使用 HTTPS
cas.tgc.secure=true
cas.warningCookie.secure=true

(2)客户端应用强制 HTTPS

在 Spring Boot 中,可以通过配置强制 HTTPS:

properties
# 强制 HTTPS
server.ssl.enabled=true
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=${SSL_KEYSTORE_PASSWORD}
server.ssl.key-store-type=PKCS12
server.port=8443

# HTTP 自动重定向到 HTTPS
security.require-ssl=true

或者通过 Filter 实现:

java
/**
 * HTTPS 强制跳转 Filter
 */
public class HttpsEnforcementFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        if (!httpRequest.isSecure()) {
            String httpsUrl = "https://" + httpRequest.getServerName()
                + (httpRequest.getServerPort() != 443 ? ":" + httpRequest.getServerPort() : "")
                + httpRequest.getRequestURI()
                + (httpRequest.getQueryString() != null ? "?" + httpRequest.getQueryString() : "");

            httpResponse.sendRedirect(httpsUrl);
            return;
        }

        chain.doFilter(request, response);
    }
}

(3)HSTS(HTTP Strict Transport Security)

HSTS 是一个 HTTP 响应头,它告诉浏览器在指定的时间内,对该网站的所有请求都必须使用 HTTPS,即使用户在地址栏中输入 HTTP,浏览器也会自动转换为 HTTPS。

properties
# Spring Security HSTS 配置
security.headers.hsts.enabled=true
security.headers.hsts.max-age-seconds=31536000
security.headers.hsts.include-subdomains=true

8.3 CSRF 防护机制

CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种常见的 Web 安全攻击。在 OAuth2.0 授权码模式中,CSRF 攻击的主要目标是劫持授权流程——攻击者构造一个恶意链接,诱导已登录用户点击,从而将用户的授权码劫持到攻击者的应用中。

OAuth2.0 中的 CSRF 防护:state 参数

OAuth2.0 授权码模式通过 state 参数来防护 CSRF 攻击。其工作原理如下:

  1. 客户端生成一个随机的 state 字符串,存入 Session
  2. 客户端将 state 附加到授权请求 URL 中
  3. CAS Server 在回调时原样返回 state
  4. 客户端验证回调中的 state 是否与 Session 中存储的一致

如果攻击者构造了一个恶意链接,由于攻击者不知道用户 Session 中的 state 值,CAS Server 回调时返回的 state 将与 Session 中的不匹配,客户端可以检测到攻击并拒绝请求。

state 参数的安全要求:

java
/**
 * 生成安全的 state 参数
 * 要求:足够的长度(至少 128 位)、使用密码学安全的随机数生成器
 */
private String generateSecureState() {
    SecureRandom random = new SecureRandom();
    byte[] bytes = new byte[32]; // 256 位
    random.nextBytes(bytes);
    return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

/**
 * 验证 state 参数
 * 要求:使用时间安全的比较方法(防止时序攻击)
 */
private boolean validateState(String receivedState, String expectedState) {
    if (receivedState == null || expectedState == null) {
        return false;
    }
    // 使用 MessageDigest.isEqual 进行时间安全的比较
    return MessageDigest.isEqual(
        receivedState.getBytes(StandardCharsets.UTF_8),
        expectedState.getBytes(StandardCharsets.UTF_8)
    );
}

额外的 CSRF 防护措施:

  1. SameSite Cookie 属性: 将 Session Cookie 设置为 SameSite=StrictSameSite=Lax,防止跨站请求携带 Cookie。

  2. Referer/Origin 检查: 在回调端点中检查请求的 Referer 或 Origin 头,确保请求来自 CAS Server。

  3. 授权码短期有效: 授权码的有效期应该尽可能短(如 30 秒),缩小攻击窗口。

8.4 Token 刷新策略

Access Token 的有效期是有限的(通常为 2 小时)。当 Access Token 过期后,客户端需要使用 Refresh Token 获取新的 Access Token,这个过程称为"Token 刷新"。

Token 刷新的流程:

1. 客户端发现 Access Token 已过期
2. 客户端使用 Refresh Token 向 CAS Server 发送刷新请求
3. CAS Server 验证 Refresh Token 的有效性
4. CAS Server 签发新的 Access Token(可选:同时签发新的 Refresh Token)
5. 客户端使用新的 Access Token 继续访问受保护资源

Token 刷新请求格式:

POST /cas/oauth2.0/accessToken HTTP/1.1
Host: cas.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
  & refresh_token=dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...
  & client_id=smart-scaffold-app
  & client_secret=your-client-secret

Token 刷新的 Java 实现:

java
/**
 * Token 刷新服务
 */
@Service
public class TokenRefreshService {

    @Autowired
    private OAuth2Config oauth2Config;

    @Autowired
    private RestTemplate restTemplate;

    /**
     * 使用 Refresh Token 获取新的 Access Token
     */
    public OAuthTokenResponse refreshToken(String refreshToken) {
        String tokenUrl = oauth2Config.getCasServerUrl()
            + oauth2Config.getTokenEndpoint();

        Map<String, String> params = new LinkedHashMap<>();
        params.put("grant_type", "refresh_token");
        params.put("refresh_token", refreshToken);
        params.put("client_id", oauth2Config.getClientId());
        params.put("client_secret", oauth2Config.getClientSecret());

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        HttpEntity<String> entity = new HttpEntity<>(buildFormBody(params), headers);

        ResponseEntity<String> response = restTemplate.exchange(
            tokenUrl, HttpMethod.POST, entity, String.class);

        if (response.getStatusCode() != HttpStatus.OK) {
            throw new RuntimeException("Token 刷新失败: " + response.getBody());
        }

        return parseTokenResponse(response.getBody());
    }

    /**
     * 在 OAuthFilter 中自动刷新 Token
     */
    public String getValidAccessToken(HttpSession session) {
        String accessToken = (String) session.getAttribute("access_token");

        // 检查 Token 是否过期(通过调用 CAS Server 验证)
        if (isTokenExpired(accessToken)) {
            String refreshToken = (String) session.getAttribute("refresh_token");
            if (refreshToken != null) {
                try {
                    OAuthTokenResponse newToken = refreshToken(refreshToken);
                    session.setAttribute("access_token", newToken.getAccessToken());
                    if (newToken.getRefreshToken() != null) {
                        session.setAttribute("refresh_token", newToken.getRefreshToken());
                    }
                    return newToken.getAccessToken();
                } catch (Exception e) {
                    log.warn("Token 刷新失败,需要重新登录", e);
                    session.invalidate();
                    return null;
                }
            }
        }

        return accessToken;
    }
}

Refresh Token 轮换策略:

为了提高安全性,CAS Server 可以配置为在每次使用 Refresh Token 时签发一个新的 Refresh Token,同时使旧的 Refresh Token 失效。这种策略称为"Refresh Token Rotation"。

Refresh Token Rotation 的好处是:

  1. 如果 Refresh Token 被窃取,攻击者只能使用一次(因为使用后旧 Token 就失效了)
  2. 可以检测到 Token 重用攻击(如果同一个 Refresh Token 被使用两次,说明可能存在窃取)

8.5 会话管理与并发控制

在统一认证体系中,会话管理是一个容易被忽视但非常重要的安全环节。

会话并发控制:

企业级应用通常需要限制同一用户在不同设备上的并发登录数量。例如,一个普通用户只能在一个设备上登录,而管理员可以同时在多个设备上登录。

java
/**
 * 会话并发控制服务
 */
@Service
public class SessionConcurrencyService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    private static final String SESSION_PREFIX = "user:session:";
    private static final int MAX_SESSIONS = 3;

    /**
     * 注册用户会话
     */
    public void registerSession(String userId, String sessionId) {
        String key = SESSION_PREFIX + userId;

        // 获取当前用户的所有会话
        ListOperations<String, String> listOps = redisTemplate.opsForList();
        Long sessionCount = listOps.size(key);

        if (sessionCount != null && sessionCount >= MAX_SESSIONS) {
            // 移除最旧的会话
            String oldestSession = listOps.rightPop(key);
            if (oldestSession != null) {
                // 通知对应的应用清除该会话
                invalidateRemoteSession(oldestSession);
            }
        }

        // 添加新会话到列表头部
        listOps.leftPush(key, sessionId);
        redisTemplate.expire(key, 24, TimeUnit.HOURS);
    }

    /**
     * 注销用户的所有会话
     */
    public void invalidateAllSessions(String userId) {
        String key = SESSION_PREFIX + userId;
        List<String> sessions = redisTemplate.opsForList().range(key, 0, -1);
        if (sessions != null) {
            for (String sessionId : sessions) {
                invalidateRemoteSession(sessionId);
            }
        }
        redisTemplate.delete(key);
    }
}

会话超时策略:

场景推荐超时时间说明
普通用户 Session30 分钟平衡安全性和用户体验
管理员 Session15 分钟更短的超时,更高的安全要求
记住我 Session7 天用户主动选择延长会话
API Token2 小时无状态的 Token 认证

登出策略:

完整的登出流程应该包括以下步骤:

  1. 清除本地 Session 中的 Token 和用户信息
  2. 清除用户信息缓存
  3. 调用 CAS Server 的登出接口,使 TGT 失效
  4. 如果使用了分布式缓存,通知其他应用节点清除对应的 Session
  5. 重定向到 CAS Server 的登出确认页面
java
/**
 * 完整的登出流程
 */
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
    HttpSession session = request.getSession(false);
    if (session != null) {
        String userId = (String) session.getAttribute("userId");
        String sessionId = session.getId();

        // 1. 清除本地 Session
        session.invalidate();

        // 2. 清除用户信息缓存
        userInfoCache.invalidateAll();

        // 3. 注销分布式会话
        if (userId != null) {
            sessionConcurrencyService.removeSession(userId, sessionId);
        }
    }

    // 4. 重定向到 CAS Server 登出页面
    String casLogoutUrl = oauth2Config.getCasServerUrl() + "/logout"
        + "?service=" + URLEncoder.encode(getApplicationUrl(request), "UTF-8");
    return "redirect:" + casLogoutUrl;
}

8.6 安全审计与日志记录

安全审计是统一认证体系中不可或缺的一环。完善的审计日志可以帮助安全团队:

  1. 检测异常行为: 如短时间内大量的登录失败、来自异常地理位置的登录等
  2. 事后追溯: 当安全事件发生时,可以通过审计日志还原事件的时间线
  3. 合规要求: 满足法律法规对审计日志的要求

审计日志记录的内容:

事件类型记录内容
登录成功用户ID、登录时间、IP地址、User-Agent、登录方式
登录失败用户ID(尝试的)、失败时间、IP地址、失败原因
Token 签发用户ID、Token 类型、Token 过期时间、客户端ID
Token 刷新用户ID、旧Token哈希、新Token哈希
登出用户ID、登出时间、登出方式(主动/超时)
权限变更用户ID、变更的权限、变更时间、操作人

审计日志实现:

java
/**
 * 认证审计日志服务
 */
@Service
public class AuthAuditService {

    private static final Logger auditLog = LoggerFactory.getLogger("AUTH_AUDIT");

    /**
     * 记录登录成功事件
     */
    public void logLoginSuccess(String userId, String ipAddress,
                                 String userAgent, String loginMethod) {
        AuditEvent event = new AuditEvent();
        event.setEventType("LOGIN_SUCCESS");
        event.setUserId(userId);
        event.setIpAddress(ipAddress);
        event.setUserAgent(userAgent);
        event.setLoginMethod(loginMethod);
        event.setTimestamp(LocalDateTime.now());

        auditLog.info(JSON.toJSONString(event));

        // 同时写入数据库,用于长期存储和查询
        auditEventRepository.save(event);
    }

    /**
     * 记录登录失败事件
     */
    public void logLoginFailure(String userId, String ipAddress,
                                 String userAgent, String reason) {
        AuditEvent event = new AuditEvent();
        event.setEventType("LOGIN_FAILURE");
        event.setUserId(userId);
        event.setIpAddress(ipAddress);
        event.setUserAgent(userAgent);
        event.setReason(reason);
        event.setTimestamp(LocalDateTime.now());

        auditLog.warn(JSON.toJSONString(event));
        auditEventRepository.save(event);
    }

    /**
     * 记录 Token 签发事件
     */
    public void logTokenIssued(String userId, String tokenType,
                                String clientId, int expiresIn) {
        AuditEvent event = new AuditEvent();
        event.setEventType("TOKEN_ISSUED");
        event.setUserId(userId);
        event.setTokenType(tokenType);
        event.setClientId(clientId);
        event.setExpiresIn(expires);
        event.setTimestamp(LocalDateTime.now());

        auditLog.info(JSON.toJSONString(event));
    }
}

审计日志的注意事项:

  1. 不要在日志中记录敏感信息: 如密码、完整的 Token 值、Client Secret 等。如果需要记录 Token,只记录 Token 的哈希值。

  2. 日志防篡改: 审计日志应该写入不可篡改的存储中(如只追加的文件、区块链存储等),防止攻击者在入侵后修改日志。

  3. 日志保留策略: 根据合规要求设置合理的日志保留时间(通常为 6 个月到 2 年)。

  4. 日志监控告警: 对审计日志进行实时监控,设置告警规则(如短时间内大量登录失败、异常 IP 登录等)。


第九章 实战案例与问题排查

9.1 典型集成场景

场景一:传统 Web 应用接入 CAS SSO

一个使用 JSP/Thymeleaf 渲染的传统 Web 应用需要接入 CAS SSO。这种场景下,使用前端模式(Session 存储 Token),用户在浏览器中访问页面时自动重定向到 CAS 登录页。

集成步骤:

  1. 在 CAS Server 上注册客户端服务(配置 serviceId、clientId、clientSecret)
  2. 在 Web 应用中引入 OAuthFilter 和相关组件
  3. 配置白名单(放行静态资源、登录页、回调端点等)
  4. 部署 OAuth2CallbackController 处理授权回调
  5. 测试完整的登录流程

场景二:前后端分离应用接入 CAS OAuth2.0

一个使用 Vue/React 的前后端分离应用需要接入 CAS OAuth2.0。这种场景下,前端通过 AJAX 调用后端 API,使用 API 模式(Header 携带 Token)。

集成步骤:

  1. 后端配置 OAuthFilter,支持 API 模式
  2. 前端实现 Axios 拦截器,处理 401/403 响应
  3. 前端在登录页中手动跳转到 CAS 授权 URL
  4. 前端在回调页面中获取授权码,调用后端接口换取 Token
  5. 前端将 Token 存储在内存中,每次请求通过 Header 携带

场景三:微服务架构下的 Token 中继

在微服务架构下,API 网关负责统一鉴权,内部微服务通过 Token 中继获取用户信息。

集成步骤:

  1. API 网关部署 OAuthFilter,验证所有请求的 Token
  2. API 网关将用户信息注入到请求 Header 中(如 X-User-IdX-User-Name
  3. 内部微服务从请求 Header 中获取用户信息,无需直接与 CAS Server 交互
  4. 使用服务间通信的 TLS 加密确保 Header 传输安全

9.2 常见问题与解决方案

问题一:无限重定向循环

现象: 用户访问应用后,浏览器不断在应用和 CAS Server 之间重定向,无法正常登录。

原因分析:

  • 回调端点 /oauth2/callback 没有加入白名单
  • redirect_uri 与 CAS Server 注册的 serviceId 不匹配
  • CAS Server 的回调 URL 配置错误

解决方案:

properties
# 确保 /oauth2/callback 在白名单中
oauth.whitelist.exact=/login,/logout,/oauth2/callback,/error,/health,/info,/favicon.ico

# 确保 redirect_uri 与 CAS Server 注册的 serviceId 匹配
# 例如:serviceId = "^https://app\\.example\\.com/.*"
# 则 redirect_uri 必须以 https://app.example.com/ 开头

问题二:CORS 预检请求失败

现象: 前端 AJAX 请求被浏览器拦截,控制台报 CORS 错误。

原因分析:

  • CORS Filter 没有在 OAuthFilter 之前执行
  • CORS 配置没有允许必要的 Header(如 Authorization)
  • CORS 配置没有允许凭证(Credentials)

解决方案:

java
// 确保 CORS Filter 的 Order 小于 OAuthFilter
@Bean
public FilterRegistrationBean<CorsFilter> corsFilterRegistration() {
    // Order = 1 (CORS) < Order = 10 (OAuth)
    registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
    // ...
}

// CORS 配置允许 Authorization Header
config.addAllowedHeader("*");
config.setAllowCredentials(true);

问题三:Token 过期后用户体验差

现象: 用户操作一段时间后,突然被重定向到登录页,丢失了正在编辑的数据。

原因分析:

  • Access Token 过期后没有自动刷新
  • 没有实现 Refresh Token 机制

解决方案:

java
// 在 OAuthFilter 中实现 Token 自动刷新
private String getValidAccessToken(HttpSession session) {
    String accessToken = (String) session.getAttribute("access_token");

    if (isTokenExpired(accessToken)) {
        String refreshToken = (String) session.getAttribute("refresh_token");
        if (refreshToken != null) {
            OAuthTokenResponse newToken = tokenRefreshService.refreshToken(refreshToken);
            session.setAttribute("access_token", newToken.getAccessToken());
            return newToken.getAccessToken();
        }
    }

    return accessToken;
}

问题四:Session 共享问题(多实例部署)

现象: 应用部署多个实例后,用户在一个实例上登录成功,但访问另一个实例时又被要求登录。

原因分析:

  • 默认的 HttpSession 存储在单个应用实例的内存中,不同实例之间不共享

解决方案:

java
// 使用 Spring Session + Redis 实现 Session 共享
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)

// 或者使用 Token 模式替代 Session 模式
// 前端每次请求都通过 Header 携带 Access Token
// 后端通过 Token 获取用户信息,不依赖 Session

问题五:CAS Server 不可用导致应用无法使用

现象: CAS Server 宕机后,所有应用都无法登录和使用。

原因分析:

  • 所有认证请求都依赖 CAS Server,CAS Server 是单点故障

解决方案:

  • CAS Server 集群部署(至少 2 个实例)
  • 使用 Redis/Hazelcast 作为共享的 Ticket Registry
  • 配置 Nginx 负载均衡和健康检查
  • 考虑实现本地 Token 缓存,在 CAS Server 短暂不可用时使用缓存中的 Token

9.3 性能优化建议

(1)用户信息缓存

每次请求都调用 CAS Server 获取用户信息会产生大量的网络开销。使用本地缓存(如 Caffeine)可以显著减少对 CAS Server 的调用次数。

java
// 缓存配置建议
private final Cache<String, UserInfo> userInfoCache = Caffeine.newBuilder()
    .expireAfterWrite(5, TimeUnit.MINUTES)  // 5 分钟过期
    .maximumSize(10000)                      // 最多缓存 10000 个用户
    .recordStats()                           // 记录缓存统计信息
    .build();

(2)HTTP 连接池

使用连接池复用 HTTP 连接,减少 TCP 握手开销:

java
@Bean
public RestTemplate restTemplate() {
    return new RestTemplateBuilder()
        .setConnectTimeout(Duration.ofSeconds(5))
        .setReadTimeout(Duration.ofSeconds(10))
        .build();
}

(3)异步 Token 刷新

在 Token 即将过期时,提前异步刷新 Token,避免用户感知到延迟:

java
@Scheduled(fixedRate = 60000) // 每分钟检查一次
public void proactiveTokenRefresh() {
    // 查找即将过期的 Token(如剩余有效期不足 10 分钟)
    // 异步刷新这些 Token
}

(4)白名单快速匹配

对于白名单匹配,使用 HashSet(精确匹配)和前缀树(前缀匹配)可以显著提高匹配性能:

java
// 使用 HashSet 进行 O(1) 的精确匹配
private Set<String> exactMatches = new HashSet<>();

// 对于前缀匹配,如果白名单条目不多,线性扫描即可
// 如果白名单条目很多,可以考虑使用 Trie 树

总结与展望

本文基于 smart-scaffold 企业级快速开发脚手架项目,完整解析了 OAuth2.0 + CAS 统一认证集成的技术方案。我们从需求分析出发,深入 OAuth2.0 授权码模式的每一个技术细节,然后逐步剖析了 OAuthFilter 鉴权过滤器、TokenRequestWrapper 请求包装器、FilterConfig 过滤器配置等核心组件的设计与实现,最后覆盖了 CAS Server 端配置和安全最佳实践。

本文的核心要点回顾:

  1. 统一认证是企业 IT 基础设施的必然选择。 它不仅提升了用户体验,更是安全管理、运维效率、合规审计的基础。

  2. OAuth2.0 授权码模式是最安全的 OAuth2.0 授权方式。 它通过"授权码"中间凭证的设计,确保 Access Token 不会经过用户浏览器,从根本上杜绝了 Token 泄露的风险。

  3. OAuthFilter 是整个客户端鉴权体系的核心。 它基于 Servlet Filter 机制,通过白名单、Token 提取、Token 验证、用户信息注入等步骤,实现了统一的请求鉴权。

  4. TokenRequestWrapper 实现了用户信息的透明注入。 下游 Controller 和 Service 可以通过简单的 request.getAttribute() 获取用户信息,无需关心 OAuth2.0 的底层细节。

  5. 前端模式和 API 模式的自动识别是关键设计。 同一个 OAuthFilter 可以同时服务于传统的 Web 应用和现代的前后端分离应用,无需额外配置。

  6. 安全是一个持续的过程。 Token 安全存储、HTTPS 传输、CSRF 防护、Token 刷新策略、会话管理、安全审计等每一个环节都不容忽视。

未来展望:

随着技术的发展,统一认证领域也在不断演进。以下是一些值得关注的技术趋势:

  1. OAuth2.1 和 OpenID Connect 的普及: OAuth2.1 在 OAuth2.0 的基础上进一步增强了安全性(如废弃隐式授权模式、强制使用 PKCE),OpenID Connect 在 OAuth2.0 之上增加了身份层,提供了标准化的用户身份验证。

  2. Passkey(通行密钥)和 FIDO2: Passkey 是由 Apple、Google、Microsoft 等公司联合推广的无密码认证标准,基于 FIDO2/WebAuthn 协议。未来,CAS Server 可以集成 Passkey 支持,实现更安全、更便捷的用户认证。

  3. 零信任架构(Zero Trust Architecture): 在零信任架构下,"永不信任,始终验证"是核心原则。每个请求都需要进行认证和授权,不再依赖网络边界的安全。OAuth2.0 Token 可以作为零信任架构中的身份凭证,在每个服务节点进行验证。

  4. AI 驱动的安全分析: 利用机器学习技术分析认证日志,自动检测异常行为(如异常登录时间、异常地理位置、异常设备指纹等),实现智能化的安全防护。

希望本文能够为你在企业统一认证方案的设计和实施中提供有价值的参考。如果你有任何问题或建议,欢迎交流讨论。


附录

附录 A:术语表

术语全称说明
OAuth2.0Open Authorization 2.0开放授权框架
CASCentral Authentication Service中央认证服务
SSOSingle Sign-On单点登录
TGTTicket Granting Ticket票据授予票据
TGCTicket Granting Cookie票据授予 Cookie
STService Ticket服务票据
MFAMulti-Factor Authentication多因素认证
CSRFCross-Site Request Forgery跨站请求伪造
XSSCross-Site Scripting跨站脚本攻击
PKCEProof Key for Code Exchange授权码交换证明密钥
RBACRole-Based Access Control基于角色的访问控制
ABACAttribute-Based Access Control基于属性的访问控制
JWTJSON Web TokenJSON Web 令牌
HSTSHTTP Strict Transport SecurityHTTP 严格传输安全

附录 B:OAuth2.0 核心端点速查

端点URL方法说明
授权端点/oauth2.0/authorizeGET获取授权码
Token 端点/oauth2.0/accessTokenPOST用授权码换取 Token
用户信息端点/oauth2.0/profileGET获取用户信息
Token 刷新/oauth2.0/accessTokenPOST用 Refresh Token 刷新
登出端点/logoutGET登出 CAS Server

附录 C:smart-scaffold 项目核心类清单

类名包路径说明
OAuthFiltercom.example.filterOAuth2.0 鉴权过滤器
TokenRequestWrappercom.example.filter请求包装器,注入用户信息
WhiteListMatchercom.example.filter白名单匹配器
FilterConfigcom.example.config过滤器配置类
OAuth2Configcom.example.configOAuth2.0 配置属性
OAuth2Servicecom.example.serviceOAuth2.0 服务(Token 换取、用户信息获取)
OAuth2CallbackControllercom.example.controllerOAuth2.0 回调控制器
UserContextcom.example.util用户信息工具类
UserInfocom.example.model用户信息数据模型
OAuthTokenResponsecom.example.modelToken 响应数据模型
TokenRefreshServicecom.example.serviceToken 刷新服务
AuthAuditServicecom.example.service认证审计日志服务

附录 D:参考资料

  1. OAuth 2.0 RFC 6749: https://tools.ietf.org/html/rfc6749
  2. OAuth 2.0 for Browser-Based Apps (RFC 6819): https://tools.ietf.org/html/rfc6819
  3. PKCE RFC 7636: https://tools.ietf.org/html/rfc7636
  4. CAS Server 官方文档: https://apereo.github.io/cas/
  5. CAS OAuth2.0 配置指南: https://apereo.github.io/cas/development/integration/REST-Protocol-OAuth2.html
  6. Spring Security OAuth2.0 参考: https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html
  7. OWASP OAuth2.0 安全指南: https://owasp.org/www-project-application-security-verification-standard/

版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。

本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。

文档内容提取自项目源码与配置文件,如需获取完整项目代码,请访问 bima.cc