Spring Security 基础:快速搭建安全认证系统

Rust练习生
2025-12-18 22:47
阅读 497

成都的夏天总是来得猝不及防,上周末还在锦里喝着冰粉看熊猫,这周一就被拉进一个“紧急”项目会议。产品经理说:“咱们这个新平台下周就要给运营团队试用,必须带登录、权限控制,最好还能对接现有的用户体系。”我看了眼日历——离 deadline 只剩 5 天,而团队里后端就我和另一个刚转岗的同事。那一刻,我真的想把咖啡泼在 Jira 任务卡片上。

但没办法,干就完了。作为 Claude Code 的早期尝鲜用户(没错,就是那个主打命令行智能体的工具),我早就习惯了用终端狂敲代码的日子。分布式系统搞过几轮,高并发也踩过坑,但说实话,Spring Security 这玩意儿我一直靠现成脚手架糊弄过去——毕竟以前公司都用 OAuth2 + 自研网关统一鉴权,业务层根本碰不到认证逻辑。

这次不行了。运营系统虽然流量不大,但涉及敏感数据操作(比如批量导出用户信息、修改计费策略),安全这块不能马虎。而且产品还提了个“骚需求”:前端要用 Vue 写管理后台,后端提供 RESTful API,但部分接口要能被 Python 脚本调用(运营同学写的数据清洗脚本)。这下好了,传统的 session-based 认证肯定不行,得上 JWT。


为什么是 Spring Security?而不是自己造轮子?

我知道有些兄弟看到“安全”俩字就热血上头,恨不得手撸 AES + RSA + 防重放攻击。但现实是:你写的加密算法,在黑客眼里可能还不如一张草稿纸。去年双11期间,隔壁组自己实现的 token 刷新机制被渗透测试打出个洞,差点让整个订单系统停摆。运维大哥半夜打电话骂街的场景我还记得清清楚楚。

Spring Security 虽然配置起来有点“魔法”,但它是经过工业级验证的。而且和 Spring Boot 天然集成,starter 一加,基本骨架就出来了。对于我们这种 deadline 驱动的小团队,能用成熟方案绝不重复造轮子——这是我在分布式系统里学到最痛的教训。


项目初始化:Spring Boot + Security Starter

先建个干净的 Spring Boot 项目(我用的 3.2.x):

spring init --dependencies=web,security,data-jpa,h2 my-secure-app
cd my-secure-app

注:H2 是为了本地快速开发,生产环境当然换成 MySQL/PostgreSQL。

加上 spring-boot-starter-security 后,启动应用你会发现:

  • 所有接口都被拦住了
  • 控制台打印出一串随机密码(形如 Using generated security password: 8a7b6c5d...

这就是 Spring Security 的“默认安全策略”——开箱即用,但也意味着你啥都干不了。我们需要自定义配置。


核心配置:从表单登录到 JWT

我们的目标很明确:

  1. 前端(Vue) 通过 /api/auth/login 提交用户名密码,返回 JWT Token
  2. 后续请求 在 Header 中携带 Authorization: Bearer <token>
  3. Python 脚本 也能用同一个 Token 访问受保护接口(比如 /api/reports/export

第一步:禁用默认的表单登录

SecurityConfig.java 里干掉那些花里胡哨的默认行为:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable() // 前后端分离,CSRF 暂不考虑(生产环境需评估!)
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态
            .and()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/login").permitAll()
                .requestMatchers("/actuator/**").permitAll() // 健康检查放行
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

注意:这里 csrf().disable() 是为了简化,实际生产中如果涉及浏览器 Cookie 认证,必须开启 CSRF 防护。但我们纯 API 场景,且用 Authorization Header,风险较低。

第二步:实现 JWT 登录接口

创建 AuthController

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

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtTokenProvider tokenProvider;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        try {
            // 触发认证流程
            Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
            );
            
            // 生成 JWT
            String jwt = tokenProvider.generateToken(authentication);
            return ResponseEntity.ok(new JwtResponse(jwt));
        } catch (BadCredentialsException e) {
            return ResponseEntity.status(401).body("Invalid credentials");
        }
    }
}

关键点在于 authenticationManager.authenticate() —— 它会委托给 UserDetailsService 去查用户。

第三步:自定义 UserDetailsService

我们用 JPA 存用户(简单起见,只存用户名、密码、角色):

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(unique = true)
    private String username;
    
    private String password; // 实际应存 BCrypt 加密后的
    
    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
    @Column(name = "role")
    private Set<String> roles = new HashSet<>();
}

对应的 UserDetailsServiceImpl

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));
        
        return org.springframework.security.core.userdetails.User.builder()
            .username(user.getUsername())
            .password(user.getPassword()) // 注意:数据库里存的是 BCrypt 密文
            .authorities(user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .toArray(GrantedAuthority[]::new))
            .build();
    }
}

别忘了在 PasswordEncoder Bean 里指定 BCrypt:

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

JWT 工具类:生成与解析

这部分网上代码很多,但要注意几点:

  • 不要把敏感信息(如密码)塞进 Token
  • 设置合理的过期时间(我们设 2 小时)
  • 用强密钥签名(别用 "secret" 这种弱鸡 key)
@Component
public class JwtTokenProvider {

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

    @Value("${app.jwt.expiration}")
    private int jwtExpirationMs;

    public String generateToken(Authentication authentication) {
        String username = authentication.getName();
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtExpirationMs);

        return Jwts.builder()
            .setSubject(username)
            .setIssuedAt(now)
            .setExpiration(expiryDate)
            .signWith(SignatureAlgorithm.HS512, jwtSecret)
            .compact();
    }

    public String getUsernameFromJWT(String token) {
        Claims claims = Jwts.parser()
            .setSigningKey(jwtSecret)
            .parseClaimsJws(token)
            .getBody();
        return claims.getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
            return true;
        } catch (SignatureException | MalformedJwtException | ExpiredJwtException |
                 UnsupportedJwtException | IllegalArgumentException ex) {
            // 日志记录具体错误类型,方便 debug
            return false;
        }
    }
}

对应的 application.yml

app:
  jwt:
    secret: ${JWT_SECRET:myStrongSecretKey123!@#} # 生产环境务必用环境变量覆盖
    expiration: 7200000 # 2 hours in ms

自定义 JWT Filter:拦截并验证 Token

最后一步,把 Token 验证逻辑插入 Spring Security 过滤器链:

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider tokenProvider;

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {
        
        String token = getJwtFromRequest(request);
        if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
            String username = tokenProvider.getUsernameFromJWT(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7); // 去掉 "Bearer " 前缀
        }
        return null;
    }
}

运营场景适配:让 Python 脚本能用!

前面提到,运营同学要用 Python 脚本调用 API。他们习惯这样写:

import requests

# 先登录拿 token
resp = requests.post('http://api.example.com/api/auth/login', 
                     json={'username': 'ops_user', 'password': 'ops_pass'})
token = resp.json()['token']

# 用 token 调用业务接口
headers = {'Authorization': f'Bearer {token}'}
data = requests.get('http://api.example.com/api/reports/export', headers=headers)

只要我们的 JWT 接口符合标准,任何语言都能调——这才是无状态认证的威力。运维同学再也不用求我们给脚本开白名单 IP 了(之前因为用 session,脚本跑在不同机器上总失效,被吐槽到死)。


生产环境踩坑实录

坑 1:Token 刷新机制缺失

上线第一天,运营小妹怒吼:“刚导一半数据,接口突然 401 了!”——原来 JWT 过期了。我们临时加了个 /api/auth/refresh 接口,用旧 Token 换新 Token(需验证旧 Token 未过期且用户存在)。但注意:不要无限续期,否则等于没过期

坑 2:权限粒度太粗

最初只按 ROLE 控制(如 ROLE_ADMIN, ROLE_OPERATOR),结果产品经理说:“运营 A 只能看华南区数据,运营 B 能看全国”。于是引入 方法级权限

@PreAuthorize("@reportService.canAccessRegion(#regionId, principal)")
@GetMapping("/reports/{regionId}")
public Report getReport(@PathVariable Long regionId) {
    // ...
}

配合自定义 PermissionEvaluator,动态判断用户是否有权访问特定资源。这比硬编码 if-else 清爽多了。

坑 3:日志埋点不足

某次安全审计要求“记录所有敏感操作”。我们在 JwtAuthenticationFilter 里加了 MDC(Mapped Diagnostic Context),把用户名透传到日志:

MDC.put("user", username);
try {
    filterChain.doFilter(request, response);
} finally {
    MDC.clear();
}

这样每条日志都带 user=xxx,排查问题快如闪电。


性能与扩展性考量

虽然运营系统 QPS 不高,但架构上我们做了几件事:

  • JWT 解析不查库:Token 自包含,验证只需密钥(除非做黑名单)
  • 用户信息缓存UserDetailsService 查 DB 结果用 Caffeine 缓存 5 分钟
  • 权限预加载:登录时把用户角色/权限列表塞进 Token 的 scope 字段,避免每次请求查权限表
方案 优点 缺点 适用场景
Session + Redis 支持实时踢人 需维护状态 强管控系统
JWT(无刷新) 无状态,易扩展 无法中途失效 内部工具、低风险API
JWT + 刷新Token 平衡安全与体验 实现稍复杂 大多数 Web 应用

我们选了第三种——用短期 Access Token + 长期 Refresh Token 组合。


写在最后

折腾完这套认证体系,已经是周五晚上 9 点。窗外玉林路的小酒馆还亮着灯,我顺手写了篇博客(就是你现在看的这篇)。虽然 Spring Security 的配置看起来啰嗦,但比起自己从零实现,省下的时间足够我多喝两杯冰啤酒

技术这东西,有时候不是越炫越好,而是刚好解决眼前的问题。运营同学现在每天用 Python 脚本跑报表,再也不用求着我们改后端代码;产品经理也终于闭嘴了——毕竟他连登录界面都没见过,直接甩链接给客户。

如果你也在赶一个“明天上线”的项目,不妨试试这套组合拳。记住:安全不是功能,而是底线。别等线上被拖库了才想起补课。

对了,Claude Code 最近更新了 Security 相关的 CLI 插件,能自动生成 JWT 配置模板。成都的夜生活刚刚开始,而我的终端,还在闪烁着绿色的光。

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝