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

炫酷之山峰
2025-12-19 07:09
阅读 1272

上周五晚上十点半,我还在公司调一个奇怪的 403 错误。窗外国贸三期的灯光已经稀疏,而我的 MacBook 已经烫得可以煎蛋。作为刚背了 30 年房贷的北漂程序员,这种加班场景早已成为生活的一部分——毕竟,新房子的月供可不会因为我搞不定权限系统就自动减免。

入职新公司两个月,我被安排接手一个内部管理后台的重构工作。产品经理上周三甩过来一句话:“下周上线前必须加上登录和权限控制。” 我看了一眼日历,离 deadline 只有四天。行吧,那就 Spring Security 上吧。

其实我对 Spring Security 一直有点敬畏。以前在 GitHub 上看开源项目时,总觉得它的配置又臭又长,XML 和 Java Config 混在一起像一锅乱炖。但这次没得选——公司技术栈是纯 Java + Spring Boot,而且领导明确说:“别整花里胡哨的 OAuth2,先做个基础认证就行。”

被迫营业:为什么选择 Spring Security?

我知道有些团队会用 Shiro,或者干脆手撸一套 JWT。但考虑到我们已经有现成的用户表结构、团队里老哥们对 Spring 生态熟悉,再加上 Spring Security 的社区支持和文档成熟度(虽然有时候文档写得像绕口令),最终还是决定用它。

更重要的是,性能。我们这个后台虽然用户不多(也就几百个运营+BD),但接口响应时间要求很严——领导说“不能让用户觉得卡”,毕竟人家还要去抢下午茶的最后一个蛋挞。Spring Security 默认是基于 Filter Chain 的,只要配置得当,开销其实很小。实测下来,加了完整认证后,P99 延迟只增加了不到 5ms。

初探:从“Hello World”到能跑起来

我新建了一个 Spring Boot 2.7 项目(别问为啥不是 3.x,问就是公司统一版本)。先引入依赖:

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

启动应用,访问任意接口,直接弹出一个丑到爆的登录页,用户名是 user,密码在控制台打印出来一串随机字符串。这显然不能上线——谁家产品敢让用户记一串 UUID 当密码?

于是,我开始自定义认证逻辑。

自定义 UserDetailsService:对接真实数据库

我们的用户信息存在 MySQL 里,表结构大概是这样:

字段名 类型 说明
id BIGINT 主键
username VARCHAR(50) 登录名
password VARCHAR(100) BCrypt 加密后的密码
enabled TINYINT 是否启用(1=是)
role VARCHAR(20) 角色(admin / operator)

注意:密码一定不要明文存储! 我们用的是 BCrypt,Spring Security 内置支持,连 encoder 都不用自己写。

接下来实现 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("用户不存在: " + username);
        }
        // 注意:这里返回的是 org.springframework.security.core.userdetails.User
        return User.builder()
                .username(user.getUsername())
                .password(user.getPassword()) // 数据库里已经是 BCrypt 加密后的
                .authorities("ROLE_" + user.getRole().toUpperCase())
                .accountExpired(false)
                .accountLocked(false)
                .credentialsExpired(false)
                .disabled(!user.isEnabled())
                .build();
    }
}

这里有个坑:角色名必须加 ROLE_ 前缀,否则 @PreAuthorize("hasRole('ADMIN')") 会失效。我第一次没加,测试时疯狂 403,差点以为是缓存问题,查了半小时才意识到是命名规范的事。

配置 SecurityFilterChain:性能与安全的平衡

Spring Boot 2.7 开始推荐用 SecurityFilterChain 代替 WebSecurityConfigurerAdapter。我一开始还按老教程写,结果 IDE 直接标红,提示 deprecated。果然,技术债还得自己还。

核心配置如下:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable() // 内部系统,且全是 JSON API,暂时关掉 CSRF
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态,用 JWT 或 Token
            .and()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/login").permitAll()
                .requestMatchers("/actuator/**").permitAll() // 健康检查放行
                .anyRequest().authenticated()
            )
            .httpBasic().disable() // 不用 Basic Auth
            .formLogin().disable(); // 不用默认表单登录

        return http.build();
    }

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

重点说几个性能相关的决策:

  1. 关闭 CSRF:因为我们是纯 API 后端,前端通过 Axios 发请求,不涉及表单提交,CSRF 防护反而增加无谓开销。
  2. STATELESS 会话:避免服务器存储 session,减轻内存压力,也方便后续做集群。
  3. 精确放行路径:比如 /actuator/health 必须放行,否则运维监控会报警。

登录接口:生成 Token(伪 JWT)

为了保持简单,我没有立刻上真正的 JWT 库(比如 jjwt),而是用一个临时方案:登录成功后返回一个 UUID 作为 token,存入 Redis,设置过期时间。

@RestController
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        try {
            Authentication authenticate = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
            );
            if (authenticate.isAuthenticated()) {
                String token = UUID.randomUUID().toString();
                // 存入 Redis,30 分钟过期
                redisTemplate.opsForValue().set("auth:" + token, request.getUsername(), 30, TimeUnit.MINUTES);
                return ResponseEntity.ok(Map.of("token", token));
            }
        } catch (BadCredentialsException e) {
            return ResponseEntity.status(401).body("用户名或密码错误");
        }
        return ResponseEntity.status(401).body("认证失败");
    }
}

对应的过滤器负责从 Header 中取 token 并验证:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("Authorization");
        if (token != null && token.startsWith("Bearer ")) {
            token = token.substring(7);
            String username = (String) redisTemplate.opsForValue().get("auth:" + token);
            if (username != null) {
                // 构造 Authentication 对象
                UsernamePasswordAuthenticationToken auth = 
                    new UsernamePasswordAuthenticationToken(username, null, List.of());
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        filterChain.doFilter(request, response);
    }
}

别忘了把这个 filter 加到 Security 配置里:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception {
    http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    // ... 其他配置
}

性能压测:到底慢了多少?

我用 JMeter 模拟了 100 并发用户持续请求一个受保护接口:

场景 平均响应时间 (ms) P99 (ms) CPU 占用
无 Security 12 28 15%
加 Spring Security(Redis 验证) 16 35 18%

多出来的 4ms 主要花在 Redis 查询上。如果把 token 放进本地缓存(比如 Caffeine),还能再优化。不过目前来看,完全在可接受范围内——毕竟我们不是高并发电商系统,内部工具而已。

踩过的坑 & 经验总结

  1. 不要用 @EnableGlobalMethodSecurity 除非你真的需要方法级安全。它会代理所有 Bean,带来额外性能损耗。我们只用了 URL 级权限控制,够用了。
  2. 调试时开启 DEBUG 日志logging.level.org.springframework.security=DEBUG,能清楚看到每个请求经过哪些 filter、为什么被拒绝。
  3. 测试账号要覆盖各种角色:有一次我只测了 admin,结果 operator 用户上线后全 403,被测试小姐姐追着问了半小时。
  4. GitHub 是好东西:遇到问题先搜 spring-security github issues,很多奇怪行为(比如重定向循环)早有人踩过坑。

最后:房贷还在,代码继续

折腾三天,终于在 deadline 前搞定。上线那天,运维小哥跑来问:“你这接口怎么突然要 token 了?” 我笑着说:“安全第一嘛,不然你半夜被叫起来修漏洞,房贷谁还?”

说到底,Spring Security 并不可怕。只要理解它的 Filter Chain 机制,合理配置,完全可以在保证安全的同时兼顾性能。对于像我这样的普通打工人来说,能快速交付、少背锅、按时下班,比什么都重要。

对了,我把这个最小可行的认证模块整理了一下,丢到了 GitHub 上:https://github.com/yourname/spring-security-starter(名字当然是假的,但你可以真去搜类似 repo)。欢迎 Star(虽然我也知道没人会 star 一个基础模板 😂)。

现在,我要去研究前端动画了——毕竟,登录页太丑,产品经理说要加个 loading 动效。唉,程序员的命,都是产品经理给的(狗头保命)。


P.S. 如果你也刚买房、刚入职、被 deadline 追着跑,别慌。一行行代码敲下去,总能搞定。毕竟,房子不会跑,bug 也不会。

评论 0

最热最新
暂无评论
炫酷之山峰Lv.1
0
影响力
0
文章
0
粉丝