Spring Security基础:快速搭建安全认证系统
上周五晚上十点半,我坐在老家县城出租屋的书桌前,盯着屏幕上疯狂报错的日志,一边啃着冷掉的螺蛳粉——没错,我又在深圳远程办公了。自从去年跳槽到这家腾讯系背景的中型公司,我就回了老家,省下房租不说,还能顺便陪陪爸妈。但代价是,每次遇到线上事故,只能靠自己硬刚。
那天的问题,说来有点尴尬:我们一个内部管理后台,本来用的是最原始的 session + 手写拦截器,结果上个月被安全团队扫出一堆高危漏洞——“未授权访问”、“会话固定攻击”、“CSRF 防御缺失”……产品经理看报告时差点把咖啡喷到显示器上:“你们后端是不是拿胶带粘的安全策略?”
更惨的是,下周就要交付新模块,领导直接在群里@我:“小张,这周把认证体系重构了,用 Spring Security,别整那些野路子。”
我?一个小镇做题家,大学靠刷 LeetCode 进的大厂外包,现在居然要搞企业级安全框架?行吧,谁让我简历上写了“熟悉微服务安全架构”呢(其实是瞎写的)。
为啥非得上 Spring Security?
其实一开始我是抗拒的。毕竟我们项目是个典型的前后端分离系统:前端用 Vue3 + TypeScript,后端是 Spring Boot,数据库是 MySQL,连 Redis 都只用来缓存用户信息。之前的手写登录逻辑虽然糙,但跑得挺稳——直到被安全团队打脸。
而且,我们组还有个 Python 写的数据分析子系统,偶尔要调用我们的 API。原来的 token 验证逻辑根本没考虑跨语言兼容性,Python 同事每次调试都得手动拼 Authorization 头,还老问我:“你这个 token 到底过期没?”
Spring Security 的优势就在这儿:开箱即用的标准实现、天然支持 JWT、无缝集成 OAuth2,还能和前端、Python 服务统一认证协议。更重要的是,它能自动防御 CSRF、点击劫持、会话固定等常见攻击——这些,手写代码很容易漏掉。
吐槽一句:有些老哥觉得“我加个 token 就安全了”,结果连 HTTPS 都没配,token 明文传,等于把家门钥匙贴在楼道公告栏上。
动手干:三步搭起认证骨架
第一步:Maven 引依赖,别漏了 Web 和 JWT
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JWT 支持 -->
<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>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
别学我,第一次漏了 jjwt-jackson,结果解析 token 时报 ClassNotFound,查了俩小时才发现是 Jackson 依赖缺失。运维同事看我日志里满屏红,还以为服务器炸了。
第二步:写个 UserService,对接数据库
我们用户表结构很简单:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| username | VARCHAR(50) | 唯一用户名 |
| password | VARCHAR(100) | BCrypt 加密后的密码 |
| role | VARCHAR(20) | 角色:ADMIN / USER |
重点来了:密码一定要用 BCrypt 加密!千万别明文存,也别用 MD5。Spring Security 自带 BCryptPasswordEncoder,注册时直接 encode 即可:
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword()) // 已经是 BCrypt 格式
.roles(user.getRole())
.build();
}
}
这里踩了个坑:我一开始返回的是自定义的 User 对象,结果 Spring Security 不认,报 ClassCastException。后来才明白,必须包装成 UserDetails 接口的实现——官方那个 User 类就行,别造轮子。
第三步:配置 SecurityFilterChain(重点!)
这是 Spring Security 5.7+ 的新写法,取代了以前的 WebSecurityConfigurerAdapter。很多人还在抄旧教程,结果配置不生效。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() // 前后端分离,且用 JWT,可关 CSRF(但需评估风险)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/login", "/api/public/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
}
几个关键点解释:
csrf().disable():因为前端是 Vue,通过 AJAX 调后端,且我们用无状态 JWT,所以可以关闭 CSRF。但如果你们有表单提交或混合架构,建议保留。SessionCreationPolicy.STATELESS:告诉 Spring 别创建 Session,纯靠 JWT。addFilterBefore:自定义 JWT 拦截器,在用户名密码认证过滤器之前执行。
自定义 JWT 拦截器:让 token 生效
光有配置不够,得写个 Filter 来解析请求头里的 token:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractTokenFromHeader(request);
if (token != null && !JwtUtil.isTokenExpired(token)) {
String username = JwtUtil.getUsernameFromToken(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);
if (JwtUtil.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
}
filterChain.doFilter(request, response);
}
private String extractTokenFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
注意:JwtUtil 是我自己封装的工具类,里面用了 Jwts.parserBuilder() 来解析 token,并验证签名和过期时间。
血泪教训:有一次我把 secret key 写死在代码里,结果 Git 提交后被 SonarQube 扫出来,安全团队直接给我发了警告邮件。现在都从配置中心动态拉取。
前端怎么配合?Vue 调用登录接口
后端写完,前端同学小李(我们组唯一的前端)跑来问:“token 我存在哪?localStorage 还是 sessionStorage?”
我:“localStorage 吧,但记得设置 HttpOnly?哦不对,那是 Cookie……你存 localStorage 也行,但要注意 XSS 风险。”
他翻了个白眼:“你后端管这么多?”
其实我们约定很简单:
- 登录接口
/api/auth/login,POST,body 带{username, password} - 成功返回:
{ token: "xxx", expireIn: 3600 } - 前端后续所有请求,在 Header 加
Authorization: Bearer xxx
Axios 拦截器示例:
// request interceptor
axios.interceptors.request.use(config => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
Python 服务那边也简单,他们用 requests:
headers = {"Authorization": f"Bearer {token}"}
response = requests.get("https://our-api.com/data", headers=headers)
统一协议的好处立马体现出来了——再也不用各自维护一套认证逻辑。
性能与生产环境考量
虽然只是基础认证,但在生产环境,还是得考虑性能:
- JWT 不要太大:payload 里只放必要字段(如 user_id, role),别塞用户全量信息。
- Redis 缓存黑名单:用户登出时,把 token 加入 Redis 黑名单,设置 TTL = 剩余有效期。这样即使 token 未过期,也能立即失效。
- 限流防爆破:登录接口加上 RateLimiter,比如 5 分钟最多 10 次失败,避免暴力破解。
我们用的是 Guava 的 RateLimiter + AOP 切面,几行代码搞定:
@Aspect
@Component
public class LoginRateLimitAspect {
private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();
@Around("@annotation(RateLimited)")
public Object limit(ProceedingJoinPoint joinPoint) throws Throwable {
String ip = ((HttpServletRequest) RequestContextHolder.currentRequestAttributes()
.resolveReference(RequestContextListener.REQUEST_ATTRIBUTES_ATTRIBUTE)).getRemoteAddr();
RateLimiter limiter = limiters.computeIfAbsent(ip, k -> RateLimiter.create(0.033)); // ~1次/30秒
if (!limiter.tryAcquire()) {
throw new TooManyRequestsException("请求太频繁");
}
return joinPoint.proceed();
}
}
上线后,安全扫描再也没报“暴力破解风险”。
最后:小镇做题家的感悟
搞完这套认证系统,我最大的感受不是技术多牛,而是——标准比炫技重要。
以前总想着自己造轮子,觉得“Spring Security 太重”、“配置太复杂”。但真遇到安全审计,才发现那些“复杂”的配置,每一条都是血泪教训换来的。
现在我们的项目,后端用 Spring Security,前端 Vue 统一鉴权,Python 服务也能无缝接入。上周双11压测,认证模块 QPS 稳稳 5000+,零故障。
昨天团建,产品经理敬我酒:“小张,现在登录终于不会莫名其妙退出了!”
我笑笑,心里想:那是因为我不再用 if (token != null) 这种脆弱判断了啊。
如果你也在县城远程、被 deadline 追着跑、或者正被安全漏洞折磨——别慌,Spring Security 没那么可怕。照着文档一步步来,该配的配,该关的关,该加密的加密。安全不是功能,是底线。
对了,下周我要开始研究 OAuth2.1 了,听说公司要接入微信登录……又得熬夜了。螺蛳粉库存告急,得去囤货了。
(完)

评论 0