使用 Sa-Token 平替 Spring Security,告别繁琐的认证与鉴权
见字如面,与大家分享实践中的经验与思考。
最近将项目升级到 SpringBoot 3 后发现 Spring Security 的框架又进行了大幅调整,又得重新学习和应用了一遍。今天准备使用 Sa-Token 进行平替 Spring Security 框架。
需求说明
使用 Sa-Token 替换 Spring Security 框架,那么就需要将之前实现的所有安全相关的需求进行替换,主要有如下:
前后端分离,支持多前端,如:PC Web、小程序、APP 等
在微服务框架中易于 Spring Cloud Gateway 集成,支持 Webflux
支持 JWT,同时可以生成 AccessToken 和 RefreshToken
支持全局过滤器,统一对请求地址进行拦截与认证
密码策略不变,便于后续两个框架的切换
用户登录后,支持灵活的鉴权配置
实战
01 环境准备
Spring Boot 3.4
JDK 21
Gradle 8.12
02 添加依赖
implementation 'cn.dev33:sa-token-spring-boot3-starter:1.39.0'
implementation 'cn.dev33:sa-token-jwt:1.39.0'
implementation 'org.springframework.security:spring-security-crypto'
注:如果你使用的不是 SpringBoot 3.x,只需要将 sa-token-spring-boot3-starter
修改为 sa-token-spring-boot-starter
即可。
建议单独引入 Spring Security 的密码模块。
若当前项目中有历史数据,且用户密码不可逆加密,那么必须引入。
若没有历史数据遗留,也建议引入,便于后续切换回 Spring Security。
03 设置配置文件
在 application.yaml
文件中添加如下配置,定制化使用框架:
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: Authorization
# token前缀
token-prefix: Bearer
# token 有效期(单位:秒) 默认30天,-1 代表永久有效,当前设置为 7 天
timeout: 604800
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
is-share: true
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: uuid
# 是否输出操作日志
is-log: true
# jwt 密钥
jwt-secret-key: asdasdasiferichueuiwyurfewbfjsdafjk0212
# jwt refresh 密钥
jwt-refresh-secret-key: asdasdasiferichueuiwyurfewbfjsdafjk7788
# refreshToken 超时时间(单位:秒),当前设置为 30 天
jwt-refresh-timeout: 2592000
注意:额外补充了 jwt-refresh-secret-key
和 jwt-refresh-timeout
用于 Refresh Token 的生成。
04 使用JWT 生成 双 Token
Sa-Token 当前的 JWT 模块是不支持生成双Token的,不利于移动端的 token 使用,参考官方 StpLogicJwtForStateless
进行扩展:
@Slf4j
@Component
public class StpLogicJwtForRefresh extends StpLogic {
@Value("${sa-token.jwt-refresh-secret-key}")
private String refreshSecretKey;
@Value("${sa-token.jwt-refresh-timeout}")
private long refreshTimeout;
public StpLogicJwtForRefresh() {
super("login"); // 指定 StpLogic 的标识
}
// 生成 refreshToken
public String createRefreshToken(Object loginId, SaLoginModel loginModel) {
// 自定义 RefreshToken 的生成逻辑
return SaJwtUtil.createToken(loginType,
loginId,
loginModel.getDeviceOrDefault(),
refreshTimeout,
loginModel.getExtraData(),
refreshSecretKey);
}
// 校验 refreshToken
public JSONObject verifyRefreshToken(String refreshToken) {
try {
return SaJwtUtil.getPayloads(refreshToken, getLoginType(), refreshSecretKey);
} catch (Exception e) {
log.error("StpLogicJwtForRefresh getPayloads failed", e);
throw new UnauthorizedException(AuthErrorCode.REFRESH_TOKEN_INVALID);
}
}
}
同时官方支持三种 JWT Token 模式,按需选择:
05 Spring Bean 注入
新增一个 SaTokenConfigure
配置类,声明 Sa-Token 所需要的 bean。
@Slf4j
@Configuration
public class SaTokenConfigure {
// Sa-Token 整合 jwt (Stateless 无状态模式)
@Bean
@Primary
@Qualifier("jwtStatelessLogic")
public StpLogic jwtStatelessLogic() {
return new StpLogicJwtForStateless();
}
@Bean
@Qualifier("jwtRefreshLogic")
public StpLogic jwtRefreshLogic() {
return new StpLogicJwtForRefresh();
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
/**
* 注意:
* SaServletFilter 默认执行顺序为 -100,如果你要自定义过滤器的执行顺序,可以使用 FilterRegistrationBean 注册
*/
@Bean
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()
// 指定 拦截路由 与 放行路由
.addInclude("/**").addExclude(AuthConstants.IGNORE_AUTH_URLS) /* 如:排除掉 /favicon.ico */
// 认证函数: 每次请求执行
.setAuth(obj -> {
// 登录认证 -- 拦截所有路由,并排除 auth 相关接口用于开放登录
SaRouter.match("/**", "/api/admin/auth/**", StpUtil::checkLogin);
})
// 异常处理函数:每次认证函数发生异常时执行此函数
.setError(e -> {
log.error("Sa-Token认证失败, 异常信息:", e);
throw new UnauthorizedException(AuthErrorCode.TOKEN_INVALID);
})
// 前置函数:在每次认证函数之前执行(BeforeAuth 不受 includeList 与 excludeList 的限制,所有请求都会进入)
.setBeforeAuth(r -> {
// ---------- 设置一些安全响应头 ----------
SaHolder.getResponse()
// 是否可以在iframe显示视图: DENY=不可以 | SAMEORIGIN=同域下可以 | ALLOW-FROM uri=指定域名下可以
.setHeader("X-Frame-Options", "SAMEORIGIN")
// 是否启用浏览器默认XSS防护: 0=禁用 | 1=启用 | 1; mode=block 启用, 并在检查到XSS攻击时,停止渲染页面
.setHeader("X-XSS-Protection", "1; mode=block")
// 禁用浏览器内容嗅探
.setHeader("X-Content-Type-Options", "nosniff")
;
});
}
}
06 生成 Token
编写 Controller 类,新增用户登录接口。
private final UserAccountRepository userAccountRepository;
private final PasswordEncoder passwordEncoder;
private final StpLogicJwtForRefresh jwtRefreshLogic;
private final UserLoginRecordDOMapper userLoginRecordDOMapper;
@Override
@Transactional
public TokenResponse adminLogin(AdminLoginCmd cmd) {
// 第一步:验证用户名密码
UserAccount userAccount = userAccountRepository.findByUsername(cmd.getUsername());
if (userAccount == null || !passwordEncoder.matches(cmd.getPassword(), userAccount.getAccountPassword())) {
throw new RuntimeException("用户名或密码错误");
}
// 第二步:登录并生成 AccessToken
SaLoginModel saLoginModel = SaLoginConfig
.setDevice("PC")
.setExtra("userId", userAccount.getId())
.setExtra("username", userAccount.getAccountName())
.setExtra("applicationType", userAccount.getApplicationType());
StpUtil.login(userAccount.getId(), saLoginModel);
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
String accessToken = tokenInfo.getTokenValue();
// 第三步:生成 RefreshToken
String refreshToken = jwtRefreshLogic.createRefreshToken(userAccount.getId(), saLoginModel);
// 第四步:保存用户登录记录
UserContext userContext = userAccount.buildUserContext();
saveLoginRecord(userContext, cmd.getClientIp(), cmd.getClientInfo());
return TokenResponse.builder().accessToken(accessToken).refreshToken(refreshToken).build();
}
07 前端使用 Token 请求
这里举例 PC Web 端,其他端代码其实类似。
let token = '';
export const getToken = (): string => {
if (token) return token;
const value = localStorage.getItem(TOKEN_KEY) ?? sessionStorage.getItem(TOKEN_KEY);
if (value) token = value;
return token;
};
export const setToken = (accessToken: string, refreshToken: string, remember: boolean): void => {
token = accessToken;
if (remember) {
localStorage.setItem(TOKEN_KEY, accessToken);
} else {
sessionStorage.setItem(TOKEN_KEY, accessToken);
}
};
interface RequestOptions {
url: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
body?: Record<string, any> | null;
headers?: Record<string, string>;
}
type Resp = Record<string, any>;
async function request(options: RequestOptions): Promise<Resp> {
const {url, method = 'GET', body} = options;
let apiUrl = `${API_URL}${url}`;
const headers = new Headers({
'Authorization': `Bearer ${getToken()}`,
'Accept-Language': 'zh',
...options.headers,
});
...
}
08 动态鉴权
以下是官方的例子,供参考。
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> {
// 遍历校验规则,依次鉴权
Map<String, String> rules = getAuthRules();
for (String path : rules.keySet()) {
SaRouter.match(path, () -> StpUtil.checkPermission(rules.get(path)));
}
})).addPathPatterns("/**");
}
// 动态获取鉴权规则
public Map<String, String> getAuthRules() {
// key 代表要拦截的 path,value 代表需要校验的权限
Map<String, String> authMap = new LinkedHashMap<>();
authMap.put("/user/**", "user");
authMap.put("/admin/**", "admin");
authMap.put("/article/**", "article");
// 更多规则 ...
return authMap;
}
最后
使用 Sa-Token 平替 Spring Security 非常的顺利,代码量也非常的少,确实符合官方的口号:
一个轻量级 Java 权限认证框架,让鉴权变得简单、优雅!
附录
平替后代码量对比:
登录验证:
参考文档
Spring Security+JWT 轻松实现前后端分离的认证架构
欢迎关注我的公众号“Eric技术圈”,原创技术文章第一时间推送。