Spring Security实战入门:从零搭建一个安全认证系统

运营说要今天
2025-06-26 00:54
阅读 542

去年我们团队接手了一个金融类项目,需求是重构一个老的后台管理系统,这个系统原本用的是基于 Shiro 的权限框架。结果上线不到一个月就因为一次越权访问导致数据泄露事故。老板震怒,项目紧急进入重构状态,目标很明确:必须尽快替换掉原有鉴权模块,构建一个更加健壮、可扩展的安全体系。

在技术选型时,我们讨论过继续优化 Shiro,也考虑过引入 OAuth2 或 CAS 做单点登录。最终,考虑到系统的复杂度和后续可能的微服务演进方向,我建议使用 Spring Security 来替代现有架构。

为什么选择 Spring Security?除了它是目前 Java 生态中最流行的安全框架以外,它还具备极强的定制能力和良好的社区生态。更重要的是,它可以很好地与 Spring Boot、JWT 甚至未来的网关设计做整合。

这篇文章会结合我当时实际开发的经历,讲一讲我是如何一步步通过 Spring Security 快速搭建起一套基础但又完备的安全认证系统的。


初探 Spring Security:我的第一印象

初探 Spring Security:我的第一印象

说实话,第一次接触 Spring Security 的时候真有点懵。官方文档洋洋洒洒上百页,概念多得让人眼花缭乱:FilterChain、AuthenticationProvider、PasswordEncoder……每一个都是新坑。

当时我们在做的用户管理模块里需要支持以下功能:

  • 用户名密码登录
  • 登录失败次数限制(防暴力破解)
  • 鉴权基于角色控制接口访问(RBAC)
  • 所有请求都必须带有有效的 token(后续会支持 JWT)

最开始我尝试照着一些教程写配置类,结果发现有些逻辑根本串不起来,比如登录成功处理器、自定义异常处理等组件之间如何配合,怎么注入等等。

这让我意识到:要真正掌握 Spring Security,理解它的底层执行流程远比记住一堆注解更重要


我们的问题与挑战

我们的问题与挑战

项目初期遇到的最大问题集中在以下几个方面:

1. 认证失败后的统一异常返回格式混乱

原系统中每个 Controller 方法都要自己 try catch 然后手动封装错误信息,而 Spring Security 抛出的各种认证异常却无法被全局捕获,前后端沟通变得非常困难。

比如 BadCredentialsExceptionDisabledException 这种安全相关的异常,需要在 FilterChain 外部统一处理,而不是散落在各个地方。

2. 自定义 Filter 插入位置搞错顺序,导致 token 无效或拦截逻辑失效

一开始我把 token 校验的 filter 直接加到最后面,结果发现 login 接口也被自己的 TokenFilter 拦截了,整个流程完全乱套。

后来查资料才知道,Spring Security 默认是有 Filter 的排列顺序的,我们要插入自定义逻辑的位置必须准确。

3. 不理解 AuthenticationManager 是什么,导致验证逻辑总是走不通

刚开始我以为直接调用 AuthenticationManager.authenticate() 就能自动完成用户名密码的验证,结果始终抛出 authentication exception。后来才发现是因为没有注册合适的 Provider!

4. 缺少清晰的权限模型设计思路,导致接口层级混乱

我们在数据库中有 role 表,也有 permission 表,但接口上还是简单用了 hasRole("ADMIN"),这样虽然能满足基本需求,但不够灵活,也无法支持动态权限变更。


解决方案:Spring Security + JWT 构建安全体系

我们的整体思路是这样的:

客户端 <==Token==> 网关 / 网站入口 <==Security Filter Chain==> 后端服务

具体实现如下:

  • 使用 Spring Security 构建认证流程
  • 使用 JWT 实现无状态的 token
  • 结合 Spring Data JPA 查询数据库中的用户和权限
  • 通过自定义 UserDetailsService 实现用户加载逻辑
  • 利用 OncePerRequestFilter 做 token 校验
  • 所有异常统一交给 AccessDeniedHandlerAuthenticationEntryPoint 处理

微服务架构示意图-1

下面我会分模块逐步讲解核心代码和实现要点。


关键代码实践

1. 自定义 UserDetails 类

我们首先需要一个类来承载用户的完整信息,包括用户名、密码和对应的权限列表。

public class MyUserDetails implements UserDetails {
    private final User user;

    public MyUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return !user.isLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return user.isEnabled();
    }
}

这里的细节是把 role 转为 Spring Security 可识别的 GrantedAuthority,其中 ROLE_ 是个固定前缀,否则在配置的时候就会报错。

2. 自定义 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("用户不存在");
        }
        return new MyUserDetails(user);
    }
}

这个 service 是 AuthenticationProvider 获取用户的核心入口。

3. 登录流程改造(使用 JWT)

为了满足分布式系统下的身份传递,我们采用了 JWT 做 token 管理。

@Component
public class JwtUtil {

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

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, userDetails.getUsername());
    }

    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
                .compact();
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .parseClaimsJws(token)
                .getBody();
    }

    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 extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }
}

这部分可以看作是一个工具类,用于生成和校验 token。

4. 配置 Security 主链路

接下来是最重要的 SecurityConfig 配置类:

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService;
    private final JwtRequestFilter jwtRequestFilter;

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

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(customUserDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
                .authorizeHttpRequests()
                .requestMatchers("/api/auth/login").permitAll()
                .anyRequest().authenticated();

        return http.build();
    }
}

这里有几个关键点说明:

  • 关闭 CSRF,因为我们用的是无状态的 API。
  • 使用 STATELESS 会话模式,避免 session 占用内存。
  • 插入 JwtRequestFilterUsernamePasswordAuthenticationFilter 之前。
  • 白名单 /login 接口允许匿名访问,其它都需要认证。

5. 编写登录接口控制器

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final UserService userService;
    private final JwtUtil jwtUtil;

    public AuthController(
            AuthenticationManager authenticationManager,
            UserService userService,
            JwtUtil jwtUtil
    ) {
        this.authenticationManager = authenticationManager;
        this.userService = userService;
        this.jwtUtil = jwtUtil;
    }


![缓存策略对比-2](https://code-guide.oss.shanghai.autogptai.club/common/file/download?name=date2025062600/9796674a-2ce4-4d90-9d21-55b31730966e.jpg)


    @PostMapping("/login")
    public ResponseEntity<?> createAuthenticationToken(@RequestBody LoginRequest request) throws Exception {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
        );
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String token = jwtUtil.generateToken(authentication.getPrincipal() instanceof UserDetails ?
                (UserDetails) authentication.getPrincipal() : null);

        return ResponseEntity.ok(new AuthResponse(token));
    }
}

这个 controller 主要是调用 authenticate() 方法,触发完整的认证流程,如果成功则颁发 token。


开发过程中的踩坑经验总结

🐢 1. 密码加密方式不一致引发的登录失败问题

最初我忘记在 UserDetailsService 中注入正确的 PasswordEncoder,导致入库的密码是明文,而 security 内部默认用的是 BCryptPasswordEncoder,自然校验不过。

解决办法
确保所有涉及密码的地方(用户注册、登录验证)都使用同一个 PasswordEncoder Bean,并且在 DaoAuthenticationProvider 注册时正确配置。


🐷 2. Token Filter 顺序错误导致死循环

前面说过,我一开始将 jwtRequestFilter 放到了 chain 的最后面,结果这个 filter 想放行 /login 请求时却发现已经被拦截了,形成了死循环。

解决办法
通过 .addFilterBefore(...) 显式指定 token filter 应该在 UsernamePasswordAuthenticationFilter 之前运行,这样就能让 login 接口顺利绕过 token 检查。


💥 3. 异常无法被捕获,前端得不到预期的错误信息

Security 框架内部抛出的异常如果不被捕获,会直接变成 HTTP 500 错误,这让前端很难处理。

解决办法
我们实现了两个关键接口:

  • AuthenticationEntryPoint 处理未授权的情况(比如 token 不存在)
  • AccessDeniedHandler 处理权限不足的情况(比如无 ROLE_ADMIN)

示例:

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

然后将其配置到 SecurityConfig 中:

http.exceptionHandling()
    .authenticationEntryPoint(entryPoint)
    .accessDeniedHandler(accessDeniedHandler);

这样无论出现哪种认证失败场景,都能统一返回 JSON 格式的 error 给前端。


成果回顾:系统变得更稳更快更易维护

自从重构完这套安全模块之后,最大的变化体现在几个方面:

  • 登录流程更可控:我们可以轻松添加验证码、登录失败锁定等机制。
  • 权限管理更灵活:后期只需调整数据库即可实现 RBAC,无需改动代码。
  • 系统性能提升:由于改成了无状态,省去了 Session 的存储和同步开销。
  • 可维护性更强:Spring Security 的日志输出非常详细,排查问题效率大大提高。

最重要的是,这一整套流程成为了后续微服务拆分的基础,很多子服务直接复用了这套安全骨架,节省了大量重复工作。


经验分享给正在学习的朋友

如果你正在学习 Spring Security 或者准备搭建一个安全系统,我想给你几点建议:

  1. 别急着写配置,先看清楚 FilterChain 流程图
    了解认证和授权发生在哪一层,有助于写出合理的 filter 插入位置。

  2. 不要迷信官方模板代码,根据业务情况裁剪
    很多教程只覆盖了基本用法,实际项目往往需要更细粒度的控制,比如区分登录方式、支持第三方登录、多租户等。

  3. 关注性能和并发控制
    安全层不能成为瓶颈。例如,token 解析是否线程安全?是否有频繁的 DB 查询?这些都可以提前优化。

  4. 预留可扩展性设计
    举个例子,如果你未来可能会接入 OAuth2 或 SSO,那现在就可以设计好 adapter 层,减少以后的重构成本。

  5. 记录每一个安全漏洞修复点
    我们的系统后来新增了一项审计功能,每次用户登录都会记录 IP、设备类型、是否首次登录等信息,这对于快速定位安全事件非常有用。


写在最后:安全不是万能钥匙,而是持续迭代的过程

回想起当年刚接手这个任务时的迷茫,如今已经可以用这套安全体系去支撑多个系统。Spring Security 虽然“难懂”,但它就像一把瑞士军刀——只要你掌握了每个部件的功能,它就能帮助你应对各种复杂的场景。

安全这件事,从来都不是一劳永逸的。每一次代码提交、每一次线上报警都提醒我们:只有不断更新知识、保持敬畏之心,才能真正构建一个可信的系统。

希望这篇来自真实项目的分享对你有所启发。如果你有任何疑问或者想了解更多关于权限细化、动态路由等内容,欢迎留言交流~

评论 0

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