Spring Security 基础:快速搭建安全认证系统 —— 一个被线上事故逼出来的后端笔记
作者注:我是某金融科技公司干了快五年的后端老狗,主攻高并发支付系统,最近一边被产品经理追着砍需求,一边偷偷刷 LeetCode 准备跳槽。上个月刚啃完《Spring Security 实战》,又顺手扒了几天 OAuth2 的源码——别问,问就是“AI 安全”成了新 buzzword,老板说“我们要拥抱”。这篇不是教科书式教程,而是我上周五加班到凌晨三点、差点把 MacBook 掼进咖啡杯后,总结出的一套能跑通、能上线、还能睡安稳觉的实战方案。
被“运营”逼出来的认证系统
事情得从两周前说起。
那天下午四点,离下班还有俩小时,我们“敏捷冲刺”群里突然弹出一条消息:
运营小王:各位大佬!咱们那个内部风控数据看板,能不能加个登录?现在谁都能访问,合规那边发飙了……
我一口冰美式差点喷在键盘上。这玩意儿三个月前上线的时候,产品经理拍胸脯说“内部用,不用鉴权”,结果现在被合规一纸通知打脸。更要命的是,明天就要给审计团队演示。
作为组里唯一写过后台权限模块的人(其实是三年前搭了个简陋的 JWT),锅自然落我头上。
“行吧,” 我心里骂了句脏话,“今晚搞个 Spring Security 快速认证,明早八点前上线。”
于是,就有了这篇带着咖啡渍和怨气的技术复盘。
为什么是 Spring Security?
我知道很多人看到 Spring Security 就头大——配置复杂、概念抽象、文档像天书。但作为金融行业的后端,安全性不是可选项,是生死线。
对比几个常见方案:
| 方案 | 上手难度 | 扩展性 | 合规支持 | 我司现状 |
|---|---|---|---|---|
| 自己写 Token 校验 | ⭐ | ❌(容易漏边界) | 弱 | 曾因 token 未设 expire 导致数据泄露 |
| Shiro | ⭐⭐ | ✅ | 中 | 老系统用,但社区活跃度低 |
| Spring Security | ⭐⭐⭐⭐ | ✅✅✅ | 强(内置 CSPRNG、防 CSRF 等) | 新项目强制使用 |
我们公司去年双11就吃过亏:有个实习生自己实现了个 session 管理,没做防重放攻击,结果被黑产刷了测试环境。从此技术委员会立下铁律——所有对外接口,必须用 Spring Security 或等效框架。
所以,尽管它有点“重”,但在金融场景,稳比快重要。
快速搭建:5 分钟跑通最简认证
别被官网那堆 AuthenticationProvider、UserDetailsService 吓到。其实核心就三步:
- 定义用户来源(数据库 or 内存)
- 配置 HTTP 安全规则
- 暴露登录/登出接口
第一步:加依赖(Maven)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
💡 注意:如果你用 WebFlux,要换
spring-boot-starter-webflux+spring-security-config,别问我怎么知道的——上周五我就在这栽了一跤,死活 403,最后发现是 reactive 和 servlet 混用了。
第二步:最简内存用户(仅用于 demo!)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.builder()
.username("ops_admin")
.password("{noop}123456") // {noop} 表示不加密,生产绝对不能这么干!
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/dashboard/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login") // 自定义登录页
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
}
这时候启动应用,访问 /dashboard 会自动跳转到 /login,输入 ops_admin / 123456 就能进。
但!这玩意儿绝对不能上生产。原因有三:
- 密码明文(哪怕加
{noop}也是自欺欺人) - 用户硬编码,无法动态管理
- 没有防暴力破解、会话固定等防护
从玩具到生产:数据库 + BCrypt + 防御加固
作为金融后端,我深知“快速上线”和“安全上线”之间只差一个凌晨三点的报警电话。
数据库设计:别偷懒!
我们用一张 sys_user 表:
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(100) NOT NULL, -- 存 BCrypt 结果
enabled TINYINT DEFAULT 1, -- 账号是否启用
account_non_expired TINYINT DEFAULT 1,
credentials_non_expired TINYINT DEFAULT 1,
account_non_locked TINYINT DEFAULT 1,
roles VARCHAR(100) -- 如 "ADMIN,OPERATOR"
);
🤯 这些字段名是不是眼熟?对,就是照着
UserDetails接口来的!Spring Security 早就替你想好了账户状态模型,直接映射就行,别自己发明轮子。
自定义 UserDetailsService
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser user = userRepository.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("User not found");
}
// 角色字符串转成 GrantedAuthority 列表
List<GrantedAuthority> authorities = Arrays.stream(user.getRoles().split(","))
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.trim()))
.collect(Collectors.toList());
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPasswordHash())
.authorities(authorities)
.accountExpired(!user.isAccountNonExpired())
.accountLocked(!user.isAccountNonLocked())
.credentialsExpired(!user.isCredentialsNonExpired())
.disabled(!user.isEnabled())
.build();
}
}
密码加密:BCrypt 是底线
注册或修改密码时,一定要加密:
@Service
public class UserService {
@Autowired
private PasswordEncoder passwordEncoder; // Spring Security 自动注入 BCryptEncoder
public void registerUser(String username, String rawPassword) {
String encoded = passwordEncoder.encode(rawPassword);
// 存入数据库
}
}
🔒 血泪教训:去年有个兄弟图省事,用 MD5+盐值,结果被安全团队扫出来,全组扣了季度奖。BCrypt 自带 salt + 自适应计算强度,是当前金融行业默认标准。
前后端联调:别让前端背锅
我们的看板是 Vue 写的,运营天天催“登录页要好看”。但 Spring Security 默认是 form 提交,返回 HTML,而现代前端基本都是 API + JSON。
于是,需要改成 JSON 登录 + JWT(可选) 或 Session + Cookie。考虑到内部系统并发不高,我选了后者(省事且天然防 CSRF)。
自定义登录成功/失败处理器
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
response.setStatus(HttpStatus.OK.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":200,\"msg\":\"登录成功\"}");
}
}
// 失败处理器类似,返回 {"code":401, "msg":"用户名或密码错误"}
然后在 SecurityConfig 里挂上:
.formLogin(form -> form
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler)
)
前端 JavaScript 调用就简单了:
// login.js
fetch('/login', {
method: 'POST',
credentials: 'include', // 关键!让浏览器自动带 JSESSIONID cookie
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `username=${username}&password=${password}`
})
.then(res => res.json())
.then(data => {
if (data.code === 200) {
window.location.href = '/dashboard';
} else {
alert('登录失败:' + data.msg);
}
});
💡 注意:
credentials: 'include'是跨域场景才需要,我们前后端同域,其实可以省略。但为了未来扩展性,建议加上。
生产级加固:这些坑我都踩过
1. CSRF 防护(默认开启,别关!)
Spring Security 默认开启 CSRF,要求 POST/PUT/DELETE 携带 _csrf token。前端需要从 /csrf 接口获取:
// 先获取 token
fetch('/csrf', { credentials: 'include' })
.then(r => r.json())
.then(csrf => {
// 然后在 header 里带上
fetch('/api/update', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf.token, ... }
})
});
🙅♂️ 别听某些人说“内部系统不用 CSRF”——去年隔壁组就被钓鱼邮件搞过,员工点了链接,自动提交了转账请求。
2. Session 固定攻击防护
Spring Security 默认开启 SessionFixationProtectionStrategy,登录成功后自动换 session ID。但如果你用了 Redis 共享 session,记得检查 spring-session-data-redis 是否兼容。
3. 登录失败锁定(防暴力破解)
虽然 Spring Security 没直接提供,但你可以结合 DaoAuthenticationProvider + 缓存实现:
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken token) {
String username = token.getName();
if (isAccountLocked(username)) {
throw new LockedException("账号已锁定,请30分钟后重试");
}
super.additionalAuthenticationChecks(userDetails, token);
}
用 Redis 记录失败次数,5 次失败锁 30 分钟——合规最爱这种“看起来很安全”的功能。
性能与监控:别等线上炸了才看
金融系统最怕慢 + 不透明。
- 性能:UserDetailsService 的查询必须走索引!我们曾因
username无索引,登录接口 P99 达 2s,被 SRE 骂到自闭。 - 日志:记录登录成功/失败日志,格式统一,方便 ELK 分析:
[SECURITY] LOGIN_SUCCESS user=ops_admin ip=10.0.0.5 [SECURITY] LOGIN_FAILURE user=hacker ip=47.88.xx.xx reason=BadCredentials - 指标:用 Micrometer 暴露
security.authentications计数器,接 Prometheus + Grafana,一眼看出异常登录潮。
总结:安全不是功能,是态度
折腾完这套,第二天审计顺利通过。运营小王还给我点了奶茶(虽然是最便宜的珍珠奶茶)。
回过头看,Spring Security 确实学习曲线陡峭,但一旦掌握,它就像个沉默的保镖——平时不显山露水,关键时刻挡子弹。
作为准备跳槽的老后端,我越来越觉得:在 AI 炒得火热的今天,扎实的安全功底反而成了稀缺能力。面试官一听你做过金融级认证,眼睛都亮了。
所以,别再把 Security 当负担。把它当成你的“技术护城河”。
最后送大家一句我们 CTO 的口头禅:
“代码可以烂,安全不能烂。烂代码最多让你加班,烂安全能让你坐牢。”
共勉。
附:避坑清单(血泪版)
- ❌ 别用
{noop}上生产 - ❌ 别关 CSRF(除非你 100% 确定不需要)
- ❌ 别在
UserDetails里存敏感信息(如身份证号) - ✅ 密码加密用
BCryptPasswordEncoder - ✅ 登录失败要模糊提示(别区分“用户不存在”和“密码错”)
- ✅ Session 超时时间设合理值(我们设 30 分钟)
本文代码已脱敏,实际项目更复杂(比如集成 LDAP、多因子认证)。但万变不离其宗——理解原理,敬畏安全,少背锅。
(写完这篇,LeetCode 还没刷……算了,先躺平五分钟)

评论 0