Spring Security 基础:从“连登录都不会”到搭出安全认证系统,我的野路子实战记

轻舟开发记
2025-12-13 03:58
阅读 551

大家好,我是小陈,一个刚毕业半年的大专生,坐标杭州未来科技城。去年秋招靠自学前端 + 一点后端基础,在一家中型电商公司混到了全栈开发岗(其实是前端为主,但老板看我会点 Java 就顺手丢后端活过来)。团队里除了我这个“野鸡出身”的,其他全是科班硕士,每次开会我都缩在角落假装自己是背景板。

不过最近可把我牛逼坏了——上周五晚上,我居然独立用 Spring Security 搭了一套完整的用户认证系统,上线后稳如老狗,连测试妹子都说“这次没崩”。要知道三个月前,我连 AuthenticationManager 是干啥的都不知道,还把 JWT 和 Session 混为一谈,被同事笑称“安全盲”。

今天这篇不是那种高大上的架构师指南,就是一个普通大专应届生的踩坑实录。如果你也像我一样,学历平平、基础一般,但又想在阿里网易扎堆的杭州活下去,那这篇可能对你有点用。毕竟,代码人生,从来不是靠学历写的,而是靠一行行 debug 出来的。


为啥突然搞 Spring Security?因为老板说“双11前必须上登录”

事情得从上个月说起。我们有个内部运营后台,原本是裸奔状态——没登录、没权限、谁拿到 URL 都能改数据。产品经理(对,就是那个总说“这个需求很简单”的 P0)突然拍脑袋:“双11前必须加登录认证,还要分角色!”

我当场就懵了。之前做过的项目顶多是用 localStorage 存个 token,前端拦一下路由就完事。现在要真刀真枪搞后端认证?我连数据库里的用户表都没建过!

硬着头皮接了活,第一反应是翻书。不是电子书,是实体书——《Spring Security 实战》(人民邮电出版社那本)。别笑,虽然现在人都刷视频学技术,但我发现,有些底层逻辑,只有书才能讲清楚。比如为什么 Spring Security 默认用 DelegatingFilterProxy 而不是直接注册 Filter?书里一页图解就让我豁然开朗。


第一步:别一上来就搞 OAuth2,先搞定最简单的表单登录

很多教程一上来就讲 JWT、OAuth2、RBAC,搞得人头晕。我一开始也犯这毛病,结果第一天就在 UserDetailsServicePasswordEncoder 的注入上卡了三小时。

后来我悟了:先跑通最原始的表单登录,再谈高级功能

1. 引入依赖(别漏了 Web)

<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>

注意:必须加 web!不然启动都起不来,报错 No qualifying bean of type 'AuthenticationManager' —— 我当时以为是配置错了,其实是因为没引入 Web 环境,Security 自动配置压根没触发。

2. 写个 UserDetailsService(重点!)

这是认证的核心。你需要告诉 Spring Security:“用户信息去哪查”。

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserService userService; // 假设你有用户服务

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }
        // 注意:这里返回的是 org.springframework.security.core.userdetails.User
        return User.builder()
                .username(user.getUsername())
                .password(user.getPassword()) // 必须是加密后的!
                .roles("USER") // 或从数据库读取
                .build();
    }
}

血泪教训:密码一定要加密存储!我第一次测试用明文密码,结果登录死活不成功。后来才发现 Spring Security 默认用 BCryptPasswordEncoder,而我数据库存的是 123456……运维大哥差点把我电脑砸了。

3. 配置 Security

@Configuration
@EnableWebSecurity
public class SecurityConfig {

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/login", "/css/**", "/js/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard")
                .permitAll()
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/login?logout")
                .permitAll()
            );
        return http.build();
    }
}

这段配置的意思很直白:

  • /login 页面和静态资源放行
  • 其他所有请求都要登录
  • 登录页是 /login,成功后跳 /dashboard
  • 登出后跳回登录页

跑起来后,你会发现 Spring Security 自动生成了一个丑到爆的登录页。别慌,这是正常现象。先让功能跑通,UI 后面再换——这是我从产品经理那学来的“敏捷哲学”(其实就是拖时间)。


从表单登录到 JWT:为了前后端分离,我被迫升级

我们的运营后台是 Vue 写的 SPA,根本不需要表单提交。所以第二天,我就被要求改成 Token 认证

这时候,很多人会直接上 JwtAuthenticationFilter,但千万别急!先理清流程:

  1. 用户 POST /login,带 username/password
  2. 后端验证,成功则生成 JWT 返回
  3. 前端存 Token,后续请求带 Authorization: Bearer <token>
  4. 后端用 Filter 解析 Token,还原 Authentication

关键改造点

  • 禁用 Sessionhttp.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
  • 自定义登录接口:不再用 formLogin(),而是写一个 /login Controller
  • 加一个 JwtFilter:放在 UsernamePasswordAuthenticationFilter 之前
// JwtFilter 示例(简化版)
public class JwtAuthenticationFilter extends OncePerRequestFilter {

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

然后在 SecurityConfig 里注册:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception {
    http
        .csrf().disable()
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        .authorizeHttpRequests(authz -> authz
            .requestMatchers("/api/login").permitAll()
            .anyRequest().authenticated()
        )
        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

踩坑现场

  • 忘了 @Component 注解,Filter 没注入,导致所有请求 403
  • Token 过期时间设成 10 年,被安全审计组骂成狗
  • SecurityContextHolder 没清理,测试时串用户(线上事故预警!)

数据库设计 & 接口规范:别只顾着写代码

很多教程只讲 Security 配置,但实际工作中,数据库和接口才是命门

用户表设计(简化版)

字段 类型 说明
id BIGINT 主键
username VARCHAR(50) 唯一
password VARCHAR(100) BCrypt 加密后
role VARCHAR(20) 如 "ADMIN", "OPERATOR"
enabled TINYINT 是否启用(用于封号)

注意

  • 密码字段长度至少 60(BCrypt 输出约 60 字符)
  • enabled 字段,比物理删除更安全
  • 角色建议用字符串而非 ID,避免联表查询(性能考虑)

接口设计

POST /api/login
{
  "username": "chen",
  "password": "123456"
}
→
{
  "code": 200,
  "data": {
    "token": "eyJhbGciOiJIUzI1NiJ9.xxxxx",
    "expiresIn": 3600
  }
}

别返回密码!别返回明文! 曾经有个实习生把整个 User 对象返回,包括加密后的密码,被运维拉去喝茶。


生产环境那些事儿:日志、监控、防爆破

你以为跑通就完了?Too young.

1. 登录失败次数限制

暴力破解怎么办?Spring Security 本身不提供,但可以结合 Redis:

// 登录时
String key = "login_fail:" + username;
Long count = redisTemplate.opsForValue().increment(key);
if (count > 5) {
    throw new RuntimeException("尝试次数过多,请10分钟后重试");
}
redisTemplate.expire(key, 10, TimeUnit.MINUTES);

2. 记录登录日志

谁在什么时候从哪登录,必须留痕:

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
    try {
        // 认证...
        logService.recordLogin(username, getClientIP(request), "SUCCESS");
        return ok(token);
    } catch (Exception e) {
        logService.recordLogin(username, getClientIP(request), "FAILED: " + e.getMessage());
        throw e;
    }
}

3. 定期轮换密钥(JWT)

JWT 的 secret 如果泄露就全完了。我们团队的做法是:

  • Secret 存在 Vault(或配置中心)
  • 每月自动轮换
  • 支持多版本 secret 验证(过渡期)

总结:大专生也能搞安全,关键是要“钻”

回头看这一个月,从连 AuthenticationAuthorization 都分不清,到现在能独立设计认证模块,我最大的体会是:

不要怕底层,越怕越弱。

Spring Security 看似复杂,拆开就是几个核心组件:

  • UserDetailsService → 用户从哪来
  • PasswordEncoder → 密码怎么校验
  • Filter → 请求怎么拦截
  • GrantedAuthority → 权限怎么判断

搞懂这些,其他都是排列组合。

另外,多看源码。我花了一晚上 debug UsernamePasswordAuthenticationFilterattemptAuthentication 方法,终于明白表单参数为什么默认是 usernamepassword。这种理解,是看一百篇博客都换不来的。

最后,分享一句我在《代码大全》里看到的话(对,我又看书了):

“安全不是功能,而是属性。”

我们写代码,不能只想着“跑起来就行”,还得想“会不会被人黑”。尤其是在杭州这种互联网重镇,阿里网易对安全的要求近乎变态——听说隔壁厂因为一个 XSS 漏洞,年终奖直接砍半。

所以啊,兄弟们,别觉得自己学历低就干不了后端。代码人生,从来不由起点决定,而由你愿意钻多深决定


作者:小陈
身份:杭州某电商公司全栈开发(大专应届)
爱好:周末逛 GitHub 看开源项目源码,偶尔给 Spring Boot 提 PR(虽然都被拒了)
近况:正在啃《Spring Security in Action》,目标是年底前能面阿里 P6

如果你也在自学路上挣扎,欢迎留言交流。别怕问“蠢问题”——我问过的问题,可能比你想象的更蠢 😅

评论 0

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