目錄
同端互斥登錄
單點登錄SSO
架構選型
模式二: URL重定向傳播
前后端分離
整體流程
準備工作
搭建客戶端
搭建認證中心SSO Server
環境配置
開放認證接口
啟動類
跨域處理
同端互斥登錄
同端互斥登陸 模塊
同端互斥登錄指:同一類型設備上只允許單地點登錄,在不同類型設備上允許同時在線。比如 QQ 可以手機電腦同時在線,但是不能在兩個手機上同時登錄一個賬號。具體步驟如下
sa-token:# token 名稱(同時也是 cookie 名稱)token-name: praxisAI# token 有效期(單位:秒) 默認30天,-1 代表永久有效timeout: 2592000# token 最低活躍頻率(單位:秒),如果 token 超過此時間沒有訪問系統就會被凍結,默認-1 代表不限制,永不凍結active-timeout: -1# 是否開啟同端互斥登錄 (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
-
在配置文件中,將
isConcurrent
配置為false -
DeviceUtils
工具類:判斷前端請求傳來的設備信息,比如pc,后續設置到Sa-Token
登錄態中
//設備工具類
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;}
}
?3.?登錄時傳入參數
// 使用 Sa-Token 登錄,并指定設備,同端登錄互斥
StpUtil.login(user.getId(), DeviceUtils.getRequestDevice(request));//例如 調用此方法登錄后,同設備的會被頂下線(不同設備不受影響)
StpUtil.login(10001, "PC");
Sa-Token 在背后做了大量的工作,包括
-
檢查此賬號是否之前已有登錄,為賬號生成
Token
(uuid 風格) 憑證與Session
會話 -
記錄 Token 活躍時間;
-
通知全局偵聽器,xx 賬號登錄成功;
-
將
Token
注入到請求上下文返回給前端 等等其它工作……(利用cookie自動注入特性)-
Cookie 可以從后端控制往瀏覽器中寫入 token 值。
-
Cookie 會在前端每次發起請求時自動提交 token 值。
-
JWT token模式
//1、登錄成功后:后端將 token 返回到前端 SaTokenInfo tokenInfo = StpUtil.getTokenInfo(); ?//包括tokenName和tokenValue return SaResult.data(tokenInfo); ?//返回給前端 ? //2.前端保存到localstorge中,下次請求帶上 //3.后端Sa-Token 就能像傳統PC端一樣自動讀取到 token 值,進行鑒權
記住我模式
原理:調用
StpUtil.login(10001, true)
,在瀏覽器寫入一個持久Cookie
儲存 Token,此時用戶即使重啟瀏覽器 Token 依然有效。
Sa-Token的登錄授權,默認就是[記住我]
模式,為了實現[非記住我]
模式,你需要在登錄時如下設置
// 設置登錄賬號id為10001,第二個參數指定是否為[記住我],當此值為false后,關閉瀏覽器后再次打開需要重新登錄 StpUtil.login(10001, false);
注銷模塊
// 檢驗當前會話是否已經登錄, 如果未登錄,則拋出異常:`NotLoginException`:代表當前會話暫未登錄 StpUtil.checkLogin(); // 移除登錄態 StpUtil.logout();
獲取當前登錄用戶
// 獲取當前會話賬號id, 如果未登錄,則返回 null 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;
單點登錄SSO
Sa-Token
單點登錄舉個例子理解
假設系統被切割為多個部分:商城、論壇、直播、社交…… 如果用戶每訪問一個模塊都要登錄一次,那么這樣就很麻煩了, 為了優化用戶體驗,需要一套機制將這N個系統的認證授權互通共享,讓用戶在一個系統登錄之后,可以暢通無阻的訪問其它所有系統。
凡是稍微上點規模的系統,統一認證中心都是繞不過去的檻。
架構選型
系統架構 | 采用模式 | 簡介 | 文檔鏈接 |
---|---|---|---|
前端同域 + 后端同 Redis | 模式一 | 共享 Cookie 同步會話 | 文檔、示例 |
前端不同域 + 后端同 Redis | 模式二 | URL重定向傳播會話 | 文檔、示例 |
前端不同域 + 后端不同 Redis | 模式三 | Http請求獲取會話 | 文檔、示例 |
-
前端同域:指多個系統可以部署在同一個主域名之下,比如:
c1.domain.com
、c2.domain.com
、c3.domain.com
。 -
后端同Redis:指多個系統可以連接同一個Redis,PS:這里并不需要把所有項目的數據都放在同一個Redis中,Sa-Token提供了
[權限緩存與業務緩存分離]
的解決方案,詳情戳: Alone獨立Redis插件。 -
模式三:Http請求獲取會話(Sa-Token對SSO提供了完整的封裝,只需要復制幾段代碼便可以輕松集成)
根據本項目場景,后端同Redis,所以選擇模式二+ 前后端分離架構
SaToken SSO優勢
-
API 簡單易用,且官方文檔介紹很詳細。
-
支持三種模式,是否跨域、是否共享Redis、是否前后端分離 等場景很輕松解決
-
內置域名校驗、密鑰校驗、
Token
防竊取,安全性很高 -
不丟參數,相比其他單點登錄框架,
Sa-Token-SSO
內有專門的算法保證了參數不丟失,登錄成功之后原路返回頁面。
模式二: URL重定向傳播
如果多個系統部署在不同的域名之下,但是后端可以連接同一個Redis,那么便可以使用 [URL重定向傳播會話]
的方式做到單點登錄
首先我們再次復習一下,多個系統之間為什么無法同步登錄狀態?
前端的
Token
無法在多個系統下共享。后端的
Session
無法在多個系統間共享。第二點官方已使用 Alone獨立Redis插件 做到權限緩存直連 SSO-Redis 數據中心,不再贅述。
未登錄,客戶端頁面顯示“登錄”鏈接。
點擊登錄,攜帶當前頁面
back
參數(包含客戶端host),跳轉到當前客戶端的/sso/login
接口
未登錄時,重定向到SSO服務器的登錄頁
用戶在SSO登錄頁登錄成功后,SSO服務器返回
ticket
,重定向回指定客戶端客戶端通過
SaSsoClientProcessor
向SSO服務器驗證ticket
的有效性,獲取用戶ID使用
StpUtil.login(userId)
在本地登錄用戶
在跨域模式下, "共享Cookie方案" 的失效,所以必須采用一種新的方案來傳遞Token。
前后端分離
首先理解對于一個前后端分離項目,即我們的系統,整體流程是這樣的
前端==>后端==>重定向到SSO認證中心(展示頁面,所以SSO服務器代碼是前后端不分離的,主要作用就是:展示登錄頁,校驗登錄和重定向)
這種情況要考慮跨域,即客戶端與 SSO 服務器部署在不同域,缺點:前端需主動調用 /sso/getSsoAuthUrl
獲取登錄地址,并處理重定向邏輯
整體流程
-
前端頁面點擊
[登錄]
后觸發調用登錄函數,調用后端接口/sso/login
,并攜帶back
參數( 包含客戶端host )-
形如:
http://{sso-client}/sso/login?back=xxx
-
-
若子系統檢測到此用戶尚未登錄,則直接重定向到SSO認證中心,并攜帶
redirect
參數(記錄子系統的登錄頁URL)-
形如:
http://{sso-server}/sso/auth?redirect=xxx?back=xxx
-
-
用戶進入 SSO認證中心 的登錄頁面,開始登錄。
-
用戶 輸入賬號密碼 并 登錄成功,SSO認證中心下放
ticket
碼參數。重定向回客戶端的登錄接口/sso/login
-
形如:
http://{sso-client}/sso/login?back=xxx&ticket=xxxxxxxxx
-
-
客戶端通過
SaSsoClientProcessor
向SSO服務器驗證ticket
的有效性,獲取用戶ID,使用StpUtil.login(userId)
在本地登錄用戶 -
其他客戶端嘗試登錄后,前面步驟一樣,到了請求發到
SSO
服務器,會通過sso/auth
判斷是否已經登錄(已登錄內部是通過redis查看用戶id來判斷的)然后直接返回Ticket進行登錄即可
整個過程,除了第四步用戶在SSO認證中心登錄時會被打斷,其余過程均是自動化的
當用戶在另一個子系統再次點擊[登錄]
按鈕,由于此用戶在SSO認證中心已有會話存在, 所以第四步也將自動化,也就是單點登錄的最終目的 一次登錄,處處通行。
流程如下
準備工作
首先修改hosts文件(C:\Windows\System32\drivers\etc\hosts)
,添加以下IP映射,方便測試:
127.0.0.1 sa-sso-server.com ? ?#ip映射,用于測試多個客戶端單點登錄
127.0.0.1 sa-sso-client1.com ?
127.0.0.1 sa-sso-client2.com
搭建客戶端
客戶端就是我們的后端服務器,考慮到前后分離架構,比如用VUE3
前端,下面是一個模擬的前端例子
<!-- 項目首頁 -->
<template><h2> Sa-Token SSO-Client 應用端(前后端分離版-Vue3) </h2><p>當前是否登錄:<b>{{isLogin}}</b></p><p><router-link :to="loginUrl">登錄</router-link> <!--點擊登錄后攜帶back參數請求后端sso/login接口--></p>
</template><script setup>
import { ref } from 'vue'
import {baseUrl, ajax} from './method-util.js'// 單點登錄地址
const loginUrl = '/api/sso/login?back=' + encodeURIComponent(location.href);
// 是否登錄
const isLogin = ref(false);// 1.查詢當前會話是否登錄
ajax('/api/sso/isLogin', {}, function (res) {console.log('/isLogin 返回數據:', res);isLogin.value = res.data;
})
</script>
<!-- Sa-Token-SSO-Client端-登錄頁 -->
<template>
</template>
<script setup>
import {onMounted} from "vue";
import {ajax, getParam} from './method-util.js';
import router from '../router';// 獲取參數
const back = getParam('back') || router.currentRoute.value.query.back;
const ticket = getParam('ticket') || router.currentRoute.value.query.ticket;console.log('獲取 back 參數:', back)
console.log('獲取 ticket 參數:', ticket)// 頁面加載后觸發
onMounted(() => {if(ticket) { //2.如果ticket存在,則嘗試通過ticket登錄doLoginByTicket(ticket);} else {goSsoAuthUrl(); //3.不存在,則重定向至認證中心}
})// 重定向至認證中心方法
function goSsoAuthUrl() {ajax('/api/sso/getSsoAuthUrl', {clientLoginUrl: location.href}, function(res) {console.log('/api/sso/getSsoAuthUrl 返回數據', res);location.href = res.data;})
}// 根據ticket值登錄 方法
function doLoginByTicket(ticket) {ajax('/api/sso/doLoginByTicket', {ticket: ticket}, function(res) {console.log('/api/sso/doLoginByTicket 返回數據', res);if(res.code === 200) {localStorage.setItem('satoken', res.data);location.href = decodeURIComponent(back);} else {alert(res.msg);}})
}
</script>
搭建后端
環境配置
<!-- Sa-Token 權限認證,在線文檔:https://sa-token.cc --><dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot-starter</artifactId><version>1.40.0</version></dependency><!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) --><dependency><groupId>cn.dev33</groupId><artifactId>sa-token-redis-jackson</artifactId><version>1.40.0</version></dependency><!-- 提供Redis連接池 --><dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId></dependency><!-- Sa-Token 插件:整合SSO --><dependency><groupId>cn.dev33</groupId><artifactId>sa-token-sso</artifactId><version>1.40.0</version></dependency><!-- Sa-Token插件:權限緩存與業務緩存分離 --><dependency><groupId>cn.dev33</groupId><artifactId>sa-token-alone-redis</artifactId><version>1.40.0</version></dependency>
配置文件中整合sso-client
相關配置+API密鑰+redis
存ticket
sa-token:# token 名稱(同時也是 cookie 名稱)token-name: praxisAI# token 有效期 2天(單位:秒) 默認30天,-1 代表永久有效timeout: 172800# token 最低活躍頻率(單位:秒),如果 token 超過此時間沒有訪問系統就會被凍結,默認-1 代表不限制,永不凍結active-timeout: -1# 是否開啟同端互斥登錄 (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# SSO-相關配置sso-client:# SSO-Server 端主機地址server-url: http://sa-sso-server.com:9000sign:# API 接口調用秘鑰,確保和服務端一致,sa-token官方文檔會給secret-key: xxxx# 配置Sa-Token單獨使用的Redis連接 (此處需要和SSO-Server端連接同一個Redis)alone-redis:# Redis數據庫索引 (默認為0)database: 1# Redis服務器地址host: xxx# Redis服務器連接端口port: 6379# Redis服務器連接密碼(默認為空)password: xxxtimeout: 10slettuce:pool:# 連接池最大連接數max-active: 200# 連接池最大阻塞等待時間(使用負值表示沒有限制)max-wait: -1ms# 連接池中的最大空閑連接max-idle: 10# 連接池中的最小空閑連接min-idle: 0
forest:# 關閉 forest 請求日志打印log-enabled: false
新建
Controller
作為客戶端接口,用于重定向到SSO服務器和接收服務器參數
//前后臺分離架構下集成SSO所需的代碼 (SSO-Client端)
@RestController
public class H5Controller {// 當前是否登錄@GetMapping("/sso/isLogin")public Object isLogin() {return SaResult.data(StpUtil.isLogin());}// 返回SSO認證中心登錄地址 @GetMapping("/sso/getSsoAuthUrl")public SaResult getSsoAuthUrl(String clientLoginUrl) {String serverAuthUrl = SaSsoUtil.buildServerAuthUrl(clientLoginUrl, "");return SaResult.data(serverAuthUrl);}// 根據ticket進行登錄@PostMapping("/sso/doLoginByTicket")public SaResult doLoginByTicket(String ticket) {SaCheckTicketResult ctr = SaSsoClientProcessor.instance.checkTicket(ticket, "/api/sso/doLoginByTicket");StpUtil.login(ctr.loginId, ctr.remainSessionTimeout);return SaResult.data(StpUtil.getTokenValue());}// 全局異常攔截 @ExceptionHandlerpublic SaResult handlerException(Exception e) {e.printStackTrace(); return SaResult.error(e.getMessage());}
}
搭建認證中心SSO Server
新建一個springboot項目作為認證中心,本項目后端系統算做一個客戶端client
,后續引入多個后端系統,也可以按照客戶端方式構建
環境配置
<!-- Sa-Token 權限認證,在線文檔:https://sa-token.cc -->
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot-starter</artifactId><version>1.40.0</version>
</dependency>
<!-- Sa-Token 插件:整合SSO -->
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-sso</artifactId><version>1.40.0</version>
</dependency>
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-redis-jackson</artifactId><version>1.40.0</version>
</dependency>
<dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId>
</dependency>
<!-- redis -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency><!-- 除此之外還需要引入mysql數據庫的一些依賴,因為要在認證中心實現登錄校驗 -->
# 端口
server:port: 9000
# Sa-Token 配置
sa-token:# ------- SSO-模式二相關配置 sso-server:# Ticket有效期 (單位: 秒),默認五分鐘 ticket-timeout: 300# 所有允許的授權回調地址allow-url: "http://sa-sso-client1/api/sso/login,http://sa-sso-client2/api/sso/login"sign:# API 接口調用秘鑰secret-key: xxx
spring:datasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://xxx/xxxusername: xxxpassword: xxx# Redis配置 (SSO模式一和模式二使用Redis來同步會話)redis:# Redis數據庫索引(默認為0)database: 1# Redis服務器地址host: xxx# Redis服務器連接端口port: 6379# Redis服務器連接密碼(默認為空)password: xxx# 連接超時時間timeout: 10slettuce:pool:# 連接池最大連接數max-active: 200# 連接池最大阻塞等待時間(使用負值表示沒有限制)max-wait: -1ms# 連接池中的最大空閑連接max-idle: 10# 連接池中的最小空閑連接min-idle: 0forest: # 關閉 forest 請求日志打印log-enabled: false
開放認證接口
新建 SsoServerController
,用于對外開放接口,先直接拉官方server-demo
,在修改
//SSO Server端 Controller
@RestController
public class SsoServerController {//處理所有SSO相關請求:拆分式路由// SSO-Server:統一認證地址,接受參數:redirect=授權重定向地址// 作用: 用戶未登錄,重定向到登陸頁面// ** 已登錄,生成Ticket重定向回客戶端**(已登錄內部是通過redis查看用戶id來判斷的)@RequestMapping("/sso/auth")public Object ssoAuth() {return SaSsoServerProcessor.instance.ssoAuth();}// SSO-Server:RestAPI 登錄接口,賬號密碼登錄接口,接受參數:name、pwd// 作用: 處理登錄表單提交,調用doLoginHandle進行驗證// 驗證成功后生成SSO票據并返回給客戶端@RequestMapping("/sso/doLogin")public Object ssoDoLogin() {return SaSsoServerProcessor.instance.ssoDoLogin();}/*** 鹽值,混淆密碼*/public static final String SALT = "kk";@Resourceprivate UserService userService;// 配置SSO相關參數 @Autowiredprivate void configSso(SaSsoServerConfig ssoServer) {// 自定義API地址,用于修改統一認證中心的地址//SaSsoServerProcessor.instance.ssoServerTemplate.apiName.ssoAuth = "/sso/auth2";// 配置:未登錄時返回的ViewssoServer.notLoginView = () -> {return new ModelAndView("sa-login.html");};// 配置:登錄處理函數 參數:賬號密碼ssoServer.doLoginHandle = (name, pwd) -> {// 1. 校驗if (StringUtils.isAnyBlank(name, pwd)) {return SaResult.error("參數為空");}if (name.length() < 4) {return SaResult.error( "賬號錯誤");}if (pwd.length() < 8) {return SaResult.error("密碼錯誤");}// 2. 加密String encryptPassword = DigestUtils.md5DigestAsHex((SALT + pwd).getBytes());// 查詢用戶是否存在QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("userAccount", name);queryWrapper.eq("userPassword", encryptPassword);User user = userService.getUserInfo(queryWrapper);// 用戶不存在if (user == null) {// 登錄失敗,重定向到 /sso/auth 并攜帶錯誤參數return "redirect:/sso/auth?error=用戶不存在或密碼錯誤";}// 獲取當前請求HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();String device = DeviceUtils.getRequestDevice(request);// 使用 Sa-Token 登錄,并指定設備,同端登錄互斥StpUtil.login(user.getId(), device);StpUtil.getSession().set("user_login", user);return SaResult.ok("登錄成功!").setData(StpUtil.getTokenValue());};}// 在 SsoServerController 或全局配置中添加@Beanpublic SaServletFilter saServletFilter() {return new SaServletFilter().setBeforeAuth(obj -> {// 設置響應頭防止緩存SaHolder.getResponse().setHeader("Cache-Control", "no-cache, no-store, must-revalidate");SaHolder.getResponse().setHeader("Pragma", "no-cache");SaHolder.getResponse().setHeader("Expires", "0");});}
}
啟動類
@SpringBootApplication
public class MainApplication {public static void main(String[] args) {SpringApplication.run(MainApplication.class, args);}System.out.println();System.out.println("---------------------- Sa-Token SSO 統一認證中心啟動成功 ----------------------");System.out.println("配置信息:" + SaSsoManager.getServerConfig());System.out.println();
}
跨域處理
配置項 sa-token.sso-server.allow-url=*
意為配置所有允許的Client端授權地址,不在此配置項中的URL將無法單點登錄成功
但是,在生產環境中,此配置項絕對不能配置為 * ,否則會有被Ticket
劫持的風險
假設攻擊者根據模仿我們的授權地址,巧妙的構造一個URL
http://sa-sso-server.com:9000/sso/auth?redirect=https://www.baidu.com/
當不知情的小白被誘導訪問了這個URL時,它將被重定向至百度首頁,代表著用戶身份的Ticket
碼也顯現到了URL之中
防范處理
對redirect參數
進行校驗,如果其不在指定的URL列表中時,拒絕下放ticket
#SSO服務器端進行配置 sa-token: sso-server: # 配置允許單點登錄的 url ? allow-url: "http://localhost:8101/sso/login" ?#配置到詳細地址
為什么不直接回傳 Token,而是先回傳 Ticket,再用 Ticket 去查詢對應的賬號id?
Token 作為長時間有效的會話憑證,任何時候都不應該直接暴露在 URL 之中(雖然 Token 很安全,但會直接暴露為很多漏洞提供可乘之機)
為了讓系統絕對安全,選擇先回傳 Ticket
,再由Ticket
獲取賬號id,而且 Ticket 一次性用完即廢,提高安全性。