文章

使用 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-keyjwt-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 模式,按需选择:

功能点

Simple 简单模式

Mixin 混入模式

Stateless 无状态模式

Token风格

jwt风格

jwt风格

jwt风格

登录数据存储

Redis中存储

Token中存储

Token中存储

Session存储

Redis中存储

Redis中存储

无Session

注销下线

前后端双清数据

前后端双清数据

前端清除数据

踢人下线API

支持

不支持

不支持

顶人下线API

支持

不支持

不支持

登录认证

支持

支持

支持

角色认证

支持

支持

支持

权限认证

支持

支持

支持

timeout 有效期

支持

支持

支持

active-timeout 有效期

支持

支持

不支持

id反查Token

支持

支持

不支持

会话管理

支持

部分支持

不支持

注解鉴权

支持

支持

支持

路由拦截鉴权

支持

支持

支持

账号封禁

支持

支持

不支持

身份切换

支持

支持

支持

二级认证

支持

支持

支持

模式总结

Token风格替换

jwt 与 Redis 逻辑混合

完全舍弃Redis,只用jwt

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 权限认证框架,让鉴权变得简单、优雅!

附录

平替后代码量对比:

image-20250114下午121854578

登录验证:

image-20250114下午122408152

image-20250114下午122546583

image-20250114下午122614417

参考文档

Sa-Token 官方文档

Spring Security+JWT 轻松实现前后端分离的认证架构


欢迎关注我的公众号“Eric技术圈”,原创技术文章第一时间推送。

License:  CC BY 4.0