Spring Security基础:快速搭建安全认证系统

程序员阿远
2025-12-17 02:52
阅读 1048

上周五晚上十点半,我坐在老家县城出租屋的书桌前,盯着屏幕上疯狂报错的日志,一边啃着冷掉的螺蛳粉——没错,我又在深圳远程办公了。自从去年跳槽到这家腾讯系背景的中型公司,我就回了老家,省下房租不说,还能顺便陪陪爸妈。但代价是,每次遇到线上事故,只能靠自己硬刚。

那天的问题,说来有点尴尬:我们一个内部管理后台,本来用的是最原始的 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)

统一协议的好处立马体现出来了——再也不用各自维护一套认证逻辑。


性能与生产环境考量

虽然只是基础认证,但在生产环境,还是得考虑性能:

  1. JWT 不要太大:payload 里只放必要字段(如 user_id, role),别塞用户全量信息。
  2. Redis 缓存黑名单:用户登出时,把 token 加入 Redis 黑名单,设置 TTL = 剩余有效期。这样即使 token 未过期,也能立即失效。
  3. 限流防爆破:登录接口加上 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

最热最新
暂无评论
程序员阿远Lv.1
0
影响力
0
文章
0
粉丝