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

VSCode信徒
2025-12-17 18:39
阅读 1595

上周五凌晨 2 点,我盯着屏幕上一行报错 AccessDeniedException: Access is denied,手边的瑞幸已经凉透。这是我在深圳腾讯系某厂做后端开发的第 18 个月,也是我第一次在生产环境里被权限问题“背刺”。

说来惭愧,作为一个写过分布式锁、调优过 Kafka 消费组的人,我之前一直对 Spring Security 敬而远之。总觉得这玩意儿又重又复杂,配置文件长得像 XML 地狱,而且——我讨厌魔法。直到上个月,产品甩过来一个需求:“加个登录功能,支持手机号+验证码,还要能对接微信和企业微信”,我才意识到:躲不掉了。

更讽刺的是,就在前一周,我还在帮实习生刷 面试题 的时候信誓旦旦地说:“现在谁还用 Spring Security 啊?OAuth2 都直接上 Auth0 或者自研网关鉴权了。” 结果转头自己就得真香现场搭一套。


为什么是 Spring Security?

其实我们团队内部早就有一套基于 JWT 的认证中间件,但那是三年前的老古董了,代码里还留着“Spring Boot 1.x”的注释。这次的需求不仅要支持多端(Web + App + 小程序),还得满足 运营 后台的 RBAC(角色-权限)模型,甚至要预留 SSO 接口。重新造轮子?时间不允许——双 11 大促排期就卡在两周后。

技术选型会上,隔壁组的老张(人称“Spring 老炮”)一句话点醒我:“你不是嫌它重吗?现在 Spring Security 5.x + Spring Boot 3 已经轻量化到可以用 Java Config 全程无 XML 了,连 OAuth2.1 都原生支持。”

行吧,那就试试。反正通宵也习惯了,深圳湾的夜景配上 IDEA 的深色主题,也算浪漫(自嘲脸)。


动手!从零搭建认证骨架

第一步:别被 starter 吓到

很多人一看到 spring-boot-starter-security 就退缩,以为会自动开启一堆拦截器。其实默认行为非常克制——只保护 //error 路径,其他接口全开放。你可以先加依赖跑起来看看:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

启动后访问任意接口,你会发现返回 401,但控制台会打印一行临时密码,比如:

Using generated security password: 7a8b9c0d-1e2f-3g4h-5i6j-7k8l9m0n1o2p

这其实是 UserDetailsServiceAutoConfiguration 在作祟。千万别在生产环境留这个!我们马上干掉它。

第二步:自定义 UserDetailsService

我们需要从数据库查用户,而不是用内存账号。假设你有一个 user 表,结构如下:

字段 类型 说明
id BIGINT 主键
phone VARCHAR(11) 手机号(唯一)
password VARCHAR(100) BCrypt 加密后的密码
enabled TINYINT 是否启用(1=是)

然后实现 UserDetailsService

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String phone) throws UsernameNotFoundException {
        User user = userRepository.findByPhone(phone)
            .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
        
        return org.springframework.security.core.userdetails.User
            .builder()
            .username(user.getPhone())
            .password(user.getPassword()) // 必须是 BCrypt 格式!
            .authorities("ROLE_USER") // 后面会动态加载
            .accountExpired(false)
            .accountLocked(false)
            .credentialsExpired(false)
            .disabled(!user.getEnabled())
            .build();
    }
}

💡 开发心得:密码一定要用 BCryptEncoder 加密!别学我早期项目用 MD5,被安全审计喷到自闭。

第三步:配置 SecurityFilterChain(重点!)

这是 Spring Security 5.7+ 的新范式,用 Java Config 替代 WebSecurityConfigurerAdapter。新建一个配置类:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable() // 前后端分离可关
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll() // 登录注册放行
                .requestMatchers("/actuator/**").hasRole("ADMIN") // 运维监控需管理员
                .anyRequest().authenticated()
            )
            .httpBasic().disable()
            .formLogin().disable();

        // 添加 JWT 过滤器(后面讲)
        http.addFilterBefore(jwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }

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

注意这里用了 requestMatchers 而不是老版的 antMatchers,语法更函数式。另外,关闭 CSRF 是常见操作,但请确保你的前端有防重放机制


实现手机号+验证码登录

Spring Security 默认只支持用户名+密码。要扩展登录方式,得自定义 AuthenticationProviderAuthenticationToken

自定义 Token

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
    private final Object principal; // 手机号
    private Object credentials; // 验证码

    public SmsCodeAuthenticationToken(String phone, String code) {
        super(null);
        this.principal = phone;
        this.credentials = code;
        setAuthenticated(false);
    }

    // ... getter/setter 略
}

自定义 Provider

@Component
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) {
        SmsCodeAuthenticationToken token = (SmsCodeAuthenticationToken) authentication;
        String phone = (String) token.getPrincipal();
        String inputCode = (String) token.getCredentials();

        // 1. 从 Redis 校验验证码
        String realCode = redisTemplate.opsForValue().get("sms:code:" + phone);
        if (!inputCode.equals(realCode)) {
            throw new BadCredentialsException("验证码错误");
        }

        // 2. 加载用户
        UserDetails userDetails = userDetailsService.loadUserByUsername(phone);

        // 3. 构造已认证 Token
        return new SmsCodeAuthenticationToken(
            userDetails, 
            userDetails.getAuthorities()
        );
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

在 Controller 中触发

@PostMapping("/login/sms")
public ResponseEntity<?> loginBySms(@RequestBody SmsLoginRequest request) {
    SmsCodeAuthenticationToken token = 
        new SmsCodeAuthenticationToken(request.getPhone(), request.getCode());
    
    Authentication auth = authenticationManager.authenticate(token);
    SecurityContextHolder.getContext().setAuthentication(auth);

    // 生成 JWT 返回
    String jwt = jwtUtil.generateToken(auth);
    return ResponseEntity.ok(new LoginResponse(jwt));
}

🚨 踩坑提醒:别忘了在 SecurityConfig 里注册这个 Provider!否则会报 ProviderNotFoundException。我当时卡了两小时,最后发现是漏了 @Component


权限控制:从 RBAC 到运营后台

产品要求运营后台能动态分配菜单权限。我们设计了经典的四表模型:

  • user:用户表
  • role:角色表(如 OPERATOR, ADMIN)
  • permission:权限表(如 user:read, order:write)
  • role_permission:角色-权限关联表

关键在于 GrantedAuthority 的动态加载。修改 CustomUserDetailsService

@Override
public UserDetails loadUserByUsername(String phone) {
    User user = userRepository.findByPhone(phone).orElseThrow(...);
    
    List<String> permissions = permissionRepository.findPermissionsByUserId(user.getId());
    List<GrantedAuthority> authorities = permissions.stream()
        .map(SimpleGrantedAuthority::new)
        .collect(Collectors.toList());

    return User.builder()
        .username(phone)
        .password(user.getPassword())
        .authorities(authorities)
        .build();
}

然后在接口上用注解控制:

@GetMapping("/orders")
@PreAuthorize("hasAuthority('order:read')")
public List<Order> listOrders() {
    return orderService.findAll();
}

💡 开发心得:@PreAuthorize 要配合 @EnableMethodSecurity 使用!别再用过时的 @EnableGlobalMethodSecurity 了。


性能与运维考量

数据库压力

每次请求都查权限?那肯定不行。我们在用户登录时就把权限列表缓存到 JWT 的 payload 里(注意大小别超 4KB)。验证时直接解析 JWT,避免 DB 查询。

日志与监控

在 Filter 里加日志:

@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(...) {
        try {
            // 解析 JWT...
            log.info("Auth success: user={}, uri={}", userId, request.getRequestURI());
        } catch (Exception e) {
            log.warn("Auth failed: uri={}, reason={}", request.getRequestURI(), e.getMessage());
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
        }
    }
}

运维同学特别喜欢这种日志,排查问题快多了。

与 Python 服务的协作

我们有个用 Python 写的风控服务,需要验证用户身份。解决方案很简单:把 JWT 透传过去,Python 用 pyjwt 库校验签名即可。两边共享同一个密钥(通过 KMS 管理),完美解耦。


从抵触到真香:我的转变

说实话,Spring Security 的学习曲线确实陡峭。光是 AuthenticationSecurityContextPrincipal 这些概念就让我翻了半天源码。但一旦理清主线——认证(Authentication)和授权(Authorization)分离——就豁然开朗。

现在回头看,它提供的不只是安全框架,更是一套安全思维模型。比如:

  • 密码加密必须用自适应算法(BCrypt/SCrypt)
  • 会话管理要考虑无状态(JWT) vs 有状态(Session)
  • 权限粒度要下沉到方法级别而非 URL

这些经验,远比应付 面试题 里的“说说 Spring Security 原理”有价值得多。


最后几句大实话

  • 别试图一次性配完美:先跑通最小闭环(比如只做登录),再逐步加 RBAC、OAuth2。
  • 测试用 Postman 别用浏览器:浏览器会自动带 Cookie,干扰 JWT 测试。
  • 线上一定关掉 Security 的 debug 日志:曾经因为 logging.level.org.springframework.security=DEBUG 泄露了用户密码哈希,被安全团队追杀三条街。

在深圳这座加班之城,能少踩一个坑,就能多睡一小时。希望这篇带血泪经验的文章,能帮你避开我走过的弯路。

毕竟,程序员的时间,不该浪费在重复造安全轮子上——除非你是在准备跳槽面试(狗头保命)。

本文完。凌晨三点,我去续杯咖啡了。

评论 0

最热最新
暂无评论
VSCode信徒Lv.1
0
影响力
0
文章
0
粉丝