从零搭建企业 OAuth2 授权中心:Spring Boot 集成 Spring Authorization Server 与

1. 为什么需要自建授权中心

微服务架构铺开之后,认证和授权迟早要从业务代码里拆出来。多个后端服务、SPA 前台、移动端、第三方开放平台------每套系统都自己维护一套登录逻辑,早晚要出问题。集中式授权中心不一定是唯一解,但大概率是最合适的那个。

早期项目用 spring-security-oauth2 2.x 的 @EnableAuthorizationServer 也挺多,但这套东西在 Spring Security 5 之后官方就不再维护了,属于被放弃的状态。社区里讨论过很多次,总结下来痛点主要有三个:

  • 它不兼容新版 Spring Boot / Spring Cloud 的过滤器链体系,升级成本很高。
  • OIDC 支持基本等于没有。ID Token 没有、Discovery 文档没有,端点和声明规范也不全。
  • 扩展点太少。令牌生成逻辑、Consent 页面、客户端管理这些,想改造成企业能用的样子很费劲。

所以我们当时选型的时候直接排除了老方案,用了 Spring 官方后来主推的 Spring Authorization Server(下称 SAS)。这货是基于 Spring Security 6 重新写的,OAuth2 和 OIDC 协议的实现比较完整,同时该留的扩展点也都留了。下面把从零搭的过程和一些生产实践整理出来,供参考。

2. 核心概念和依赖搭建

SAS 本质上不是一个独立服务,它是嵌入 Spring Security 过滤器链里的一个模块。引入之后会自动暴露几个核心端点:

  • /oauth2/authorize ------ 授权码授权请求的入口
  • /oauth2/token ------ 令牌签发
  • /oauth2/revoke ------ 令牌撤销
  • OIDC 相关的 /.well-known/openid-configuration 和 /userinfo

版本方面,我们用的是 Spring Boot 3.x + SAS 1.3,对应 Spring Security 6.2 这条线。1.0 那种早期版本就不建议碰了,坑比较多。

依赖清单

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

spring-boot-starter-oauth2-authorization-server 这个 starter 是 SAS 自带的,版本跟着 Spring Boot 的 BOM 走就行,不需要额外指定。

核心配置

SAS 的基本套路是配两条 SecurityFilterChain。一条管授权服务器自身的端点,另一条管普通的登录请求。

java 复制代码
@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.oauth2ResourceServer(resourceServer -> resourceServer.jwt(jwt -> {}));
        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    @Primary
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() throws NoSuchAlgorithmException {
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    private static RSAKey generateRsa() throws NoSuchAlgorithmException {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }
}

这段配置里有几个点要留意:

authorizationServerSecurityFilterChain 上的 @Order(1) 不能省,否则请求进入后可能落到默认的 filter chain 上,会话校验方式会不对。

jwtDecoder 上标注了 @Primary,因为默认的 filter chain 也会尝试找 JwtDecoder 来处理某些逻辑,如果不加,启动时可能因为存在多个候选 bean 而报注入冲突。

关于这段代码里的 generateRsa() 方法内部的 generateRsaKey() 具体的 KeyPairGenerator 生成细节下面给出一个可运行的写法:

java 复制代码
private static KeyPair generateRsaKey() throws NoSuchAlgorithmException {
    KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
    keyPairGenerator.initialize(2048);
    return keyPairGenerator.generateKeyPair();
}

临时生成 RSA 密钥在开发环境没问题,生产环境必须换成从外部 KeyStore 或 KMS 加载。

3. 客户端注册管理

授权中心必须得知道"谁"是合法的调用方。这里的"谁"就是客户端应用------一个 React 前端、一个移动端 App、或者一个内部服务。客户端必须先注册,拿到 client_id 和 client_secret,然后才能走授权流程。

SAS 里客户端配置的读写是靠 RegisteredClientRepository 来完成的。我们可以用内存模式、JDBC 或自定义实现。这里使用 JDBC 方式,SAS 官方提供了建表脚本,在数据库里执行一下即可。

关键在于,客户端配置要有个集中的存储和管理入口,业务系统对接时才好维护。

java 复制代码
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
    JdbcRegisteredClientRepository repository = new JdbcRegisteredClientRepository(jdbcTemplate);

    // startup 时检查,没有示例客户端就插入一条 --- 生产环境建议走管理后台维护,不要写在启动逻辑里
    String clientId = "portal-web";
    if (repository.findByClientId(clientId) == null) {
        RegisteredClient portalClient = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId(clientId)
            .clientSecret("{noop}portal-secret")
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("https://app.example.com/login/oauth2/code/portal")
            .scope(OidcScopes.OPENID)
            .scope("profile")
            .scope("user.read")
            .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
            .tokenSettings(TokenSettings.builder()
                    .accessTokenTimeToLive(Duration.ofMinutes(30))
                    .refreshTokenTimeToLive(Duration.ofDays(7))
                    .build())
            .build();
        repository.save(portalClient);
    }

    return repository;
}

几个细节,都是实际开发中比较容易忽略的:

redirect_uri 的匹配是精确的 ,必须由客户端申请时提交的 URL 与授权请求中的 redirect_uri 参数完全一致,SAS 才会放行。如果要做多环境(dev/staging/prod),需要提前在客户端配置里注册好所有合法的回调地址,或者提供一套管理功能来维护。

授权类型要按客户端类型收敛 。面向浏览器的 Web 应用和 SPA,只开 AUTHORIZATION_CODE + REFRESH_TOKEN 就好,没必要给它们开 client_credentials------那种是服务端到服务端用的,凭据直接暴露给浏览器风险太大。内部服务间调用就比较适合 client_credentials,不涉及用户授权。

{noop} 前缀代表明文密码 。不要在生产环境这样用。密码编码器建议用 {bcrypt},DelegatingPasswordEncoder 会识别这个前缀选择匹配算法------这和你平时给用户密码做 BCrypt 哈希是同一套逻辑。

如果想要一套运营后台来动态管理客户端(例如走审批流创建客户端),直接把 RegisteredClientRepository 换成支持保存到 DB 的实现即可。管理后台复用同一个 repository,增删改查做完后广播一下配置变更,就能做到热更新。

4. 授权码模式完整流程

授权码模式是 OAuth2 授权流程中最常打交道的,也是 OIDC 登录里用得最多的。这里过一下完整流程。

4.1 请求授权

用户访问某个 Web 应用时发现未登录,应用把浏览器重定向到授权中心:

复制代码
GET /oauth2/authorize?response_type=code
    &client_id=portal-web
    &redirect_uri=https://app.example.com/login/oauth2/code/portal
    &scope=openid%20profile%20user.read
    &state=xyz

授权服务器拿到这个请求后,先看当前用户的 session 是否已登录。如果没登录,302 跳到 /login 页面------也就是配置里 defaultSecurityFilterChain 中通过 formLogin 提供的那个登录页。

用户输入用户名密码之后,由 Spring Security 的认证过滤器链接管。认证成功之后,Spring Security 会把 Authentication 放进 context(默认基于 Session)。

紧接着跳转回授权流程的处理链路,此时在处理 OAuth2AuthorizationCodeRequestAuthenticationProvider,检查用户之前是否已经同意过该客户端申请的 scope。

如果客户端设置了 requireAuthorizationConsent(true) 并且用户没有做过授权确认,那么用户会先看到一个 Consent 页。展示申请权限的 scope 列表,让用户点确认或拒绝。默认实现 SAS 自带了一个页面,长得比较朴素,模板化太强,企业一般会自己做一个带品牌性质的。具体做法是实现一个 /oauth2/consent 端点渲染页面,并在提交时构造一个 AuthorizationConsent 写入保存。SAS 官方仓库里那个 ConsentController 就是一个可以直接参考的实现。

4.3 换取 Token

用户点完同意,授权服务器带上 code 和 state 重定向回业务系统填好的回调地址。state 必须校验,防 CSRF。

回调地址拿到 code 之后,客户端应用在后端发起 token 请求:

http 复制代码
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic cG9ydGFsLXdlYjpwb3J0YWwtc2VjcmV0

grant_type=authorization_code
&code=ABC123
&redirect_uri=https://app.example.com/login/oauth2/code/portal

注意 Authorization 是 Basic Auth,client_id:client_secret 做 Base64。

返回的 JSON:

json 复制代码
{
  "access_token": "eyJraWQiOiI...",
  "token_type": "Bearer",
  "expires_in": 1800,
  "refresh_token": "V8u...",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6..."
}

SPA 场景下,如果不能安全保存 client_secret,就要用 PKCE。SAS 对 PKCE 是原生支持的,自动兼容 S256 的 code_challenge 流程,只需要在客户端发起认证请求时传递 code_challenge,即可在无 secret 的情况下安全使用授权码流程。如果要强制要求 PKCE,那就把配置改为 .clientSettings(ClientSettings.builder().requireProofKey(true).build()),这样即使回调地址中的授权请求没有带 code_challenge,SAS 也会直接拒绝,杜绝授权码被恶意截获后换取 Token 的可能性。

5. 自定义 JWT:claims 扩展、签名算法与密钥管理

实际业务中单纯的 sub 和 scope 往往不够用。资源服务器需要知道用户工号、角色、部门等来做更精细的权限控制。ID Token 本身按 OIDC 规范应当尽量精简,所以这类业务属性建议放在 Access Token 中,或者资源服务器再通过 /userinfo 或内部 API 获取。

在 SAS 中向 JWT 内添加额外信息的方式是实现 OAuth2TokenCustomizer<JwtEncodingContext>:

java 复制代码
@Component
public class CustomTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {

    private final EmployeeService employeeService;

    @Override
    public void customize(JwtEncodingContext context) {
        // 只定制 access token,不动 refresh token 和 id token
        if (!OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
            return;
        }

        Authentication principal = context.getPrincipal();
        if (principal != null && principal.getPrincipal() instanceof UserDetails user) {
            Employee employee = employeeService.findByUsername(user.getUsername());
            
            Map<String, Object> employeeClaims = new HashMap<>();
            employeeClaims.put("employee_id", employee.getEmployeeId());
            employeeClaims.put("department", employee.getDepartmentName());
            employeeClaims.put("roles", user.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.toSet()));

            context.getClaims().claims(claims -> claims.putAll(employeeClaims));
        }
    }
}

有一点值得留意:不要直接 context.getClaims().claim(...) 一个方法接一个方法地写,稍微多一点代码就乱了;用 claims(claims -> claims.putAll(map)) 这种批量化的写法会清晰不少。

注册这个 Bean 后,SAS 内部的 NimbusJwtEncoder 会自动识别到自定义器,在生成 JWT 前调用。

关于签名算法,SAS 默认是 RS256(即 JWT 使用 RSA 私钥签名,验证方使用 JWKS 中的公钥验签)。RSA 密钥对其实不需要我们手动去管理,我们只需要把它加载到 JWKSource 中即可:

java 复制代码
@Bean
public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
    return new NimbusJwtEncoder(jwkSource);
}

对于密钥管理,这里展开讲一下,因为这是一个比较容易出问题的地方。从 KeyStore 加载 RSA 密钥:把私钥放在 KeyStore 文件中,路径、口令通过环境变量传入。基础实现来看,只需新建一个 JWKSource 实现,从 KeyStore 读出来,构造 RSAKey,然后放入 JWKSet:

java 复制代码
@Bean
public JWKSource<SecurityContext> jwkSource() throws Exception {
    String keyStorePath = env.getProperty("security.keystore.path");
    String keyStorePassword = env.getProperty("security.keystore.password");
    String alias = env.getProperty("security.keystore.alias");

    KeyStore keyStore = KeyStore.getInstance("PKCS12");
    try (InputStream is = new FileInputStream(keyStorePath)) {
        keyStore.load(is, keyStorePassword.toCharArray());
    }

    KeyStoreKeyFactory keyFactory = new KeyStoreKeyFactory(keyStore, keyStorePassword.toCharArray());
    KeyPair keyPair = keyFactory.getKeyPair(alias);
    RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
    RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

    RSAKey rsaKey = new RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(alias)   // 显式指定 kid,别用随机 UUID,轮换时才能对应上
            .build();
    return new ImmutableJWKSet(new JWKSet(rsaKey));
}

关于多密钥轮换,JWKSet 中可以同时放多个 RSAKey,但每个 kid 不同。令牌发布所使用的私钥是哪个,由 JWT 头信息中携带的 kid 来标识------这个 ID 在 token JWT 的 header 里会有对应字段,验证方拿这个 kid 到 JWK Set 里找对应的公钥来验签名。推荐的做法是在发布新私钥时保留旧的私钥,置入一个宽限期(一般一两天),让在途的旧 Token 仍然可以通过旧公钥验证。同时保持签发时优先使用新私钥。当确认流量已经全部迁移到了新签发的 Token 后,再把旧私钥从 JWKSet 中移除。

6. 让资源服务器可以通过 Discovery 自动校验 Token

授权中心不是终点------下游资源服务器/API 网关需要校验每个请求的 JWT。SAS 启动后,天然就能对外暴露以下两个 URL:

  • /.well-known/openid-configuration
  • /oauth2/jwks

这两个端点分别提供了全站的 OIDC 配置信息和公钥集合,客户端和资源服务器可以基于它们自动完成初始化,而不用硬编码任何密钥或者端点路径。

资源服务器配置

资源服务器只需要引入依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

然后在 application.yml 指定 issuer:

yaml 复制代码
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com

Spring Boot 会自动请求 https://auth.example.com/.well-known/openid-configuration,从中读取 jwks_uri 并自动拉取公钥。

如果本地开发时服务暴露的域名和实际配置不一致,那么 issuer-uri 的自动发现可能会失败,此时通常直接显式指定 jwk-set-uri 也会是一个实用的兜底方式。

然后定义安全规则:

java 复制代码
@Configuration
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.oauth2ResourceServer(resourceServer -> resourceServer.jwt(Customizer.withDefaults()))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/user/**").hasAuthority("SCOPE_user.read")
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

这里的权限表达式中,hasAuthority("SCOPE_user.read") 指的是请求方的 token 中携带了 scope=user.read 时,访问才是放行的,注意名称前必须加上 SCOPE_ 前缀,因为 Spring Security 的 JwtGrantedAuthoritiesConverter 默认会把 scope claim 中的每个值都加上这个前缀后映射成 authority。

issuer-uri 这一个配置已经包含了所有必要信息(通过 discovery 找到 JWK Set,以及默认的 issuer 校验),省掉了很多手写 JwtDecoder 的重复代码。

7. 将现有用户体系和 MFA 融入授权服务器

大多数企业都不是从零开始设计用户体系的------会有自己的用户库、Ldap、AD 或者现成的中台。SAS 本身不强绑定任何用户来源,只要是 Spring Security 的 UserDetailsService 或 AuthenticationProvider 能实现即可对接。

7.1 接入现有用户库

把现有的用户查询实现成 UserDetailsService 即可:

java 复制代码
@Service
public class ExistingUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser appUser = userRepository.findByLoginId(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
        return User.withUsername(appUser.getLoginId())
                .password(appUser.getPasswordHash())
                .roles(appUser.getRoleCodes().toArray(String[]::new))
                .disabled(appUser.isDisabled())
                .build();
    }
}

替换 Spring Security 默认的 user details 源之后,登录表单就会自动通过它来验证。

LDAP 的场景也可以直接配置 LdapUserDetailsService 或 LdapAuthenticationProvider 来替代,原理都是对登录请求做适配,SAS 本身不需要改任何代码。接入方式上值得注意的一点是:用户实体与 Spring Security 的 UserDetails 是两种不同的模型,我们只是做了一层映射,而不是把整个用户实体的生命周期都塞进 UserDetailsService 中------保存、更新这类用户库自身的逻辑还是在原有的业务代码里做。

7.2 MFA

企业做 OAuth2 授权中心,MFA 基本都是硬性要求。比较常见的两种形式:

  1. TOTP:基于 Authenticator App 的 6 位动态验证码,离线可用。
  2. 短信/邮箱验证码:每次登录发送一次性验证码。

Spring Security 目前没有内置 MFA 登录流程,所以在授权服务器里集成会稍微多一点工作。我们的做法是在默认登录链路中叠加一层:

  • 用户在 /login 提交用户名密码后,先走系统自带的 DaoAuthenticationProvider 验证密码。
  • 认证通过先不要放行,判断该用户是否绑定了 MFA。
  • 如果绑定了,把当前的通过密码验证的 Authentication 临时存入 Session,并设置一个标记(例如 pendingMfaUserId),然后重定向到一个 MFA 页面(例如 /mfa)。
  • 用户在 /mfa 页面提交验证码后,由 TotpAuthenticationFilter 从 Session 中取出暂存的认证对象,校验 TOTP。
  • 校验通过,构造完整的 Authentication 并设置到 SecurityContext,跳转回 /oauth2/authorize。

这段流程在编码上比较繁琐,有一个关键点,授权端点 /oauth2/authorize 并不会判断用户是走了哪种登录方式 。它只看当前 SecurityContext 是否存在有效的 Authentication。这正好让我们有能力把 MFA 流程做成一个前置步骤------只要 MFA 步骤完成后成功设置 SecurityContext,OAuth2 授权流程无需任何改动就能继续。

业务实现上代码可能类似下面的结构:

java 复制代码
public class TotpAuthenticationFilter extends OncePerRequestFilter {

    private final TotpValidator totpValidator;
    private final SessionMfaRegistry mfaRegistry;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
        String servletPath = request.getServletPath();

        if ("/mfa".equals(servletPath) && "POST".equals(request.getMethod())) {
            Authentication pendingAuth = mfaRegistry.retrieve(request.getSession());
            if (pendingAuth != null) {
                String code = request.getParameter("totpCode");
                String username = pendingAuth.getName();
                if (totpValidator.isValid(username, code)) {
                    // 校验通过,将完整认证对象写入 SecurityContext
                    SecurityContextHolder.getContext().setAuthentication(pendingAuth);
                    mfaRegistry.clear(request.getSession());
                    // 继续走授权链路,SAS 会看到已认证的 SecurityContext 而放行
                    chain.doFilter(request, response);
                    return;
                }
            }
            response.sendRedirect("/login?mfa_error=true");
            return;
        }
        chain.doFilter(request, response);
    }
}

TOTP 的校验算法可以用 com.warrenstrange:googleauth 库来实现。

SessionMfaRegistry 则封装了将 pending authentication 临时保存/读取的逻辑。这中间有个容易踩的小坑:直接 session.setAttribute 序列化 Authentication 时,如果里面的 UserDetails 等对象没有实现 Serializable,在 session 持久化到 Redis 时就会报序列化错误。所以临时存放的对象尽量用简单的数据结构(比如只存放 username),等 MFA 通过后再调一次 UserDetailsService 来重新组装认证对象。

8. 生产安全与部署要点

8.1 HTTPS

这一点基本属于常识,但部署时仍然偶尔会漏。OAuth2 的场景里,Cookie、授权码、Token、密码,中间任何一个环节被明文抓包都是安全事故。因此:

  • 生产环境在所有接入层强制加密。如果用了 Nginx 或网关做 TLS 终结,那么授权中心本身可以只监听 8080/内网流量。
  • 关键的是要让应用知道外部协议是 HTTPS,否则生成的 issuer 可能会是 http://,导致 OIDC discovery 的地址不正确,客户端的自动配置也可能走到 http 上。在 application.yml 中加:
yaml 复制代码
server:
  forward-headers-strategy: framework

这样托管的反向代理转发的 X-Forwarded-Proto: https 就能被正确识别,所有自生成的链接才会使用 https:// 前缀。只加这一行配置是不够的,Nginx 那边需要在 location 中将 X-Forwarded-Proto 正确传递给你,否则框架侧收不到真实的协议头,无法生成正确的 https 链接。

授权服务器最好有独立的域名或专门的路径前缀,和其他业务系统在 Cookie 层面隔离开:

  • 若与业务系统同域部署,需要给 session cookie 设置 SameSite=Lax 甚至 SameSite=Strict。登录相关的 CSRF 风险主要发生在跨站请求上,限制了 SameSite 之后可以比较有效地防范攻击。
  • 同时确保 Cookie 带有 Secure 和 HttpOnly 标记。
  • JS 不能读取到 Session Cookie------读取到之后,就可以被 XSS 劫持,你的会话数据会直接暴露。这是 Spring Security 默认设置中默认开启的,只需要确保没有在配置中被刻意覆盖就好。

如果是多租户场景,不要在一个 Cookie 会话里全局共享,建议为不同租户配置不同的 context-path 或独立域名,避免租户间 Session 串号。

SAS 在默认情形下为 CSRF 提供了保护。但如果你在自定义 /login 或 consent POST 逻辑时忘了加入 CSRF token,登录链路可能就变成可以被 CSRF 攻击的状态。一个比较常见的问题就是:开发环境关掉了 CSRF 测试通过,但生产环境没有开------这时候 POST 请求会报 403,排查时往往很难一眼看出来。建议默认保持开启,并且不要去设置 csrf(AbstractHttpConfigurer::disable)。

8.3 密钥轮换

关于密钥生命周期管理这块,再补充一些实践细节:

  • 生产环境私钥必须存在于 KeyStore(PKCS12)或 HSM(硬件安全模块)中,应用从环境变量指定的路径启动加载后,运行时私钥只存在于内存中,不应以其他方式散落到业务代码或配置文件中。
  • kid 不要随机生成。我们一开始在开发阶段用 UUID,结果后面做轮换的时候发现完全没法做公钥对应关系追踪,改成 alias/版本号后才理顺。
  • 轮换频率一般是 30 到 90 天一次。每次发布新密钥后,可以检查一段时间的验签失败率(旧公钥校验失败率上升,说明有使用旧公钥的客户端仍然在访问),确认告警与用户反馈情况后再决定何时移除旧公钥。
  • 多实例部署时,最好有统一的外部配置托管中心(如 Nacos、Consul)或任务调度来同步新密钥,否则每次轮换都意味着一次全量发布,运维效率就比较低了。

8.4 部署形态和监控

授权中心本身是个典型的无状态服务(Session 数据放到 Redis),所以 K8s 多副本部署完全没有问题。这种形态下需要关注:

  • 共享 Session 存储是必须的,否则用户在一个副本登录后,下个请求被负载均衡转发到别的副本,还得重新登录。
  • 核心指标需要盯紧,包括 /oauth2/token 的成功率与 P99 延迟、/oauth2/authorize 请求量、Consent 页面的转化率等。不用自己造轮子,Micrometer 指标接入 Prometheus/Grafana 即可。
  • 审计日志字段建议至少包含:时间、用户标识、客户端ID、授权类型、申请的 scope、客户端 IP、最终决策(批准/拒绝)。我们当时把这部分下沉成一个 Logback 插件,单独输出到独立的审计日志文件/通道,这样安全合规巡检时可以直接把文件扔给合规同学。

收个尾

关于技术选型这块,Spring Authorization Server 取代旧 @EnableAuthorizationServer 是没有悬念的,只是 SAS 的一些 SPI 实践资料没有那么多,网上零散的信息也良莠不齐,早期踩坑多半都耗在调试默认行为和阅读源码上。

这篇文章从客户端注册到授权码流程,从 JWT claims 定制到 MFA 集成,再到生产安全实践,基本把自建授权中心的完整路径梳理了一遍。整套方案的框架设计和代码部分在我们这边的生产环境已经跑了两年多,稳定性比较可靠,所以整理出来分享。对照着做一遍就行,实践中的关键点是:先在本地把标准 OAuth2/OIDC 流程跑通,再逐步替换加密、存储和用户体系,最终产出的是一个贴合自身企业场景的认证基础设施。


🎁 福利时间

如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。

知识库地址:https://farerboy.com/


相关推荐
小天源39 分钟前
智叟云集政策监测技术方案——原文解析、证据提取与可核验摘要
java·服务器·数据库
小羊没烦恼!1 小时前
在.net中读写config文件的各种方法
java·大数据·运维·服务器·前端·数据库·oracle
NocoBase1 小时前
本周更新|优化及缺陷修复
数据库·人工智能·开源·零代码·无代码
流烟默1 小时前
Spring Boot 异常响应被序列化为 XML 的排查与修复
xml·spring boot·后端
大侠归来1 小时前
Redis Cluster 的 16384 个槽(Slot)是什么?
数据库·redis·缓存
墨天梦1 小时前
D26_数据库迁移与可复现构建
数据库·oracle
骑着蜗牛撵大象3272 小时前
Spring AI + Qwen3.8 周报助手:从事项采集到人工确认卡的闭环实战
人工智能·spring·elasticsearch·自动化·大模型应用·qwen3.8·rag溯源
paopaokaka_luck2 小时前
民宿管理系统 (AI民宿总结、协同过滤推荐算法、WebSocket 在线咨询、ECharts 经营分析、房态与结算联动、 民宿预订业务闭环)
java·人工智能·spring boot·websocket·数据分析·echarts·推荐算法
java资料站2 小时前
十四、Spring AI Alibaba · 案例 · 基于 HumanInTheLoop 的内容审核系统
java·spring·状态模式