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

全栈打工仔
2025-12-12 20:08
阅读 749

上周五晚上 10 点半,我正瘫在出租屋里刷 B 站,突然收到 Leader 的微信:“明早站会前搞个基础的登录认证,别整太复杂,但得能跑。” 我盯着消息愣了两秒——这不就是典型的“简单需求”吗?字节人谁不懂,产品经理嘴里的“简单”,往往意味着你得通宵。

我是字节跳动基础架构组的一名后端开发,5 年经验,日常搬砖内容包括但不限于:K8s 调度器魔改、中间件性能优化、以及偶尔被拉去救火。最近还在偷偷卷 AI,想着万一哪天大模型把 CRUD 都干了,我还能靠 prompt engineering 混口饭吃。不过眼下,还是得先搞定这个“简单”的认证系统。

事情起因是团队要给一个内部工具加上用户登录功能。之前这玩意儿直接裸奔,谁拿到 URL 都能进,测试同学上周误删了生产配置,差点让双 11 的流量调度策略崩掉。运维大哥当场黑脸,甩出一句:“再没权限控制,我就把你们服务从网关里摘了。”

于是,轮到我上场了。

为什么选 Spring Security?

其实我们内部有统一的 IAM(Identity and Access Management)平台,但接入成本高、流程长,还得排队等安全团队审核。Leader 说了:“先搞个 MVP,能拦住外人就行,后续再对接统一认证。”

MVP 意味着快、稳、少写代码。Spring Boot + Spring Security 几乎是 Java 后端的默认选项。它抽象了认证(Authentication)和授权(Authorization)的核心逻辑,而且社区文档丰富,GitHub 上一堆 demo,连我这种平时只看源码不写业务的人都能快速上手。

更重要的是——简历上能写。别笑,这很真实。跳槽面试时,如果你说“用 Spring Security 做了 RBAC 权限控制”,至少说明你不是只会调接口的“API Boy”。在如今这行情下,“资源”管控能力几乎是每个中高级岗位的硬性要求。

动手:5 分钟搭个能跑的认证

先理清需求:

  • 用户通过用户名/密码登录
  • 登录后返回 JWT Token
  • 后续请求携带 Token 才能访问受保护的资源
  • 管理员和普通用户权限不同

第一步:依赖安排上

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

别小看这两行,去年有个实习生漏加 jjwt,结果线上登录接口 500,报错 ClassNotFoundException: io.jsonwebtoken.Jwts,被测试追着问了三天。

第二步:UserDetails & UserDetailsService

Spring Security 要求你提供一个 UserDetailsService,用来加载用户信息。我建了个简单的 User 实体:

public class User {
    private Long id;
    private String username;
    private String password; // 实际项目务必加密!
    private String role; // "ADMIN" or "USER"
}

然后实现 UserDetailsService

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

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username);
        if (user == null) throw new UsernameNotFoundException("User not found");
        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .authorities("ROLE_" + user.getRole())
                .build();
    }
}

注意:这里我把角色转成了 ROLE_ADMIN 格式,因为 Spring Security 默认的 hasRole('ADMIN') 会自动加前缀。不然你会踩坑,比如写 hasRole('ROLE_ADMIN') 反而失效。

第三步:JWT 工具类

为了无状态认证,我们用 JWT。写个工具类生成和解析 Token:

public class JwtUtil {
    private String secret = "mySecretKey"; // 生产环境务必用强密钥+配置中心管理!

    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + 86400000)) // 24h
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public String extractUsername(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody().getSubject();
    }

    public boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

血泪教训:千万别把 secret 写死在代码里!我们曾因 hardcode 密钥导致安全扫描告警,被安全团队 call 进会议室“喝茶”。

第四步:Security 配置

重头戏来了。继承 WebSecurityConfigurerAdapter(虽然已 deprecated,但在 Spring Boot 2.x 仍是主流),配置过滤器链:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // 别再用 MD5 了兄弟!
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
                .antMatchers("/auth/login").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            .and()
            .addFilterBefore(new JwtAuthenticationFilter(authenticationManagerBean()),
                             UsernamePasswordAuthenticationFilter.class);
    }
}

关键点:

  • 关闭 CSRF(因为是 stateless API)
  • 设置 session 为 STATELESS
  • /auth/login 公开,/admin/** 仅 ADMIN 可访问
  • 加入自定义的 JwtAuthenticationFilter

第五步:登录接口 & Filter

登录 Controller 很简单:

@PostMapping("/auth/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
    try {
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
        );
        UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());
        String token = jwtUtil.generateToken(userDetails);
        return ResponseEntity.ok(new JwtResponse(token));
    } catch (BadCredentialsException e) {
        return ResponseEntity.status(401).body("Invalid credentials");
    }
}

JwtAuthenticationFilter 负责从 Header 中提取 Token,验证并设置 SecurityContext:

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    // ... 构造注入 jwtUtil, userDetailsService

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String token = extractToken(request);
        if (token != null && jwtUtil.validateToken(token, userDetails)) {
            UsernamePasswordAuthenticationToken auth = 
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        chain.doFilter(request, response);
    }
}

踩过的坑 & 生产建议

  1. 密码加密:本地测试用 {noop} 能跑,但上生产必须用 BCryptPasswordEncoder。我们有一次压测,发现明文密码库被 DBA 发现,差点被当成安全事件上报。

  2. Token 刷新机制:上面 demo 没做 refresh token。真实场景建议加上,否则用户每 24 小时就得重新登录,体验极差。

  3. 权限粒度hasRole 只适合粗粒度控制。如果要做数据级权限(比如“只能看自己创建的资源”),得结合 @PreAuthorize 和自定义表达式。

  4. 日志与监控:记录失败登录尝试,防暴力破解。我们接入了公司内部的风控系统,连续 5 次失败就封 IP。

  5. 资源隔离:不同角色能访问的“资源”必须严格划分。比如 /api/v1/orders/{id},普通用户只能查自己的订单 ID,这块光靠 Spring Security 不够,得在 Service 层二次校验。

效果 & 心得

第二天站会前,我提交了 PR。测试同学跑完冒烟测试,居然一次过。Leader 看了眼代码,点点头:“行,先上线灰度。” 当晚部署后,再也没人能随便进那个内部工具了。

回头想想,Spring Security 虽然配置有点“魔法”,但一旦摸清套路,搭认证系统真的很快。更重要的是,它强迫你思考“谁可以访问什么资源”——这是构建安全系统的基石。

现在我的简历里又多了一行:“基于 Spring Security 实现 JWT 无状态认证,支持 RBAC 权限模型”。虽然听起来平平无奇,但在面试时,只要能讲清楚背后的线程安全(SecurityContext 是 ThreadLocal)、过滤器顺序、以及如何防御常见攻击(比如 Token 泄露),基本就能碾压 80% 的候选人。

最后送大家一句我在代码人生中学到的道理:安全不是功能,而是基础设施。别等到线上被黑了才想起加认证——那时候,你的简历可能已经在 HR 那里被打上了“风险”标签。

(完)

P.S. 本文所有代码已在 GitHub 开源,搜 “bytearch-spring-security-demo” 即可。顺便,招人,字节基础架构组,上海,急缺懂安全又会调优的后端,简历砸过来~

评论 0

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