Skip to content

用户认证授权与会话管理

OAuth 2.0 协议解析

OAuth 2.0是当前最流行的授权协议,广泛应用于第三方登录场景。理解其核心机制是构建安全认证系统的基础。

核心角色定义

mermaid
graph TD
    A["资源拥有者<br/>Resource Owner"] --> B["客户端应用<br/>Client"]
    B --> C["授权服务器<br/>Authorization Server"]
    C --> D["资源服务器<br/>Resource Server"]
    
    A -.->|拥有| E["受保护资源"]
    D -.->|存储| E
    B -.->|请求访问| E
    
    style A fill:#4A90E2,color:#fff,rx:10,ry:10
    style B fill:#E74C3C,color:#fff,rx:10,ry:10
    style C fill:#9B59B6,color:#fff,rx:10,ry:10
    style D fill:#27AE60,color:#fff,rx:10,ry:10
    style E fill:#E67E22,color:#fff,rx:10,ry:10

角色说明

  • 资源拥有者:通常是终端用户,拥有受保护资源的访问权限
  • 客户端应用:需要获取用户资源的第三方应用
  • 授权服务器:验证用户身份并颁发访问令牌
  • 资源服务器:存储用户资源,验证访问令牌的有效性

四种授权模式对比

模式适用场景安全级别实现复杂度
授权码模式Web应用最高较复杂
隐式模式单页应用中等简单
密码模式受信任应用较低简单
客户端凭证模式服务间调用最简单

授权码模式详解

授权码模式是最安全也是最常用的授权方式,微信、QQ等平台均采用此模式。

mermaid
sequenceDiagram
    participant User as 用户
    participant App as 第三方应用
    participant Auth as 授权服务器
    participant Resource as 资源服务器
    
    User->>App: 1. 点击第三方登录
    App->>Auth: 2. 重定向到授权页面<br/>(携带client_id, redirect_uri)
    Auth->>User: 3. 展示授权确认页
    User->>Auth: 4. 用户同意授权
    Auth->>App: 5. 重定向回调地址<br/>(携带authorization_code)
    App->>Auth: 6. 用code换取token<br/>(携带client_secret)
    Auth->>App: 7. 返回access_token
    App->>Resource: 8. 携带token请求资源
    Resource->>App: 9. 返回用户信息

集成第三方登录实现

以电商平台集成微信登录为例:

java
@RestController
@RequestMapping("/oauth")
public class OAuthController {
    
    @Value("${wechat.appId}")
    private String appId;
    
    @Value("${wechat.appSecret}")
    private String appSecret;
    
    @Value("${wechat.redirectUri}")
    private String redirectUri;
    
    /**
     * 发起微信授权
     */
    @GetMapping("/wechat/authorize")
    public void authorize(HttpServletResponse response) throws IOException {
        String state = UUID.randomUUID().toString().replace("-", "");
        // 缓存state用于回调验证
        redisTemplate.opsForValue().set(
            "oauth:state:" + state, "1", 5, TimeUnit.MINUTES);
        
        String authUrl = "https://open.weixin.qq.com/connect/qrconnect?"
            + "appid=" + appId
            + "&redirect_uri=" + URLEncoder.encode(redirectUri, "UTF-8")
            + "&response_type=code"
            + "&scope=snsapi_login"
            + "&state=" + state;
        
        response.sendRedirect(authUrl);
    }
    
    /**
     * 微信授权回调
     */
    @GetMapping("/wechat/callback")
    public Result<LoginResult> callback(
            @RequestParam String code,
            @RequestParam String state) {
        
        // 验证state防止CSRF攻击
        String cachedState = redisTemplate.opsForValue()
            .get("oauth:state:" + state);
        if (cachedState == null) {
            return Result.error("授权已过期,请重新登录");
        }
        
        // 用code换取access_token
        WechatTokenResponse tokenResp = wechatClient.getAccessToken(
            appId, appSecret, code);
        
        // 获取用户信息
        WechatUserInfo userInfo = wechatClient.getUserInfo(
            tokenResp.getAccessToken(), 
            tokenResp.getOpenId());
        
        // 处理用户绑定逻辑
        User user = userService.bindOrCreateUser(userInfo);
        
        // 生成本系统token
        String token = jwtService.generateToken(user);
        
        return Result.success(new LoginResult(token, user));
    }
}

用户拉黑与踢人下线

拉黑功能架构设计

用户拉黑是平台安全管理的重要功能,需要考虑实时性和性能的平衡。

mermaid
graph TD
    A["拉黑功能"] --> B["单用户拉黑"]
    A --> C["批量拉黑"]
    
    B --> D["管理后台操作"]
    C --> E["文件批量导入"]
    C --> F["规则自动拉黑"]
    
    D --> G{"存储方案"}
    E --> G
    F --> G
    
    G --> H["独立黑名单表"]
    G --> I["用户状态字段"]
    
    H --> J["解耦性好"]
    H --> K["支持扩展属性"]
    I --> L["实现简单"]
    I --> M["查询高效"]
    
    style A fill:#4A90E2,color:#fff,rx:10,ry:10
    style G fill:#9B59B6,color:#fff,rx:10,ry:10
    style H fill:#27AE60,color:#fff,rx:10,ry:10
    style I fill:#E67E22,color:#fff,rx:10,ry:10

黑名单存储方案

方案一:独立黑名单表

适用于用户量大、拉黑规则复杂的场景:

sql
CREATE TABLE user_blacklist (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL COMMENT '用户ID',
    reason VARCHAR(500) COMMENT '拉黑原因',
    operator_id BIGINT COMMENT '操作人ID',
    start_time DATETIME NOT NULL COMMENT '拉黑开始时间',
    end_time DATETIME COMMENT '拉黑结束时间(NULL表示永久)',
    status TINYINT DEFAULT 1 COMMENT '状态: 1-生效 0-失效',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_user_id(user_id),
    INDEX idx_status_end_time(status, end_time)
) COMMENT='用户黑名单表';

方案二:用户表状态字段

适用于用户量不大、规则简单的场景:

sql
ALTER TABLE user ADD COLUMN is_blocked TINYINT DEFAULT 0 
    COMMENT '是否拉黑: 0-否 1-是';
ALTER TABLE user ADD COLUMN blocked_until DATETIME 
    COMMENT '拉黑截止时间';

高性能黑名单校验

对于大规模用户系统,黑名单校验需要高性能支撑:

mermaid
graph LR
    A["用户请求"] --> B{"布隆过滤器<br/>快速排除"}
    
    B -->|可能存在| C{"Redis缓存<br/>精确判断"}
    B -->|一定不存在| D["放行"]
    
    C -->|命中| E["拦截"]
    C -->|未命中| F{"数据库查询"}
    
    F -->|存在| G["加入缓存"]
    F -->|不存在| D
    G --> E
    
    style A fill:#4A90E2,color:#fff,rx:10,ry:10
    style B fill:#E67E22,color:#fff,rx:10,ry:10
    style C fill:#9B59B6,color:#fff,rx:10,ry:10
    style E fill:#E74C3C,color:#fff,rx:10,ry:10
    style D fill:#27AE60,color:#fff,rx:10,ry:10

布隆过滤器实现

java
@Component
public class BlacklistChecker {
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private BlacklistMapper blacklistMapper;
    
    private static final String BLOOM_KEY = "user:blacklist:bloom";
    private static final String CACHE_PREFIX = "user:blacklist:";
    
    /**
     * 初始化布隆过滤器
     */
    @PostConstruct
    public void initBloomFilter() {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_KEY);
        bloomFilter.tryInit(10000000L, 0.01);  // 1000万容量,1%误判率
        
        // 加载现有黑名单
        List<Long> blackUserIds = blacklistMapper.selectAllBlockedUserIds();
        for (Long userId : blackUserIds) {
            bloomFilter.add(userId);
        }
    }
    
    /**
     * 检查用户是否在黑名单中
     */
    public boolean isBlocked(Long userId) {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_KEY);
        
        // 布隆过滤器快速判断
        if (!bloomFilter.contains(userId)) {
            return false;  // 一定不在黑名单
        }
        
        // 可能存在,查询Redis确认
        String cacheKey = CACHE_PREFIX + userId;
        Boolean cached = redisTemplate.hasKey(cacheKey);
        if (Boolean.TRUE.equals(cached)) {
            return true;
        }
        
        // 查询数据库
        boolean blocked = blacklistMapper.isBlocked(userId);
        if (blocked) {
            redisTemplate.opsForValue().set(cacheKey, "1", 1, TimeUnit.HOURS);
        }
        return blocked;
    }
}

踢人下线实现

当用户被拉黑时,需要将其已登录的会话失效:

java
@Service
public class SessionKickService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String SESSION_PREFIX = "user:session:";
    private static final String TOKEN_PREFIX = "user:token:";
    
    /**
     * 踢用户下线
     */
    public void kickUser(Long userId) {
        // 获取用户的所有会话
        String sessionKey = SESSION_PREFIX + userId;
        Set<Object> tokens = redisTemplate.opsForSet().members(sessionKey);
        
        if (tokens != null && !tokens.isEmpty()) {
            // 删除所有token
            for (Object token : tokens) {
                redisTemplate.delete(TOKEN_PREFIX + token);
            }
            // 清除会话记录
            redisTemplate.delete(sessionKey);
        }
        
        log.info("用户{}已被踢下线,清除{}个会话", userId, 
            tokens != null ? tokens.size() : 0);
    }
    
    /**
     * 踢指定设备下线
     */
    public void kickDevice(Long userId, String deviceId) {
        String sessionKey = SESSION_PREFIX + userId;
        Set<Object> tokens = redisTemplate.opsForSet().members(sessionKey);
        
        if (tokens != null) {
            for (Object token : tokens) {
                String tokenStr = token.toString();
                // 解析token中的设备信息
                TokenPayload payload = jwtService.parseToken(tokenStr);
                if (deviceId.equals(payload.getDeviceId())) {
                    redisTemplate.delete(TOKEN_PREFIX + tokenStr);
                    redisTemplate.opsForSet().remove(sessionKey, tokenStr);
                    break;
                }
            }
        }
    }
}

Token管理最佳实践

Access Token 与 Refresh Token

双Token机制能够在安全性和用户体验之间取得平衡:

mermaid
graph TD
    A["用户登录"] --> B["颁发双Token"]
    B --> C["Access Token<br/>有效期2小时"]
    B --> D["Refresh Token<br/>有效期7天"]
    
    C --> E["访问受保护资源"]
    E --> F{"Token是否过期"}
    
    F -->|未过期| G["正常访问"]
    F -->|已过期| H["使用Refresh Token"]
    
    H --> I{"Refresh Token有效"}
    I -->|是| J["重新颁发Access Token"]
    I -->|否| K["重新登录"]
    
    J --> E
    
    style A fill:#4A90E2,color:#fff,rx:10,ry:10
    style C fill:#27AE60,color:#fff,rx:10,ry:10
    style D fill:#9B59B6,color:#fff,rx:10,ry:10
    style K fill:#E74C3C,color:#fff,rx:10,ry:10

Token失效场景

除了自然过期,以下场景Token应该主动失效:

场景处理方式
用户修改密码使该用户所有Token失效
用户主动退出使当前Token失效
账号被冻结使该用户所有Token失效
取消授权使对应应用的Token失效
安全事件批量使相关Token失效

Token刷新实现

java
@RestController
@RequestMapping("/auth")
public class TokenController {
    
    @Autowired
    private TokenService tokenService;
    
    /**
     * 刷新访问令牌
     */
    @PostMapping("/refresh")
    public Result<TokenPair> refreshToken(
            @RequestHeader("Refresh-Token") String refreshToken) {
        
        // 验证Refresh Token
        RefreshTokenPayload payload = tokenService.validateRefreshToken(refreshToken);
        if (payload == null) {
            return Result.error(401, "刷新令牌无效或已过期");
        }
        
        // 检查用户状态
        User user = userService.getById(payload.getUserId());
        if (user == null || user.getStatus() != UserStatus.NORMAL) {
            return Result.error(403, "账号状态异常");
        }
        
        // 生成新的Token对
        String newAccessToken = tokenService.generateAccessToken(user);
        String newRefreshToken = tokenService.generateRefreshToken(user);
        
        // 使旧的Refresh Token失效
        tokenService.revokeRefreshToken(refreshToken);
        
        return Result.success(new TokenPair(newAccessToken, newRefreshToken));
    }
}

单点登录集成

对于企业内部多系统场景,单点登录(SSO)是常见需求。可以借助成熟的SSO框架快速实现,如Sa-Token:

java
// Sa-Token配置示例
@Configuration
public class SaTokenConfig {
    
    @Bean
    public SaTokenDao saTokenDao() {
        // 使用Redis存储
        return new SaTokenDaoRedisJackson();
    }
}

// 登录接口
@PostMapping("/login")
public SaResult login(@RequestBody LoginRequest request) {
    // 验证用户名密码
    User user = userService.validateCredentials(
        request.getUsername(), 
        request.getPassword());
    
    if (user != null) {
        // Sa-Token登录
        StpUtil.login(user.getId());
        return SaResult.ok("登录成功");
    }
    return SaResult.error("用户名或密码错误");
}

// 踢人下线
@PostMapping("/kick/{userId}")
public SaResult kickOut(@PathVariable Long userId) {
    StpUtil.kickout(userId);
    return SaResult.ok("已踢下线");
}

更新: 2025-12-06 17:30:33
原文: https://www.yuque.com/u22210564/zoxfmt/xl1kr2c6kao5m17y

Java 后端面试知识库