用 Spring Security 快速搭建安全认证系统,这波我赌你会踩坑

Prompt造梦师
2025-06-23 13:00
阅读 783

大家好,我是做了五年后端开发的程序员小陈。今天想和大家分享一下我在实际项目中使用 Spring Security 搭建基础认证系统的心得。这个话题看似基础,但如果你真正上过生产环境,就知道里面藏着不少细节。

背景:为什么我们需要一个安全的认证系统?

背景:为什么我们需要一个安全的认证系统?

我们团队之前接了一个企业内部系统的重构项目,主要功能是给员工提供审批、查看数据报表等功能。客户特别强调——必须保证用户权限清晰,不能有越权访问的情况。

最开始我打算直接手写一套登录逻辑:用户名+密码验证,存 session,然后在每个接口加拦截器判断是否登录。不过领导看了一眼就建议:“你这不如用 Spring Security,能少写不少 bug。”

我当时心里还嘀咕:听说 Spring Security 配置复杂,源码难懂,会不会反倒是给自己找麻烦?但在后来的实践中我发现,如果用得好,它确实能帮你规避很多安全问题,节省大量重复工作。


问题来了:初次尝试踩了哪些坑?

服务器部署方案-1

问题来了:初次尝试踩了哪些坑?

第一次试水是在本地搭了个 demo 环境,结果上来就被几个问题绊住了脚:

坑一:默认的登录页面怎么去掉?

我想用自己的登录接口,不想走 /login 页面跳转那一套,但一开始无论怎么改配置,还是会进到那个烦人的 login 页面。

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.formLogin();
}

这个 formLogin() 默认会启用表单登录页面,必须改成 .loginProcessingUrl("/api/login") 才行。

坑二:RESTful 接口怎么返回 JSON?

默认情况下,认证成功或失败返回的是 HTML 页面或者重定向,但我希望返回标准的 JSON 格式,例如:

{
    "code": 0,
    "message": "success",
    "data": {}
}

这个需要自定义 AuthenticationSuccessHandlerAuthenticationFailureHandler,才能统一格式。

坑三:CSRF 是什么?为什么要关掉?

我们的前端是前后端分离架构,采用 JWT 的方式做认证,不需要 Session。这时候 CSRF 实际上是没有必要的,而且会导致 POST 请求被拒绝。

最终我通过:

http.csrf().disable();

解决了这个问题。


我们是怎么做的?从零搭建 Spring Security 认证系统

我们是怎么做的?从零搭建 Spring Security 认证系统

下面我就以我们项目的初期阶段为例,讲讲我们是如何快速搭建起一套基于 Spring Security 的认证系统的。

第一步:明确认证流程需求

我们需要满足以下几个核心点:

  1. 用户名 + 密码登录
  2. 登录成功返回 token(这里我们用 JWT)
  3. 后续请求带上 token,由服务端校验
  4. 支持多角色权限控制(比如普通员工、部门主管、管理员)

第二步:选择技术栈

  • Spring Boot 2.7.x(兼容性更好)
  • Spring Security 5.7.x
  • MyBatis Plus 操作数据库
  • JWT:用的 jjwt 库生成 token
  • MySQL 存储用户信息与角色关系

第三步:设计数据库结构

用户表(user)

id username password nickname deleted
1 admin xxx 管理员 0

角色表(role)

id role_name
1 ROLE_ADMIN
2 ROLE_USER

用户角色关联表(user_role)

user_id role_id
1 1

第四步:关键代码实现

自定义 UserDetailsService

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.getUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }
        List<GrantedAuthority> authorities = new ArrayList<>();
        List<String> roleNames = userService.getRolesByUserId(user.getId());
        for (String roleName : roleNames) {
            authorities.add(new SimpleGrantedAuthority(roleName));
        }
        return new UserDetailImpl(user, authorities);
    }
}

自定义过滤器处理登录逻辑

@Component
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtUtils jwtUtils;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            LoginRequest loginReq = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class);
            return authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(loginReq.getUsername(), loginReq.getPassword())
            );
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException {
        String token = jwtUtils.generateToken(authResult.getName());
        response.setHeader("Authorization", "Bearer " + token);
        ResponseUtil.writeSuccess(response, Collections.singletonMap("token", token));
    }
}

添加 Token 校验的过滤器

@Component
public class JwtVerifyFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtils jwtUtils;

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

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

配置类 SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Autowired
    private JwtLoginFilter jwtLoginFilter;

    @Autowired
    private JwtVerifyFilter jwtVerifyFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(jwtVerifyFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeRequests()
            .antMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated();


![服务器部署方案-2](https://code-guide.oss.shanghai.autogptai.club/common/file/download?name=date2025062313/adbf6662-b825-47c5-b41c-2e1ec8721a9c.jpg)


        return http.build();
    }

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

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

踩过的那些坑,现在回头看都很值

坑一:Security 上下文传递失败?

我们在分布式系统中用了 Feign 进行服务调用,刚开始每次调用其他服务时,SecurityContext 总是为空,导致鉴权失败。

解决方法:在 FeignClient 上加上 configuration = FeignConfig.class,并在 FeignConfig 中注入 RequestInterceptor。

@Bean
public RequestInterceptor requestInterceptor() {
    return requestTemplate -> {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes != null) {
            HttpServletRequest request = attributes.getRequest();
            String authorization = request.getHeader("Authorization");
            if (authorization != null) {
                requestTemplate.header("Authorization", authorization);
            }
        }
    };
}

坑二:多线程环境下 SecurityContext 丢失?

我们有些业务操作涉及异步执行,比如发送通知邮件,但发现在线程池中获取不到当前用户信息。

解决办法是包装 Runnable:

SecurityContext context = SecurityContextHolder.getContext();
executor.execute(() -> {
    SecurityContextHolder.setContext(context);
    // do something
});

结果与收益:效率提升明显

整套认证体系上线之后,效果非常不错:

  1. 登录响应平均时间 < 100ms
  2. 接口请求鉴权开销几乎可以忽略
  3. 安全性增强:没有再出现过越权问题
  4. 权限模型灵活扩展:后续新增角色和权限很容易

更重要的是,我们把这一整套封装成了独立模块,供多个项目复用,极大提高了新项目接入速度。


经验分享:给刚入门的同学几点建议

  1. 不要一开始就啃 Spring Security 源码
    先动手搭起来,再逐步理解它的机制。否则容易陷入“源码看懂了但不会用”的尴尬。

  2. 搞清认证和鉴权的区别
    Spring Security 更擅长的是认证流程管理,权限控制部分可以根据业务需要自己扩展。

  3. 关注线程上下文问题
    尤其是在异步编程或多线程场景下,SecurityContext 一定要手动传过去。

  4. 合理关闭不必要的保护机制
    比如前后端分离项目完全可以关闭 CSRF,别为了兼容默认设置而强行绕弯路。

  5. 日志监控很重要
    建议记录所有认证失败的请求 IP、时间等信息,方便后续审计和风控。


写在最后:安全不是万能的,但它值得认真对待

做后端开发这么多年,我越来越意识到一点:安全不是锦上添花的功能,而是最基本的保障。

尤其是现在的系统动辄要面对外部攻击、内部数据泄露等问题。Spring Security 虽然学习曲线陡峭,但是一旦掌握熟练,你会发现它是你在后端攻防战中的最佳战友。

希望这篇文章能帮助你在 Spring Security 的学习路上少走点弯路,也欢迎留言交流,我们一起成长 🚀!


如果你对这套方案感兴趣,也可以在我的 GitHub 上看到完整的示例工程(后续会同步开源)。有问题随时欢迎来骚扰!

评论 0

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