Spring Security 基础:快速搭建安全认证系统 —— 一个被线上事故逼出来的后端笔记

敏锐之战士
2025-12-16 20:17
阅读 416

作者注:我是某金融科技公司干了快五年的后端老狗,主攻高并发支付系统,最近一边被产品经理追着砍需求,一边偷偷刷 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 分钟跑通最简认证

别被官网那堆 AuthenticationProviderUserDetailsService 吓到。其实核心就三步:

  1. 定义用户来源(数据库 or 内存)
  2. 配置 HTTP 安全规则
  3. 暴露登录/登出接口

第一步:加依赖(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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝