前端转全栈被Spring Security教做人的那些日子

LangChain路人
2026-07-11 21:54
阅读 345

说出来你们可能不信,我写这篇文章的时候,刚被Spring Security折磨完,头发又掉了两把。

先自我介绍一下,我是个纯前端出身的小码农,React、Vue写得飞起,什么Webpack打包优化、Vite插件开发,闭着眼睛都能搞。两个月前刚入职现在这家公司,leader说"小X啊,咱们团队缺全栈,你后端也学学呗",得,一句话,我就从"切图仔"变成了"全栈工程师"(其实就是干两个人的活拿一份工资)。

最近公司要搞个内部管理系统,需要一套完整的用户认证和权限控制。leader大手一挥:"用Spring Boot + Spring Security,行业标准,稳!"我当时心里就咯噔一下——Spring Security这玩意儿,前端圈子里传说的"劝退神器"之一,我连Spring Boot都还没玩明白呢。

被逼上梁山的学(cai)习(keng)之路

刚接手这活的时候,我天真地以为,认证系统嘛,不就是登录、注册、Token校验那点事?前端我闭着眼都能写。结果一打开Spring Security的官方文档,好家伙,那密密麻麻的过滤器链、那层层叠叠的SecurityContext,直接给我干懵了。

先说我的学习路径吧。一开始我买了本《Spring Security实战》,想着系统学一下。书是好书,但我这个前端脑回路看Java的安全框架,真的是每一页都在怀疑人生。书里讲AuthenticationManagerUserDetailsService这些概念的时候,我满脑子都是前端的localStorage存Token、axios拦截器加Authorization头的画面。两个世界的碰撞,那叫一个惨烈。

后来我学聪明了,直接上GitHub找开源项目看源码。我翻了几个Star比较高的Spring Boot后台管理模板,发现大家用的套路其实都差不多。但问题是,每个项目的Security配置都不一样,有的用JWT,有的用Session,有的还加了OAuth2,看得我眼花缭乱。

最让我崩溃的是,我照着网上的教程配了半天,一启动项目,好家伙,连Swagger都访问不了了!浏览器一直弹那个默认的登录框,丑得一批,而且我自定义的登录接口死活不生效。当时真的想砸电脑,心想这玩意儿比CSS居中还难搞。

被过滤器链支配的恐惧

Spring Security的核心就是那一串过滤器(Filter Chain),这个概念理解透了,后面就顺了。但理解的过程,真的是血泪史。

先说说我踩的第一个大坑:默认的表单登录和HTTP Basic认证

Spring Security只要你引入了依赖,不写任何配置,它就会自动给你加上安全拦截。你访问任何接口,它都会弹出一个丑丑的登录框。我当时不知道这个机制,写了个/api/login接口,用Postman一测,返回401。我以为是接口写错了,调了半天,后来才知道请求根本没到我的Controller,直接在过滤器链里被拦截了。

// 我最初的配置,啥也没写,以为默认就能用
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    // 空的,啥也没有
}

这个坑我踩了整整一个下午。后来搞明白了,Spring Security的默认行为是:所有请求都需要认证,认证方式是HTTP Basic(就是浏览器弹框那种)。你要自定义,就得自己配置。

第二个坑是CSRF防护。因为我们的系统是前后端分离的,前端用JWT做无状态认证,根本不需要CSRF Token。但我一开始没关CSRF,导致所有POST请求都报403。排查了好久,最后在一个Stack Overflow的帖子里看到有人提了一嘴,才恍然大悟。

// 关闭CSRF,前后端分离必做
http.csrf(csrf -> csrf.disable());

就这一行代码,我找了两个小时。当时真的想骂人,报错信息也不说清楚是CSRF的问题,就一个干巴巴的403。

我的最终配置方案

踩了无数坑之后,我终于搞出了一套能用的配置。这里分享一下,希望能帮到跟我一样从前端转过来的兄弟。

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Autowired
    private CustomAuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    private CustomAccessDeniedHandler accessDeniedHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 关闭CSRF,前后端分离不需要
            .csrf(csrf -> csrf.disable())
            // 关闭Session,我们用JWT
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // 配置请求授权规则
            .authorizeHttpRequests(auth -> auth
                // 这些接口不需要认证
                .requestMatchers(
                    "/api/auth/login",
                    "/api/auth/register",
                    "/api/auth/refresh-token",
                    "/swagger-ui/**",
                    "/v3/api-docs/**"
                ).permitAll()
                // 其他所有接口都需要认证
                .anyRequest().authenticated()
            )
            // 自定义认证失败处理(返回JSON而不是跳转页面)
            .exceptionHandling(exception -> exception
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)
            )
            // 把我们的JWT过滤器加到UsernamePassword过滤器前面
            .addFilterBefore(jwtAuthenticationFilter, 
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

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

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

这里有几个关键点,是我踩坑之后总结出来的:

配置项 说明 踩坑点
csrf.disable() 关闭CSRF 不关的话POST请求全403
STATELESS 无状态Session 不设置的话每次请求都会创建Session,JWT白做了
permitAll() 放行接口 Swagger的静态资源也要放行,不然文档打不开
addFilterBefore 自定义过滤器位置 一定要加在UsernamePasswordAuthenticationFilter前面

JWT过滤器:核心中的核心

JWT过滤器是整个认证系统的灵魂。它的职责很简单:从请求头里取出Token,解析验证,然后把用户信息塞到SecurityContext里。

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String token = getTokenFromRequest(request);

        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
            String username = jwtTokenProvider.getUsernameFromToken(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());

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

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

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

这个过滤器我改了好几版。第一版没处理Token过期的情况,导致过期Token也能访问接口;第二版没处理Token格式错误的情况,直接抛异常把请求搞崩了。最后这版算是比较稳了,validateToken方法里会catch各种异常,Token有问题就当没Token处理,走未认证逻辑。

数据库设计的一些思考

说到用户表设计,我一开始就建了个user表,里面就usernamepasswordemail几个字段。结果leader一看,直接打回来:"你这权限怎么控制?角色呢?菜单权限呢?"

好吧,老老实实重新设计。最后搞了一套RBAC(基于角色的访问控制)模型:

-- 用户表
CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(200) NOT NULL,
    email VARCHAR(100),
    status TINYINT DEFAULT 1 COMMENT '0-禁用 1-启用',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 角色表
CREATE TABLE sys_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    role_code VARCHAR(50) NOT NULL UNIQUE,
    role_name VARCHAR(50) NOT NULL,
    description VARCHAR(200)
);

-- 权限表
CREATE TABLE sys_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    permission_code VARCHAR(100) NOT NULL UNIQUE,
    permission_name VARCHAR(100) NOT NULL,
    type TINYINT COMMENT '1-菜单 2-按钮 3-接口',
    parent_id BIGINT DEFAULT 0
);

-- 用户角色关联表
CREATE TABLE sys_user_role (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id)
);

-- 角色权限关联表
CREATE TABLE sys_role_permission (
    role_id BIGINT NOT NULL,
    permission_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, permission_id)
);

这套表结构虽然经典,但确实够用。有个小细节要注意:password字段长度给到200,因为BCrypt加密后的密码串比较长,给短了存不下,我就因为这个bug排查了半小时,插入用户的时候报字段截断错误。

接口设计的小细节

认证相关的接口,我设计了这么几个:

POST /api/auth/login          -- 登录
POST /api/auth/register       -- 注册
POST /api/auth/refresh-token  -- 刷新Token
POST /api/auth/logout         -- 登出
GET  /api/auth/user-info      -- 获取当前用户信息

这里有个坑:登出接口。因为我们用的是JWT无状态认证,服务端不存Token,所以"登出"其实是个伪需求。你没法让一个已经发出去的Token失效。

我一开始的做法是前端把Token删了就算登出,但这有个问题:如果Token被窃取了,窃取者依然可以用这个Token访问接口。

后来我加了一个Redis黑名单机制:用户登出时,把Token的jti(JWT ID)存到Redis里,设置过期时间和Token剩余有效期一致。每次JWT过滤器校验Token的时候,先去Redis查一下这个jti在不在黑名单里。

// 登出逻辑
public void logout(String token) {
    String jti = jwtTokenProvider.getJtiFromToken(token);
    long expiration = jwtTokenProvider.getExpirationFromToken(token);
    long remainTime = expiration - System.currentTimeMillis();
    
    if (remainTime > 0) {
        // 存入Redis,过期时间就是Token剩余有效期
        redisTemplate.opsForValue().set(
            "token:blacklist:" + jti, "1", remainTime, TimeUnit.MILLISECONDS);
    }
}

这个方案不算完美,但至少比裸奔强。生产环境跑了一个多月,没出过安全问题。

借助AI工具加速开发

说实话,学Spring Security的过程中,AI工具帮了我大忙。我用的Codeium,这玩意儿在VS Code和IDEA里都能用,代码补全确实比原生的智能不少。

最爽的是写那些重复性高的代码,比如UserDetailsService的实现、各种DTO的转换、Controller里的参数校验。以前我要自己一个个字段敲,现在Codeium能直接猜出我想写什么,Tab一下就完事了。

还有个场景特别好用:写单元测试。我以前最烦写测试了,但有了AI辅助,我把业务代码写完,让Codeium帮我生成测试用例的骨架,我再稍微改改就能跑。这次Spring Security相关的接口,我写了二十多个测试用例,覆盖率到了85%以上,这在以前我是不可能做到的。

不过AI也不是万能的。Spring Security的配置代码,AI经常给我生成一些过时的写法,比如用WebSecurityConfigurerAdapter(这个在Spring Security 5.7之后就被废弃了)。所以我还是得自己看文档确认,不能完全依赖AI。

上线后的运维经验

系统上线之后,也遇到了一些生产环境的问题,分享几个:

1. Token过期时间怎么设?

我们设的是access_token 2小时,refresh_token 7天。一开始我想设长一点,省得用户老要重新登录。但leader说安全审计那边有要求,access_token不能超过4小时。折中了一下,2小时+前端无感刷新,用户体验也还行。

2. 并发登录控制

有个需求是同一个账号不能同时在两个地方登录。这个用JWT做有点麻烦,因为JWT本身是无状态的。我的做法是:在Redis里存一个user:login:{username}的key,value是当前的Token jti。每次登录的时候更新这个key,JWT过滤器里校验当前Token的jti和Redis里存的是不是一致。这样后登录的会把先登录的挤掉。

3. 密码重置的坑

有一次用户反馈说密码重置后还能用旧密码登录。排查了半天,发现是密码重置接口没有走BCrypt加密,直接存了明文。而登录的时候BCrypt去匹配明文和密文,居然也匹配上了(因为BCrypt每次生成的密文不一样,但验证的时候是用密文里的salt去加密明文再比较)。这个问题排查了我一上午,教训就是:密码相关的操作,一定要写单元测试覆盖加密逻辑

一些心得体会

搞了两个月的Spring Security,从一个前端小白到现在能独立搞定认证授权系统,说说我的感受:

  1. 不要怕看源码。Spring Security的源码确实复杂,但你不需要全看懂。重点看FilterChainProxy和那几个核心Filter就行了。理解过滤器链的执行顺序,90%的问题都能自己排查。

  2. 前后端分离的思维要转过来。以前做前端,状态都存浏览器里,localStoragesessionStorage随便用。到了后端,状态管理要严谨得多。Token存在哪、怎么刷新、怎么失效,每个环节都要想清楚。

  3. 安全无小事。我们系统上线后做过一次安全扫描,扫出来几个低危漏洞,比如接口没有限流、密码复杂度没校验之类的。虽然都是小问题,但修起来也花了不少时间。安全这块,宁可一开始就做好,也别等出了事再补。

  4. 善用工具。不管是Codeium这种AI辅助工具,还是Swagger这种接口文档工具,能省时间就省。程序员的时间应该花在思考业务逻辑上,而不是重复劳动上。

  5. 多跟后端同事交流。我是前端出身,很多后端的最佳实践不了解。多问问同事,能少走很多弯路。比如数据库索引怎么加、接口怎么设计才合理,这些经验书本上学不到。

写在最后

从前端转全栈这条路,说难也难,说简单也简单。难的是要学的东西太多了,Spring Security只是其中一个小点,后面还有Spring Cloud、微服务、消息队列一大堆要学。简单的是,一旦你跨过了那个门槛,会发现后端的很多思想和前端是相通的。

比如Spring Security的过滤器链,跟前端的路由守卫、axios拦截器,本质上都是"拦截-处理-放行"的模式。理解了这一点,学起来就没那么痛苦了。

好了,不说了,leader又在群里@我了,说有个权限的bug要紧急修复。希望这次不是CSRF的锅,我真的不想再碰那个玩意儿了。

如果你也是前端转全栈的兄弟,在Spring Security上遇到了什么问题,欢迎在评论区交流。咱们一起踩坑,一起成长。毕竟,程序员的快乐,不就是解决一个bug之后那种"我真是个天才"的错觉吗?

评论 0

最热最新
暂无评论
LangChain路人Lv.1
0
影响力
0
文章
0
粉丝