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

超凡的森林
2025-12-17 10:52
阅读 753

上周五晚上九点半,我盯着屏幕上一堆403 Forbidden的错误日志,内心一万只草泥马奔腾而过。又是产品临时加需求,说是下周就要给客户演示新功能,结果认证这块完全没做。领导还美其名曰:“你不是搞安全的吗?这种东西对你来说不是分分钟的事?”

呵,分分钟?我真想把键盘扔他脸上。

不过说真的,在我们这个云原生团队干了快两年,每天和各种漏洞、渗透测试、安全审计打交道,Spring Security 确实是我最常用的工具之一。尤其是现在微服务架构下,每个服务都要有独立的认证授权机制,Spring Security 配合 OAuth2 或 JWT 几乎成了标配。

今天就趁着周末,把最近踩过的坑整理一下,写个快速上手指南。顺便也给准备跳槽的兄弟们攒点简历素材——毕竟现在面试不问 Spring Security 都不好意思说自己招 Java 后端。


为啥又是 Spring Security?

先说清楚背景。我们组主要做的是一个面向金融客户的 SaaS 平台,技术栈是典型的 Spring Boot + Kubernetes。去年双11期间,因为一个低级的权限绕过漏洞,差点让客户数据泄露(别问,问就是血泪史)。从那以后,老板拍板:所有新项目必须通过安全评审,认证授权模块统一用 Spring Security。

说实话,一开始我对 Spring Security 是又爱又恨。配置复杂、文档晦涩、默认行为反人类……但用熟了之后发现,它真的稳。特别是和 Spring Boot 自动装配一结合,很多东西开箱即用。

至于为什么不用其他方案?比如 Shiro?坦白讲,Shiro 轻量是轻量,但在云原生环境下扩展性差太多。而且现在主流招聘 JD 里清一色写着“熟悉 Spring Security”,你懂的——为了简历好看,也得会

哦对了,最近还有人问我:“你们做金融的,要不要上区块链?”
我直接笑出声。兄弟,区块链解决的是信任问题,不是认证问题。你让用户登录用私钥签名?怕不是想被用户骂死。认证还是老老实实用 OAuth2 + JWT 吧。


快速搭建:从零到可上线

好了,废话不多说,直接上干货。假设你现在要搭一个新项目,需要支持:

  • 用户名/密码登录
  • 基于角色的权限控制(ROLE_ADMIN, ROLE_USER)
  • 接口级细粒度权限(比如 /api/admin/* 只能 admin 访问)
  • 支持 JSON 格式返回错误(而不是跳转 HTML 页面)

第一步:依赖引入

<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>

注意:不要同时引入 spring-boot-starter-oauth2-resource-server,除非你明确要用 OAuth2。新手很容易在这里搞混。

第二步:用户实体设计

数据库表很简单,就三张核心表:

表名 字段说明
users id, username (唯一), password (BCrypt加密), enabled
roles id, name (如 ROLE_ADMIN)
user_roles user_id, role_id (多对多关联)

这里有个坑:千万不要明文存密码!我们组去年就因为一个实习生把密码存成明文,被安全扫描工具打了个高危。后来强制要求所有密码必须用 BCryptPasswordEncoder 加密。

代码示例:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("User not found: " + username);
        }
        
        // 从 DB 拿到角色列表,转成 GrantedAuthority
        List<GrantedAuthority> authorities = user.getRoles().stream()
            .map(role -> new SimpleGrantedAuthority(role.getName()))
            .collect(Collectors.toList());

        return new org.springframework.security.core.userdetails.User(
            user.getUsername(),
            user.getPassword(),
            user.isEnabled(),
            true, true, true, // accountNonExpired, credentialsNonExpired, accountNonLocked
            authorities
        );
    }
}

第三步:核心配置 —— WebSecurityConfigurerAdapter(已过时?别慌)

我知道 Spring Security 5.7+ 废弃了 WebSecurityConfigurerAdapter,但在生产环境,很多人还在用。原因很简单:新方式(基于组件注册)对新手极不友好,而且文档混乱。

所以我们团队目前还是用旧方式,等 Spring Boot 3 全面普及再迁移。别杠,能跑就行。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable() // 前后端分离项目通常关掉 CSRF
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态
            .and()
            .authorizeRequests()
                .antMatchers("/api/auth/**").permitAll() // 登录接口放行
                .antMatchers("/api/admin/**").hasRole("ADMIN") // 注意:这里写 ADMIN,不是 ROLE_ADMIN
                .anyRequest().authenticated()
            .and()
            .exceptionHandling()
                .authenticationEntryPoint((request, response, authException) -> {
                    response.setStatus(HttpStatus.UNAUTHORIZED.value());
                    response.setContentType("application/json;charset=UTF-8");
                    response.getWriter().write("{\"error\":\"Unauthorized\"}");
                })
                .accessDeniedHandler((request, response, accessDeniedException) -> {
                    response.setStatus(HttpStatus.FORBIDDEN.value());
                    response.setContentType("application/json;charset=UTF-8");
                    response.getWriter().write("{\"error\":\"Forbidden\"}");
                });
    }

    // 暴露 AuthenticationManager Bean,用于登录时手动认证
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

重点解释几个地方:

  • hasRole("ADMIN") 会自动加上 ROLE_ 前缀,所以数据库里存 ROLE_ADMIN,这里写 ADMIN
  • 关闭 CSRF 是因为我们的前端是 Vue/React,用 Token 认证,CSRF 不适用
  • STATELESS 表示不创建 Session,适合 API 服务
  • 自定义 authenticationEntryPointaccessDeniedHandler 是为了让错误返回 JSON,而不是跳转登录页

第四步:登录接口实现

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        try {
            Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    request.getUsername(),
                    request.getPassword()
                )
            );

            // 生成 Token(这里简化为 UUID,实际建议用 JWT)
            String token = UUID.randomUUID().toString();
            // TODO: 把 token 存 Redis,关联用户信息,设置过期时间

            return ResponseEntity.ok(new LoginResponse(token));
        } catch (BadCredentialsException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body("Invalid username or password");
        }
    }
}

⚠️ 注意:上面的 Token 生成是示意。生产环境强烈建议用 JWT,并配合 Redis 做黑名单(用于登出)。JWT 的好处是无状态、可跨域、自带过期时间。


生产环境踩坑实录

坑1:路径匹配顺序问题

有一次线上事故,/api/user/profile 被拦截了,但明明配置了 .antMatchers("/api/user/**").hasRole("USER")。查了半天发现,Spring Security 的路径匹配是有顺序的!后面的规则不会覆盖前面的。

正确写法应该是从具体到宽泛

.authorizeRequests()
    .antMatchers("/api/admin/**").hasRole("ADMIN")
    .antMatchers("/api/user/**").hasRole("USER")
    .anyRequest().authenticated()

如果反过来,/api/user/** 先匹配了,那 /api/admin/** 就永远不会生效。

坑2:BCrypt 加密强度太高导致登录慢

我们有个老系统迁移,用户量大,BCrypt 默认强度是 10。结果压测时发现登录接口 P99 超过 800ms!后来调成 6 才缓解。虽然安全性略降,但在用户体验和安全之间得权衡。

BCrypt Strength 加密耗时(毫秒) 安全性
4 ~10ms
6 ~50ms
10 ~400ms
12 ~1600ms 极高

建议:新项目用 10,老系统或高并发场景可适当降低。

坑3:跨域(CORS)和安全配置冲突

前端同事经常抱怨:“本地调试 403,但 Postman 能通!”
原因:浏览器发 OPTIONS 预检请求,而 Spring Security 默认拦截了 OPTIONS。

解决方案:在 configure(HttpSecurity http) 里加一行:

.cors().and()

并在配置类里提供 CorsConfigurationSource:

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOriginPatterns(Arrays.asList("*"));
    configuration.setAllowedMethods(Arrays.asList("*"));
    configuration.setAllowedHeaders(Arrays.asList("*"));
    configuration.setAllowCredentials(true);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
}

性能与架构考量

在 K8s 环境下,我们通常把认证服务拆成独立的 auth-service,其他服务作为 Resource Server 验证 Token。这样做的好处:

  • 认证逻辑集中,便于审计和升级
  • 减少每个服务的依赖复杂度
  • 可以单独扩缩容认证服务

但如果你只是小项目,没必要搞这么重。直接在单体应用里集成 Spring Security 更高效。

另外,不要把权限判断放在 Controller 里!比如:

// ❌ 错误示范
@GetMapping("/delete")
public String delete() {
    if (!currentUser.hasRole("ADMIN")) {
        throw new AccessDeniedException();
    }
    // ...
}

应该用注解:

// ✅ 正确做法
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/delete")
public String delete() {
    // ...
}

记得在配置类开启方法级安全:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
}

这样权限逻辑和业务逻辑分离,也方便单元测试。


最后:安全不是功能,是习惯

写这篇文章的时候,我又想起去年那个漏洞。其实根本原因是开发图省事,直接在 Controller 里写 if (userId != currentUserId) return;,结果被人用 ID 遍历搞了。

Spring Security 再强大,也挡不住人写 bug。安全工程师的价值,不是写多少过滤器,而是推动团队建立安全开发流程

比如我们现在:

  • 所有 PR 必须过 SonarQube 扫描
  • 敏感操作必须二次验证(短信/邮箱)
  • 权限变更必须走审批流
  • 每月一次红蓝对抗演练

这些可能比你会不会配 Spring Security 更重要。


结语

Spring Security 上手确实有点门槛,但一旦掌握,你会发现它像一把瑞士军刀——小到登录认证,大到 OAuth2 集成、SAML 单点登录,都能搞定。

如果你正在准备跳槽,建议把这套流程跑通,然后写到简历的“项目经验”里。面试官一问“你怎么做权限控制的”,你就能侃半小时,稳了。

至于区块链?等哪天 Spring Security 官方出个 spring-security-blockchain-starter 再说吧 😂

最后送大家一句话:永远不要相信客户端传来的任何数据,包括用户 ID、角色、甚至 Token。后端必须二次校验。

好了,凌晨一点,咖啡喝完了,该去修下一个 CVE 了。下次见!


作者:某大厂安全工程师,日常和 CVE 斗智斗勇,业余时间研究开源项目源码。K8s 重度用户,坚信“一切皆可容器化”。

评论 0

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