Skip to content
第 31 章 架构 ⏱ 12 分钟阅读

第 31 章:认证与授权 ​

学习目标 ​

  • 掌握 OAuth2.0 流程
  • 学会 JWT 与 Token 实战
  • 实现 RBAC 权限模型
  • 避免 Token 泄漏、权限过大的坑

一、认证 vs 授权 ​

text
认证(Authentication):你是谁?
授权(Authorization):你能做什么?
java
// 认证:校验用户名密码
public User login(String username, String password) {
    User user = userRepository.findByUsername(username);
    if (!encoder.matches(password, user.getPassword())) {
        throw new BadCredentialsException();
    }
    return user;
}

// 授权:校验用户能做什么
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) {
    userRepository.deleteById(id);
}

二、Session 认证 ​

java
// 1. 登录后创建 session
@PostMapping("/login")
public void login(@RequestBody LoginRequest req, HttpSession session) {
    User user = authService.login(req);
    session.setAttribute("user", user);
}

// 2. 拦截器校验
@Component
public class AuthInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        HttpSession session = req.getSession();
        User user = (User) session.getAttribute("user");
        if (user == null) {
            res.setStatus(401);
            return false;
        }
        return true;
    }
}
text
Session 缺点
├── 需要服务端存储
├── 多节点要共享 Session(Redis)
└── CSRF 风险

三、Token 认证 ​

java
// 1. 登录后返回 token
@PostMapping("/login")
public TokenDto login(@RequestBody LoginRequest req) {
    User user = authService.login(req);
    String token = jwtService.generate(user);
    return new TokenDto(token, jwtService.getExpiry());
}

// 2. 拦截器校验
public class TokenInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        String token = req.getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            res.setStatus(401);
            return false;
        }
        String userId = jwtService.parseUserId(token);
        req.setAttribute("userId", userId);
        return true;
    }
}

四、JWT 实战 ​

java
// 1. 引入依赖
// io.jsonwebtoken:jjwt-api, jjwt-impl, jjwt-jackson

// 2. 生成 JWT
@Service
public class JwtService {
    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiry:3600}")
    private long expirySec;

    public String generate(User user) {
        return Jwts.builder()
            .setSubject(user.getId().toString())
            .claim("username", user.getUsername())
            .claim("role", user.getRole())
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + expirySec * 1000))
            .signWith(SignatureAlgorithm.HS256, secret)
            .compact();
    }

    public Claims parse(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(secret)
            .build()
            .parseClaimsJws(token)
            .getBody();
    }
}
java
// 3. 过滤链
public class JwtFilter extends OncePerRequestFilter {
    @Resource private JwtService jwtService;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
        String header = req.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            try {
                Claims claims = jwtService.parse(token);
                Long userId = Long.parseLong(claims.getSubject());
                UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(userId, null, getAuthorities(claims));
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (ExpiredJwtException e) {
                res.setStatus(401);
                res.getWriter().write("token expired");
                return;
            }
        }
        chain.doFilter(req, res);
    }
}

⚠️ 坑 1:JWT 默认无刷新机制,过期就要重新登录。双 Token:access_token(短)+ refresh_token(长)。

五、Refresh Token ​

java
// 1. 登录返回两个 token
public TokenPair login(LoginRequest req) {
    User user = authService.login(req);
    String accessToken = jwtService.generateAccess(user);
    String refreshToken = jwtService.generateRefresh(user);
    refreshTokenRepository.save(new RefreshToken(user.id(), refreshToken));
    return new TokenPair(accessToken, refreshToken);
}

// 2. 用 refresh_token 换 access_token
@PostMapping("/refresh")
public TokenDto refresh(@RequestBody RefreshRequest req) {
    Claims claims = jwtService.parse(req.getRefreshToken());
    Long userId = Long.parseLong(claims.getSubject());

    RefreshToken stored = refreshTokenRepository.findByUserIdAndToken(userId, req.getRefreshToken());
    if (stored == null || stored.isExpired()) {
        throw new UnauthorizedException();
    }
    // 重新生成
    User user = userRepository.findById(userId).orElseThrow();
    return new TokenDto(jwtService.generateAccess(user));
}

六、OAuth2.0 ​

text
角色
├── Resource Owner: 用户
├── Client: 第三方应用
├── Authorization Server: 授权服务器(发 token)
└── Resource Server: 资源服务器(API)

四种模式
├── Authorization Code(最常用)
├── Implicit(已废弃)
├── Client Credentials
└── Password(已废弃,推荐用 Authorization Code)
java
// Authorization Server(Spring Authorization Server)
@Bean
public SecurityFilterChain authServerSecurity(HttpSecurity http) throws Exception {
    OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
    return http.build();
}

// 客户端注册
@Bean
public RegisteredClientRepository registeredClientRepository() {
    RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("order-client")
        .clientSecret("{noop}secret")
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri("https://app.example.com/callback")
        .scope("read")
        .scope("write")
        .build();
    return new InMemoryRegisteredClientRepository(client);
}
java
// 资源服务器校验 token
@Bean
public SecurityFilterChain resourceServerSecurity(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
    return http.build();
}

⚠️ 坑 2:OAuth2 client_secret 写死在代码,泄漏 = 被攻击。用环境变量或 K8s Secret。

七、RBAC 权限模型 ​

sql
-- 用户
CREATE TABLE user (
    id BIGINT PRIMARY KEY,
    username VARCHAR(50)
);

-- 角色
CREATE TABLE role (
    id BIGINT PRIMARY KEY,
    name VARCHAR(50)  -- ADMIN / USER
);

-- 权限
CREATE TABLE permission (
    id BIGINT PRIMARY KEY,
    name VARCHAR(50)  -- order:read, order:write
);

-- 用户-角色
CREATE TABLE user_role (
    user_id BIGINT,
    role_id BIGINT
);

-- 角色-权限
CREATE TABLE role_permission (
    role_id BIGINT,
    permission_id BIGINT
);
java
// 实体关系
public class User {
    private Long id;
    private String username;
    private List<Role> roles;
}

public class Role {
    private Long id;
    private String name;
    private List<Permission> permissions;
}

public class Permission {
    private Long id;
    private String name;
    private String resource;
    private String action;
}
java
// 授权
@PreAuthorize("hasAuthority('order:write')")
public Order createOrder(@RequestBody OrderDto dto) {
    return orderService.create(dto);
}

@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) {
    userRepository.deleteById(id);
}

八、Spring Security 配置 ​

java
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

⚠️ 坑 3:Spring Security 默认生成 URL 静态资源权限,生产环境必须限制 /api/** 需认证,但静态资源放行。

九、Token 存储策略 ​

java
// 1. 客户端:localStorage(不安全,XSS 风险)
// 2. 客户端:HttpOnly Cookie(防 XSS,但 CSRF 风险)
// 3. 服务端:Redis(可主动失效)

// 实战:Refresh Token 必须服务端存储
public class RefreshToken {
    private Long id;
    private Long userId;
    private String token;
    private LocalDateTime expiresAt;
    private boolean revoked;
}
java
// 登出 = 撤销 token
@PostMapping("/logout")
public void logout(@RequestHeader("Authorization") String auth) {
    String token = auth.substring(7);
    String jti = jwtService.getJti(token);
    blacklistService.add(jti, Duration.ofSeconds(jwtService.getExpiry()));
}

十、单点登录(SSO) ​

text
SSO 流程
1. 用户访问 app1.example.com
2. app1 重定向到 sso.example.com/login
3. 用户登录,sso 写 sso.example.com Cookie
4. sso 重定向回 app1?token=xxx
5. app1 拿 token 调 sso /verify,获取用户信息
6. 后续访问其他 app,同样流程,有 sso Cookie 不登录
java
// JWT 是天然 SSO 方案
// 同一授权服务签发的 token,所有资源服务共享

本章小结 ​

方案适用
Session传统 Web
JWT前后端分离
OAuth2第三方授权
SSO多应用统一
关键点建议
Token 存储HttpOnly Cookie
Refresh Token服务端可撤销
密码BCrypt
权限RBAC + 注解

动手练习 ​

  1. JWT 集成:实现 JWT 生成与校验,加在过滤器链
  2. 双 Token:实现 access + refresh,过期自动换
  3. RBAC 模型:设计 5 个权限,2 个角色,验证越权拦截
  4. OAuth2:用 Spring Authorization Server 部署一个授权服务

下一章:第 32 章:数据加密 →

本站基于 VitePress 构建 · 由 StackHub 团队维护