Spring Security基础:快速搭建安全认证系统

程序员的日常信号
2025-06-25 02:14
阅读 621

开篇:一次真实的项目挑战

开篇:一次真实的项目挑战

去年接手了一个金融数据服务的内部平台重构项目。这个平台原本是一个遗留系统,安全性几乎为零——接口没有权限控制、用户信息明文存储,甚至有些重要数据直接暴露在外部调用中。当时我们的目标是:在两周内完成新系统的安全模块设计与初步实现,保障后续业务模块的开发可以基于一个可靠的安全架构进行推进。

在这种时间紧任务重的压力下,我决定采用 Spring Security 来快速搭建一个安全认证和权限控制系统。虽然我们团队之前有使用过 Shiro 和自定义认证逻辑的经验,但考虑到 Spring Boot 已成为主流框架,以及 Spring Security 本身对 OAuth2、JWT 等现代安全协议的良好支持,最终还是选择了它。

这篇文章就来分享一下我是如何带着团队快速上手并落地 Spring Security 的安全认证系统,同时也聊聊我们在开发过程中踩过的坑和学到的经验。


背景介绍:为什么选择 Spring Security?

背景介绍:为什么选择 Spring Security?

项目的后端服务基于 Spring Boot 构建,前端由 Vue.js 实现,整体采用 RESTful 风格通信。我们面临的核心问题包括:

  • 用户登录鉴权机制缺失;
  • 接口无权限校验,敏感接口可以随意访问;
  • 用户信息存储不安全(例如密码明文存储);
  • 需要为未来的角色权限管理打好基础;
  • 支持未来集成 CAS 或 OAuth2 第三方授权体系。

在这样的背景下,我们需要快速搭建一套安全认证体系,确保至少满足以下功能:

  1. 用户注册、登录、鉴权全流程;
  2. 请求必须携带 Token,用于身份识别;
  3. 不同用户角色能访问的资源不同;
  4. 密码加密存储;
  5. 登录失败次数限制和锁定机制;
  6. 支持未来可扩展的安全模型升级路径。

技术选型与架构设计

技术选型与架构设计

安全模型选择

最初考虑了两种方案:

  • 使用传统 Session + Cookie 模式;
  • 使用 JWT + Token 的无状态方式。

结合我们前后端分离的设计以及部署环境(计划部署在 Kubernetes 上),最终选择了 JWT(JSON Web Token)作为认证凭据,这样更符合无状态服务的特性,也便于横向扩展。

💡 小插曲:曾经有个同事建议使用 Redis 存储 token 来做吊销管理,这确实是一种方法,但在初期阶段会增加运维复杂度。我们决定先实现无吊销流程,在后面版本再加入黑名单机制。

整体架构图简述

[浏览器] 
   ↓ HTTPS
[Spring Boot API]
   ↓ Spring Security Filter Chain
[UserDetailsService]
   ↓ 数据库/缓存加载用户信息
[JWT Token Generator]
   ↓ 返回给客户端

具体实现步骤与代码实践

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>

这里使用了 JJWT 这个库来生成和解析 JWT Token。


2. 创建 User Entity & Repository

用户实体类:

@Entity
public class AppUser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String role; // 可以改为关联 Role 表

    // getters / setters
}

Repository:

public interface UserRepository extends JpaRepository<AppUser, Long> {
    Optional<AppUser> findByUsername(String username);
}

3. 自定义 UserDetailsService

实现 Spring Security 的 UserDetailsService 接口:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    public UserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
        
        return User.withUsername(user.getUsername())
                .password(user.getPassword())
                .roles(user.getRole()) // 注意这里需要 ROLE_ 前缀,Spring Security 默认要求
                .build();
    }
}

4. 添加 JWT 工具类

用于生成和验证 token 的工具类:

@Component
public class JwtUtils {

    private final String jwtSecret = "your-very-secret-key-here";
    private final int jwtExpirationMs = 86400000; // 一天毫秒数

    public String generateJwtToken(Authentication authentication) {
        UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal();

        return Jwts.builder()
                .setSubject((userPrincipal.getUsername()))
                .setIssuedAt(new Date())
                .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
    }

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

    public boolean validateJwtToken(String authToken) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
            return true;
        } catch (SignatureException e) {
            System.err.println("Invalid JWT signature: " + e.getMessage());
        } catch (MalformedJwtException e) {
            System.err.println("Invalid JWT token: " + e.getMessage());
        } catch (ExpiredJwtException e) {
            System.err.println("JWT token is expired: " + e.getMessage());
        } catch (UnsupportedJwtException e) {
            System.err.println("JWT token is unsupported: " + e.getMessage());
        } catch (IllegalArgumentException e) {
            System.err.println("JWT claims string is empty: " + e.getMessage());
        }

        return false;
    }
}

5. 实现登录接口

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

    private final AuthenticationManager authenticationManager;
    private final UserService userService;
    private final JwtUtils jwtUtils;

    public AuthController(AuthenticationManager authenticationManager, UserService userService, JwtUtils jwtUtils) {
        this.authenticationManager = authenticationManager;
        this.userService = userService;
        this.jwtUtils = jwtUtils;
    }

    @PostMapping("/login")
    public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword())
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);

        String jwt = jwtUtils.generateJwtToken(authentication);

        UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();

        return ResponseEntity.ok(new JwtResponse(jwt,
                userDetails.getId(),
                userDetails.getUsername(),
                userDetails.getAuthorities()));
    }

    @PostMapping("/register")
    public ResponseEntity<?> registerUser(@RequestBody RegisterRequest registerRequest) {
        if (userService.existsByUsername(registerRequest.getUsername())) {
            return ResponseEntity.badRequest().body("用户名已被占用");
        }

        AppUser user = new AppUser();
        user.setUsername(registerRequest.getUsername());
        user.setPassword(new BCryptPasswordEncoder().encode(registerRequest.getPassword()));
        user.setRole("USER"); // 初期默认角色

        userService.save(user);

        return ResponseEntity.ok("注册成功");
    }
}

6. 自定义 Filter 实现 Token 校验

public class JwtAuthTokenFilter extends OncePerRequestFilter {

    private final JwtUtils jwtUtils;
    private final UserDetailsServiceImpl userDetailsService;

    public JwtAuthTokenFilter(JwtUtils jwtUtils, UserDetailsServiceImpl userDetailsService) {
        this.jwtUtils = jwtUtils;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String jwt = parseJwt(request);
            if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
                String username = jwtUtils.getUserNameFromJwtToken(jwt);

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());

                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            logger.error("无法设置用户认证: {}", e);
        }

        filterChain.doFilter(request, response);
    }

    private String parseJwt(HttpServletRequest request) {
        String headerAuth = request.getHeader("Authorization");

        if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7);
        }

        return null;
    }
}

7. 安全配置类 SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final UserDetailsServiceImpl userDetailsService;
    private final JwtAuthTokenFilter jwtAuthTokenFilter;

    public SecurityConfig(UserDetailsServiceImpl userDetailsService, JwtAuthTokenFilter jwtAuthTokenFilter) {
        this.userDetailsService = userDetailsService;
        this.jwtAuthTokenFilter = jwtAuthTokenFilter;
    }

    @Bean
    public AuthenticationManager authenticationManagerBean(HttpSecurity http) throws Exception {
        return http.getSharedObject(AuthenticationManagerBuilder.class)
                .userDetailsService(userDetailsService)
                .and()
                .build();
    }

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

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

踩坑经验总结

1. 角色权限前缀问题

刚开始测试的时候发现某些接口无论怎么配置 .hasRole("ADMIN") 总是拒绝访问。后来才发现 Spring Security 的角色权限需要带 "ROLE_" 前缀。

.roles("ADMIN")  // 实际等价于 ROLE_ADMIN

解决方案有两种:

  • 在配置时手动加上:
.antMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
  • 或者让数据库中的 role 字段自动加前缀:
User.withUsername("admin").roles("ADMIN"); // 自动生成 ROLE_ADMIN

2. Token 失效处理机制不完善

最开始我们使用的是无状态 Token 认证,一旦签发出去,除非自然失效,否则无法主动销毁。在实际测试中发现注销或密码修改后,老 Token 依然有效,存在安全隐患。

后期我们引入了一个 Redis 缓存 Token 黑名单,每次登出将 Token 加入黑名单,并在 Filter 中判断是否在黑名单内。虽然增加了运维成本,但保证了更强的安全性。

3. 登录失败限制没做

上线初期被攻击尝试弱口令爆破,幸好被监控发现及时处理。后来我们实现了简单的登录失败计数器:

// 可以用 Redis 记录每个用户的失败次数,达到一定阈值则锁定账户若干分钟。

效果与收益

项目上线一个月后,我们做了几个维度评估:

指标 结果
用户认证耗时 <50ms
接口访问失败率 <0.5%
登录成功率 >99%
安全事件 0次
并发性能表现 单节点支持并发500+请求

Spring Security 的集成非常顺利,整体提升了整个系统的可信度和稳定性。

特别是配合 Spring Boot 的自动配置和日志监控,让我们能在生产环境中轻松排查安全相关的问题。


经验分享与建议

1. 不要一开始就过度设计

刚开始我们团队有人想把 JWT、OAuth2、SSO 都一起整合进去,其实并不合适。尤其是初期阶段,先把核心认证链路打通才是关键。

2. 日志和异常处理是调试利器

一定要把 Spring Security 的 debug 日志打开,遇到权限拦截等问题时能快速定位:

logging:
  level:
    org.springframework.security: DEBUG

同时统一返回结构体,不要让前端看到堆栈错误信息。

3. 合理划分权限层级

我们一开始是按 URL 级别来做权限控制的,后来发现粒度过大,于是改为“接口 + 权限标签”的方式,比如:

@PreAuthorize("hasAuthority('PERMISSION_USER_READ')")
@GetMapping("/users/{id}")
public User getUserById(@PathVariable Long id) { ... }

这种细粒度控制方式更适合复杂系统。

4. 定期更新密钥和策略

  • 定期更换 JWT 的签名密钥;
  • 对密码强度进行校验;
  • 设置合适的 Token 过期时间;
  • 提供 Token 刷新机制;
  • 登录失败限制和封锁策略。

这些都是提升系统安全性的低成本方式。


写在最后

Spring Security 是一个强大而灵活的框架,但也因为其复杂的配置体系,很多开发者一上来就被劝退。通过这次实战项目,我深刻体会到:只要掌握核心组件的工作原理,并围绕自己的业务场景合理封装,Spring Security 并不像想象中那么难用。

如果你正在搭建一个新的后台服务,或者想要为现有项目增加安全能力,不妨试试用 Spring Security 快速搭起一个认证系统。它可能不是最轻量的选择,但它能让你的系统真正具备企业级的安全防护能力。

希望这篇来自真实项目经验的分享能对你有所启发。如有任何问题或交流,欢迎留言讨论!


📌 附录:推荐学习资源

祝你在 Spring Security 的学习路上少走弯路,多写稳定好用的权限系统!

评论 0

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