跳转至

认证协议与标准

目录


Cookie/Session机制

Cookie基础

什么是Cookie?

Cookie是服务器发送到用户浏览器并保存在本地的小型文本文件,浏览器在后续请求中会自动携带Cookie。

Cookie属性:

Cookie cookie = new Cookie("sessionId", "abc123");

// 1. Domain - 指定Cookie的域名
cookie.setDomain(".example.com"); // 可用于 example.com 及其子域

// 2. Path - 指定Cookie的路径
cookie.setPath("/"); // 对所有路径有效

// 3. Max-Age / Expires - 过期时间
cookie.setMaxAge(3600); // 3600秒后过期
// -1: 会话Cookie(浏览器关闭后删除)
// 0: 立即删除
// >0: 持久Cookie,指定秒数后过期

// 4. HttpOnly - 禁止JavaScript访问
cookie.setHttpOnly(true); // 防止XSS攻击

// 5. Secure - 仅通过HTTPS传输
cookie.setSecure(true);

// 6. SameSite - 限制跨站发送
cookie.setAttribute("SameSite", "Strict");
// Strict: 完全禁止跨站发送
// Lax: 允许安全的跨站请求(GET导航)
// None: 允许跨站发送(需配合Secure=true)

Cookie的局限性: - 大小限制:通常4KB - 数量限制:每个域名约20-50个 - 安全性:容易被窃取(需HTTPS + HttpOnly) - 跨域限制:受同源策略限制

Session机制

工作流程:

1. 客户端请求 → 服务器
2. 服务器创建Session对象,生成SessionID
3. 服务器通过Set-Cookie响应头发送SessionID
   Set-Cookie: JSESSIONID=ABC123; Path=/; HttpOnly
4. 客户端后续请求自动携带Cookie
   Cookie: JSESSIONID=ABC123
5. 服务器通过SessionID查找Session对象

Java实现:

/**
 * Session管理示例
 */
@Controller
public class SessionController {

    /**
     * 登录 - 创建Session
     */
    @PostMapping("/login")
    public String login(@RequestParam String username,
                       @RequestParam String password,
                       HttpSession session) {

        // 验证用户名密码
        User user = userService.authenticate(username, password);
        if (user == null) {
            return "redirect:/login?error";
        }

        // 存储用户信息到Session
        session.setAttribute("userId", user.getId());
        session.setAttribute("username", user.getUsername());
        session.setAttribute("roles", user.getRoles());

        // 设置Session超时时间(秒)
        session.setMaxInactiveInterval(1800); // 30分钟

        return "redirect:/dashboard";
    }

    /**
     * 获取当前用户
     */
    @GetMapping("/api/me")
    @ResponseBody
    public User getCurrentUser(HttpSession session) {
        Long userId = (Long) session.getAttribute("userId");
        if (userId == null) {
            throw new UnauthorizedException("未登录");
        }
        return userService.findById(userId);
    }

    /**
     * 登出 - 销毁Session
     */
    @PostMapping("/logout")
    public String logout(HttpSession session) {
        session.invalidate(); // 销毁Session
        return "redirect:/login";
    }
}

分布式Session解决方案:

  1. Session复制(Session Replication)
    <!-- 使用Tomcat集群Session复制 -->
    <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"/>
    
  2. 优点:无需额外组件
  3. 缺点:性能开销大,不适合大规模集群

  4. Session粘性(Sticky Session)

    # Nginx配置
    upstream backend {
        ip_hash; # 根据客户端IP分配到固定服务器
        server backend1.example.com;
        server backend2.example.com;
    }
    

  5. 优点:实现简单
  6. 缺点:服务器宕机导致Session丢失,负载不均衡

  7. 集中式Session存储(推荐)

使用Redis存储Session:

<!-- Spring Session + Redis -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

/**
 * Spring Session配置
 */
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {

    @Bean
    public LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }
}
# application.properties
spring.redis.host=localhost
spring.redis.port=6379
spring.session.store-type=redis
spring.session.redis.namespace=spring:session

Redis中的Session数据结构:

# Key格式
spring:session:sessions:<sessionId>
spring:session:sessions:expires:<sessionId>
spring:session:expirations:<expirationTime>

# Session内容(Hash结构)
HGETALL spring:session:sessions:abc123
1) "sessionAttr:userId"
2) "10001"
3) "sessionAttr:username"
4) "zhangsan"
5) "creationTime"
6) "1609459200000"
7) "lastAccessedTime"
8) "1609462800000"
9) "maxInactiveInterval"
10) "1800"


Token认证

Token vs Session

特性 Session Token
状态 有状态(服务器存储) 无状态(客户端存储)
存储位置 服务器内存/数据库/Redis 客户端(Cookie/LocalStorage)
扩展性 需要Session共享机制 天然支持分布式
跨域 受Cookie限制 容易实现跨域
移动端 不友好 友好
吊销 容易(直接删除Session) 困难(需要黑名单机制)
性能 需要查询存储 验证签名即可

JWT (JSON Web Token)

JWT结构:

Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

1. Header(头部)

{
  "alg": "HS256",  // 签名算法
  "typ": "JWT"      // 令牌类型
}

2. Payload(载荷)

{
  // 标准声明(Registered Claims)
  "iss": "https://auth.example.com",  // 签发者
  "sub": "user123",                    // 主题(用户ID)
  "aud": "https://api.example.com",    // 受众
  "exp": 1735689600,                   // 过期时间(时间戳)
  "nbf": 1735686000,                   // 生效时间
  "iat": 1735686000,                   // 签发时间
  "jti": "uuid-1234",                  // JWT ID(唯一标识)

  // 自定义声明
  "username": "zhangsan",
  "roles": ["USER", "ADMIN"],
  "email": "zhangsan@example.com"
}

3. Signature(签名)

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

JWT实现(使用jjwt库)

<!-- Maven依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
/**
 * JWT工具类
 */
@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.expiration:3600000}") // 默认1小时
    private long validityInMilliseconds;

    private Key key;

    @PostConstruct
    protected void init() {
        // 生成密钥
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    /**
     * 生成Token
     */
    public String createToken(User user) {
        Date now = new Date();
        Date validity = new Date(now.getTime() + validityInMilliseconds);

        return Jwts.builder()
            .setSubject(user.getUsername())
            .setIssuer("https://auth.example.com")
            .setIssuedAt(now)
            .setExpiration(validity)
            .claim("userId", user.getId())
            .claim("email", user.getEmail())
            .claim("roles", user.getRoles())
            .signWith(key, SignatureAlgorithm.HS512)
            .compact();
    }

    /**
     * 验证Token
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            logger.error("Invalid JWT token: {}", e.getMessage());
            return false;
        }
    }

    /**
     * 从Token获取用户名
     */
    public String getUsername(String token) {
        Claims claims = Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .getBody();

        return claims.getSubject();
    }

    /**
     * 从Token获取Claims
     */
    public Claims getClaims(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .getBody();
    }

    /**
     * 检查Token是否即将过期
     */
    public boolean isTokenExpiringSoon(String token) {
        Claims claims = getClaims(token);
        Date expiration = claims.getExpiration();
        long timeUntilExpiry = expiration.getTime() - System.currentTimeMillis();

        // 如果在15分钟内过期,返回true
        return timeUntilExpiry < 900000;
    }
}

使用Filter进行JWT认证:

/**
 * JWT认证过滤器
 */
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider tokenProvider;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                   HttpServletResponse response,
                                   FilterChain filterChain) 
            throws ServletException, IOException {

        try {
            // 从请求头获取Token
            String token = extractTokenFromRequest(request);

            if (token != null && tokenProvider.validateToken(token)) {
                // 从Token获取用户信息
                String username = tokenProvider.getUsername(token);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                // 创建认证对象
                UsernamePasswordAuthenticationToken authentication = 
                    new UsernamePasswordAuthenticationToken(
                        userDetails, 
                        null, 
                        userDetails.getAuthorities()
                    );

                authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request)
                );

                // 设置到Security上下文
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            logger.error("Cannot set user authentication: {}", e.getMessage());
        }

        filterChain.doFilter(request, response);
    }

    /**
     * 从请求中提取Token
     */
    private String extractTokenFromRequest(HttpServletRequest request) {
        // 方式1: 从Authorization header获取
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        // 方式2: 从Cookie获取
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("token".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }

        // 方式3: 从查询参数获取(不推荐,仅用于特殊场景)
        return request.getParameter("token");
    }
}

JWT最佳实践

1. Token刷新策略

方案一:双Token机制(Access Token + Refresh Token)

/**
 * 双Token实现
 */
@Service
public class TokenService {

    // Access Token: 短期有效(如15分钟)
    public String generateAccessToken(User user) {
        return Jwts.builder()
            .setSubject(user.getUsername())
            .setExpiration(new Date(System.currentTimeMillis() + 900000)) // 15分钟
            .claim("type", "access")
            .signWith(accessTokenKey)
            .compact();
    }

    // Refresh Token: 长期有效(如7天)
    public String generateRefreshToken(User user) {
        String token = Jwts.builder()
            .setSubject(user.getUsername())
            .setExpiration(new Date(System.currentTimeMillis() + 604800000)) // 7天
            .claim("type", "refresh")
            .signWith(refreshTokenKey)
            .compact();

        // 存储Refresh Token到数据库(用于吊销)
        refreshTokenRepository.save(new RefreshToken(token, user.getId()));

        return token;
    }

    /**
     * 使用Refresh Token获取新的Access Token
     */
    public TokenResponse refreshAccessToken(String refreshToken) {
        // 验证Refresh Token
        if (!validateToken(refreshToken)) {
            throw new InvalidTokenException("无效的Refresh Token");
        }

        // 检查Refresh Token是否在数据库中(未被吊销)
        if (!refreshTokenRepository.existsByToken(refreshToken)) {
            throw new TokenRevokedException("Refresh Token已被吊销");
        }

        // 生成新的Access Token
        String username = getUsername(refreshToken);
        User user = userService.findByUsername(username);
        String newAccessToken = generateAccessToken(user);

        return new TokenResponse(newAccessToken, refreshToken);
    }
}

API端点:

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest request) {
        User user = authenticationService.authenticate(
            request.getUsername(), 
            request.getPassword()
        );

        String accessToken = tokenService.generateAccessToken(user);
        String refreshToken = tokenService.generateRefreshToken(user);

        return new TokenResponse(accessToken, refreshToken);
    }

    @PostMapping("/refresh")
    public TokenResponse refresh(@RequestBody RefreshRequest request) {
        return tokenService.refreshAccessToken(request.getRefreshToken());
    }

    @PostMapping("/logout")
    public void logout(@RequestBody LogoutRequest request) {
        // 吊销Refresh Token
        refreshTokenRepository.deleteByToken(request.getRefreshToken());

        // 可选:将Access Token加入黑名单(Redis)
        String accessToken = request.getAccessToken();
        Claims claims = tokenService.getClaims(accessToken);
        long expirationTime = claims.getExpiration().getTime();
        long ttl = expirationTime - System.currentTimeMillis();

        if (ttl > 0) {
            redisTemplate.opsForValue().set(
                "blacklist:" + accessToken, 
                "1", 
                ttl, 
                TimeUnit.MILLISECONDS
            );
        }
    }
}

2. Token吊销(Revocation)

/**
 * Token黑名单检查
 */
@Component
public class TokenBlacklistChecker {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 检查Token是否在黑名单中
     */
    public boolean isBlacklisted(String token) {
        return Boolean.TRUE.equals(
            redisTemplate.hasKey("blacklist:" + token)
        );
    }

    /**
     * 将Token加入黑名单
     */
    public void addToBlacklist(String token, long ttlMillis) {
        redisTemplate.opsForValue().set(
            "blacklist:" + token,
            "1",
            ttlMillis,
            TimeUnit.MILLISECONDS
        );
    }
}

// 在JWT过滤器中检查黑名单
if (tokenBlacklistChecker.isBlacklisted(token)) {
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    return;
}

3. JWT安全建议

/**
 * JWT安全配置
 */
public class JwtSecurityConfig {

    // ✅ 1. 使用强密钥(至少256位)
    private static final String SECRET_KEY = generateStrongSecret(); // 不要硬编码!

    private static String generateStrongSecret() {
        SecureRandom random = new SecureRandom();
        byte[] secret = new byte[32]; // 256位
        random.nextBytes(secret);
        return Base64.getEncoder().encodeToString(secret);
    }

    // ✅ 2. 使用强签名算法
    // 对称: HS512 (推荐) / HS256
    // 非对称: RS256, RS512, ES256

    // ✅ 3. 设置合理的过期时间
    private static final long ACCESS_TOKEN_VALIDITY = 15 * 60 * 1000; // 15分钟
    private static final long REFRESH_TOKEN_VALIDITY = 7 * 24 * 60 * 60 * 1000; // 7天

    // ✅ 4. 验证所有声明
    public Claims validateAndParseClaims(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(key)
            .requireIssuer("https://auth.example.com") // 验证签发者
            .requireAudience("https://api.example.com") // 验证受众
            .build()
            .parseClaimsJws(token)
            .getBody();
    }

    // ❌ 不要在Payload中存储敏感信息
    // Payload是Base64编码,不是加密!
    // 不要存储: 密码、信用卡号、SSN等

    // ✅ 5. 实现Token轮换(Rotation)
    // 每次使用Refresh Token时,颁发新的Refresh Token

    // ✅ 6. HTTPS Only
    // 仅通过HTTPS传输Token
}

OAuth 2.0协议

OAuth 2.0概述

OAuth 2.0是什么?

OAuth 2.0是一个授权协议,允许第三方应用在用户授权下访问用户在另一个服务上的资源,而无需获取用户的密码。

核心角色:

┌──────────────┐
│  Resource    │  资源所有者(用户)
│   Owner      │
└──────┬───────┘
       │ 授权
┌──────────────┐      ┌──────────────┐
│   Client     │ ───→ │ Authorization│  授权服务器
│ (第三方应用)  │ ←─── │   Server     │
└──────┬───────┘      └──────────────┘
       │ Access Token
┌──────────────┐
│  Resource    │  资源服务器
│   Server     │
└──────────────┘

示例场景:

用户想让"印象笔记"访问他在"Google Drive"中的文件

  • Resource Owner: 用户
  • Client: 印象笔记
  • Authorization Server: Google认证服务器
  • Resource Server: Google Drive API

OAuth 2.0授权模式

1. 授权码模式(Authorization Code)

最安全、最常用的模式,适用于有后端服务器的Web应用。

流程:

用户                客户端              授权服务器           资源服务器
 │                    │                    │                   │
 │ 1. 访问应用         │                    │                   │
 │──────────────────→│                    │                   │
 │                    │ 2. 重定向到授权页   │                   │
 │                    │───────────────────→│                   │
 │ 3. 用户登录并授权   │                    │                   │
 │───────────────────────────────────────→│                   │
 │                    │ 4. 返回授权码       │                   │
 │←────────────────────────────────────────│                   │
 │                    │ 5. 用授权码换Token  │                   │
 │                    │───────────────────→│                   │
 │                    │ 6. 返回Access Token │                   │
 │                    │←───────────────────│                   │
 │                    │ 7. 请求资源(带Token)│                   │
 │                    │───────────────────────────────────────→│
 │                    │ 8. 返回受保护资源   │                   │
 │                    │←───────────────────────────────────────│

详细步骤:

Step 1: 客户端请求授权

GET /oauth/authorize?
    response_type=code&
    client_id=CLIENT_ID&
    redirect_uri=https://client.com/callback&
    scope=read_profile,read_photos&
    state=random_state_string
Host: auth.example.com

参数说明: - response_type=code: 表示使用授权码模式 - client_id: 客户端ID(应用注册时获得) - redirect_uri: 授权后的回调地址 - scope: 请求的权限范围 - state: 防CSRF攻击的随机字符串

Step 2: 用户授权 用户登录并同意授权

Step 3: 返回授权码

HTTP/1.1 302 Found
Location: https://client.com/callback?
    code=AUTHORIZATION_CODE&
    state=random_state_string

Step 4: 客户端用授权码换取Token

POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=https://client.com/callback&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET

Step 5: 返回Access Token

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
  "scope": "read_profile read_photos"
}

Java实现(使用Spring Security OAuth2):

/**
 * OAuth2授权服务器配置
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * 配置客户端
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
            .withClient("client-app")
                .secret(passwordEncoder.encode("client-secret"))
                .authorizedGrantTypes(
                    "authorization_code",
                    "refresh_token",
                    "password",
                    "client_credentials"
                )
                .scopes("read", "write")
                .redirectUris("https://client.com/callback")
                .accessTokenValiditySeconds(3600)      // Access Token 1小时
                .refreshTokenValiditySeconds(86400);   // Refresh Token 24小时
    }

    /**
     * 配置授权和Token端点
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
            .authenticationManager(authenticationManager)
            .userDetailsService(userDetailsService)
            .tokenStore(tokenStore())
            .accessTokenConverter(accessTokenConverter());
    }

    /**
     * Token存储(使用JWT)
     */
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }

    /**
     * JWT Token转换器
     */
    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("jwt-secret-key"); // 生产环境使用非对称密钥
        return converter;
    }
}

/**
 * 资源服务器配置
 */
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
            .antMatchers("/api/public/**").permitAll()
            .antMatchers("/api/**").authenticated();
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        resources.tokenStore(tokenStore());
    }
}

客户端实现:

/**
 * OAuth2客户端配置
 */
@Configuration
public class OAuth2ClientConfig {

    @Bean
    public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext context) {
        return new OAuth2RestTemplate(resourceDetails(), context);
    }

    @Bean
    public OAuth2ProtectedResourceDetails resourceDetails() {
        AuthorizationCodeResourceDetails details = new AuthorizationCodeResourceDetails();
        details.setClientId("client-app");
        details.setClientSecret("client-secret");
        details.setAccessTokenUri("https://auth.example.com/oauth/token");
        details.setUserAuthorizationUri("https://auth.example.com/oauth/authorize");
        details.setScope(Arrays.asList("read", "write"));
        return details;
    }
}

/**
 * 使用OAuth2调用API
 */
@Service
public class UserService {

    @Autowired
    private OAuth2RestTemplate restTemplate;

    public UserProfile getUserProfile() {
        String url = "https://api.example.com/user/profile";
        return restTemplate.getForObject(url, UserProfile.class);
    }
}

2. 隐式模式(Implicit)

适用于纯前端应用(SPA),已不推荐使用,建议使用授权码模式+PKCE。

流程:

# 1. 请求授权(注意response_type=token)
GET /oauth/authorize?
    response_type=token&
    client_id=CLIENT_ID&
    redirect_uri=https://client.com/callback&
    scope=read&
    state=random_state

# 2. 直接返回Access Token(在URL Fragment中)
HTTP/1.1 302 Found
Location: https://client.com/callback#
    access_token=ACCESS_TOKEN&
    token_type=Bearer&
    expires_in=3600&
    state=random_state

安全问题: - Token暴露在URL中,容易泄露 - 浏览器历史记录可能记录Token - 无法使用client_secret(前端代码可见)

3. 密码模式(Resource Owner Password Credentials)

用户直接将用户名密码提供给客户端,仅适用于高度信任的应用。

POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=password&
username=user@example.com&
password=userpassword&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET&
scope=read write
/**
 * 密码模式实现
 */
@RestController
public class TokenController {

    @PostMapping("/oauth/token")
    public TokenResponse getToken(@RequestParam("grant_type") String grantType,
                                  @RequestParam("username") String username,
                                  @RequestParam("password") String password) {

        if (!"password".equals(grantType)) {
            throw new UnsupportedGrantTypeException();
        }

        // 验证用户名密码
        User user = userService.authenticate(username, password);
        if (user == null) {
            throw new InvalidCredentialsException();
        }

        // 生成Token
        String accessToken = tokenService.generateAccessToken(user);
        String refreshToken = tokenService.generateRefreshToken(user);

        return TokenResponse.builder()
            .accessToken(accessToken)
            .tokenType("Bearer")
            .expiresIn(3600)
            .refreshToken(refreshToken)
            .build();
    }
}

4. 客户端模式(Client Credentials)

适用于服务器到服务器的通信,不涉及用户。

POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET&
scope=api_access
/**
 * 客户端模式实现
 */
@Service
public class ClientCredentialsService {

    public String getServiceAccessToken(String clientId, String clientSecret) {
        // 验证客户端凭证
        Client client = clientRepository.findByClientId(clientId);
        if (client == null || !client.checkSecret(clientSecret)) {
            throw new InvalidClientException();
        }

        // 生成Token(不包含用户信息)
        return Jwts.builder()
            .setSubject(clientId)
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + 3600000))
            .claim("client_id", clientId)
            .claim("scope", client.getScopes())
            .signWith(key)
            .compact();
    }
}

/**
 * 微服务间调用示例
 */
@Service
public class OrderService {

    @Autowired
    private RestTemplate restTemplate;

    public Inventory checkInventory(String productId) {
        // 获取服务访问Token
        String token = oauth2Client.getClientCredentialsToken();

        // 调用库存服务
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(token);
        HttpEntity<String> entity = new HttpEntity<>(headers);

        ResponseEntity<Inventory> response = restTemplate.exchange(
            "https://inventory-service/api/products/" + productId,
            HttpMethod.GET,
            entity,
            Inventory.class
        );

        return response.getBody();
    }
}

PKCE扩展(Proof Key for Code Exchange)

解决问题: 增强授权码模式的安全性,特别是对于无法安全存储client_secret的公共客户端(移动应用、SPA)。

流程:

1. 客户端生成code_verifier(随机字符串)
2. 客户端计算code_challenge = BASE64URL(SHA256(code_verifier))
3. 请求授权时携带code_challenge
4. 用授权码换Token时携带code_verifier
5. 服务器验证:SHA256(code_verifier) == code_challenge

/**
 * PKCE实现
 */
public class PKCEHelper {

    /**
     * 生成Code Verifier
     */
    public static String generateCodeVerifier() {
        SecureRandom random = new SecureRandom();
        byte[] bytes = new byte[32];
        random.nextBytes(bytes);
        return Base64.getUrlEncoder()
            .withoutPadding()
            .encodeToString(bytes);
    }

    /**
     * 生成Code Challenge
     */
    public static String generateCodeChallenge(String codeVerifier) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
            return Base64.getUrlEncoder()
                .withoutPadding()
                .encodeToString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}

// 请求授权
String codeVerifier = PKCEHelper.generateCodeVerifier();
String codeChallenge = PKCEHelper.generateCodeChallenge(codeVerifier);

String authorizeUrl = "https://auth.example.com/oauth/authorize?" +
    "response_type=code&" +
    "client_id=CLIENT_ID&" +
    "redirect_uri=https://app.com/callback&" +
    "code_challenge=" + codeChallenge + "&" +
    "code_challenge_method=S256";

// 换取Token时携带code_verifier
POST /oauth/token
grant_type=authorization_code&
code=AUTHORIZATION_CODE&
client_id=CLIENT_ID&
redirect_uri=https://app.com/callback&
code_verifier=CODE_VERIFIER

OpenID Connect (OIDC)

OIDC概述

OpenID Connect (OIDC) 是基于OAuth 2.0的身份认证层,解决"用户是谁"的问题。

OAuth 2.0 vs OIDC: - OAuth 2.0: 授权协议(你能做什么) - OIDC: 认证协议(你是谁)+ 授权

核心概念: - ID Token: JWT格式,包含用户身份信息 - UserInfo Endpoint: 获取用户详细信息的端点 - Standard Claims: 标准化的用户属性

OIDC流程

1. 客户端请求授权(scope包含openid)
2. 用户认证并授权
3. 返回授权码
4. 客户端用授权码换取Token
5. 服务器返回: Access Token + ID Token
6. 客户端验证ID Token
7. (可选)使用Access Token调用UserInfo接口获取更多用户信息

ID Token结构

{
  // 标准声明
  "iss": "https://auth.example.com",          // 签发者
  "sub": "user123",                            // 用户唯一标识
  "aud": "client-app",                         // 受众(客户端ID)
  "exp": 1735689600,                           // 过期时间
  "iat": 1735686000,                           // 签发时间
  "auth_time": 1735685900,                     // 用户认证时间
  "nonce": "random-nonce",                     // 防重放攻击
  "acr": "urn:mace:incommon:iap:silver",      // 认证上下文类
  "amr": ["pwd", "mfa"],                       // 认证方法
  "azp": "client-app",                         // 授权方

  // 标准用户声明
  "name": "Zhang San",
  "given_name": "San",
  "family_name": "Zhang",
  "middle_name": "",
  "nickname": "zhangsan",
  "preferred_username": "zhangsan",
  "profile": "https://example.com/profile/zhangsan",
  "picture": "https://example.com/avatar/zhangsan.jpg",
  "website": "https://zhangsan.blog",
  "email": "zhangsan@example.com",
  "email_verified": true,
  "gender": "male",
  "birthdate": "1990-01-01",
  "zoneinfo": "Asia/Shanghai",
  "locale": "zh-CN",
  "phone_number": "+86 138 0000 0000",
  "phone_number_verified": true,
  "address": {
    "formatted": "北京市朝阳区某某路123号",
    "street_address": "某某路123号",
    "locality": "朝阳区",
    "region": "北京市",
    "postal_code": "100000",
    "country": "CN"
  },
  "updated_at": 1735686000
}

Scope说明

openid           - 必需,表示使用OIDC
profile          - 基本资料(name, picture等)
email            - 电子邮件
address          - 地址
phone            - 电话号码
offline_access   - 获取Refresh Token

Java实现(使用Spring Security OAuth2)

/**
 * OIDC提供者配置
 */
@Configuration
public class OIDCProviderConfig {

    /**
     * 配置OIDC端点
     */
    @Bean
    public OAuth2AuthorizationServerConfigurer authorizationServerConfigurer() {
        return new OAuth2AuthorizationServerConfigurer()
            .oidc(oidc -> oidc
                .userInfoEndpoint("/oidc/userinfo")
                .clientRegistrationEndpoint("/oidc/register")
            );
    }

    /**
     * UserInfo端点实现
     */
    @RestController
    public class UserInfoController {

        @GetMapping("/oidc/userinfo")
        public Map<String, Object> userInfo(Authentication authentication) {
            OAuth2Authentication oauth2Auth = (OAuth2Authentication) authentication;
            String username = oauth2Auth.getName();
            User user = userService.findByUsername(username);

            Map<String, Object> claims = new HashMap<>();
            claims.put("sub", user.getId());
            claims.put("name", user.getName());
            claims.put("email", user.getEmail());
            claims.put("email_verified", user.isEmailVerified());
            claims.put("picture", user.getAvatarUrl());

            return claims;
        }
    }
}

/**
 * OIDC客户端配置(Spring Boot 2.x/3.x)
 */
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/", "/login/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .oidcUserService(oidcUserService())
                )
            );

        return http.build();
    }

    /**
     * 自定义OIDC用户服务
     */
    @Bean
    public OidcUserService oidcUserService() {
        OidcUserService delegate = new OidcUserService();

        return (userRequest) -> {
            OidcUser oidcUser = delegate.loadUser(userRequest);

            // 自定义处理:同步用户到本地数据库
            syncUserToDatabase(oidcUser);

            return oidcUser;
        };
    }
}

/**
 * 使用OIDC信息
 */
@Controller
public class ProfileController {

    @GetMapping("/profile")
    public String profile(Model model, @AuthenticationPrincipal OidcUser oidcUser) {
        // 从ID Token获取用户信息
        String name = oidcUser.getName();
        String email = oidcUser.getEmail();
        String picture = oidcUser.getPicture();

        // 访问所有claims
        Map<String, Object> claims = oidcUser.getClaims();

        // ID Token
        OidcIdToken idToken = oidcUser.getIdToken();
        String tokenValue = idToken.getTokenValue();

        model.addAttribute("name", name);
        model.addAttribute("email", email);
        model.addAttribute("picture", picture);

        return "profile";
    }
}

application.yml配置:

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: YOUR_CLIENT_ID
            client-secret: YOUR_CLIENT_SECRET
            scope:
              - openid
              - profile
              - email
        provider:
          google:
            issuer-uri: https://accounts.google.com
            # 或手动配置端点
            # authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
            # token-uri: https://oauth2.googleapis.com/token
            # user-info-uri: https://openidconnect.googleapis.com/v1/userinfo
            # jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs


SAML 2.0

SAML概述

SAML (Security Assertion Markup Language) 是基于XML的企业级单点登录(SSO)标准,主要用于企业环境。

核心概念: - Identity Provider (IdP): 身份提供者(如Active Directory、Okta) - Service Provider (SP): 服务提供者(应用) - Assertion: XML格式的身份断言

SAML vs OAuth/OIDC: - SAML: 企业级、XML、重量级、SSO - OAuth/OIDC: 互联网、JSON、轻量级、API访问

SAML流程(SP-Initiated)

用户              服务提供者(SP)    身份提供者(IdP)
 │                    │                  │
 │ 1. 访问受保护资源   │                  │
 │──────────────────→│                  │
 │                    │ 2. 生成SAML请求   │
 │                    │  重定向到IdP     │
 │                    │─────────────────→│
 │ 3. 用户登录IdP     │                  │
 │─────────────────────────────────────→│
 │                    │ 4. IdP生成SAML断言│
 │                    │  重定向回SP       │
 │←────────────────────────────────────  │
 │ 5. SP验证断言      │                  │
 │   创建本地会话     │                  │
 │←───────────────────│                  │

Java实现(使用Spring Security SAML)

<!-- Maven依赖 -->
<dependency>
    <groupId>org.springframework.security.extensions</groupId>
    <artifactId>spring-security-saml2-core</artifactId>
    <version>1.0.10.RELEASE</version>
</dependency>
/**
 * SAML配置
 */
@Configuration
@EnableWebSecurity
public class SAMLConfig extends WebSecurityConfigurerAdapter {

    @Value("${saml.idp.metadata.url}")
    private String idpMetadataUrl;

    @Value("${saml.sp.entity-id}")
    private String spEntityId;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/saml/**").permitAll()
                .anyRequest().authenticated()
            .and()
            .apply(saml());
    }

    /**
     * SAML配置器
     */
    private SAMLConfigurer saml() {
        return new SAMLConfigurer()
            .serviceProvider(sp -> sp
                .entityId(spEntityId)
                .signRequests(true)
                .wantAssertionsSigned(true)
            )
            .identityProvider(idp -> idp
                .metadataUrl(idpMetadataUrl)
            );
    }

    /**
     * SAML认证成功处理器
     */
    @Bean
    public SAMLAuthenticationSuccessHandler successHandler() {
        return (request, response, authentication) -> {
            SAMLCredential credential = (SAMLCredential) authentication.getCredentials();

            // 获取用户属性
            String nameID = credential.getNameID().getValue();
            String email = credential.getAttributeAsString("email");
            String displayName = credential.getAttributeAsString("displayName");

            // 创建本地用户
            User user = userService.findOrCreateUser(nameID, email, displayName);

            // 重定向到主页
            response.sendRedirect("/home");
        };
    }
}

单点登录(SSO)

SSO概述

单点登录(Single Sign-On) 允许用户使用一组凭证登录多个相关但独立的应用系统。

优点: - 用户体验好(一次登录,多处使用) - 减少密码疲劳 - 集中的访问控制 - 统一的安全策略

SSO实现方式

1. 基于Cookie的SSO(同域)

主域名: example.com
子系统: app1.example.com, app2.example.com, app3.example.com

实现: 设置Cookie的Domain为.example.com
// SSO服务器登录成功后
Cookie ssoCookie = new Cookie("SSO_TOKEN", ssoToken);
ssoCookie.setDomain(".example.com");  // 所有子域名可访问
ssoCookie.setPath("/");
ssoCookie.setHttpOnly(true);
ssoCookie.setSecure(true);
ssoCookie.setMaxAge(3600);
response.addCookie(ssoCookie);

2. CAS (Central Authentication Service)

CAS协议流程:

用户           应用A              CAS服务器           应用B
 │               │                    │                 │
 │ 1. 访问A      │                    │                 │
 │──────────────→│                    │                 │
 │               │ 2. 重定向到CAS     │                 │
 │               │───────────────────→│                 │
 │ 3. 用户登录   │                    │                 │
 │───────────────────────────────────→│                 │
 │               │ 4. 返回TGT/TGC     │                 │
 │               │    重定向回A(带ST)  │                 │
 │←──────────────────────────────────  │                 │
 │               │ 5. A验证ST         │                 │
 │               │───────────────────→│                 │
 │               │ 6. 返回用户信息     │                 │
 │               │←───────────────────│                 │
 │               │ 7. A创建会话       │                 │
 │←───────────────│                    │                 │
 │ 8. 访问B      │                    │                 │
 │───────────────────────────────────────────────────→│
 │               │                    │ 9. 重定向到CAS  │
 │←────────────────────────────────────────────────────│
 │               │                    │ 10. CAS检查TGC  │
 │               │                    │     已登录      │
 │               │                    │ 11. 直接返回ST  │
 │               │                    │    (无需再登录) │
 │←────────────────────────────────────────────────────│

关键概念: - TGT (Ticket Granting Ticket): 票据授予票据,存储在CAS服务器 - TGC (Ticket Granting Cookie): 票据授予Cookie,存储在浏览器 - ST (Service Ticket): 服务票据,一次性使用

Java实现(使用Apereo CAS Client):

<dependency>
    <groupId>org.jasig.cas.client</groupId>
    <artifactId>cas-client-core</artifactId>
    <version>3.6.4</version>
</dependency>
/**
 * CAS客户端配置
 */
@Configuration
public class CASClientConfig {

    @Value("${cas.server.url}")
    private String casServerUrl;

    @Value("${cas.client.url}")
    private String clientUrl;

    /**
     * CAS认证过滤器
     */
    @Bean
    public FilterRegistrationBean<AuthenticationFilter> casAuthenticationFilter() {
        FilterRegistrationBean<AuthenticationFilter> registration = 
            new FilterRegistrationBean<>();

        AuthenticationFilter filter = new AuthenticationFilter();
        filter.setCasServerLoginUrl(casServerUrl + "/login");
        filter.setServerName(clientUrl);

        registration.setFilter(filter);
        registration.addUrlPatterns("/*");
        registration.setOrder(1);

        return registration;
    }

    /**
     * CAS票据验证过滤器
     */
    @Bean
    public FilterRegistrationBean<Cas30ProxyReceivingTicketValidationFilter> 
            casValidationFilter() {

        FilterRegistrationBean<Cas30ProxyReceivingTicketValidationFilter> registration = 
            new FilterRegistrationBean<>();

        Cas30ProxyReceivingTicketValidationFilter filter = 
            new Cas30ProxyReceivingTicketValidationFilter();
        filter.setServerName(clientUrl);
        filter.setTicketValidator(ticketValidator());

        registration.setFilter(filter);
        registration.addUrlPatterns("/*");
        registration.setOrder(2);

        return registration;
    }

    @Bean
    public Cas30ServiceTicketValidator ticketValidator() {
        return new Cas30ServiceTicketValidator(casServerUrl);
    }
}

/**
 * 获取当前用户
 */
@Controller
public class UserController {

    @GetMapping("/api/me")
    @ResponseBody
    public Map<String, Object> getCurrentUser(HttpServletRequest request) {
        // 从CAS获取用户信息
        AttributePrincipal principal = 
            (AttributePrincipal) request.getUserPrincipal();

        if (principal == null) {
            throw new UnauthorizedException();
        }

        Map<String, Object> userInfo = new HashMap<>();
        userInfo.put("username", principal.getName());
        userInfo.put("attributes", principal.getAttributes());

        return userInfo;
    }
}

3. 基于OAuth2/OIDC的SSO

现代SSO的主流实现方式,详见前面的OAuth2和OIDC章节。

示例:使用Keycloak实现SSO

# application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: my-app
            client-secret: secret
            scope: openid,profile,email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/keycloak"
        provider:
          keycloak:
            issuer-uri: http://keycloak-server/realms/myrealm
            user-name-attribute: preferred_username

协议选型指南

场景 推荐协议 理由
现代Web应用 OAuth 2.0 + OIDC 标准化、广泛支持、轻量级
企业内部SSO SAML 2.0 或 OIDC SAML成熟稳定,OIDC更现代
移动应用 OAuth 2.0 + PKCE 安全、适合公共客户端
前后端分离 JWT Token 无状态、跨域友好
微服务架构 OAuth 2.0 Client Credentials 服务间认证
传统Web应用 Session/Cookie 简单、可靠

总结

本文档介绍了主流的认证授权协议和标准:

  1. Cookie/Session: 传统的Web会话管理方式
  2. JWT: 现代无状态Token认证方案
  3. OAuth 2.0: 授权协议标准,支持多种授权模式
  4. OIDC: 基于OAuth 2.0的身份认证层
  5. SAML: 企业级单点登录标准
  6. SSO: 多种单点登录实现方式

继续学习: - 上一章:认证授权基础 - 下一章:Java认证框架对比