一个iOS老兵跨界搞Spring Security的心路历程
说实话,写这篇博客的时候我心情挺复杂的。
做了6年iOS开发,从Objective-C时代一路摸爬滚打到Swift全面普及,见证了苹果生态里大大小小的技术变迁。在公司待了3年多,业务代码写得飞起,但总觉得自己的技术栈有点单一。去年下半年开始琢磨着换个环境,面试了几家才发现,现在稍微好点的公司都要求你懂点后端、懂点云原生,最好还能搞搞K8s。得,那就学呗。
刚好前阵子公司接了个内部小产品,需要搞一套安全认证系统。后端那帮哥们儿忙得脚朝天,我就自告奋勇揽了这活儿——反正我也想练练手,顺便给简历加点料。折腾了大概两周,总算把Spring Security的基础认证给跑通了,踩了不少坑,也攒了些心得,干脆写篇文章记录一下。
为啥要自己搞认证
先说下背景。我们那个产品是个面向B端客户的数据分析平台,类似于Dify那种AI应用开发平台的定位,但更偏向企业内部使用。产品经理(我又要吐槽了,我们那个PM真的是需求变更狂魔)上周突然说,客户对数据安全特别敏感,必须加上完善的权限认证体系,什么多租户隔离啊、RBAC权限模型啊、Token刷新机制啊,巴拉巴拉提了一堆。
后端leader老王听完直接摆手:"这活儿至少得两个人干两周。"我心想这不正好是个学习机会嘛,就举手说我来试试。老王看我的眼神就像看一个外星人——"你一个写iOS的,能搞定Spring Security?"嘿,这话我可不爱听了,虽然我是iOS出身,但好歹也玩过K8s,对云原生那套东西还算熟悉,后端也不是完全没碰过。
从零开始搭架子
说干就干。我先去翻了翻Spring Security的官方文档,好家伙,那文档写得跟天书似的,上来就是一堆Filter链的概念,看得我脑仁疼。后来换了个思路,直接找了几个开源项目参考,结合官方文档里的核心概念,才算理清楚了整体架构。
Spring Security的核心其实就是一堆过滤器(Filter)组成的责任链。请求进来之后,会依次经过这些Filter,每个Filter负责处理特定的安全逻辑。理解了这个,后面的配置就好办了。
先看看我的项目依赖,用的是Spring Boot 3.x版本:
<dependencies>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<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>
</dependencies>
这里有个小插曲。一开始我用的Spring Boot 2.7,配SecurityConfig的时候还是继承WebSecurityConfigurerAdapter,结果升级到3.x之后这个类直接被干掉了,API全变了。当时对着屏幕愣了十分钟,心想这Spring团队是真狠啊,说废弃就废弃。后来老老实实看了迁移指南,改用组件化的方式来配置。
SecurityConfig核心配置
这是整个认证系统的心脏,我改了好几版才稳定下来:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthFilter;
@Autowired
private AuthenticationProvider authenticationProvider;
@Autowired
private AuthenticationEntryPoint unauthorizedHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception ->
exception.authenticationEntryPoint(unauthorizedHandler));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
几个关键点说一下:
第一,CSRF我直接关了。为啥?因为我们做的是前后端分离的RESTful API,前端用JWT Token来认证,不存在CSRF攻击的场景。当然,如果是传统的Session+Cookie模式,CSRF防护是必须开的。
第二,Session策略设置成STATELESS。这个很重要,意味着服务端不保存任何Session信息,完全靠Token来维持认证状态。好处是天然支持水平扩展,坏处就是Token一旦签发,在过期之前没法主动让它失效(除非引入黑名单机制)。
第三,路径权限的配置。/api/v1/auth/**是登录注册相关的接口,必须放行;/api/v1/public/**是一些公开数据接口;其他所有接口都需要认证。这个配置我调了好几次,因为一开始忘了放行健康检查接口,导致K8s的liveness probe一直失败,Pod反复重启,运维小哥差点来找我拼命。
JWT Token的生成和校验
Token这块我用了jjwt这个库,算是Java生态里比较成熟的JWT实现了。先看看Token的生成逻辑:
@Component
public class JwtService {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration}")
private long expiration;
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("role", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private SecretKey getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
这里有个坑我必须提一下。一开始我把secretKey直接写在代码里,后来代码review的时候被老王一顿输出:"你是想让黑客直接拿你的key去伪造Token吗?"好吧,老老实实把密钥放到了配置中心,通过环境变量注入。
另外关于Token过期时间,我们产品讨论了很久。最终方案是Access Token 30分钟过期,Refresh Token 7天过期。用户无感刷新Token的逻辑放在了前端,后端只需要提供一个刷新接口就行。
认证过滤器的实现
JWT认证过滤器是插在UsernamePasswordAuthenticationFilter之前的,每次请求都会先经过它:
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String username;
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
try {
username = jwtService.extractUsername(jwt);
if (username != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails =
this.userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (Exception e) {
logger.error("JWT认证失败: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
}
这段代码看着简单,但我当时调试的时候真的想砸电脑。问题出在哪呢?Token校验一直失败,报Signature verification failed。我对着代码看了半天,Token生成和校验用的都是同一个secretKey啊,怎么就验签失败了呢?
后来排查了两个小时才发现,是因为配置中心里的secretKey在注入的时候被Base64编码了一次,而我在代码里又做了一次Base64解码。相当于编解码了两次,key对不上当然验签失败了。这种bug真的让人抓狂,因为不会有任何明确的错误提示,就是一个冷冰冰的验签失败。
数据库设计
说到数据库,简单提一下我们的用户表和权限表设计。因为产品要支持多租户,所以用户表里加了tenant_id字段:
CREATE TABLE `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL,
`password` VARCHAR(255) NOT NULL,
`email` VARCHAR(100) NOT NULL,
`tenant_id` BIGINT NOT NULL,
`status` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username_tenant` (`username`, `tenant_id`),
UNIQUE KEY `uk_email_tenant` (`email`, `tenant_id`),
KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `roles` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(50) NOT NULL,
`description` VARCHAR(200),
`tenant_id` BIGINT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name_tenant` (`name`, `tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `user_roles` (
`user_id` BIGINT NOT NULL,
`role_id` BIGINT NOT NULL,
PRIMARY KEY (`user_id`, `role_id`),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`),
FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `permissions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`description` VARCHAR(200),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `role_permissions` (
`role_id` BIGINT NOT NULL,
`permission_id` BIGINT NOT NULL,
PRIMARY KEY (`role_id`, `permission_id`),
FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`),
FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里有个设计上的取舍。一开始我想把permissions也做成租户级别的,每个租户可以自定义权限。但后来跟产品讨论后发现,大部分B端产品其实不需要这么灵活,权限模型基本是固定的,差异主要在角色上。所以最终permissions是全局的,roles是租户级别的。
另外密码存储用的是BCrypt,cost factor设成了12。这个值不是越大越好,需要在安全性和性能之间取个平衡。我在测试环境跑了一下,cost 12的情况下,加密一次大概需要200多毫秒,对于登录接口来说完全可以接受。
登录接口的实现
最后看看登录接口长什么样:
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService authService;
@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> login(
@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}
@PostMapping("/register")
public ResponseEntity<AuthenticationResponse> register(
@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.ok(authService.register(request));
}
@PostMapping("/refresh")
public ResponseEntity<AuthenticationResponse> refresh(
@Valid @RequestBody RefreshTokenRequest request) {
return ResponseEntity.ok(authService.refreshToken(request));
}
}
@Service
@RequiredArgsConstructor
public class AuthenticationService {
private final UserRepository userRepository;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final PasswordEncoder passwordEncoder;
public AuthenticationResponse login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword()));
var user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
var token = jwtService.generateToken(user);
var refreshToken = jwtService.generateRefreshToken(user);
return AuthenticationResponse.builder()
.accessToken(token)
.refreshToken(refreshToken)
.expiresIn(1800)
.build();
}
public AuthenticationResponse register(RegisterRequest request) {
var user = User.builder()
.username(request.getUsername())
.password(passwordEncoder.encode(request.getPassword()))
.email(request.getEmail())
.tenantId(request.getTenantId())
.build();
userRepository.save(user);
var token = jwtService.generateToken(user);
var refreshToken = jwtService.generateRefreshToken(user);
return AuthenticationResponse.builder()
.accessToken(token)
.refreshToken(refreshToken)
.expiresIn(1800)
.build();
}
}
上线后的一些思考
这套认证系统上线之后跑了大概一个月,整体还算稳定。但过程中也暴露了一些问题,我记录一下自己的开发心得:
性能方面,每次请求都要查数据库验证用户信息,在高并发场景下肯定扛不住。后来我加了Redis缓存,把用户信息和权限信息缓存起来,设置5分钟的过期时间。这样大部分请求就不需要查库了,QPS直接翻了好几倍。
安全方面,我们后来还加了登录失败次数限制。同一个IP连续失败5次就锁定15分钟,同一个账号连续失败10次就锁定30分钟。这个逻辑我写了一个自定义的Filter,放在JWT过滤器之前。
运维方面,因为用的是无状态认证,所以扩容缩容特别方便,直接改K8s的replicas就行。但有个问题就是Token黑名单,如果用户被禁用或者修改了密码,已经签发的Token在过期之前还是有效的。为了解决这个问题,我们引入了Redis来维护一个Token黑名单,用户状态变更的时候把对应的Token加进去。
说回来,作为一个写了6年iOS的老兵,这次跨界搞Spring Security确实让我学到了很多。以前总觉得后端就是写写CRUD,真正上手了才发现里面的门道一点都不比客户端少。而且现在回头看,客户端和后端的很多设计理念其实是相通的,比如依赖注入、面向接口编程、责任链模式等等,只是在不同技术栈里的实现方式不一样而已。
最近也在研究Dify的源码,发现他们做RAG(Retrieval-Augmented Generation)的时候,权限控制这块也是用的类似方案。不同的租户、不同的知识库,权限隔离做得很细。这给了我不少启发,等后面有空了,打算把我们产品的权限模型再优化一下,支持到知识库级别的细粒度控制。
好了,这篇文章就写到这里。如果你也是从客户端转后端的朋友,希望我的这些踩坑经验能帮到你少走点弯路。Spring Security这东西,入门确实有点劝退,但一旦理解了它的设计思路,后面配置起来其实还是挺顺手的。
不说了,PM又催需求了,我得去搬砖了。下次有机会再聊聊Spring Security的高级玩法,比如OAuth2、SAML这些,先让我缓缓。


评论 0