Spring Security 基础:快速搭建安全认证系统(别再裸奔了!)

数据库守门员
2025-12-16 04:08
阅读 790

作者:一个被爬虫和认证需求轮番轰炸的成都后端工程师,日常靠 Claude 写 CRUD,偶尔研究分布式事务到凌晨三点


上周五晚上十一点半,我正躺在沙发上刷《黑神话》,突然钉钉“叮”一声——产品小李发来消息:“哥,咱们后台管理界面能不能加个登录?现在谁都能进,刚有运营同学手滑删了全站用户配置……”

我默默放下 Switch,叹了口气。这已经是本月第三次因为没做权限控制出事了。我们团队有个“优良传统”:MVP(Minimum Viable Product)做到极致,能跑就行,安全?那是下个迭代的事。

但这次真不行了。领导已经拍桌子:“再出一次事故,整个组周末团建改上线值班!” 我寻思着,与其每次修修补补,不如直接上 Spring Security,一劳永逸。

于是,这个周末我没去太古里喝咖啡,也没去青城山吸氧,而是窝在出租屋,一边啃兔头一边搭认证系统。今天这篇,就是我的实战复盘,全程案例驱动,不讲理论八股,只聊怎么快速把安全防护搞起来,顺便防住那些想偷偷爬我们接口的家伙。


背景:为什么是现在?

其实我一直对 Spring Security 有点抗拒。倒不是它不好,而是配置太“魔法”了——各种 @EnableWebSecurityUserDetailsServicePasswordEncoder,看文档像看天书。之前项目都用公司封装好的 OAuth2 中间件,开箱即用,哪用操心这些?

但这次是个新项目,独立部署,没中间件可用。而且,最近发现有人在用脚本疯狂请求我们的公开 API,虽然没敏感数据,但 QPS 直接飙到 500+,数据库连接池差点被打爆。运维老王在群里咆哮:“再这样下去,服务器成本要超预算了!”

所以,这次的目标很明确:

  1. 快速实现用户名/密码登录
  2. 区分普通用户和管理员权限
  3. 对接口做基础频率限制(防爬虫)
  4. 所有敏感操作留审计日志

说白了,就是一个轻量但完整的安全认证骨架。别整那些花里胡哨的 SSO、JWT 双令牌刷新,先让系统别裸奔!


开搞:从零搭建认证模块

第一步:依赖安排上

新建一个 Spring Boot 3.x 项目(注意,3.x 默认用 Jakarta EE 9,包名变了),先把 Security 依赖加上:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

顺手建个用户表,结构极简:

字段 类型 说明
id BIGINT 主键
username VARCHAR(50) 唯一
password VARCHAR(100) 加密存储
role ENUM('USER','ADMIN') 角色
enabled TINYINT(1) 是否启用

💡 小贴士:生产环境千万别用 VARCHAR(50) 存密码明文!后面会用 BCrypt 加密。

第二步:核心配置 —— 别怕,其实就三块

Spring Security 的核心是 过滤器链(Filter Chain)。你只要告诉它:

  • 哪些路径要保护?
  • 用户信息从哪来?
  • 密码怎么校验?

于是我建了个 SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/public/**").permitAll()      // 公开接口
                .requestMatchers("/admin/**").hasRole("ADMIN")   // 管理员专属
                .anyRequest().authenticated()                    // 其他都要登录
            )
            .formLogin(form -> form
                .loginPage("/login")           // 自定义登录页
                .defaultSuccessUrl("/dashboard")
                .permitAll()
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/login?logout")
                .permitAll()
            )
            .csrf().disable(); // 开发阶段先关掉,上线记得开!

        return http.build();
    }

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

这段代码其实很直白:

  • /public/** 谁都能访问(比如健康检查)
  • /admin/** 必须是 ADMIN 角色
  • 登录走表单,成功后跳转 dashboard
  • 密码用 BCrypt 加密(强度默认 10,够用)

但坑就藏在细节里。


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

坑 1:角色前缀 ROLE_ 是个隐形炸弹

我数据库里存的是 role = 'ADMIN',但死活进不了 /admin 页面,返回 403。查了半天日志,发现 Spring Security 默认会给角色加前缀 ROLE_,也就是说,它实际校验的是 ROLE_ADMIN

解决办法有两个:

  • 要么数据库存 ROLE_ADMIN
  • 要么在配置里关掉前缀:
@Bean
public GrantedAuthorityDefaults grantedAuthorityDefaults() {
    return new GrantedAuthorityDefaults(""); // 禁用 ROLE_ 前缀
}

我选了后者,更干净。

坑 2:自定义 UserDetailsService 返回的权限集合必须带 ROLE_

即使你禁用了前缀,在 UserDetails 实现里,getAuthorities() 返回的权限字符串仍然要手动加 ROLE_,否则权限校验还是失败。

我的 UserDetailsServiceImpl 长这样:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserRepository userRepo;

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

        // 注意!这里必须加 ROLE_ 前缀
        Collection<? extends GrantedAuthority> authorities = 
            Collections.singletonList(
                new SimpleGrantedAuthority("ROLE_" + user.getRole())
            );

        return new org.springframework.security.core.userdetails.User(
            user.getUsername(),
            user.getPassword(),
            user.isEnabled(),
            true, true, true,
            authorities
        );
    }
}

🤦‍♂️ 当时真的想砸电脑:文档里根本没强调这点!全靠 Stack Overflow 救命。

坑 3:CSRF 不是随便关的

开发时为了方便我把 CSRF 关了,结果上线前一天测试说“登录按钮点不动”。一查 Network,POST 请求被拦截了——因为前端没带 _csrf token。

解决方案:

  • 如果用 Thymeleaf 模板,加 <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
  • 如果是前后端分离,建议用 JWT 或 Session + Cookie,CSRF 防护另做处理

但我们项目是服务端渲染,所以最后还是开了 CSRF,并在登录表单里加了隐藏字段。


综合防御:不止是登录,还要防爬虫

光有认证不够。上周那个爬虫事件让我意识到:公开接口也得有限流

Spring Security 本身不提供限流,但我们可以结合 OncePerRequestFilter 自己写一个简易版。

思路很简单:

  • 用 Redis 记录每个 IP 在时间窗口内的请求次数
  • 超过阈值就返回 429
@Component
public class RateLimitFilter extends OncePerRequestFilter {

    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;

    private static final int MAX_REQUESTS = 100; // 每分钟最多100次
    private static final int WINDOW_SECONDS = 60;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   FilterChain filterChain) throws IOException, ServletException {
        
        String clientIp = getClientIP(request);
        String key = "rate_limit:" + clientIp;
        
        Integer count = redisTemplate.opsForValue().get(key);
        if (count == null) {
            redisTemplate.opsForValue().set(key, 1, Duration.ofSeconds(WINDOW_SECONDS));
        } else if (count >= MAX_REQUESTS) {
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.getWriter().write("Too many requests");
            return;
        } else {
            redisTemplate.opsForValue().increment(key);
        }

        filterChain.doFilter(request, response);
    }

    private String getClientIP(HttpServletRequest request) {
        String xff = request.getHeader("X-Forwarded-For");
        if (xff != null && !xff.isEmpty()) {
            return xff.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

然后在 SecurityConfig 里注册:

http.addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class);

✅ 效果立竿见影:第二天监控显示异常 IP 的请求量断崖式下跌。运维老王终于笑了。


生产经验:上线前必做的三件事

  1. 密码加密强度别偷懒
    BCrypt 的 strength 参数默认是 10,足够安全。别为了“性能”改成 4,那等于裸奔。

  2. Session 超时设置合理
    application.yml 里配:

    server:
      servlet:
        session:
          timeout: 1800s # 30分钟无操作自动登出
    
  3. 审计日志不能少
    @EventListener 监听登录登出事件:

    @EventListener
    public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
        log.info("用户 {} 登录成功", event.getAuthentication().getName());
    }
    

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

折腾完这套系统,总共花了不到两天。现在:

  • 后台管理界面必须登录才能进
  • 管理员操作隔离清晰
  • 公开接口有基础限流,不怕简单爬虫
  • 所有登录行为可追溯

最重要的是,再也不用半夜被报警电话叫醒了。

Spring Security 其实没那么可怕。它的“复杂”更多是因为灵活性太高,而我们往往只需要 20% 的功能就能解决 80% 的问题。关键是要理解它的核心模型:认证(你是谁) + 授权(你能干啥) + 过滤器链(怎么拦你)

至于那些分布式 Session、OAuth2、OIDC……等业务真的需要时再上也不迟。别一上来就想着造航空母舰,先把自行车骑稳了再说。

对了,产品小李昨天又来找我:“哥,能不能加个短信验证码登录?”
我微微一笑:“行啊,不过得排期,下个月再说。”
毕竟,成都的火锅还在等我呢。


附:常用配置速查表

场景 配置方式
放行静态资源 .requestMatchers("/css/**", "/js/**").permitAll()
自定义登录页 .formLogin().loginPage("/login")
角色权限校验 .hasRole("ADMIN").hasAuthority("ROLE_ADMIN")
禁用 CSRF .csrf().disable()(仅开发!)
密码加密 new BCryptPasswordEncoder()
Session 超时 server.servlet.session.timeout=1800s

代码已上传 GitHub(私信我拿链接),欢迎 star & 提 issue。如果你也在成都搞 Java,约个茶馆聊聊分布式锁?

评论 0

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