快速上手Spring Security:别再裸奔你的API了

机器学习厨子
2025-12-21 15:10
阅读 1871

上周五晚上九点半,我正蹲在实验室角落啃着冷掉的黄焖鸡,盯着屏幕上一行刺眼的日志:

2024-06-14 21:18:32 ERROR o.s.s.w.a.ExceptionTranslationFilter - Access is denied

不是因为代码写错了,而是——我们的内部管理后台居然没做任何认证!产品经理昨天刚提了个需求:“加个登录页吧,现在谁都能进后台删数据。” 我差点一口饭喷出来。这都研二了,在实验室干了快两年,天天研究分布式一致性、看Raft论文、扒开源项目的源码,结果自家项目连基础安全都没做?说出去怕是要被GitHub上那些star过万的项目作者笑死。

更讽刺的是,隔壁组搞Python微服务的同学前几天还在群里炫耀他们用FastAPI+OAuth2三行代码搞定鉴权。而我们这个Spring Boot项目,愣是裸奔了半年多,靠“内网IP白名单”这种祖传防御机制苟活。运维大哥听说后直接发了个“😅”表情包,懂的都懂。

行吧,既然躲不过,那就干。这篇就记录下我如何用Spring Security快速搭起一个能上线的安全认证系统。不整那些花里胡哨的理论,全是实打实能跑起来的代码和踩过的坑。


为什么偏偏是Spring Security?

我知道,一提到Spring Security,很多人的第一反应是:“配置地狱”、“XML噩梦”、“文档比代码还难懂”。说实话,我一开始也这么想。但架不住它是Spring生态的亲儿子啊。咱们实验室的项目从Spring Boot 2.3一路升到3.2,Security的starter集成得那叫一个丝滑。而且,它真的能扛住生产环境的压力

去年双11期间,我们给学校某个区块链溯源平台做后端支持(没错,就是那个号称要用区块链解决农产品信任问题的项目),QPS峰值冲到3000+。当时临时加的Security模块,在配合Redis做Session共享后,延迟只增加了不到5ms。对比之下,某些轻量级框架在高并发下直接崩了线程池。

所以,别被它的复杂外表吓到。只要抓住核心概念,快速搭建完全没问题。


核心思路:认证 + 授权 = 安全

Spring Security的本质就两件事:

  1. 你是谁?(Authentication)
  2. 你能干啥?(Authorization)

我们这次的目标很简单:

  • 用户通过用户名/密码登录
  • 登录后获得JWT Token
  • 后续请求携带Token才能访问受保护的API

至于OAuth2、LDAP、多因子认证?以后再说。先让系统别裸奔!


动手:三步走战略

第一步:加依赖,别手抖

pom.xml里加上这些(注意版本兼容性,我们用的是Spring Boot 3.2 + Java 17):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

💡 提醒:别直接抄网上那些过时的教程!Spring Boot 3.x已经移除了javax.*包,全部换成jakarta.*了。我第一次跑的时候ClassNotFoundException直接给我整懵了。


第二步:用户信息从哪来?数据库设计很关键

我们用最朴素的方案:MySQL存用户。表结构如下:

字段 类型 说明
id BIGINT 主键
username VARCHAR(50) 唯一,登录用
password VARCHAR(100) 必须加密存储!
role VARCHAR(20) 角色,如ADMIN, USER

重点来了:密码绝对不能明文存! 我们用BCryptPasswordEncoder:

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

注册时这样存:

// UserService.java
public void register(String username, String rawPassword) {
    String encodedPassword = passwordEncoder.encode(rawPassword);
    userRepository.save(new User(username, encodedPassword, "USER"));
}

曾经有个实习生图省事,把密码MD5后存进去,结果被我当场抓包。现在他每次见我都绕道走 😏


第三步:配置Security,别被过滤器链搞晕

这是最容易翻车的地方。Spring Security背后是一堆Filter,顺序错了直接403。我们的目标是:

  • /api/auth/login 允许匿名访问
  • 其他/api/** 都要带有效Token
  • 静态资源(比如Swagger UI)放行

配置类长这样:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // 前后端分离,关掉CSRF
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/login").permitAll()
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter();
    }
}

看到那个.addFilterBefore()了吗?这就是自定义的JWT校验过滤器,它会在标准的用户名密码认证之前执行。如果Token有效,就直接放行;否则交给后面的流程处理。


JWT工具类:自己造轮子还是用现成的?

虽然网上有无数JWT工具类,但我还是建议自己写一个简单的,至少知道原理。毕竟我们不是在搞金融级安全,没必要过度设计。

@Component
public class JwtUtil {
    private final String SECRET_KEY = "your-very-long-secret-key-here"; // 实际要用环境变量!
    private final long EXPIRATION = 86400000; // 24小时

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

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

    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

⚠️ 注意:SECRET_KEY 千万别写死在代码里!我们实验室的做法是用K8s Secret挂载,或者从Vault读取。本地开发可以用.env文件,配合@Value("${jwt.secret}")注入。


自定义JWT过滤器:核心逻辑在这

这个过滤器会拦截所有请求,检查Header里的Authorization: Bearer <token>

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        
        String header = request.getHeader("Authorization");
        String username = null;
        String jwtToken = null;

        if (header != null && header.startsWith("Bearer ")) {
            jwtToken = header.substring(7);
            try {
                username = jwtUtil.getUsernameFromToken(jwtToken);
            } catch (Exception e) {
                logger.error("JWT解析失败: {}", e.getMessage());
            }
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
            if (jwtUtil.validateToken(jwtToken)) {
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        chain.doFilter(request, response);
    }
}

这段代码干了三件事:

  1. 从Header提取Token
  2. 解析出用户名
  3. 如果Token有效,就把用户信息塞进Spring Security上下文

之后在Controller里,你就能用@AuthenticationPrincipal直接拿到当前用户:

@GetMapping("/profile")
public ResponseEntity<User> getProfile(@AuthenticationPrincipal UserDetails userDetails) {
    return ResponseEntity.ok(userService.findByUsername(userDetails.getUsername()));
}

登录接口:别忘了返回Token

最后补上登录入口:

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

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtUtil jwtUtil;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        try {
            authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    request.getUsername(), request.getPassword())
            );
        } catch (BadCredentialsException e) {
            return ResponseEntity.status(401).body("用户名或密码错误");
        }

        UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());
        String token = jwtUtil.generateToken(userDetails);
        return ResponseEntity.ok(new JwtResponse(token));
    }
}

这里用到了AuthenticationManager,它会调用我们之前配置的UserDetailsService去查数据库验证密码。整个流程闭环了。


踩坑实录:那些让我想砸键盘的瞬间

  1. CORS跨域问题
    前端Vue项目跑在localhost:8080,后端在8081,登录时浏览器报CORS error。解决方案:在SecurityConfig里加上:

    http.cors(cors -> cors.configurationSource(request -> {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(Arrays.asList("http://localhost:8080"));
        config.setAllowedMethods(Arrays.asList("*"));
        config.setAllowedHeaders(Arrays.asList("*"));
        return config;
    }));
    
  2. Swagger被拦了
    本地调试时Swagger UI打不开,因为Security默认拦截所有路径。记得在authorizeHttpRequests里放行Swagger相关路径。

  3. 密码加密不一致
    测试账号是我手动插进数据库的,但忘了用BCrypt加密,导致一直认证失败。后来写了个初始化脚本自动加密测试数据。


和Python、区块链、GitHub有啥关系?

你可能会问:标题里提了Python、区块链、GitHub,结果正文没见着?别急,这恰恰是我想说的。

  • Python:我们组有个Python服务负责区块链数据上链,它需要调用我们Java服务的API。有了统一的JWT认证后,Python那边只需要在请求头加Token就行,不用再搞一套独立的鉴权逻辑。多语言协作的痛,谁用谁知道。

  • 区块链:那个溯源平台要求所有操作可审计。我们在Security的AuthenticationSuccessHandler里加了日志,记录谁在什么时候登录了系统,后续可以写入区块链作为操作证明。安全认证成了可信数据流的第一环。

  • GitHub:整个Security模块的代码,我参考了spring-projects/spring-security官方仓库的samples。开源的力量啊!顺手给文档提了个PR,修正了一个过时的配置示例,现在已经被merged了 🎉


总结:安全不是功能,是底线

折腾完这一套,我们的管理后台终于不再是“公开的秘密”。从开始到上线,实际编码时间不到一天(当然,前期调研和踩坑花了三天)。Spring Security虽然学习曲线陡峭,但一旦掌握,你会发现它提供了极其灵活的安全模型。

最后送大家一句我们导师常说的话:“你可以没有高性能,但不能没有安全性。” 尤其在涉及用户数据的系统里,裸奔API无异于把家门钥匙挂在小区公告栏。

对了,下周产品经理又要加“第三方微信登录”……我已经预感到又要和OAuth2搏斗了。不过没关系,先把基础打牢,后面的扩展才稳。

代码已上传到我们实验室的私有GitLab(因为涉及内部业务,就不放GitHub了),但核心逻辑和本文一致。如果你也在用Spring Boot,别等事故发生了才想起安全——现在就去加个Security吧!

P.S. 写完这篇文章,我发现自己的JWT_SECRET_KEY居然提交到Git历史里了……赶紧rebase重写了提交记录。程序员的自我修养,从撤销错误提交开始 😅

评论 0

最热最新
暂无评论
机器学习厨子Lv.1
0
影响力
0
文章
0
粉丝