作者:洛水石

阅读时间:约 14 分钟

关键词:OAuth2.1、Spring Security、JWT、认证、授权、SSO、PKCE

---

目录

  1. [引言:OAuth2.1 解决了什么问题?](#引言)
  2. [OAuth2.1 核心授权流程](#oauth21-核心授权流程)
  3. [Spring Authorization Server 搭建](#spring-authorization-server-搭建)
  4. [JWT 令牌生成与解析](#jwt-令牌生成与解析)
  5. [资源服务器保护 API](#资源服务器保护-api)
  6. [客户端接入实战](#客户端接入实战)
  7. [高级特性:PKCE、Refresh Token、SSO](#高级特性)
  8. [总结](#总结)

---

引言:OAuth2.1 解决了什么问题?

OAuth2.1 是 OAuth2.0 的安全增强版,主要变化:

  • **废弃 Implicit 流程**(不安全)
  • **废弃 Password 凭证流程**(不推荐)
  • **强制 PKCE** 用于公共客户端
  • **推荐 Authorization Code + PKCE** 作为标准流程

典型场景:

  • **第三方登录**:微信/QQ/Github 授权登录你的应用
  • **API 开放平台**:向第三方开发者开放接口,控制访问范围
  • **微服务认证**:统一认证中心,各服务验证令牌
  • **单点登录(SSO)**:登录一次,访问多个系统

---

OAuth2.1 核心授权流程

Authorization Code + PKCE 流程

┌─────────┐                                    ┌─────────────────┐

│  Client │──(1) 授权请求 + code_challenge ──→│  Authorization  │

│ (浏览器) │                                    │     Server      │

└─────────┘                                    └─────────────────┘

     │                                                │

     │←────────(2) 重定向到登录页 + scope ───────────│

     │                                                │

     │──(3) 用户登录并授权────────────────────────────→│

     │                                                │

     │←────────(4) 重定向回 Client + auth_code ──────│

     │                                                │

     │──(5) 请求令牌 + code + code_verifier ────────→│

     │                                                │

     │←────────(6) 返回 access_token + refresh_token─│

PKCE 作用: 防止授权码被截获后恶意换取令牌。

四种角色

角色

说明

示例

Resource Owner

资源所有者

用户本人

Client

第三方应用

你的网站/App

Authorization Server

授权服务器

Spring Authorization Server

Resource Server

资源服务器

你的 API 服务

Resource Owner

资源所有者

用户本人

Client

第三方应用

你的网站/App

Authorization Server

授权服务器

Spring Authorization Server

Resource Server

资源服务器

你的 API 服务

Client

第三方应用

你的网站/App

Authorization Server

授权服务器

Spring Authorization Server

Resource Server

资源服务器

你的 API 服务

Authorization Server

授权服务器

Spring Authorization Server

Resource Server

资源服务器

你的 API 服务

---

Spring Authorization Server 搭建

1. 依赖配置

<dependencies>

    <!-- Spring Authorization Server -->

    <dependency>

        <groupId>org.springframework.security</groupId>

        <artifactId>spring-security-oauth2-authorization-server</artifactId>

    </dependency>

    <!-- JWT 支持 -->

    <dependency>

        <groupId>org.springframework.security</groupId>

        <artifactId>spring-security-oauth2-jose</artifactId>

    </dependency>

</dependencies>

2. 授权服务器配置

@Configuration

@EnableWebSecurity

public class AuthorizationServerConfig {

    @Bean

    @Order(Ordered.HIGHEST_PRECEDENCE)

    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {

        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)

            .oidc(Customizer.withDefaults());  // 启用 OpenID Connect

        return http.build();

    }

    @Bean

    public RegisteredClientRepository registeredClientRepository() {

        RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())

            .clientId("web-client")

            .clientSecret("{bcrypt}" + new BCryptPasswordEncoder().encode("secret"))

            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)

            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)

            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)

            .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)

            .redirectUri("http://localhost:8080/login/oauth2/code/web-client")

            .scope(OidcScopes.OPENID)

            .scope("read")

            .scope("write")

            .clientSettings(ClientSettings.builder()

                .requireAuthorizationConsent(true)  // 需要用户确认授权

                .requireProofKey(true)               // 强制 PKCE

                .build())

            .tokenSettings(TokenSettings.builder()

                .accessTokenTimeToLive(Duration.ofMinutes(30))

                .refreshTokenTimeToLive(Duration.ofDays(7))

                .build())

            .build();

        return new InMemoryRegisteredClientRepository(client);

    }

    @Bean

    public JWKSource<SecurityContext> jwkSource() {

        RSAKey rsaKey = generateRsaKey();

        JWKSet jwkSet = new JWKSet(rsaKey);

        return (jwkSelector, context) -> jwkSelector.select(jwkSet);

    }

    private static RSAKey generateRsaKey() {

        KeyPair keyPair = generateRsaKeyPair();

        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();

        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

        return new RSAKey.Builder(publicKey)

            .privateKey(privateKey)

            .keyID(UUID.randomUUID().toString())

            .build();

    }

    private static KeyPair generateRsaKeyPair() {

        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");

        keyGen.initialize(2048);

        return keyGen.generateKeyPair();

    }

    @Bean

    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {

        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);

    }

}

3. 用户认证配置

@Configuration

public class SecurityConfig {

    @Bean

    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http

            .authorizeHttpRequests(auth -> auth

                .requestMatchers("/login", "/error").permitAll()

                .anyRequest().authenticated()

            )

            .formLogin(form -> form.loginPage("/login"));

        return http.build();

    }

    @Bean

    public UserDetailsService userDetailsService() {

        UserDetails user = User.builder()

            .username("admin")

            .password("{bcrypt}" + new BCryptPasswordEncoder().encode("123456"))

            .roles("USER", "ADMIN")

            .build();

        return new InMemoryUserDetailsManager(user);

    }

}

---

JWT 令牌生成与解析

JWT 结构

eyJhbGciOiJSUzI1NiJ9.  ← Header (Base64URL)

eyJzdWIiOiJhZG1pbiJ9.   ← Payload (Base64URL)

SflKxwRJSMeKKF2QT4fwpMe...  ← Signature

自定义 JWT 内容

@Component

public class CustomJwtTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {

    @Override

    public void customize(JwtEncodingContext context) {

        JwtClaimsSet.Builder claims = context.getClaims();

        // 添加自定义声明

        Authentication authentication = context.getPrincipal();

        if (authentication != null) {

            claims.claim("user_id", ((User) authentication.getPrincipal()).getId());

            claims.claim("username", authentication.getName());

            claims.claim("permissions", getPermissions(authentication));

        }

        // 缩短令牌有效期

        claims.expiresAt(Instant.now().plus(30, ChronoUnit.MINUTES));

    }

    private List<String> getPermissions(Authentication auth) {

        return auth.getAuthorities().stream()

            .map(GrantedAuthority::getAuthority)

            .collect(Collectors.toList());

    }

}

JWT 解析与验证

@Component

public class JwtTokenProvider {

    @Value("${jwt.public-key}")

    private RSAPublicKey publicKey;

    public Claims parseToken(String token) {

        return Jwts.parser()

            .verifyWith(publicKey)

            .build()

            .parseSignedClaims(token)

            .getPayload();

    }

    public boolean validateToken(String token) {

        try {

            parseToken(token);

            return true;

        } catch (ExpiredJwtException e) {

            log.warn("Token expired");

        } catch (UnsupportedJwtException e) {

            log.warn("Unsupported token");

        } catch (MalformedJwtException e) {

            log.warn("Malformed token");

        }

        return false;

    }

}

---

资源服务器保护 API

1. 资源服务器配置

@Configuration

@EnableWebSecurity

@EnableMethodSecurity

public class ResourceServerConfig {

    @Bean

    public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {

        http

            .securityMatcher("/api/**")

            .authorizeHttpRequests(auth -> auth

                .requestMatchers("/api/public/**").permitAll()

                .requestMatchers("/api/admin/**").hasRole("ADMIN")

                .anyRequest().authenticated()

            )

            .oauth2ResourceServer(oauth2 -> oauth2

                .jwt(jwt -> jwt

                    .jwtAuthenticationConverter(jwtAuthenticationConverter())

                )

            );

        return http.build();

    }

    @Bean

    public JwtAuthenticationConverter jwtAuthenticationConverter() {

        JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();

        authoritiesConverter.setAuthorityPrefix("ROLE_");

        authoritiesConverter.setAuthoritiesClaimName("permissions");

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();

        converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);

        converter.setPrincipalClaimName("username");

        return converter;

    }

}

2. 方法级权限控制

@RestController

@RequestMapping("/api")

public class OrderController {

    @GetMapping("/orders")

    @PreAuthorize("hasAuthority('order:read')")

    public List<Order> getOrders() {

        return orderService.findAll();

    }

    @PostMapping("/orders")

    @PreAuthorize("hasAuthority('order:write')")

    public Order createOrder(@RequestBody Order order) {

        return orderService.create(order);

    }

    @DeleteMapping("/orders/{id}")

    @PreAuthorize("hasRole('ADMIN')")

    public void deleteOrder(@PathVariable Long id) {

        orderService.delete(id);

    }

}

---

客户端接入实战

1. Spring Boot 客户端

@Configuration

public class ClientConfig {

    @Bean

    public ClientRegistrationRepository clientRegistrationRepository() {

        return new InMemoryClientRegistrationRepository(

            ClientRegistration.withRegistrationId("web-client")

                .clientId("web-client")

                .clientSecret("secret")

                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)

                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)

                .redirectUri("http://localhost:8080/login/oauth2/code/web-client")

                .scope("read", "write", "openid")

                .authorizationUri("http://localhost:9000/oauth2/authorize")

                .tokenUri("http://localhost:9000/oauth2/token")

                .userInfoUri("http://localhost:9000/userinfo")

                .userNameAttributeName("sub")

                .clientName("Web Client")

                .build()

        );

    }

}

// application.yml

spring:

  security:

    oauth2:

      client:

        registration:

          web-client:

            client-id: web-client

            client-secret: secret

            scope: read,write,openid

2. 前端 Vue3 接入

// auth.js

const AUTH_URL = 'http://localhost:9000/oauth2/authorize';

const CLIENT_ID = 'web-client';

const REDIRECT_URI = 'http://localhost:5173/callback';

function generatePKCE() {

    const codeVerifier = btoa(Math.random().toString()).substring(0, 43);

    const codeChallenge = btoa(

        await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))

    ).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

    return { codeVerifier, codeChallenge };

}

export function login() {

    const { codeVerifier, codeChallenge } = generatePKCE();

    localStorage.setItem('code_verifier', codeVerifier);

    const params = new URLSearchParams({

        response_type: 'code',

        client_id: CLIENT_ID,

        redirect_uri: REDIRECT_URI,

        scope: 'read write openid',

        code_challenge: codeChallenge,

        code_challenge_method: 'S256'

    });

    window.location.href = ${AUTH_URL}?${params};

}

---

高级特性

1. Refresh Token 自动续期

@Service

public class TokenRefreshService {

    public TokenResponse refreshToken(String refreshToken) {

        HttpHeaders headers = new HttpHeaders();

        headers.setBasicAuth("web-client", "secret");

        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();

        body.add("grant_type", "refresh_token");

        body.add("refresh_token", refreshToken);

        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);

        return restTemplate.postForObject(

            "http://localhost:9000/oauth2/token",

            request,

            TokenResponse.class

        );

    }

}

2. SSO 单点登录

@Configuration

public class SsoConfig {

    @Bean

    public SecurityFilterChain ssoFilterChain(HttpSecurity http) throws Exception {

        http

            .oauth2Login(oauth2 -> oauth2

                .loginPage("/login")

                .userInfoEndpoint(userInfo -> userInfo

                    .userService(oAuth2UserService())

                )

            );

        return http.build();

    }

    @Bean

    public OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService() {

        DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();

        return request -> {

            OAuth2User user = delegate.loadUser(request);

            // 同步用户到本地数据库

            syncUserToLocal(user);

            return user;

        };

    }

}

3. 令牌自省(Token Introspection)

@RestController

public class TokenInfoController {

    @PostMapping("/oauth2/introspect")

    public Map<String, Object> introspect(@RequestParam String token) {

        // 验证令牌有效性,返回令牌信息

        boolean active = tokenProvider.validateToken(token);

        if (!active) {

            return Map.of("active", false);

        }

        Claims claims = tokenProvider.parseToken(token);

        return Map.of(

            "active", true,

            "sub", claims.getSubject(),

            "exp", claims.getExpiration(),

            "scope", claims.get("scope", String.class)

        );

    }

}

---

总结

OAuth2.1 使用 checklist

授权服务器:

  • [ ] 使用 Authorization Code + PKCE 流程
  • [ ] 配置 JWT 签名密钥(RSA 非对称加密)
  • [ ] 设置合理的令牌过期时间(access_token 30min,refresh_token 7d)
  • [ ] 启用用户授权确认页面

资源服务器:

  • [ ] 验证 JWT 签名和过期时间
  • [ ] 提取权限声明到 Spring Security Context
  • [ ] 使用方法级注解控制接口权限

客户端:

  • [ ] 安全存储 client_secret(服务端)
  • [ ] 使用 PKCE 防止授权码截获
  • [ ] 实现 refresh token 自动续期

一句话总结

**OAuth2.1 = Authorization Code + PKCE + JWT,授权服务器发证,资源服务器验票,客户端安全保管密钥。**

---

更多硬核技术文章每周更新。

配图1:OAuth2.1 Authorization Code + PKCE 流程

配图2:JWT 令牌结构与验证流程

— 作者:洛水石 | 认证授权 | OAuth2.1实战 —

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐