Spring Security基础:从零搭建安全认证系统的一次实战经历

技术森林
2025-06-16 08:43
阅读 420

去年我参与一个新项目,需要为公司构建一个新的企业级后台管理系统。这本身不算难事,但需求里明确提到了用户权限体系的建设必须支持多角色、多层级管理,并且具备高安全性。作为后端负责人,我一开始想着用传统的拦截器+自定义逻辑的方式处理权限控制,后来一想,这种方案虽然短期能搞定,但长期来看维护成本会很高。

于是我们决定引入 Spring Security 进行统一的安全治理,不仅能满足现有的权限结构需求,还能为以后可能接入 SSO、OAuth2 等功能打下基础。

这篇文章就是我在那段时间的实践总结,希望能给正在学习或准备使用 Spring Security 的同学一点参考价值。我会结合当时真实的项目背景、遇到的问题和解决方案,带大家一步步搭起一个基于 Spring Boot + Spring Security 的基础认证系统。


项目背景与挑战初现

项目背景与挑战初现

我们的目标是打造一个企业级后台系统,核心模块包括:

  • 用户管理(角色、权限配置)
  • 操作日志审计
  • 接口鉴权(如不同角色访问不同接口)
  • 登录状态保持及 Token 失效机制
  • 多租户架构预埋(暂未上线)

最开始的构想挺简单:用户登录之后返回 JWT token,后续请求携带 Token 访问,每次都要验证 Token 合法性、是否过期、是否有对应权限。但是随着业务发展,越来越多的安全细节暴露出来:

  1. Token 到底怎么生成?签名方式选哪种?
  2. 如何优雅地整合数据库中的角色信息?
  3. 权限粒度要细到接口级别,怎么设计才不乱?
  4. 登录失败次数多了要不要限流?防爆破攻击怎么做?
  5. 如何对接 LDAP 或未来的 OAuth2?

这些问题如果全靠自研,不仅工作量大而且容易出错。于是我决定直接采用 Spring Security 提供的标准安全框架,它提供了从认证、授权、CSRF防御到Session管理等完整能力,非常适合做系统级安全治理。


技术方案选择与设计思路

技术方案选择与设计思路

技术栈

  • Spring Boot 2.6.x
  • Spring Security 5.7
  • MyBatis Plus
  • MySQL
  • Redis(用于 Token 缓存和黑名单管理)
  • JWT(用于无状态认证)

总体架构设计

整个认证流程大致如下:

用户登录 --> 验证用户名密码 --> 成功则签发 JWT Token --> 客户端存储并后续携带 Token 访问资源
                                                                 ↓
                                       Token 在每次请求时由 Security 自动校验 → 检查权限 → 放行/拒绝

在 Spring Security 中,关键组件包括:

  • AuthenticationManager:负责认证流程
  • UserDetailsService:加载用户详情
  • JwtFilter:JWT Token 解析和验证
  • AccessDeniedHandler:权限不足的异常处理
  • SecurityProperties:安全配置类

接下来,我会详细讲讲每一步是如何落地的。


核心代码实现与配置详解

核心代码实现与配置详解

Step 1:基础依赖引入

首先是在 pom.xml 中添加相关依赖:

<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>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
</dependency>

Step 2:自定义 UserDetailsService 实现

为了把数据库中的用户信息读取进来,我们需要继承 UserDetailsService 并重写方法:

@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("用户不存在");
        }

        List<GrantedAuthority> authorities = new ArrayList<>();
        // 获取该用户拥有的角色列表
        List<Role> roles = user.getRoles();
        for (Role role : roles) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getCode()));
        }

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

这里有个小技巧:将数据库里的角色前缀统一加上 "ROLE_" 是 Spring Security 的标准做法,方便后面通过 @PreAuthorize("hasRole('ADMIN')") 做注解式鉴权。


Step 3:编写 JWT 工具类

为了减少重复代码,封装了一个 JWT 工具类:

@Component
public class JwtUtil {

    private static final String SECRET = "your-secret-key";
    private static final long EXPIRATION = 86400000; // 24小时

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(userDetails.getUsername())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
                .signWith(SignatureAlgorithm.HS512, SECRET)
                .compact();
    }

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

    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    private Boolean isTokenExpired(String token) {
        return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody().getExpiration().before(new Date());
    }
}

需要注意的是,这里的密钥建议放在外部配置文件中,比如 application.yml,不要硬编码在代码里。


Step 4:创建 JWT Filter 拦截 Token

为了让 Spring Security 拦截每个请求中的 Token 并进行验证,需要自定义一个 Filter:

@Component
public class JwtRequestFilter extends OncePerRequestFilter {

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private JwtUtil jwtUtil;

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

        String username = null;
        String jwt = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            username = jwtUtil.extractUsername(jwt);
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            if (jwtUtil.validateToken(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities()
                );
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}

这个 Filter 会自动识别带有 Bearer 头的 Token,并解析验证,验证成功后设置 Security 上下文的 Authentication 对象,这样后续鉴权就能顺利执行。


Step 5:配置 SecurityConfig 类

最后在配置类中注册 Filter 和其他权限策略:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    @Autowired
    private CustomUserDetailsService customUserDetailsService;

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

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeRequests()
            .antMatchers("/auth/login").permitAll()
            .anyRequest().authenticated();
    }
}

这段配置做了几件重要的事情:

  • 禁用了 CSRF(因为是前后端分离的接口系统)
  • 设置为无状态 Session,配合 JWT 使用更合适
  • 添加了 Token 过滤器
  • 开启了方法级别的权限控制(prePostEnabled = true),方便后续使用 @PreAuthorize 注解控制权限

踩坑经验分享:那些让我掉头发的小问题

踩坑经验分享:那些让我掉头发的小问题

在实际开发过程中,我也踩了不少坑,下面几个问题是比较典型的:

❌ Token 验证失败:时间戳超前?

刚开始测试的时候,发现生成的 Token 经常提示“已过期”。调试才发现:

Date expiration = new Date(System.currentTimeMillis() + EXPIRATION);

这个时间其实比服务器系统时间快了一点点,结果导致验证的时候报错了。解决办法很简单:生成 Token 的时候留一点容错空间

后来改成这样:

.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION - 10000))

减去10秒,避免因网络延迟或时间同步问题导致的误判。


❌ 角色权限不起作用:忘记 ROLE_ 前缀?

在使用 @PreAuthorize("hasRole('ADMIN')") 的时候一直不生效,后来发现是因为我们在构造 SimpleGrantedAuthority 的时候没加 ROLE_ 前缀。正确写法应该是:

new SimpleGrantedAuthority("ROLE_ADMIN")

或者你也可以在注解中写成:

@PreAuthorize("hasAuthority('ADMIN')")

区别在于,前者是角色(Role),后者是权限(Authority)。而 Spring Security 的 hasRole 会默认加上 ROLE_ 前缀查找。


❌ Filter 顺序错误导致无法生效?

Spring Security 的过滤器链是有严格顺序的,如果你把 Token Filter 放在错误的位置,可能就完全失效了。例如:

http.addFilterBefore(jwtRequestFilter, SomeOtherFilter.class);

应该确保放在 UsernamePasswordAuthenticationFilter 之前,才能保证在认证之前先进行 Token 检查:

.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)

实施后的效果与收益

项目上线后运行稳定,基本满足所有安全需求:

功能 实现情况
登录认证 ✅ 完善
角色分级控制 ✅ 支持接口级别权限
Token 生效/失效控制 ✅ Redis 黑名单机制
密码加密 ✅ 使用 BCrypt 加密
多租户预留 ✅ 可扩展

性能方面,由于采用了 JWT 无状态机制,系统压力相对较小。通过 Redis 缓存 Token 黑名单,也有效防止了 Token 被恶意复用。

运维上我们也加了一些日志埋点,在网关层记录每次请求的来源、用户ID和操作类型,方便后续审计。


一些经验和建议

这是我第一次大规模使用 Spring Security,回头看确实有很多可以改进的地方,但也积累了一些宝贵的经验:

💡 1. 不要一开始就追求完美,先跑起来再说

很多同事刚开始会觉得 Security 的结构太复杂了,不知道从哪儿下手。我的建议是:先搞通一个最小可运行流程,再逐步扩展功能。比如先实现登录+ Token 认证,再加入角色权限、黑名单、登出等功能。


💡 2. 结合日志和 Debug 看上下文变化

Security 的流程很复杂,可以通过打印 SecurityContext 的内容来观察认证是否成功:

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
System.out.println(auth);

同时记得在开发阶段打开 DEBUG 日志:

logging:
  level:
    org.springframework.security: DEBUG

💡 3. 做好 Token 过期与刷新机制

目前我们还没有做 Token 的自动刷新逻辑,下次打算结合 Refresh Token 实现双 Token 机制。这也是很多线上系统的常见做法。


💡 4. 注意生产环境的安全加固

以下是一些生产环境中推荐的做法:

  • 禁用默认的 /error 页面,防止泄露敏感信息;
  • 限制登录失败次数并锁定账户,防范暴力破解;
  • 使用 HTTPS,避免中间人攻击;
  • 定期更换 JWT 秘钥,增强 Token 安全性。

写在最后:安全感来自于扎实的基本功

通过这次项目的实践,我对 Spring Security 的整体架构有了更深的理解。它不是一个简单的拦截器工具包,而是一个真正意义上的安全框架。它的设计哲学强调“开箱即用”与“高度可定制”,既适合快速搭建,也能支撑复杂的企业级安全需求。

如果你刚开始接触 Spring Security,别担心它的复杂性,多动手调试、结合真实项目练习,你会发现它其实非常强大也很友好。

最后我想说,安全从来不是“加个过滤器”那么简单的事。它是一种思维,一种对未知风险的敬畏。而我们能做的,就是在每一次开发中多考虑一步,让系统更加健壮可靠。

希望这篇文章对你有帮助,也欢迎留言交流~


如有任何技术问题,欢迎关注我并在评论区留言讨论!

评论 0

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