Sa-Token
简介
Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证、权限认证、单点登录、OAuth2.0、分布式Session会话、微服务网关鉴权 等一系列权限相关问题。
官方文档
常见功能
登录认证
本框架
- 用户提交 name + password 参数,调用登录接口。
- 登录成功,返回这个用户的 Token 会话凭证
- 用户后续的每次请求,都携带上这个 Token。
- 服务器根据 Token 判断此会话是否登录成功。
测试
/**
* 登录测试
*/
@RestController
@RequestMapping("/acc/")
public class LoginController {
// 测试登录 ---- http://localhost:8081/acc/doLogin?name=zhang&pwd=123456
@RequestMapping("doLogin")
public SaResult doLogin(String name, String pwd) {
// 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对
if("zhang".equals(name) && "123456".equals(pwd)) {
会话登录:参数填写要登录的账号id,建议的数据类型:long | int | String, 不可以传入复杂类型,如:User、Admin 等等。Sa-Token 为这个账号创建了一个Token凭证,且通过 Cookie 上下文返回给了前端。
StpUtil.login(10001);
return SaResult.ok("登录成功");
}
return SaResult.error("登录失败");
}
// 查询登录状态 ---- http://localhost:8081/acc/isLogin
@RequestMapping("isLogin")
public SaResult isLogin() {
return SaResult.ok("是否登录:" + StpUtil.isLogin());
}
// 查询 Token 信息 ---- http://localhost:8081/acc/tokenInfo
@RequestMapping("tokenInfo")
public SaResult tokenInfo() {
return SaResult.data(StpUtil.getTokenInfo());
}
// 测试注销 ---- http://localhost:8081/acc/logout
@RequestMapping("logout")
public SaResult logout() {
StpUtil.logout();
return SaResult.ok();
}
}
Session
基于 Session 的认证是一种服务器端维护用户会话状态的机制,旨在解决 HTTP 协议无状态的问题
- 用户登录:客户端提交用户名和密码至服务器,服务器验证身份后生成唯一的 Session ID,并将用户信息(如角色、权限等)存储在服务器的Session 对象中
- Session 传递:服务器通过响应头的 Set-Cookie 字段将 Session ID返回给客户端,客户端浏览器自动保存此 Cookie
- 会话验证:后续请求中,客户端自动携带该 Cookie(包含 SessionID),服务器通过 Session ID 查找对应的 Session 对象,验证用户身份
- 会话管理:若 Session
超时或用户主动登出,服务器销毁 Session 对象,客户端 Cookie 失效
JWT
JWT(JSON Web Token) 是一种开放标准(RFC 7519),用于在网络应用间安全传输 JSON 格式的信息。其核心设计为 紧凑性(体积小)和 自包含性(信息完整无需额外存储),由三部分组成Header 、Payload、Signature
组成
- Header(头部)
包含令牌类型(typ)和签名算法(如 HS256 或 RSA)
{ "alg": "HS256", "typ": "JWT" }
2.Payload(载荷)
存储用户身份信息及其他声明(Claims),分为三类:
- 注册声明(标准字段):如 sub(用户标识)、exp(过期时间)、iat(签发时间)等。
- 公共声明(自定义但建议标准化):如用户姓名、角色等。
- 私有声明(业务自定义):如用户偏好设置。
注意:Payload 内容虽可验证但非加密,避免存储敏感信息(如密码)
- Signature(签名)
使用密钥对 Header 和 Payload 的编码结果进行签名,确保数据完整性和真实性。签名是 JWT 安全的核心,密钥泄露将导致伪造风险
流程
- 用户登录:
用户提交凭证(如用户名/密码),服务器验证成功后生成 JWT,包含用户身份信息和有效期 - Token 下发:服务器将 JWT 返回客户端,客户端需存储于 Cookie、LocalStorage 或 SessionStorage 中。推荐通过Authorization 请求头传递
- Token 验证:服务器接收请求后: 解码并验证签名:使用密钥验证数据是否被篡改。 检查有效期:如 exp 字段是否过期。 提取用户信息:直接从
Payload 中获取用户身份,无需查询数据库
实战
JWT工具类:生成和解析JWT
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
public class JwtUtil {
/**
* 生成jwt
* 使用Hs256算法, 私匙使用固定秘钥
*
* @param secretKey jwt秘钥
* @param ttlMillis jwt过期时间(毫秒)
* @param claims 设置的信息
* @return
*/
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
// 指定签名的时候使用的签名算法,也就是header那部分
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 生成JWT的时间
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);
// 设置jwt的body
JwtBuilder builder = Jwts.builder()
// 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
.setClaims(claims)
// 设置签名使用的签名算法和签名使用的秘钥
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
// 设置过期时间
.setExpiration(exp);
return builder.compact();
}
/**
* Token解密
*
* @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个
* @param token 加密后的token
* @return
*/
public static Claims parseJWT(String secretKey, String token) {
// 得到DefaultJwtParser
Claims claims = Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(token).getBody();
return claims;
}
}
定义拦截器
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* jwt令牌校验的拦截器
*/
@Component //生成Bean
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
/**
* 校验jwt
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断当前拦截到的是Controller的方法还是其他资源
if (!(handler instanceof HandlerMethod)) {
//当前拦截到的不是动态方法,直接放行
return true;
}
//1、从请求头中获取令牌
String token = request.getHeader(jwtProperties.getAdminTokenName());
//2、校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:{}", empId);
//前端发一次请求,是一次线程(拦截器,controller,service,mapper共有同一线程) (threadLocal)
BaseContext.setCurrentId(empId);
//3、通过,放行
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
}
}
注册拦截器
/**
* 配置类,注册web层相关组件
*/
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {
@Autowired
private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;
@Autowired
private JwtTokenUserInterceptor jwtTokenUserInterceptor;
/**
* 注册自定义拦截器 配置路径
*
* @param registry
*/
protected void addInterceptors(InterceptorRegistry registry) {
log.info("开始注册自定义拦截器...");
registry.addInterceptor(jwtTokenAdminInterceptor)
//在以下路径判断
.addPathPatterns("/admin/**")
//排除以下路径
.excludePathPatterns("/admin/employee/login");
}
/**
* 设置静态资源映射
* @param registry
*/
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
log.info("开始设置静态资源映射...");
registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}
//扩展springmvc框架的消息转化器
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转化器...");
//创建一个消息转化器对象
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
//需要为消息转化器设置一个对象转换器,对象转换器可以将Java对象序列化为json数据
converter.setObjectMapper(new JacksonObjectMapper());
//将自己的消息转化器加入容器中
converters.add(0,converter);
}
}
权限认证
自定义注解+AOP实现
实战(判断管理员)
自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {
/**
* 必须有某个角色
*
* @return
*/
String mustRole() default "";
}
编写切面类
@Aspect
@Component
public class AuthInterceptor {
@Resource
private UserService userService;
/**
* 执行拦截
*
* @param joinPoint
* @param authCheck
* @return
*/
@Around("@annotation(authCheck)")
public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
String mustRole = authCheck.mustRole();
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
// 当前登录用户
User loginUser = userService.getLoginUser(request);
UserRoleEnum mustRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
// 不需要权限,放行
if (mustRoleEnum == null) {
return joinPoint.proceed();
}
// 必须有该权限才通过
UserRoleEnum userRoleEnum = UserRoleEnum.getEnumByValue(loginUser.getUserRole());
if (userRoleEnum == null) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 如果被封号,直接拒绝
if (UserRoleEnum.BAN.equals(userRoleEnum)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 必须有管理员权限
if (UserRoleEnum.ADMIN.equals(mustRoleEnum)) {
// 用户没有管理员权限,拒绝
if (!UserRoleEnum.ADMIN.equals(userRoleEnum)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
}
// 通过权限校验,放行
return joinPoint.proceed();
}
}
补充AOP
切面(Aspect):封装横切逻辑的模块(如日志切面类)
连接点(Join Point):程序执行过程中的特定点(如方法调用、异常抛出)
通知(Advice):切面在连接点执行的操作,分为:
- 前置通知(Before):方法执行前触发(如权限校验)。
- 后置通知(After):方法执行后触发(无论是否异常)。
- 返回通知(AfterReturning):方法正常返回后触发。
- 异常通知(AfterThrowing):方法抛出异常后触发。
- 环绕通知(Around):包裹目标方法,控制执行流程(如事务管理)。在连接点执行目标方法调用前后可以自己编写逻辑。
@Aspect
@Component
public class LoggingAspect {
@Around("execution(* com.example.service.*.*(..))")
public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
// 前置逻辑(如参数校验)
Object result = pjp.proceed(); // 调用目标方法
// 后置逻辑(如日志记录)
return result; // 可修改返回值
}
}
切点(Pointcut):定义哪些连接点会被切面拦截(通过表达式指定),在本业务中只有有自定义注解并是admin,AOP就会拦截。
利用Sa-token来进行权限校验
链接
实战
1.引入依赖
<!-- Sa-Token 权限认证 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.39.0</version>
</dependency>
2.编写配置文件
# Sa-Token 配置
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: mianshiya
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录(为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: false
# 在多人登录同一账号时,是否共用一个 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
3.注册Sa-token拦截器
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
// 注册 Sa-Token 拦截器,打开注解式鉴权功能
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册 Sa-Token 拦截器,打开注解式鉴权功能
registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
}
}
4.定义权限与角色获取逻辑
实现 StpInterface 接口。该接口提供了获取当前登录用户的权限和角色的方法,在每次调用鉴权代码时,都会执行接口中的方法。
@Component // 保证此类被 SpringBoot 扫描,完成 Sa-Token 的自定义权限验证扩展
public class StpInterfaceImpl implements StpInterface {
/**
* 返回一个账号所拥有的权限码集合 (目前没用)
*/
@Override
public List<String> getPermissionList(Object loginId, String s) {
return new ArrayList<>();
}
/**
* 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
*/
@Override
public List<String> getRoleList(Object loginId, String s) {
// 从当前登录用户信息中获取角色
User user = (User) StpUtil.getSessionByLoginId(loginId).get(USER_LOGIN_STATE);
return Collections.singletonList(user.getUserRole());
}
}
5.设备信息获取工具类
/**
* 设备工具类
*/
public class DeviceUtils {
/**
* 根据请求获取设备信息
**/
public static String getRequestDevice(HttpServletRequest request) {
String userAgentStr = request.getHeader(Header.USER_AGENT.toString());
// 使用 Hutool 解析 UserAgent
UserAgent userAgent = UserAgentUtil.parse(userAgentStr);
ThrowUtils.throwIf(userAgent == null, ErrorCode.OPERATION_ERROR, "非法请求");
// 默认值是 PC
String device = "pc";
// 是否为小程序
if (isMiniProgram(userAgentStr)) {
device = "miniProgram";
} else if (isPad(userAgentStr)) {
// 是否为 Pad
device = "pad";
} else if (userAgent.isMobile()) {
// 是否为手机
device = "mobile";
}
return device;
}
/**
* 判断是否是小程序
* 一般通过 User-Agent 字符串中的 "MicroMessenger" 来判断是否是微信小程序
**/
private static boolean isMiniProgram(String userAgentStr) {
// 判断 User-Agent 是否包含 "MicroMessenger" 表示是微信环境
return StrUtil.containsIgnoreCase(userAgentStr, "MicroMessenger")
&& StrUtil.containsIgnoreCase(userAgentStr, "MiniProgram");
}
/**
* 判断是否为平板设备
* 支持 iOS(如 iPad)和 Android 平板的检测
**/
private static boolean isPad(String userAgentStr) {
// 检查 iPad 的 User-Agent 标志
boolean isIpad = StrUtil.containsIgnoreCase(userAgentStr, "iPad");
// 检查 Android 平板(包含 "Android" 且不包含 "Mobile")
boolean isAndroidTablet = StrUtil.containsIgnoreCase(userAgentStr, "Android")
&& !StrUtil.containsIgnoreCase(userAgentStr, "Mobile");
// 如果是 iPad 或 Android 平板,则返回 true
return isIpad || isAndroidTablet;
}
}
6.编写方法
// Sa-Token 登录,并指定设备,同端登录互斥
StpUtil.login(user.getId(), DeviceUtils.getRequestDevice(request));
StpUtil.getSession().set(USER_LOGIN_STATE, user);
@Override
public User getLoginUser(HttpServletRequest request) {
// 先判断是否已登录
Object loginUserId = StpUtil.getLoginIdDefaultNull();
if (loginUserId == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
// 从数据库查询(追求性能的话可以注释,直接走缓存)
User currentUser = this.getById((String) loginUserId);
if (currentUser == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
return currentUser;
}
7.在方法上添加注解
@SaCheckRole(UserConstant.ADMIN_ROLE)