簡介
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的bodyJwtBuilder 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) {// 得到DefaultJwtParserClaims 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 {@Autowiredprivate 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 {@Autowiredprivate JwtTokenAdminInterceptor jwtTokenAdminInterceptor;@Autowiredprivate 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框架的消息轉化器@Overrideprotected 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 {@Resourceprivate 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 攔截器,打開注解式鑒權功能 @Overridepublic void addInterceptors(InterceptorRegistry registry) {// 注冊 Sa-Token 攔截器,打開注解式鑒權功能 registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**"); }
}
4.定義權限與角色獲取邏輯
實現 StpInterface 接口。該接口提供了獲取當前登錄用戶的權限和角色的方法,在每次調用鑒權代碼時,都會執行接口中的方法。
@Component // 保證此類被 SpringBoot 掃描,完成 Sa-Token 的自定義權限驗證擴展
public class StpInterfaceImpl implements StpInterface {/*** 返回一個賬號所擁有的權限碼集合 (目前沒用)*/@Overridepublic List<String> getPermissionList(Object loginId, String s) {return new ArrayList<>();}/*** 返回一個賬號所擁有的角色標識集合 (權限與角色可分開校驗)*/@Overridepublic 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 解析 UserAgentUserAgent userAgent = UserAgentUtil.parse(userAgentStr);ThrowUtils.throwIf(userAgent == null, ErrorCode.OPERATION_ERROR, "非法請求");// 默認值是 PCString device = "pc";// 是否為小程序if (isMiniProgram(userAgentStr)) {device = "miniProgram";} else if (isPad(userAgentStr)) {// 是否為 Paddevice = "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 平板,則返回 truereturn 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)