前端转全栈被Spring Security教做人的那些日子
说出来你们可能不信,我写这篇文章的时候,刚被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的安全框架,真的是每一页都在怀疑人生。书里讲AuthenticationManager、UserDetailsService这些概念的时候,我满脑子都是前端的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表,里面就username、password、email几个字段。结果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,从一个前端小白到现在能独立搞定认证授权系统,说说我的感受:
不要怕看源码。Spring Security的源码确实复杂,但你不需要全看懂。重点看
FilterChainProxy和那几个核心Filter就行了。理解过滤器链的执行顺序,90%的问题都能自己排查。前后端分离的思维要转过来。以前做前端,状态都存浏览器里,
localStorage、sessionStorage随便用。到了后端,状态管理要严谨得多。Token存在哪、怎么刷新、怎么失效,每个环节都要想清楚。安全无小事。我们系统上线后做过一次安全扫描,扫出来几个低危漏洞,比如接口没有限流、密码复杂度没校验之类的。虽然都是小问题,但修起来也花了不少时间。安全这块,宁可一开始就做好,也别等出了事再补。
善用工具。不管是Codeium这种AI辅助工具,还是Swagger这种接口文档工具,能省时间就省。程序员的时间应该花在思考业务逻辑上,而不是重复劳动上。
多跟后端同事交流。我是前端出身,很多后端的最佳实践不了解。多问问同事,能少走很多弯路。比如数据库索引怎么加、接口怎么设计才合理,这些经验书本上学不到。
写在最后
从前端转全栈这条路,说难也难,说简单也简单。难的是要学的东西太多了,Spring Security只是其中一个小点,后面还有Spring Cloud、微服务、消息队列一大堆要学。简单的是,一旦你跨过了那个门槛,会发现后端的很多思想和前端是相通的。
比如Spring Security的过滤器链,跟前端的路由守卫、axios拦截器,本质上都是"拦截-处理-放行"的模式。理解了这一点,学起来就没那么痛苦了。
好了,不说了,leader又在群里@我了,说有个权限的bug要紧急修复。希望这次不是CSRF的锅,我真的不想再碰那个玩意儿了。
如果你也是前端转全栈的兄弟,在Spring Security上遇到了什么问题,欢迎在评论区交流。咱们一起踩坑,一起成长。毕竟,程序员的快乐,不就是解决一个bug之后那种"我真是个天才"的错觉吗?


评论 0