用 Spring Security 快速搭建安全认证系统,我在项目中踩过的那些坑

程序员Dev
2025-06-14 09:59
阅读 454

去年公司有个新项目的启动会,我作为后端负责人被指派去带一个小型团队开发一个新的 SaaS 平台。这个平台的核心功能是为中小企业提供数据管理和分析服务,其中涉及大量用户敏感数据和权限控制。安全性自然是整个项目最核心的一环,特别是用户认证和权限体系的搭建,我们一开始就把它列为优先级最高的模块。

项目背景与问题描述:从零开始设计权限系统有多难?

项目背景与问题描述:从零开始设计权限系统有多难?

项目初期,我们的目标很明确:

  • 用户登录必须有完善的认证机制(用户名/密码 + 多因素可选)
  • 支持角色、权限的划分,比如管理员、普通用户、访客
  • 接口级别的访问控制
  • 后续可能需要支持 OAuth2 或 JWT 扩展

但我们不是从头造轮子。一开始我们评估了几个方案:Apache Shiro 和 Spring Security。Shiro 比较轻量,但缺乏对 RESTful API 的原生支持;而 Spring Security 虽然学习曲线陡峭一些,但它本身基于 Spring,集成度高,社区活跃,未来扩展性强,最终我们选择了它。

真正动手后才发现,Spring Security 并不像官方文档写得那么“友好”。尤其是对于刚接触它的同学,一上来就被各种自动配置绕晕。再加上我们在做的是前后端分离架构,传统基于 Session 的方式不太适用,很多默认行为都要调整,包括跨域处理、CSRF 配置等等。

我们的解决方案:快速上手 + 灵活扩展的思路

我们的解决方案:快速上手 + 灵活扩展的思路

经过一番调研,结合过往经验,我决定按照如下步骤来构建这个认证系统:

  1. 引入基础依赖:确保项目中已集成 Spring Boot 和 Spring Security 相关依赖;
  2. 自定义 UserDetailsService 实现用户加载逻辑
  3. 使用 BCrypt 加密保存用户密码
  4. 实现基于 Token 的无状态认证流程(后续升级为 JWT)
  5. 配置接口级别的权限控制
  6. 设置全局异常处理器统一返回格式
  7. 后续接入 OAuth2 / 社交登录作为扩展项

下面我会以实际开发过程为例,给出关键代码和配置说明。


开始实践:一步步搭建 Spring Security 认证系统

第一步:添加依赖

<!-- Spring Security 核心 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- 用于生成和验证 JWT Token -->
<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>

Spring Security 默认会拦截所有请求并要求登录,所以我们第一步先关闭掉默认登录页,在 application.yml 中添加:

spring:
  security:
    user:
      name: admin
      password: 123456

第二步:自定义 UserDetails 和 UserDetailsService

为了适应数据库结构,我们需要自己实现 UserDetails 接口和 UserDetailsService 接口:

public class CustomUserDetails implements UserDetails {

    private String username;
    private String password;
    private List<SimpleGrantedAuthority> authorities;

    public CustomUserDetails(User user) {
        this.username = user.getUsername();
        this.password = user.getPassword();
        this.authorities = user.getRoles().stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
            .collect(Collectors.toList());
    }

    // ...省略实现方法
}

然后实现 UserDetailsService:

@Service
public class UserDetailsServiceImpl 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("用户不存在");
        }
        return new CustomUserDetails(user);
    }
}

这一步的关键点在于:要把你数据库中的用户信息正确映射成 Spring Security 可理解的 UserDetails 对象。

第三步:安全配置类(重点)

这是整个安全认证的核心配置:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

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

        return http.build();
    }

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

这里做了几个非常重要的事:

  • 禁用了 CSRF:因为我们是前后端分离架构,没有 Cookie 登录场景;
  • 设定了 session 管理策略为 STATELESS,即每次请求都独立认证;
  • 在过滤器链中加入 JWT 校验逻辑
  • 开放了 /auth/login 接口供匿名访问

第四步:JWT 认证逻辑实现

我们采用了一个简单的 JWT Token 认证方式,下面是关键部分:

JWT 工具类:

public class JwtUtils {

    private static final String SECRET_KEY = "my_secret_key";
    private static final long EXPIRATION = 86400_000; // 24 小时

    public static 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_KEY)
            .compact();
    }

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

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

    private static boolean isTokenExpired(String token) {
        Date expiration = Jwts.parser()
            .setSigningKey(SECRET_KEY)
            .parseClaimsJws(token)
            .getBody()
            .getExpiration();
        return expiration.before(new Date());
    }
}

自定义过滤器:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtUtils jwtUtils;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = getTokenFromRequest(request);
        if (token != null && jwtUtils.validateToken(token, userDetails)) {
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities()
            );
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }

    private String getTokenFromRequest(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

这一块代码其实挺容易出错的地方:

  • Token 解析失败怎么处理?
  • 请求头没带 Token 怎么办?
  • 用户不存在怎么办?

这些问题我们后面专门用一个 全局异常处理器 来集中处理。


第五步:全局异常处理统一输出格式

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<?> handleBadCredentials() {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("账号或密码错误");
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<?> handleAccessDenied() {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body("权限不足");
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> handleGenericError(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("系统异常:" + ex.getMessage());
    }
}

这样前端就可以通过统一 JSON 格式获取错误信息,而不必担心抛出堆栈信息泄露。


踩坑经验分享:那几天差点崩溃的夜晚 😅

说实话,虽然现在看起来整个流程还挺顺畅的,但在开发初期我们也踩了很多坑,下面分享几个印象特别深的问题:

坑一:登录接口怎么也进不了

最初我们把登录接口放在 /login,但发现即使写了 .antMatchers("/login").permitAll(),仍然被拦截。

后来查到原因是因为 Spring Security 默认有一个 /login 接口触发 FormLogin 页面跳转。所以要么改路径,要么显式禁止 FormLogin:

http.formLogin().disable();

坑二:Token 过期时间总是不对

刚开始我们设置了 token 过期时间是 24 小时,但测试人员反馈说经常过一会儿就失效。

排查之后发现是在本地调试的时候没有考虑服务器时间和本地时间同步问题,建议所有时间都使用 UTC 时间进行计算和比较。

坑三:跨域请求一直报错

因为我们的前端是部署在另一个域名下的静态服务,API 是后端地址。刚开始没加跨域配置,导致 OPTIONS 请求都被拦截。

解决办法是在配置类里加上:

@Bean
public WebMvcConfigurer corsConfigurer() {
    return new WebMvcConfigurer() {
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/api/**")
                .allowedOrigins("http://frontend-domain.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowCredentials(true);
        }
    };
}

效果总结:上线后的收益远超预期

整套认证系统搭建完成后,我们很快实现了以下能力:

  • 所有接口都需要授权才能访问
  • 提供了清晰的角色权限粒度管理
  • 支持后续扩展 OAuth2 第三方登录
  • 接入了统一的鉴权异常处理机制,提升用户体验
  • 数据库设计合理,方便后续多租户改造

而且随着业务增长,这套安全体系表现非常稳定,没有出现大规模并发下的性能问题。后来我们还将这部分抽象成通用组件,复用到了其他几个项目上,大大缩短了新项目的启动周期。


给你的几点建议和注意事项

如果你也在准备搭建自己的认证系统,不妨参考以下几个小建议:

✅ 不要怕复杂,先跑起来再优化

Spring Security 配置看似复杂,但其实只要搞清楚几个核心概念(过滤器链、AuthenticationManager、UserDetailsService),剩下的都是组合搭配。

✅ 把认证逻辑和业务逻辑分离

不要把认证逻辑和具体业务混在一起。可以封装成模块,甚至做成独立服务(如 Auth Center),后续扩展性更强。

✅ 关注性能,避免重复校验

尤其在高并发下,Token 解析频繁操作容易造成瓶颈。建议缓存 Token 有效期内的信息或者采用 Redis 存储黑名单机制。

✅ 安全永远不能忽视细节

  • 密码一定要加密存储(BCrypt 推荐)
  • Token 有效期不宜过长
  • 使用 HTTPS,保护传输安全
  • 定期审查权限配置,防止越权访问

✅ 拓展方向:OAuth2、RBAC、JWT 多签等

当你这套基础认证体系跑稳以后,可以进一步扩展:

  • 接入 GitHub、微信等第三方登录
  • 引入 RBAC 模型细粒度授权
  • 多租户环境下的隔离策略
  • 支持刷新 Token、注销 Token 黑名单机制

写在最后:技术这条路,走得踏实才有底气

记得当时我们在会议室里反复调试那个 JWT 过滤器,整整一天也没搞定。第二天早上我突然想到是不是请求顺序的问题,把过滤器提前了两个位置,居然奇迹般地成功了。那种“顿悟”的喜悦感至今难忘。

技术的成长就是这样,在一次次的折腾中摸索前进。希望这篇结合我真实工作经历的文章,能帮助你少走一点弯路。如果你正在用 Spring Security 搭建系统,欢迎留言一起交流——我们都有属于自己的那道坎,但也总有一群人愿意一起走过。

评论 0

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