Skip to content

第61课:Spring Security

🎯 学习目标

  • 理解认证、授权、过滤器链、SecurityContext、UserDetailsService 的关系。
  • 掌握 Spring Security 6 风格的 SecurityFilterChain 配置。
  • 能实现基于表单、HTTP Basic 或 JWT 的登录认证流程。
  • 能使用密码加密、方法级权限和角色/权限模型。
  • 能识别明文密码、CSRF 误用、JWT 密钥硬编码、权限边界混乱等风险。

📖 一、认证与授权

两个概念必须区分:

text
认证 Authentication:你是谁?
授权 Authorization:你能做什么?

示例:

text
用户登录成功,系统确认他是 userId=1001,这是认证。
用户访问 /admin/users,系统判断他是否有 ADMIN 权限,这是授权。

Spring Security 的核心是过滤器链。请求进入 Controller 前,会先经过多个安全过滤器。


📖 二、基本依赖

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

加入依赖后,Spring Boot 默认保护所有接口,并生成临时密码。这是为了安全默认值。


📖 三、SecurityFilterChain

Spring Security 6 推荐配置方式:

java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**", "/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }

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

REST API 如果使用 Token,通常关闭 CSRF;传统表单 + Cookie Session 应认真评估 CSRF 防护。


📖 四、UserDetailsService

java
@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {
        UserEntity user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));

        return org.springframework.security.core.userdetails.User
            .withUsername(user.getUsername())
            .password(user.getPasswordHash())
            .authorities(user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getCode()))
                .toList())
            .accountLocked(user.isLocked())
            .disabled(!user.isEnabled())
            .build();
    }
}

密码必须存哈希:

java
String hash = passwordEncoder.encode(rawPassword);
boolean matches = passwordEncoder.matches(rawPassword, hash);

不要存明文密码,也不要自己设计加密算法。


📖 五、JWT 认证流程

JWT 常用于前后端分离:

text
1. 用户提交用户名密码。
2. 后端验证成功后生成 JWT。
3. 前端后续请求带 Authorization: Bearer <token>。
4. 后端过滤器解析 token,构建 Authentication。
5. Controller 或方法权限基于 Authentication 判断。

过滤器伪代码:

java
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {
        String token = resolveToken(request);
        if (token != null && jwtService.validate(token)) {
            Authentication authentication = jwtService.toAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }
}

注册到过滤器链:

java
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

JWT 密钥、过期时间、刷新机制、黑名单、退出登录都需要设计。


📖 六、方法级权限

开启:

java
@EnableMethodSecurity

使用:

java
@PreAuthorize("hasRole('ADMIN')")
public List<UserResponse> listAllUsers() {
    return userService.findAll();
}
java
@PreAuthorize("#userId == authentication.principal.userId or hasRole('ADMIN')")
public UserResponse getProfile(Long userId) {
    return userService.getProfile(userId);
}

方法级权限适合保护 Service 层敏感操作,不要只依赖前端隐藏按钮。


📖 七、角色与权限模型

常见模型:

text
User -> Role -> Permission

示例:

text
USER
  ROLE_ADMIN
    user:create
    user:delete
  ROLE_AUDITOR
    audit:read

Spring Security 中:

text
hasRole('ADMIN') 实际匹配 ROLE_ADMIN
hasAuthority('user:delete') 精确匹配权限字符串

角色适合粗粒度,权限适合细粒度。


📖 八、统一认证失败响应

REST API 不应该跳转登录页,而应该返回 JSON。

java
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"请先登录\"}");
    }
}

403 访问拒绝也应统一处理:

java
public class RestAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":\"FORBIDDEN\",\"message\":\"无权限访问\"}");
    }
}

⚠️ 九、常见陷阱

1. 明文存密码

必须使用 BCrypt、Argon2 等成熟算法。不要 MD5 加盐后就自认为安全。

2. JWT 密钥硬编码

密钥应放环境变量、配置中心或密钥系统,并定期轮换。

3. 误关 CSRF

纯 Token REST API 通常可以关闭 CSRF;Cookie Session 表单应用不能无脑关闭。

4. 只做前端权限

前端隐藏按钮不是安全措施。后端必须校验权限。

5. 角色和权限混乱

ROLE_ADMINuser:delete 应有清晰层级,不要随意混用。


🆚 十、Java vs C 对比

维度C Web 服务Spring Security
认证手写中间件/回调FilterChain 标准化
密码处理手动调用加密库PasswordEncoder
上下文手动传递用户信息SecurityContext
方法权限手写判断@PreAuthorize
扩展函数链Filter、Provider、Handler

Spring Security 很强,但学习成本也高,因为它把认证授权做成了一套完整框架。


💡 十一、最佳实践

  • 密码只存哈希,不存明文。
  • REST API 使用统一 401/403 JSON 响应。
  • Token 密钥不硬编码。
  • 权限必须在后端校验。
  • 角色用于粗粒度,权限用于细粒度。
  • 登录、刷新、退出、Token 失效策略要一起设计。
  • 管理后台和普通 API 可以拆分不同 SecurityFilterChain。
  • 安全配置写集成测试,避免误放开接口。

🔍 十二、自测问题

text
认证和授权有什么区别?
SecurityFilterChain 在请求流程中做什么?
UserDetailsService 负责什么?
PasswordEncoder 为什么不能省?
hasRole 和 hasAuthority 有什么区别?
JWT 退出登录为什么比 Session 复杂?
CSRF 什么情况下需要开启?
401 和 403 有什么区别?

🧭 十三、安全设计清单

一个 REST API 项目至少检查:

text
登录接口是否限流?
密码是否使用 BCrypt 或 Argon2?
Token 是否有合理过期时间?
Refresh Token 是否可吊销?
退出登录是否能让 Token 失效?
管理接口是否只允许管理员访问?
方法级权限是否覆盖关键业务?
401/403 是否返回统一 JSON?
安全日志是否记录登录失败和越权访问?
生产密钥是否不在代码仓库中?

安全不是最后上线前补一个配置,而是接口设计的一部分。


🧪 十四、实战案例:获取当前用户

业务代码经常需要当前登录用户:

java
public class SecurityUtils {
    public static String currentUsername() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new BusinessException("UNAUTHORIZED", "请先登录");
        }
        return authentication.getName();
    }
}

更好的方式是封装自己的 Principal:

java
public record LoginUser(Long userId, String username, Set<String> permissions) {
}

这样业务层可以使用稳定的用户上下文,而不是到处解析框架对象。


📌 十五、学习建议

建议分三步练习:

text
先做 HTTP Basic,理解过滤器链。
再做表单登录,理解 Session 和 CSRF。
最后做 JWT,理解无状态认证和 Token 失效问题。

如果一开始直接复制 JWT 代码,很容易只会粘贴配置,不理解认证授权模型。


📚 十六、常见状态码

状态码含义场景
401未认证未登录、Token 无效、Token 过期
403已认证但无权限普通用户访问管理员接口
429请求过多登录限流、防爆破
500系统异常安全逻辑之外的未知错误

认证失败和授权失败必须区分。否则前端无法判断是跳登录页,还是显示“无权限”。


📌 十七、权限模型建议

权限字符串建议稳定、可读:

text
user:create
user:read
user:update
user:delete
order:refund
admin:dashboard:view

角色只是权限集合,不要把所有判断都硬编码成角色。

权限设计应尽量稳定,因为权限字符串一旦被前端、网关、审计系统或脚本引用,随意改名会造成联动故障。

权限变更也应该进入发布记录。


🎓 小结

Spring Security 是 Java 后端安全的核心框架。它的重点不是背配置,而是理解过滤器链、认证对象、权限模型和失败处理。安全设计必须保守,任何“为了方便暂时放开”的配置都可能成为真实漏洞。