Spring Security 基础:快速搭建安全认证系统(一个考研失败应届生的血泪实践)

马涛◇
2025-12-18 02:28
阅读 678

去年12月,我查完成绩那一刻就知道自己完了——398分?不,是政治39分。那一刻我坐在图书馆角落里,盯着屏幕发呆了半小时,脑子里只有一个念头:“完了,春招要开始了,我连项目都还没写完。”

但生活不会因为你情绪崩溃就暂停。为了赶上深圳春招黄金期,我咬着牙把毕业设计从“基于深度学习的图像识别”临时改成了“前后端分离的在线考试系统”——毕竟 Python 写 CV 模型跑不动,不如老老实实用 Java 搞个能跑起来的后端项目。而既然是考试系统,用户登录、权限控制这些功能自然逃不掉。于是,我和 Spring Security 的孽缘就此开始。


为什么不用 Python?因为现实很骨感

我知道很多同学(包括我自己)本科阶段主攻 Python,Django + DRF 能快速搭出一套带认证的 API。但来了深圳才发现,这边大厂清一色 Java 技术栈,尤其是腾讯系公司(别问我怎么知道的,面试面多了自然懂)。我们组的老哥直接说:“Python 在我们这主要用来写脚本和自动化测试,核心业务全是 Springboot。”

所以,哪怕我对 Java 又爱又恨(IDEA 启动慢到我想砸电脑),也得硬着头皮上。好在 Springboot 生态成熟,配合 Spring Security,其实比想象中简单——只要你别被它那堆 AuthenticationProviderUserDetailsServicePasswordEncoder 绕晕就行。


需求场景:一个“看似简单”的登录功能

上周五晚上 9 点,产品经理突然在群里@我:“明天 demo 要给客户看,登录页加个验证码,普通用户只能看自己的试卷,管理员能管理所有用户。”
我:“???”
这不就是典型的 RBAC(Role-Based Access Control)吗?但 deadline 就在眼前,没时间造轮子,必须用现成的安全框架。

于是,我翻出了尘封已久的 Spring Security 文档,准备速通。


第一步:别慌,先加依赖

新建一个 Springboot 项目(我用的 3.2.0),pom.xml 里加上:

<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>
<!-- 如果你用 JWT,再加这个 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>

启动项目,访问任意接口,你会看到一个默认的登录页——用户名 user,密码在控制台打印出来(形如 Using generated security password: abc123...)。
但这玩意儿显然不能上线,连数据库都没连,纯内存用户,属于“玩具级”认证。


第二步:自定义用户认证逻辑(重点!)

我们需要从数据库读取用户信息。假设你的用户表长这样:

字段名 类型 说明
id BIGINT 主键
username VARCHAR(50) 唯一用户名
password VARCHAR(100) 加密后的密码
role VARCHAR(20) 角色:USER / ADMIN

1. 实现 UserDetailsService

这是 Spring Security 获取用户信息的核心接口。我们写一个自己的实现类:

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository; // 假设你用 JPA

    @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 加密后的
                .roles(user.getRole()) // 自动转为 ROLE_USER 或 ROLE_ADMIN
                .build();
    }
}

💡 坑点提醒:很多人在这里直接返回自己的 User 实体类,结果报错 ClassCastException。记住,必须包装成 Spring Security 的 UserDetails

2. 配置密码加密器

绝对不要存明文密码!Spring Security 推荐用 BCryptPasswordEncoder

@Configuration
@EnableWebSecurity
public class SecurityConfig {

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

    @Bean
    public UserDetailsService userDetailsService() {
        return new CustomUserDetailsService();
    }
}

注册时记得加密:

@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody UserRegisterDTO dto) {
    String encodedPassword = passwordEncoder.encode(dto.getPassword());
    // 保存到数据库...
}

第三步:配置权限规则(URL 级别控制)

现在用户能登录了,但怎么控制谁能看到什么?比如 /admin/** 只有管理员能访问。

SecurityConfig 里重写 configure(HttpSecurity http) 方法(注意:Springboot 3.x 用的是 authorizeHttpRequests):

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf().disable() // 前后端分离项目通常关掉 CSRF
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态,配合 JWT
        .and()
        .authorizeHttpRequests(authz -> authz
            .requestMatchers("/api/auth/login", "/api/auth/register").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .requestMatchers("/api/exam/**").hasRole("USER")
            .anyRequest().authenticated()
        )
        .httpBasic(); // 先用 Basic Auth 测试,后面换成 JWT

    return http.build();
}

🤯 真实踩坑:一开始我没关 CSRF,前端 POST 登录一直 403。运维小哥还嘲讽我:“你是不是没加 _csrf token?” 我:“……我们是 Vue + Axios,哪来的 form 表单?”


第四步:集成 JWT(无状态认证)

因为是前后端分离,Session 不太合适(尤其以后可能上微服务)。所以换成 JWT。

生成 Token 的逻辑

@Component
public class JwtUtil {
    private String secret = "mySecretKey"; // 生产环境请用更长的随机字符串
    private int jwtExpirationMs = 86400000; // 24小时

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

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

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

自定义 Filter 拦截 Token

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private CustomUserDetailsService userDetailsService;
    @Autowired
    private JwtUtil jwtUtil;

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

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

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

最后在 SecurityConfig 里注册这个 Filter:

@Autowired
private JwtAuthenticationFilter jwtAuthFilter;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf().disable()
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        .authorizeHttpRequests(authz -> authz
            .requestMatchers("/api/auth/**").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
        )
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); // 插入到认证过滤器前

    return http.build();
}

前端每次请求带上:

axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;

生产环境注意事项(来自血泪教训)

  1. 密钥管理:别把 secret 写死在代码里!用配置中心或环境变量。
  2. Token 刷新:24 小时过期太短?可以加 Refresh Token 机制,但别学某大厂搞成“永久有效”(安全审计会骂死你)。
  3. 日志监控:记录登录失败次数,防暴力破解。我们组上周就被扫了 10 万次 /login,还好加了 IP 限流。
  4. 权限粒度hasRole("ADMIN") 够用吗?复杂系统建议用 @PreAuthorize("hasPermission(#examId, 'READ')") 做数据级权限。

性能与架构思考

  • 认证服务独立化:如果未来拆微服务,可以把认证单独做成 auth-service,其他服务通过内网调用验证 Token。

  • 缓存用户信息:频繁查 DB?把 UserDetails 缓存到 Redis,key 用 username。

  • 压测结果参考(本地 MacBook Pro M1):

    场景 QPS 平均延迟
    无 Security 3200 8ms
    Basic Auth 2800 12ms
    JWT(无 DB 查询) 2500 15ms
    JWT + Redis 缓存 2300 18ms

    可见 Spring Security 开销可控,别被“性能差”的谣言吓到。


写在最后:从考研失败到能跑通认证

说实话,第一次看到 AccessDeniedException 时我真的想删库跑路。但当你在 Postman 里成功拿到 Token,然后带着它访问 /api/admin/users 返回 200 的那一刻——那种成就感,比考研多考 50 分还爽。

虽然我现在还在投简历(深圳前端岗卷成麻花),但至少我的 GitHub 项目不再是 “Hello World”。Spring Security 看似复杂,其实核心就三点:

  1. 你是谁(UserDetails)
  2. 你有没有权限(GrantedAuthority)
  3. 你怎么证明你是你(Token / Session)

如果你也是应届生,别怕技术栈转换。Python 很香,但 Java 在企业级开发的地位短期内不会动摇。掌握 Springboot + Security,至少能让你在面试时说出:“我做过完整的权限系统,不是只会写 CRUD。”

对了,上周 demo 客户很满意,产品经理请我喝了杯瑞幸。他说:“小伙子,下次需求文档写清楚点。”
我:“……好的,下次我直接写进代码注释里。”


附:完整代码结构建议

src/main/java
├── config
│   └── SecurityConfig.java
├── security
│   ├── CustomUserDetailsService.java
│   ├── JwtUtil.java
│   └── JwtAuthenticationFilter.java
├── controller
│   └── AuthController.java
└── repository
    └── UserRepository.java

希望这篇带点情绪、有点干货的文章能帮到和我一样的“落榜打工人”。技术路上,谁不是一边崩溃一边前进呢?

评论 0

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