一、簡介 單點登錄(SingleSignOn,SSO) 指的是在多個應用系統中,只需登錄一次,就可以訪問其他相互信任的應用系統。 JWT Json Web Token,這裡不詳細描述,簡單說是一種認證機制。 OAuth2.0 OAuth2.0是一個認證流程,一共有四種方式,這裡用的是最常用的授權碼方 ...
一、簡介
單點登錄(SingleSignOn,SSO)
指的是在多個應用系統中,只需登錄一次,就可以訪問其他相互信任的應用系統。
JWT
Json Web Token,這裡不詳細描述,簡單說是一種認證機制。
Auth2.0
Auth2.0是一個認證流程,一共有四種方式,這裡用的是最常用的授權碼方式,流程為:
1、系統A向認證中心先獲取一個授權碼code。
2、系統A通過授權碼code獲取 token,refresh_token,expiry_time,scope。
token:系統A向認證方獲取資源請求時帶上的token。
refresh_token:token的有效期比較短,用來刷新token用。
expiry_time:token過期時間。
scope:資源域,系統A所擁有的資源許可權,比喻scope:["userinfo"],系統A只擁有獲取用戶信息的許可權。像平時網站接入微信登錄也是只能授權獲取微信用戶基本信息。
這裡的SSO都是公司自己的系統,都是獲取用戶信息,所以這個為空,第三方需要接入我們的登錄時才需要scope來做資源許可權判斷。
二、實現目標
1、一處登錄,全部登錄
流程圖為:
1、瀏覽器訪問A系統,發現A系統未登錄,跳轉到統一登錄中心(SSO),帶上A系統的回調地址,
地址為:https://sso.com/SSO/Login?redirectUrl=https://web1.com/Account/LoginRedirect&clientId=web1,輸入用戶名,密碼,登錄成功,生成授權碼code,創建一個全局會話(cookie,redis),帶著授權碼跳轉回A系統地址:https://web1.com/Account/LoginRedirect?AuthCode=xxxxxxxx。然後A系統的回調地址用這個AuthCode調用SSO獲取token,獲取到token,創建一個局部會話(cookie,redis),再跳轉到https://web1.com。這樣A系統就完成了登錄。
2、瀏覽器訪問B系統,發現B系統沒登錄,跳轉到統一登錄中心(SSO),帶上B系統的回調地址,
地址為:https://sso.com/SSO/Login?redirectUrl=https://web2.com/Account/LoginRedirect&clientId=web2,SSO有全局會話證明已經登錄過,直接用全局會話code獲取B系統的授權碼code,
帶著授權碼跳轉回B系統https://web2.com/Account/LoginRedirect?AuthCode=xxxxxxxx,然後B系統的回調地址用這個AuthCode調用SSO獲取token,獲取到token創建一個局部會話(cookie,redis),再跳轉到https://web2.com。整個過程不用輸入用戶名密碼,這些跳轉基本是無感的,所以B就自動登錄好了。
為什麼要多個授權碼而不直接帶token跳轉回A,B系統呢?因為地址上的參數是很容易被攔截到的,可能token會被截取到,非常不安全
還有為了安全,授權碼只能用一次便銷毀,A系統的token和B系統的token是獨立的,不能相互訪問。
2、一處退出,全部退出
流程圖為:
A系統退出,把自己的會話刪除,然後跳轉到SSO的退出登錄地址:https://sso.com/SSO/Logout?redirectUrl=https://web1.com/Account/LoginRedirect&clientId=web1,SSO刪除全局會話,然後調介面刪除獲取了token的系統,然後在跳轉到登錄頁面,https://sso.com/SSO/Login?redirectUrl=https://web1.com/Account/LoginRedirect&clientId=web1,這樣就實現了一處退出,全部退出了。
3、雙token機制
也就是帶刷新token,為什麼要刷新token呢?因為基於token式的鑒權授權有著天生的缺陷
token設置時間長,token泄露了,重放攻擊。
token設置短了,老是要登錄。問題還有很多,因為token本質決定,大部分是解決不了的。
所以就需要用到雙Token機制,SSO返回token和refreshToken,token用來鑒權使用,refreshToken刷新token使用,
比喻token有效期10分鐘,refreshToken有效期2天,這樣就算token泄露了,最多10分鐘就會過期,影響沒那麼大,系統定時9分鐘刷新一次token,
這樣系統就能讓token滑動過期了,避免了頻繁重新登錄。
三、功能實現和核心代碼
1、一處登錄,全部登錄實現
建三個項目,SSO的項目,web1的項目,web2項目。
這裡的流程就是web1跳轉SSO輸用戶名登錄成功獲取code,把會話寫到SSO的cookie,然後跳轉回來根據code跟SSO獲取token登錄成功;
然後訪問web2跳轉到SSO,SSO已經登錄,自動獲取code跳回web2根據code獲取token。
能實現一處登錄處處登錄的關鍵是SSO的cookie。
然後這裡有一個核心的問題,如果我們生成的token有效期都是24小時,那麼web1登錄成功,獲取的token有效期是24小時,
等到過了12個小時,我訪問web2,web2也得到一個24小時的token,這樣再過12小時,web1的登錄過期了,web2還沒過期,
這樣就是web2是登錄狀態,然而web1卻不是登錄狀態需要重新登錄,這樣就違背了一處登錄處處登錄的理念。
所以後面獲取的token,只能跟第一次登錄的token的過期時間是一樣的。怎麼做呢,就是SSO第一次登錄時過期時間緩存下來,後面根據SSO會話獲取的code,
換到的token的過期時間都和第一次一樣。
SSO項目
SSO項目配置文件appsettings.json中加入web1,web2的信息,用來驗證來源和生成對應項目的jwt token,實際項目應該存到資料庫。
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "AppSetting": { "appHSSettings": [ { "domain": "https://localhost:7001", "clientId": "web1", "clientSecret": "Nu4Ohg8mfpPnNxnXu53W4g0yWLqF0mX2" }, { "domain": "https://localhost:7002", "clientId": "web2", "clientSecret": "pQeP5X9wejpFfQGgSjyWB8iFdLDGHEV8" } ] } }
domain:接入系統的功能變數名稱,可以用來校驗請求來源是否合法。
clientId:接入系統標識,請求token時傳進來識別是哪個系統。
clientSecret:接入系統密鑰,用來生成對稱加密的JWT。
建一個IJWTService定義JWT生成需要的方法
/// <summary> /// JWT服務介面 /// </summary> public interface IJWTService { /// <summary> /// 獲取授權碼 /// </summary> /// <param name="userName"></param> /// <param name="password"></param> /// <returns></returns> /// <exception cref="NotImplementedException"></exception> ResponseModel<string> GetCode(string clientId, string userName, string password); /// <summary> /// 根據會話Code獲取授權碼 /// </summary> /// <param name="clientId"></param> /// <param name="sessionCode"></param> /// <returns></returns> ResponseModel<string> GetCodeBySessionCode(string clientId, string sessionCode); /// <summary> /// 根據授權碼獲取Token+RefreshToken /// </summary> /// <param name="authCode"></param> /// <returns>Token+RefreshToken</returns> ResponseModel<GetTokenDTO> GetTokenWithRefresh(string authCode); /// <summary> /// 根據RefreshToken刷新Token /// </summary> /// <param name="refreshToken"></param> /// <param name="clientId"></param> /// <returns></returns> string GetTokenByRefresh(string refreshToken, string clientId); }
建一個抽象類JWTBaseService加模板方法實現詳細的邏輯
/// <summary> /// jwt服務 /// </summary> public abstract class JWTBaseService : IJWTService { protected readonly IOptions<AppSettingOptions> _appSettingOptions; protected readonly Cachelper _cachelper; public JWTBaseService(IOptions<AppSettingOptions> appSettingOptions, Cachelper cachelper) { _appSettingOptions = appSettingOptions; _cachelper = cachelper; } /// <summary> /// 獲取授權碼 /// </summary> /// <param name="userName"></param> /// <param name="password"></param> /// <returns></returns> /// <exception cref="NotImplementedException"></exception> public ResponseModel<string> GetCode(string clientId, string userName, string password) { ResponseModel<string> result = new ResponseModel<string>(); string code = string.Empty; AppHSSetting appHSSetting = _appSettingOptions.Value.appHSSettings.Where(s => s.clientId == clientId).FirstOrDefault(); if (appHSSetting == null) { result.SetFail("應用不存在"); return result; } //真正項目這裡查詢資料庫比較 if (!(userName == "admin" && password == "123456")) { result.SetFail("用戶名或密碼不正確"); return result; } //用戶信息 CurrentUserModel currentUserModel = new CurrentUserModel { id = 101, account = "admin", name = "張三", mobile = "13800138000", role = "SuperAdmin" }; //生成授權碼 code = Guid.NewGuid().ToString().Replace("-", "").ToUpper(); string key = $"AuthCode:{code}"; string appCachekey = $"AuthCodeClientId:{code}"; //緩存授權碼 _cachelper.StringSet<CurrentUserModel>(key, currentUserModel, TimeSpan.FromMinutes(10)); //緩存授權碼是哪個應用的 _cachelper.StringSet<string>(appCachekey, appHSSetting.clientId, TimeSpan.FromMinutes(10)); //創建全局會話 string sessionCode = $"SessionCode:{code}"; SessionCodeUser sessionCodeUser = new SessionCodeUser { expiresTime = DateTime.Now.AddHours(1), currentUser = currentUserModel }; _cachelper.StringSet<CurrentUserModel>(sessionCode, currentUserModel, TimeSpan.FromDays(1)); //全局會話過期時間 string sessionExpiryKey = $"SessionExpiryKey:{code}"; DateTime sessionExpirTime = DateTime.Now.AddDays(1); _cachelper.StringSet<DateTime>(sessionExpiryKey, sessionExpirTime, TimeSpan.FromDays(1)); Console.WriteLine($"登錄成功,全局會話code:{code}"); //緩存授權碼取token時最長的有效時間 _cachelper.StringSet<DateTime>($"AuthCodeSessionTime:{code}", sessionExpirTime, TimeSpan.FromDays(1)); result.SetSuccess(code); return result; } /// <summary> /// 根據會話code獲取授權碼 /// </summary> /// <param name="clientId"></param> /// <param name="sessionCode"></param> /// <returns></returns> public ResponseModel<string> GetCodeBySessionCode(string clientId, string sessionCode) { ResponseModel<string> result = new ResponseModel<string>(); string code = string.Empty; AppHSSetting appHSSetting = _appSettingOptions.Value.appHSSettings.Where(s => s.clientId == clientId).FirstOrDefault(); if (appHSSetting == null) { result.SetFail("應用不存在"); return result; } string codeKey = $"SessionCode:{sessionCode}"; CurrentUserModel currentUserModel = _cachelper.StringGet<CurrentUserModel>(codeKey); if (currentUserModel == null) { return result.SetFail("會話不存在或已過期", string.Empty); } //生成授權碼 code = Guid.NewGuid().ToString().Replace("-", "").ToUpper(); string key = $"AuthCode:{code}"; string appCachekey = $"AuthCodeClientId:{code}"; //緩存授權碼 _cachelper.StringSet<CurrentUserModel>(key, currentUserModel, TimeSpan.FromMinutes(10)); //緩存授權碼是哪個應用的 _cachelper.StringSet<string>(appCachekey, appHSSetting.clientId, TimeSpan.FromMinutes(10)); //緩存授權碼取token時最長的有效時間 DateTime expirTime = _cachelper.StringGet<DateTime>($"SessionExpiryKey:{sessionCode}"); _cachelper.StringSet<DateTime>($"AuthCodeSessionTime:{code}", expirTime, expirTime - DateTime.Now); result.SetSuccess(code); return result; } /// <summary> /// 根據刷新Token獲取Token /// </summary> /// <param name="refreshToken"></param> /// <param name="clientId"></param> /// <returns></returns> public string GetTokenByRefresh(string refreshToken, string clientId) { //刷新Token是否在緩存 CurrentUserModel currentUserModel = _cachelper.StringGet<CurrentUserModel>($"RefreshToken:{refreshToken}"); if(currentUserModel==null) { return String.Empty; } //刷新token過期時間 DateTime refreshTokenExpiry = _cachelper.StringGet<DateTime>($"RefreshTokenExpiry:{refreshToken}"); //token預設時間為600s double tokenExpiry = 600; //如果刷新token的過期時間不到600s了,token過期時間為刷新token的過期時間 if(refreshTokenExpiry>DateTime.Now&&refreshTokenExpiry<DateTime.Now.AddSeconds(600)) { tokenExpiry = (refreshTokenExpiry - DateTime.Now).TotalSeconds; } //從新生成Token string token = IssueToken(currentUserModel, clientId, tokenExpiry); return token; } /// <summary> /// 根據授權碼,獲取Token /// </summary> /// <param name="userInfo"></param> /// <param name="appHSSetting"></param> /// <returns></returns> public ResponseModel<GetTokenDTO> GetTokenWithRefresh(string authCode) { ResponseModel<GetTokenDTO> result = new ResponseModel<GetTokenDTO>(); string key = $"AuthCode:{authCode}"; string clientIdCachekey = $"AuthCodeClientId:{authCode}"; string AuthCodeSessionTimeKey = $"AuthCodeSessionTime:{authCode}"; //根據授權碼獲取用戶信息 CurrentUserModel currentUserModel = _cachelper.StringGet<CurrentUserModel>(key); if (currentUserModel == null) { throw new Exception("code無效"); } //清除authCode,只能用一次 _cachelper.DeleteKey(key); //獲取應用配置 string clientId = _cachelper.StringGet<string>(clientIdCachekey); //刷新token過期時間 DateTime sessionExpiryTime = _cachelper.StringGet<DateTime>(AuthCodeSessionTimeKey); DateTime tokenExpiryTime = DateTime.Now.AddMinutes(10);//token過期時間10分鐘 //如果刷新token有過期期比token預設時間短,把token過期時間設成和刷新token一樣 if (sessionExpiryTime > DateTime.Now && sessionExpiryTime < tokenExpiryTime) { tokenExpiryTime = sessionExpiryTime; } //獲取訪問token string token = this.IssueToken(currentUserModel, clientId, (sessionExpiryTime - DateTime.Now).TotalSeconds); TimeSpan refreshTokenExpiry; if (sessionExpiryTime != default(DateTime)) { refreshTokenExpiry = sessionExpiryTime - DateTime.Now; } else { refreshTokenExpiry = TimeSpan.FromSeconds(60 * 60 * 24);//預設24小時 } //獲取刷新token string refreshToken = this.IssueToken(currentUserModel, clientId, refreshTokenExpiry.TotalSeconds); //緩存刷新token _cachelper.StringSet($"RefreshToken:{refreshToken}", currentUserModel, refreshTokenExpiry); //緩存刷新token過期時間 _cachelper.StringSet($"RefreshTokenExpiry:{refreshToken}",DateTime.Now.AddSeconds(refreshTokenExpiry.TotalSeconds), refreshTokenExpiry); result.SetSuccess(new GetTokenDTO() { token = token, refreshToken = refreshToken, expires = 60 * 10 }); Console.WriteLine($"client_id:{clientId}獲取token,有效期:{sessionExpiryTime.ToString("yyyy-MM-dd HH:mm:ss")},token:{token}"); return result; } #region private /// <summary> /// 簽發token /// </summary> /// <param name="userModel"></param> /// <param name="clientId"></param> /// <param name="second"></param> /// <returns></returns> private string IssueToken(CurrentUserModel userModel, string clientId, double second = 600) { var claims = new[] { new Claim(ClaimTypes.Name, userModel.name), new Claim("Account", userModel.account), new Claim("Id", userModel.id.ToString()), new Claim("Mobile", userModel.mobile), new Claim(ClaimTypes.Role,userModel.role), }; //var appHSSetting = getAppInfoByAppKey(clientId); //var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(appHSSetting.clientSecret)); //var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var creds = GetCreds(clientId); /** * Claims (Payload) Claims 部分包含了一些跟這個 token 有關的重要信息。 JWT 標準規定了一些欄位,下麵節選一些欄位: iss: The issuer of the token,簽發主體,誰給的 sub: The subject of the token,token 主題 aud: 接收對象,給誰的 exp: Expiration Time。 token 過期時間,Unix 時間戳格式 iat: Issued At。 token 創建時間, Unix 時間戳格式 jti: JWT ID。針對當前 token 的唯一標識 除了規定的欄位外,可以包含其他任何 JSON 相容的欄位。 * */ var token = new JwtSecurityToken( issuer: "SSOCenter", //誰給的 audience: clientId, //給誰的 claims: claims, expires: DateTime.Now.AddSeconds(second),//token有效期 notBefore: null,//立即生效 DateTime.Now.AddMilliseconds(30),//30s後有效 signingCredentials: creds); string returnToken = new JwtSecurityTokenHandler().WriteToken(token); return returnToken; } /// <summary> /// 根據appKey獲取應用信息 /// </summary> /// <param name="clientId"></param> /// <returns></returns> private AppHSSetting getAppInfoByAppKey(string clientId) { AppHSSetting appHSSetting = _appSettingOptions.Value.appHSSettings.Where(s => s.clientId == clientId).FirstOrDefault(); return appHSSetting; } /// <summary> /// 獲取加密方式 /// </summary> /// <returns></returns> protected abstract SigningCredentials GetCreds(string clientId); #endregion }
新建類JWTHSService實現對稱加密
/// <summary> /// JWT對稱可逆加密 /// </summary> public class JWTHSService : JWTBaseService { public JWTHSService(IOptions<AppSettingOptions> options, Cachelper cachelper):base(options,cachelper) { } /// <summary> /// 生成對稱加密簽名憑證 /// </summary> /// <param name="clientId"></param> /// <returns></returns> protected override SigningCredentials GetCreds(string clientId) { var appHSSettings=getAppInfoByAppKey(clientId); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(appHSSettings.clientSecret)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); return creds; } /// <summary> /// 根據appKey獲取應用信息 /// </summary> /// <param name="clientId"></param> /// <returns></returns> private AppHSSetting getAppInfoByAppKey(string clientId) { AppHSSetting appHSSetting = _appSettingOptions.Value.appHSSettings.Where(s => s.clientId == clientId).FirstOrDefault(); return appHSSetting; } }
新建JWTRSService類實現非對稱加密,和上面的對稱加密,只需要一個就可以里,這裡把兩種都寫出來了
/// <summary> /// JWT非對稱加密 /// </summary> public class JWTRSService : JWTBaseService { public JWTRSService(IOptions<AppSettingOptions> options, Cachelper cachelper):base(options, cachelper) { } /// <summary> /// 生成非對稱加密簽名憑證 /// </summary> /// <param name="clientId"></param> /// <returns></returns> protected override SigningCredentials GetCreds(string clientId) { var appRSSetting = getAppInfoByAppKey(clientId); var rsa = RSA.Create(); byte[] privateKey = Convert.FromBase64String(appRSSetting.privateKey);//這裡只需要私鑰,不要begin,不要end rsa.ImportPkcs8PrivateKey(privateKey, out _); var key = new RsaSecurityKey(rsa); var creds = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); return creds; } /// <summary> /// 根據appKey獲取應用信息 /// </summary> /// <param name="clientId"></param> /// <returns></returns> private AppRSSetting getAppInfoByAppKey(string clientId) { AppRSSetting appRSSetting = _appSettingOptions.Value.appRSSettings.Where(s => s.clientId == clientId).FirstOrDefault(); return appRSSetting; } }
什麼時候用JWT的對稱加密,什麼時候用JWT的非對稱加密呢?
對稱加密:雙方保存同一個密鑰,簽名速度快,但因為雙方密鑰一樣,所以安全性比非對稱加密低一些。
非對稱加密:認證方保存私鑰,系統方保存公鑰,簽名速度比對稱加密慢,但公鑰私鑰互相不能推導,所以安全性高。
所以註重性能的用對稱加密,註重安全的用非對稱加密,一般是公司的系統用對稱加密,第三方接入的話用非對稱加密。
web1項目:
appsettings.json存著web1的信息
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "SSOSetting": { "issuer": "SSOCenter", "audience": "web1", "clientId": "web1", "