请写一篇关于【Spring Security基础:快速搭建安全认证系统】的技术文章
去年十月,北京的秋天冷得特别早。我裹着那件穿了三年的优衣库摇粒绒,在国贸站挤进10号线时,手机震动了一下——是老婆发来的消息:“妈今天又咳嗽了,药快吃完了。”
那一刻,我站在车厢中间,背包被挤得歪到一边,心里像被人攥了一把。房贷每月8200,房租3500,孩子奶粉一个月1200,老家父母医药费不定……我月薪22k,听起来体面,可扣完五险一金、税、花呗,月底能存下的钱,连给丈母娘买台制氧机都得分期。
那天晚上十一点半,我拖着身子回到家,打开电脑准备改一个紧急bug。突然收到HR的消息:“公司下周开始推行全员远程办公试点,你有兴趣吗?”
我盯着屏幕愣了三秒,手指有点抖。远程?回老家?省下3500房租?
我立刻拨通老婆电话:“喂,如果……我说如果,我能回老家工作,你愿意吗?”
电话那头沉默了几秒,然后她轻声说:“只要你在,哪儿都是家。”
一周后,我退了租,打包行李,带着笔记本和一颗忐忑的心回到了河南小县城。没有CBD,没有深夜加班的灯光,只有院子里那棵老槐树,和每天早上六点准时打鸣的公鸡。
但生活不会因为你换了地方就变得简单。新项目上线在即,我负责后端安全模块——用 Spring Security 搭建一套完整的认证授权系统。说实话,以前在公司都是“调包侠”,复制粘贴别人的配置,跑通就行。可现在一个人在家,没人帮你兜底,出问题只能自己扛。
一、从“Hello World”到“403 Forbidden”:我的安全初体验
项目是个内部管理系统,前端用 Vue 写的,后端是 Spring Boot。老板的要求很朴素:“登录要安全,不同角色能看到不同的菜单,别让人随便删数据就行。”
我一开始想偷懒,直接上 Spring Security 的默认配置:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.formLogin();
return http.build();
}
}
跑起来,浏览器自动跳转到 /login 页面——一个丑到爆的表单,连 CSS 都没加载。更糟的是,前端同事(其实就一个兼职的大学生)发来消息:“哥,我们用的是 JSON 登录,不是表单!”
我这才意识到:Spring Security 默认是面向传统 Web 应用的,而我们是前后端分离架构。前端发的是 POST /api/auth/login,带 JSON 体;后端要返回 token,而不是重定向。
那一刻,我坐在老家客厅的小书桌前,窗外是邻居家孩子的哭闹声,键盘上还沾着中午吃的胡辣汤油渍。我深吸一口气,打开官方文档,开始啃。
二、改造登录:从表单到 JSON + JWT
我决定用 JWT(JSON Web Token)做无状态认证。思路很清晰:
- 用户提交用户名密码(JSON)
- 后端验证,生成 JWT 返回
- 前端保存 token,后续请求带在 Header 里
- 后端拦截请求,解析 token,还原用户身份
关键在于自定义认证入口和成功/失败处理器。
先写一个 JwtAuthenticationFilter:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = getTokenFromHeader(request);
if (token != null && jwtUtil.validateToken(token)) {
String username = jwtUtil.getUsernameFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
private String getTokenFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
然后重写登录逻辑:
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
UserDetails user = userDetailsService.loadUserByUsername(request.getUsername());
String token = jwtUtil.generateToken(user);
return ResponseEntity.ok(new JwtResponse(token));
} catch (BadCredentialsException e) {
return ResponseEntity.status(401).body("用户名或密码错误");
}
}
}
最后,在 SecurityConfig 中禁用表单登录,加入自定义过滤器:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
搞定!本地测试通过。我兴奋地给前端发消息:“接口好了,试试!”
结果他回:“403 Forbidden。”
我懵了。查日志发现,CORS 跨域问题!前端在 localhost:8080,后端在 localhost:8081,浏览器拦了 OPTIONS 预检请求。
赶紧加 CORS 配置:
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(Arrays.asList("*"));
config.setAllowedMethods(Arrays.asList("*"));
config.setAllowedHeaders(Arrays.asList("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
再次测试,终于通了。那一刻,我靠在椅子上,看着屏幕上绿色的 200 OK,眼眶有点热。不是因为技术多难,而是在这座小县城的深夜,我一个人,搞定了本该团队协作的事。
三、权限控制:不是所有按钮都能点
系统有三个角色:ADMIN、EDITOR、VIEWER。
需求:ADMIN 能删用户,EDITOR 只能编辑内容,VIEWER 只读。
Spring Security 的 @PreAuthorize 注解简直是神器:
@RestController
@RequestMapping("/api/users")
public class UserController {
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.ok().build();
}
@PutMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN','EDITOR')")
public ResponseEntity<?> updateUser(@PathVariable Long id, @RequestBody UserUpdateDto dto) {
userService.update(id, dto);
return ResponseEntity.ok().build();
}
}
但要注意:默认角色前缀是 ROLE_,所以数据库里存的角色名要是 ROLE_ADMIN,否则 hasRole('ADMIN') 会失效。这个坑我踩了整整两个小时,差点砸键盘。
前端那边也得配合:根据用户角色动态渲染菜单和按钮。比如 Vue 组件里:
<template>
<div>
<button v-if="hasPermission('ADMIN')">删除</button>
<button v-if="hasPermission('ADMIN', 'EDITOR')">编辑</button>
</div>
</template>
<script>
export default {
methods: {
hasPermission(...roles) {
return roles.includes(this.currentUser.role);
}
}
}
</script>
安全不能只靠前端隐藏!必须前后端双重校验。曾有个实习生以为“按钮不显示=不能操作”,结果被人用 Postman 直接调删除接口,数据全没了。血的教训。
四、代码人生:在安全与生活之间找平衡
写这篇文的时候,已经是凌晨一点。孩子睡了,老婆在隔壁房间刷短视频。我泡了杯浓茶,回想这几个月。
远程办公省了房租,多了陪伴家人的时间,但也意味着孤独和责任加倍。没人盯着你打卡,但 bug 必须修;没人催进度,但上线日期不会推迟。
Spring Security 看似只是几行配置,背后却是对“信任边界”的思考:谁可以访问什么?如何证明你是你?怎样防止别人冒充你?
这不也像极了我们的生活吗?
在北漂时,我拼命证明自己“值得留在北京”;回老家后,又担心“会不会被时代抛弃”。
安全的本质,是建立可信的身份和可控的权限——无论是系统,还是人生。
五、给后来者的几点建议
- 别迷信默认配置:Spring Security 很强大,但默认行为可能不符合你的架构(比如前后端分离)。
- JWT 不是银弹:它适合无状态场景,但无法主动失效 token(除非加黑名单)。敏感操作建议用短期 token + 刷新机制。
- 权限最小化原则:给用户刚好够用的权限,别图省事给所有人 ADMIN。
- 永远不要只靠前端做权限控制:后端是最后的防线。
- 日志很重要:记录谁在什么时候尝试访问了什么,出事才能溯源。
结语:在代码与烟火气之间
上周五晚上,我陪孩子在院子里放烟花。他举着小手喊:“爸爸,你看!像不像你电脑上的绿色对勾?”
我笑了。是啊,200 OK,403 Forbidden,500 Internal Error……这些冰冷的状态码,构成了我谋生的工具,也成了孩子眼中的童话。
房贷还在还,技术也在学。但我不再焦虑了。
因为我知道,无论在北京的地铁里,还是在老家的书桌前,只要代码能跑,日子就能过。
Spring Security 教会我的,不只是如何保护一个系统,更是如何在混乱的世界里,守住自己的边界——既不让外界轻易入侵,也不把自己锁死。
共勉。

评论 0