Spring Boot Sa-Token Integration: High-Availability Auth with Dynamic Permissions & Replay Protection
This article details integrating Sa-Token 1.37.0 with Spring Boot 2.7.x and Redis 6.x for authentication, covering dependency setup, configuration, login implementation, dynamic permission via StpInterface, API replay protection using nonce/timestamp/signature, cluster deployment, comparison with Spring Security+JWT, and production pitfalls.
Technology Selection
The author chose Sa-Token over Spring Security for a two-month internal ops management project. Spring Security's complex filter chain configuration was deemed excessive for a business-focused team. JWT was considered but lacks server-side token revocation (e.g., kick user offline, fine-grained expiration). Sa-Token offers lightweight setup, built-in session management, account banning, and deep Redis integration for cluster session sharing.
Integration Steps
1. Dependencies
Add core starter:
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.37.0</version>
</dependency>For Redis support, add:
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.37.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>Version consistency is critical to avoid serialization issues. Jackson is preferred over JDK serialization for readable Redis data.
2. Configuration (application.yml)
server:
port: 8080
spring:
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
sa-token:
token-name: satoken
timeout: 2592000
active-timeout: -1
is-concurrent: true
is-share: false
is-read-header: true
is-read-cookie: true
is-read-body: false
token-style: uuid
is-log: trueKey pitfalls: is-concurrent and is-share must align (if is-concurrent=false, is-share must be true). active-timeout sets idle expiration; use cautiously for high-frequency systems.
3. Filter and Interceptor Setup
Two components: SaServletFilter (global filter for CORS, pre-auth logic) and SaInterceptor (Spring MVC interceptor for route interception and annotation-based auth). Both are typically used together.
Global filter example with CORS handling and exception mapping:
@Configuration
public class SaTokenConfigure {
@Bean
public SaServletFilter saServletFilter() {
return new SaServletFilter()
.addInclude("/**")
.addExclude("/user/login", "/user/register", "/error")
.setAuth(obj -> { /* global logic */ })
.setError(e -> {
if (e instanceof NotLoginException) {
return SaResult.error("未登录,请先登录").setCode(401);
}
if (e instanceof NotPermissionException) {
return SaResult.error("无权限访问").setCode(403);
}
return SaResult.error("认证失败:" + e.getMessage()).setCode(500);
})
.setBeforeAuth(r -> {
SaHolder.getResponse()
.setHeader("Access-Control-Allow-Origin", "*")
.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
.setHeader("Access-Control-Allow-Headers", "*")
.setHeader("Access-Control-Max-Age", "3600");
if ("OPTIONS".equals(SaHolder.getRequest().getMethod())) {
SaRouter.back();
}
});
}
} setBeforeAuthhandles CORS and short-circuits OPTIONS requests via SaRouter.back().
Interceptor registration for @SaCheckPermission:
@Configuration
public class SaTokenInterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
.addPathPatterns("/**")
.excludePathPatterns("/user/login", "/user/register");
}
}Login Endpoint and StpUtil API
Login controller validates user, checks BCrypt password, verifies account status, then calls StpUtil.login(userId, "web") and returns StpUtil.getTokenInfo().
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/login")
public SaResult login(@RequestBody LoginDTO loginDTO) {
SysUser user = userService.getByUsername(loginDTO.getUsername());
if (user == null) return SaResult.error("用户名不存在");
if (!BCrypt.checkpw(loginDTO.getPassword(), user.getPassword())) {
return SaResult.error("密码错误");
}
if (user.getStatus() == 0) return SaResult.error("账号已被禁用");
StpUtil.login(user.getId(), "web");
return SaResult.data(StpUtil.getTokenInfo());
}
@PostMapping("/logout")
public SaResult logout() {
StpUtil.logout();
return SaResult.ok("登出成功");
}
@GetMapping("/info")
public SaResult info() {
long userId = StpUtil.getLoginIdAsLong();
SysUser user = userService.getById(userId);
StpUtil.getSession().set("userInfo", user);
return SaResult.data(user);
}
}Token info JSON includes token name, value, login status, login ID, type, timeouts, and device.
Common StpUtil methods: logout(), logout(10001) (kick by ID), logoutByTokenValue(token), isLogin(), getLoginId(), getTokenValue(), checkLogin(), getSession(), kickout(10001).
Dynamic Permissions: Implementing StpInterface
Implement StpInterface to provide runtime role/permission lists:
@Component
public class StpInterfaceImpl implements StpInterface {
@Autowired
private SysUserService userService;
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return userService.getPermissionsByUserId(Long.parseLong(loginId.toString()));
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
return userService.getRolesByUserId(Long.parseLong(loginId.toString()));
}
}Service queries roles then permissions via MyBatis-Plus:
public List<String> getPermissionsByUserId(Long userId) {
List<SysRole> roles = sysRoleMapper.selectRolesByUserId(userId);
if (roles.isEmpty()) return new ArrayList<>();
List<Long> roleIds = roles.stream().map(SysRole::getId).collect(Collectors.toList());
return sysPermissionMapper.selectPermsByRoleIds(roleIds);
}Controller usage:
@GetMapping("/user/add")
@SaCheckPermission("user:add")
public SaResult addUser() { return SaResult.ok("新增用户成功"); }
@DeleteMapping("/admin/delete")
@SaCheckRole("admin")
public SaResult deleteAdmin() { return SaResult.ok("删除管理员成功"); }For URL-based dynamic permissions, map URLs to permission codes in sys_menu, then in SaServletFilter.setAuth resolve current path, query required permission (with caching), and call StpUtil.checkPermission().
API Replay Protection: Nonce + Timestamp + Signature
Client sends timestamp, nonce, and signature. Signature = MD5(sorted params + timestamp + nonce + secretKey). Server validates:
Timestamp within 5-minute window.
Nonce uniqueness via Redis setIfAbsent with 5-minute TTL.
Recalculated signature matches.
Interceptor implementation:
@Component
public class SignVerifyInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final long MAX_TIME_WINDOW = 5 * 60 * 1000;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equals(request.getMethod())) return true;
String timestamp = request.getHeader("timestamp");
String nonce = request.getHeader("nonce");
String sign = request.getHeader("sign");
if (StrUtil.hasBlank(timestamp, nonce, sign)) {
throw new ApiException("签名参数缺失");
}
long ts = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() - ts) > MAX_TIME_WINDOW) {
throw new ApiException("请求已过期");
}
String nonceKey = "sign:nonce:" + nonce;
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(nonceKey, "1", Duration.ofMinutes(5));
if (Boolean.FALSE.equals(ifAbsent)) {
throw new ApiException("重复请求");
}
Map<String, String[]> params = request.getParameterMap();
TreeMap<String, String> sortedParams = new TreeMap<>();
for (Map.Entry<String, String[]> entry : params.entrySet()) {
sortedParams.put(entry.getKey(), String.join(",", entry.getValue()));
}
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
sb.append("timestamp=").append(timestamp).append("&nonce=").append(nonce);
sb.append("&secretKey=").append(secretKey);
String calcSign = DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8));
if (!calcSign.equalsIgnoreCase(sign)) {
throw new ApiException("签名验证失败");
}
return true;
}
}Register interceptor for /api/** excluding login. Client-side sign generation uses same algorithm. Secret key must never be exposed in frontend JS; deliver via secure backend channel over HTTPS.
Cluster Deployment: Unified Token via Redis
Default in-memory token storage loses data on restart and doesn't share across instances. Adding sa-token-redis-jackson and configuring Redis automatically switches storage. Tested with two instances on different ports: login on one, access protected endpoint on the other works; logout invalidates token on both.
Caveats: Avoid custom RedisTemplate overriding Sa-Token's serialization. Don't set Redis timeout too short to prevent connection timeouts under high concurrency.
Comparison: Spring Security + JWT vs Sa-Token
Spring Security excels with standard protocols (OAuth2, SAML) and complex flows but has steep learning curve (filter chain, Provider, AuthenticationManager). JWT is stateless; server cannot actively revoke tokens without a blacklist, adding complexity.
Sa-Token provides static API calls, built-in kick/ban/session query, and stateful tokens via Redis enabling instant revocation. Horizontal scaling works with Redis. Sa-Token suits business systems; Spring Security fits complex protocol requirements.
Production Pitfalls
Token Serialization Exception
Custom RedisTemplate serializer conflicted with Sa-Token's Jackson serializer. Fix: remove custom serializer or avoid overriding RedisTemplate.
Filter/Interceptor Order Confusion
Filters execute before interceptors. Don't put StpUtil.checkLogin() in SaServletFilter.setAuth; use setBeforeAuth for CORS, delegate login check to SaInterceptor.
@SaCheckPermission Not Working
Missing SaInterceptor registration. Annotation requires the interceptor; global filter alone is insufficient.
Slow Permission Queries
High-frequency permission lookups cached in Redis with key user:perms:userId (5-min TTL). On login, store permissions in session; subsequent reads from session avoid Redis. On permission change, call StpUtil.getSessionByLoginId(userId).delete("permList") to force refresh.
Nonce Failure in Cluster
Nonce must be stored in Redis with atomic setIfAbsent.
Clock Skew in Timestamp Validation
Client/server clock drift can reject valid requests. Use 5-minute window and enable NTP. If sync impossible, drop timestamp check but keep nonce and signature.
CORS with Token in Header vs Cookie
If token in header (localStorage), CORS Access-Control-Allow-Headers must include satoken. If using cookies, Access-Control-Allow-Origin cannot be *; must specify exact domain.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Xiaolin Talks Programming
Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
