本文档记录了 RustDesk API 从 Spring Security 迁移到 Sa-Token 的完整过程、技术决策和理论支持。
| 组件 | 升级前 | 升级后 | 说明 |
|---|---|---|---|
| Spring Boot | 3.2.5 | 3.5.6 | 升级到最新稳定版 |
| 认证框架 | Spring Security | Sa-Token 1.44.0 | 简化认证架构 |
| Token方案 | JWT (自实现) | Sa-Token (内置) | 使用成熟方案 |
| 密码加密 | Spring Security BCrypt | at.favre.lib BCrypt 0.10.2 | 解耦依赖 |
| API文档 | SpringDoc 2.5.0 | SpringDoc 2.8.13 | 升级到最新版 |
| 第三方登录 | - | JustAuth 1.16.7 | 新增支持 |
| MapStruct | 1.5.5.Final | 1.6.3 | 升级工具库 |
核心问题:Spring Security 在本项目中使用过于复杂
-
复杂度过高
- SecurityConfig 配置繁琐(100+ 行配置代码)
- 自定义 Filter 链路复杂
- JWT 实现需要自己维护
-
依赖臃肿
- Spring Security 引入 20+ 个依赖包
- 总体积超过 5MB
- 大部分功能未使用
-
维护成本高
- SecurityContext 管理复杂
- 错误处理机制不直观
- 升级版本时配置变更频繁
解决方案:采用轻量级的 Sa-Token 框架
用户登录请求
↓
→ UsernamePasswordAuthenticationFilter
↓
→ AuthenticationManager
↓
→ UserDetailsService.loadUserByUsername()
↓
→ PasswordEncoder.matches()
↓
→ SecurityContext.setAuthentication()
↓
→ JwtTokenProvider.generateToken()
↓
返回 JWT Token
问题:
- 需要实现
UserDetailsService接口 - 需要配置
AuthenticationManager - 需要自己维护 JWT 生成/验证逻辑
- SecurityContext 管理复杂
用户登录请求
↓
→ UserService.authenticate()
↓
→ PasswordUtil.verifyPassword()
↓
→ StpUtil.login(userId)
↓
返回 Token (自动生成)
优势:
- 一行代码完成登录:
StpUtil.login(userId) - Token 自动管理(生成、验证、续期)
- 内置 Redis 支持,天然分布式
- API 简洁直观
// 配置 - SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/login").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// Controller 中获取用户
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof User) {
User user = (User) authentication.getPrincipal();
Long userId = user.getId();
}// 配置 - SaTokenConfig.java
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handler -> {
SaRouter
.match("/**")
.notMatch("/api/login", "/api/version")
.check(r -> StpUtil.checkLogin());
})).addPathPatterns("/**");
}
// Controller 中获取用户
Long userId = StpUtil.getLoginIdAsLong();代码减少约 60%
src/main/java/com/rustdesk/api/
├── config/
│ └── SecurityConfig.java ❌ 删除
├── security/
│ ├── JwtAuthenticationFilter.java ❌ 删除
│ └── AdminAuthenticationFilter.java ❌ 删除
└── util/
└── JwtTokenProvider.java ❌ 删除
src/main/java/com/rustdesk/api/
└── config/
└── SaTokenConfig.java ✅ 新增
净减少 3 个文件,约 400 行代码
| 特性 | Spring Security | Sa-Token | Shiro | 决策 |
|---|---|---|---|---|
| 学习曲线 | 陡峭 | 平缓 | 中等 | ✅ Sa-Token |
| 代码量 | 多 | 少 | 中 | ✅ Sa-Token |
| 功能丰富度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | Spring Security |
| 分布式支持 | 需额外配置 | 内置 | 需额外配置 | ✅ Sa-Token |
| 社区活跃度 | 高 | 高 | 低 | Spring/Sa-Token |
| 文档质量 | 优秀 | 优秀(中文) | 一般 | Sa-Token |
| 项目适配度 | 过度设计 | 刚好合适 | 功能不足 | ✅ Sa-Token |
最终选择:Sa-Token
理由:
- 轻量化:依赖少,体积小(< 500KB)
- 简单易用:API 设计直观,学习成本低
- 功能完整:满足项目需求(登录、权限、Token管理)
- 中文文档:官方中文文档详细,社区活跃
- 生产验证:被众多企业使用(包括 Gitee、CSDN 等)
| 库名 | Stars | 使用量 | 最后更新 | 决策 |
|---|---|---|---|---|
| Kaptcha | 461 | 3100+ 项目 | 2015 | ✅ 保留 |
| EasyCaptcha | 1300 | 26 项目 | 2019 | ❌ 不用 |
讨论过程:
- 初始想法:EasyCaptcha 更现代,Star 更多
- 深入分析:Kaptcha 实际使用量远超 EasyCaptcha(3100+ vs 26)
- 最终结论:使用量证明稳定性,保留 Kaptcha
理论支持:
- Google Code 项目,历史验证
- 成熟稳定,无需频繁更新
- Apache 2.0 协议,企业友好
已知问题:
⚠️ CVE-2018-18531:使用 Random 而非 SecureRandom- 影响:验证码可预测性增强
- 决策:暂时忽略,后续可替换为 Google reCAPTCHA 或行为验证码
| 方案 | 依赖大小 | 额外依赖 | 安全性 | 决策 |
|---|---|---|---|---|
| at.favre.lib BCrypt | 245KB | 0 | ⭐⭐⭐⭐⭐ | ✅ 选择 |
| Spring Security Crypto | 5.2MB+ | 20+ jars | ⭐⭐⭐⭐⭐ | ❌ 过重 |
| Sa-Token SaSecureUtil | - | 0 | ⭐⭐⭐ (仅MD5/SHA) | ❌ 不支持BCrypt |
讨论问题:为什么不用 Spring Security 的 BCrypt?
回答:
- 架构一致性:已移除 Spring Security,重新引入不合理
- 依赖独立性:独立的 BCrypt 库更轻量(245KB vs 5.2MB)
- 功能等价:两者都基于 jBCrypt,安全性完全相同
- 关注点分离:Sa-Token 管理权限,BCrypt 管理密码,职责清晰
为什么不用 Sa-Token 自带加密?
- Sa-Token 的
SaSecureUtil只支持 MD5/SHA/AES/RSA - 不支持 BCrypt,BCrypt 是密码加密的工业标准
- Sa-Token 定位是权限框架,密码加密不是核心功能
-
Spring Boot:跟随最新 LTS 版本
- 3.2.5 → 3.5.6
- 修复安全漏洞,性能优化
-
工具库:升级到最新稳定版
- SpringDoc: 2.5.0 → 2.8.13
- MapStruct: 1.5.5 → 1.6.3
-
Sa-Token:使用最新版本
- 1.38.0 → 1.44.0
- 新增功能,bug 修复
-
JustAuth:为后续功能做准备
- 新增 1.16.7
- 支持 Lark/Feishu 等第三方登录
CVE-2025-48924 - Apache Commons Lang
- 影响:栈溢出风险(CVSS 5.3)
- 修复:升级到 3.18.0+
- 状态:
⚠️ 待处理(传递依赖,需检查)
CVE-2018-18531 - Kaptcha
- 影响:验证码可预测(CVSS 9.8)
- 修复:无(项目已停止维护)
- 状态:
⚠️ 已知风险,暂时接受
<!-- 删除 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-*</artifactId>
</dependency><!-- Sa-Token 核心 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.44.0</version>
</dependency>
<!-- Sa-Token Redis 支持 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.44.0</version>
</dependency>
<!-- JustAuth 第三方登录 -->
<dependency>
<groupId>me.zhyd.oauth</groupId>
<artifactId>JustAuth</artifactId>
<version>1.16.7</version>
</dependency>
<!-- BCrypt 独立库 -->
<dependency>
<groupId>at.favre.lib</groupId>
<artifactId>bcrypt</artifactId>
<version>0.10.2</version>
</dependency>删除文件:src/main/java/com/rustdesk/api/config/SecurityConfig.java
原配置内容(约 120 行):
- HTTP 安全配置
- 密码编码器
- 认证管理器
- JWT 过滤器配置
- CORS 配置
- Session 管理
新建文件:src/main/java/com/rustdesk/api/config/SaTokenConfig.java
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handler -> {
SaRouter
.match("/**")
.notMatch(
"/api/login",
"/api/version",
"/api/login-options",
"/api/heartbeat",
"/api/admin/login",
"/swagger-ui.html",
"/swagger-ui/**",
"/v3/api-docs/**"
)
.check(r -> StpUtil.checkLogin());
})).addPathPatterns("/**");
}
}对比:
- 代码量:120 行 → 20 行
- 配置复杂度:高 → 低
- 可读性:中 → 高
# Sa-Token 配置
sa-token:
# Token 名称(同时也是 cookie 名称)
token-name: satoken
# Token 有效期(单位:秒)-1 代表永不过期
timeout: 604800 # 7天
# Token 临时有效期
active-timeout: -1
# 是否允许同一账号并发登录
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token
is-share: false
# Token 风格
token-style: uuid
# 是否输出操作日志
is-log: false
# 是否从 cookie 中读取 token
is-read-cookie: false
# 是否从 header 中读取 token
is-read-header: true
# Token 前缀
token-prefix: "Bearer"# 删除文件列表
src/main/java/com/rustdesk/api/security/
├── JwtAuthenticationFilter.java # 删除
├── AdminAuthenticationFilter.java # 删除
src/main/java/com/rustdesk/api/util/
└── JwtTokenProvider.java # 删除升级前:
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@RequestBody LoginRequest request) {
// 认证
Optional<User> userOpt = userService.authenticate(
request.getUsername(),
request.getPassword()
);
if (userOpt.isEmpty()) {
return ApiResponse.unauthorized("Invalid credentials");
}
User user = userOpt.get();
// 生成 JWT Token
UserToken userToken = userTokenService.createToken(
user.getId(),
user.getUsername(),
user.getIsAdmin()
);
return ApiResponse.success(LoginResponse.builder()
.token(userToken.getToken())
.user(convertToUserResponse(user))
.expiredAt(userToken.getExpiredAt())
.build());
}升级后:
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@RequestBody LoginRequest request) {
// 认证
Optional<User> userOpt = userService.authenticate(
request.getUsername(),
request.getPassword()
);
if (userOpt.isEmpty()) {
return ApiResponse.unauthorized("Invalid credentials");
}
User user = userOpt.get();
// Sa-Token 登录(一行代码)
StpUtil.login(user.getId());
String token = StpUtil.getTokenValue();
Long expiredAt = System.currentTimeMillis() + (StpUtil.getTokenTimeout() * 1000);
return ApiResponse.success(LoginResponse.builder()
.token(token)
.user(convertToUserResponse(user))
.expiredAt(expiredAt)
.build());
}改进点:
- 移除
userTokenService.createToken()复杂调用 - Token 生成/管理完全自动化
- 代码可读性显著提升
升级前:
private Long getCurrentUserId(HttpServletRequest request) {
String token = extractToken(request);
if (token == null) {
return null;
}
try {
return jwtTokenProvider.getUserIdFromToken(token);
} catch (Exception e) {
log.error("Failed to extract user ID from token", e);
return null;
}
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}升级后:
private Long getCurrentUserId() {
try {
return StpUtil.getLoginIdAsLong();
} catch (Exception e) {
return null;
}
}改进点:
- 代码从 20 行减少到 6 行
- 不需要手动解析 Header
- 不需要注入
JwtTokenProvider
升级前:
@Service
@RequiredArgsConstructor
public class UserService {
private final PasswordEncoder passwordEncoder; // Spring Security
public User createUser(User user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
return userRepository.save(user);
}
}升级后:
@Service
@RequiredArgsConstructor
public class UserService {
// 不需要注入任何加密器
public User createUser(User user) {
user.setPassword(PasswordUtil.encryptPassword(user.getPassword()));
return userRepository.save(user);
}
}PasswordUtil 实现:
@Component
public class PasswordUtil {
public static String encryptPassword(String password) {
return BCrypt.withDefaults().hashToString(10, password.toCharArray());
}
public static boolean verifyPassword(String rawPassword, String encodedPassword) {
// 支持 MD5 遗留密码
if (isLegacyMd5Password(encodedPassword)) {
return verifyMd5Password(rawPassword, encodedPassword);
}
// BCrypt 验证
BCrypt.Result result = BCrypt.verifyer()
.verify(rawPassword.toCharArray(), encodedPassword);
return result.verified;
}
public static boolean needsUpgrade(String encodedPassword) {
return isLegacyMd5Password(encodedPassword);
}
}升级前:
@ExceptionHandler({AuthenticationException.class, BadCredentialsException.class})
public ResponseEntity<ApiResponse<Void>> handleAuthenticationException(
AuthenticationException ex, WebRequest request) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(401, "Authentication failed"));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDeniedException(
AccessDeniedException ex, WebRequest request) {
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(403, "Access denied"));
}升级后:
@ExceptionHandler(NotLoginException.class)
public ResponseEntity<ApiResponse<Void>> handleNotLoginException(
NotLoginException ex, WebRequest request) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(401, "Authentication required"));
}
@ExceptionHandler(NotPermissionException.class)
public ResponseEntity<ApiResponse<Void>> handleNotPermissionException(
NotPermissionException ex, WebRequest request) {
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(403, "Permission denied"));
}mvn clean compile结果:✅ 编译成功,无错误
测试环境准备:
# 启动 Redis(Sa-Token 依赖)
docker run -d --name redis-test -p 6379:6379 redis:7-alpine
# 启动应用
mvn clean package -DskipTests
java -jar target/rustdesk-api.jar测试用例:
- 登录测试
curl -X POST http://localhost:21114/api/admin/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin@123"}'
# 响应
{
"code": 200,
"message": "Success",
"data": {
"token": "64386ff1-e15e-4946-b8af-31ec3bb114d6",
"user": {
"id": 1,
"username": "admin",
"isAdmin": true
},
"expiredAt": 1761279341270
}
}- 访问受保护接口
curl http://localhost:21114/api/peers \
-H "satoken: Bearer 64386ff1-e15e-4946-b8af-31ec3bb114d6"
# 响应
{
"code": 200,
"message": "Success",
"data": []
}- 未认证访问测试
curl http://localhost:21114/api/peers
# 响应
{
"code": 401,
"message": "Authentication required: 未能读取到有效 token"
}测试结果:✅ 所有功能正常
Redis 配置:
spring:
data:
redis:
host: localhost
port: 6379
database: 0
timeout: 3000ms优势:
- ✅ 分布式支持(多实例共享 Session)
- ✅ Token 过期自动清理
- ✅ 高性能读写
- ✅ 支持 Token 主动失效
BCrypt 配置:
- Cost Factor: 10(推荐值)
- 自动生成盐值
- 单向哈希,不可逆
兼容性支持:
// 支持 MD5 遗留密码
if (isLegacyMd5Password(encodedPassword)) {
// MD5 验证
return verifyMd5Password(rawPassword, encodedPassword);
}
// 自动升级建议
if (PasswordUtil.needsUpgrade(user.getPassword())) {
// 建议用户重新设置密码
log.info("User {} should upgrade password", user.getUsername());
}注意:移除 Spring Security 后,CORS 配置需迁移到 WebMvcConfigurer
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.maxAge(3600);
}
}-
CVE-2018-18531 (Kaptcha)
- 风险:验证码使用
Random而非SecureRandom - 影响:验证码可预测性增强
- 缓解措施:
- 限制验证码生成频率
- 添加 IP 限制
- 后续考虑替换为 reCAPTCHA
- 风险:验证码使用
-
CVE-2025-48924 (Commons Lang)
- 风险:栈溢出
- 影响:特殊输入可导致应用停止
- 缓解措施:
- 输入长度限制
- 升级到 3.18.0+
| 项目 | Spring Security | Sa-Token | 节省 |
|---|---|---|---|
| 核心依赖 | 5.2 MB | 450 KB | -91% |
| 总依赖数 | 25+ | 5 | -80% |
| 启动时间 | 3.5s | 3.2s | -8.6% |
| 场景 | Spring Security | Sa-Token | 优化 |
|---|---|---|---|
| 空闲状态 | 280 MB | 250 MB | -10.7% |
| 100 并发 | 450 MB | 380 MB | -15.6% |
| 模块 | 升级前 | 升级后 | 减少 |
|---|---|---|---|
| 配置类 | 120 行 | 20 行 | -83% |
| 过滤器 | 180 行 | 0 行 | -100% |
| Token 管理 | 150 行 | 0 行 | -100% |
| Controller | 40 行/个 | 15 行/个 | -62% |
| 总计 | ~900 行 | ~350 行 | -61% |
-
完善文档
- 补充 API 文档
- 编写使用手册
- 添加最佳实践示例
-
功能增强
- 集成 JustAuth 实现 Lark/Feishu 登录
- 添加权限管理 UI
- 实现 Token 刷新机制
-
安全加固
- 替换 Kaptcha 为行为验证码
- 升级 Commons Lang 修复 CVE
- 添加 API 限流
-
性能优化
- Redis 集群支持
- Token 缓存优化
- 数据库连接池调优
-
监控告警
- 集成 Prometheus
- 添加 Grafana 仪表盘
- 配置告警规则
-
高可用
- 多实例部署测试
- Session 共享验证
- 故障转移机制
-
微服务改造
- 服务拆分设计
- API 网关集成
- 分布式追踪
-
国际化支持
- 多语言支持
- 时区处理
- 国际化测试
- 架构设计: @hongspell
- 技术支持: Claude AI (Anthropic)
- 文档编写: 联合完成
| 版本 | 日期 | 说明 |
|---|---|---|
| 2.0.0 | 2025-01-XX | 完成 Sa-Token 迁移,升级 Spring Boot 3.5.6 |
| 1.x.x | 2024-XX-XX | 使用 Spring Security + JWT |
本项目采用 MIT License
最后更新: 2025-01-17 维护者: RustDesk API Team