一、簡介
單點登錄(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", "clientSecret": "Nu4Ohg8mfpPnNxnXu53W4g0yWLqF0mX2" } }
Program.cs檔案加入認證代碼,加入builder.Services.AddAuthentication(,,,和加入app.UseAuthentication(),完整代碼如下:
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using RSAExtensions; using SSO.Demo.Web1.Models; using SSO.Demo.Web1.Utils; using System.Security.Cryptography; using System.Text; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllersWithViews(); builder.Services.AddHttpClient(); builder.Services.AddSingleton<Cachelper>(); builder.Services.Configure<AppOptions>(builder.Configuration.GetSection("AppOptions")); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { //Audience,Issuer,clientSecret的值要和sso的一致 //JWT有一些默認的屬性,就是給鑒權時就可以篩選了 ValidateIssuer = true,//是否驗證Issuer ValidateAudience = true,//是否驗證Audience ValidateLifetime = true,//是否驗證失效時間 ValidateIssuerSigningKey = true,//是否驗證client secret ValidIssuer = builder.Configuration["SSOSetting:issuer"],// ValidAudience = builder.Configuration["SSOSetting:audience"],//Issuer,這兩項和前面簽發jwt的設定一致 IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["SSOSetting:clientSecret"]))//client secret }; }); #region 非對稱加密-鑒權 //var rsa = RSA.Create(); //byte[] publickey = Convert.FromBase64String(AppSetting.PublicKey); //公鑰,去掉begin... end ... ////rsa.ImportPkcs8PublicKey 是一個擴展方法,來源于RSAExtensions包 //rsa.ImportPkcs8PublicKey(publickey); //var key = new RsaSecurityKey(rsa); //var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaPKCS1); //builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) // .AddJwtBearer(options => // { // options.TokenValidationParameters = new TokenValidationParameters // { // //Audience,Issuer,clientSecret的值要和sso的一致 // //JWT有一些默認的屬性,就是給鑒權時就可以篩選了 // ValidateIssuer = true,//是否驗證Issuer // ValidateAudience = true,//是否驗證Audience // ValidateLifetime = true,//是否驗證失效時間 // ValidateIssuerSigningKey = true,//是否驗證client secret // ValidIssuer = builder.Configuration["SSOSetting:issuer"],// // ValidAudience = builder.Configuration["SSOSetting:audience"],//Issuer,這兩項和前面簽發jwt的設定一致 // IssuerSigningKey = signingCredentials.Key // }; // }); #endregion var app = builder.Build(); ServiceLocator.Instance = app.Services; //用于手動獲取DI物件 // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Home/Error"); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication();//這個加在UseAuthorization 前 app.UseAuthorization(); app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); app.Run();
然后加介面根據授權code獲取token,增加AccountController
/// <summary> /// 用戶資訊 /// </summary> public class AccountController : Controller { private IHttpClientFactory _httpClientFactory; private readonly Cachelper _cachelper; public AccountController(IHttpClientFactory httpClientFactory, Cachelper cachelper) { _httpClientFactory = httpClientFactory; _cachelper = cachelper; } /// <summary> /// 獲取用戶資訊,介面需要進行權限校驗 /// </summary> /// <returns></returns> [MyAuthorize] [HttpPost] public ResponseModel<UserDTO> GetUserInfo() { ResponseModel<UserDTO> user = new ResponseModel<UserDTO>(); return user; } /// <summary> /// 登錄成功回呼 /// </summary> /// <returns></returns> public ActionResult LoginRedirect() { return View(); } //根據authCode獲取token [HttpPost] public async Task<ResponseModel<GetTokenDTO>> GetAccessCode([FromBody] GetAccessCodeRequest request) { ResponseModel<GetTokenDTO> result = new ResponseModel<GetTokenDTO>(); //請求SSO獲取 token var client = _httpClientFactory.CreateClient(); var param = new { authCode = request.authCode }; string jsonData =https://www.cnblogs.com/wei325/p/ System.Text.Json.JsonSerializer.Serialize(param); StringContent paramContent = new StringContent(jsonData); //請求sso獲取token var response = await client.PostAsync("https://localhost:7000/SSO/GetToken", new StringContent(jsonData, Encoding.UTF8, "application/json")); string resultStr = await response.Content.ReadAsStringAsync(); result = System.Text.Json.JsonSerializer.Deserialize<ResponseModel<GetTokenDTO>>(resultStr); if (result.code == 0) //成功 { //成功,快取token到區域會話 string token = result.data.token; string key = $"SessionCode:{request.sessionCode}"; string tokenKey = $"token:{token}"; _cachelper.StringSet<string>(key, token, TimeSpan.FromSeconds(result.data.expires)); _cachelper.StringSet<bool>(tokenKey, true, TimeSpan.FromSeconds(result.data.expires)); Console.WriteLine($"獲取token成功,區域會話code:{request.sessionCode},{Environment.NewLine}token:{token}"); } return result; } /// <summary> /// 退出登錄 /// </summary> /// <param name="request"></param> /// <returns></returns> [HttpPost] public ResponseModel LogOut([FromBody] LogOutRequest request) { string key = $"SessionCode:{request.SessionCode}"; //根據會話取出token string token = _cachelper.StringGet<string>(key); if (!string.IsNullOrEmpty(token)) { //清除token string tokenKey = $"token:{token}"; _cachelper.DeleteKey(tokenKey); } Console.WriteLine($"會話Code:{request.SessionCode}退出登錄"); return new ResponseModel().SetSuccess(); } }
還有得到的token還沒過期,如果我退出登錄了,怎么判斷這個會話token失效了呢?
這里需要攔截認證過濾器,判斷token在快取中被洗掉,則認證不通過,增加檔案MyAuthorize
/// <summary> /// 攔截認證過濾器 /// </summary> public class MyAuthorize : Attribute, IAuthorizationFilter { private static Cachelper _cachelper = ServiceLocator.Instance.GetService<Cachelper>(); public void OnAuthorization(AuthorizationFilterContext context) { string id = context.HttpContext.User.FindFirst("id")?.Value; if(string.IsNullOrEmpty(id)) { //token檢驗失敗 context.Result = new StatusCodeResult(401); //回傳鑒權失敗 return; } Console.WriteLine("我是Authorization過濾器"); //請求的地址 var url = context.HttpContext.Request.Path.Value; //獲取列印頭部資訊 var heads = context.HttpContext.Request.Headers; //取到token "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoi5byg5LiJIiwiQWNjb3VudCI6ImFkbWluIiwiSWQiOiIxMDEiLCJNb2JpbGUiOiIxMzgwMDEzODAwMCIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IlN1cGVyQWRtaW4iLCJleHAiOjE2NTMwNjA0MDIsImlzcyI6IlNTT0NlbnRlciIsImF1ZCI6IndlYjIifQ.aAi5a0zr_nLQQaSxSBqEhHZQ6ALFD_rWn2tnLt38DeA" string token = heads["Authorization"]; token = token.Replace("Bearer", "").TrimStart();//去掉 "Bearer "才是真正的token if (string.IsNullOrEmpty(token)) { Console.WriteLine("校驗不通過"); return; } //redis校驗這個token的有效性,確定來源是sso和確定會話沒過期 string tokenKey = $"token:{token}"; bool isVaid = _cachelper.StringGet<bool>(tokenKey); //token無效 if (isVaid == false) { Console.WriteLine($"token無效,token:{token}"); context.Result = new StatusCodeResult(401); //回傳鑒權失敗 } } }
然后需要認證的控制器或方法頭部加上[MyAuthorize]即能自動認證,
web1需要登錄的頁面
@{ ViewData["Title"] = "Home Page"; } <div class="text-center"> <h1 class="display-4">歡迎來到Web1</h1> <p>Learn about <a href=https://www.cnblogs.com/wei325/p/"https://web2.com:7002">跳轉到Web2</a>.</p> <p>Learn about <a onclick="logOut()" href=https://www.cnblogs.com/wei325/p/"javascript:void(0);">退出登錄</a>.</p> </div> @section Scripts{ <script src=https://www.cnblogs.com/wei325/p/"~/js/Common.js"></script> <script> getUserInfo() //獲取用戶資訊 function getUserInfo(){ //1.cookie是否有 token const token=getCookie('token') console.log('gettoken',token) if(!token) { redirectLogin() } $.ajax({ type: 'POST', url: '/Account/GetUserInfo', headers:{"Authorization":'Bearer ' + token}, success: success, error:error }); } function success(){ console.log('成功') } function error(xhr, exception){ if(xhr.status===401) //鑒權失敗 { console.log('未鑒權') redirectLogin() } } //重定向到登錄 function redirectLogin(){ window.location.href="https://sso.com:7000/SSO/Login?clientId=web1&redirectUrl=https://web1.com:7001/Account/LoginRedirect" } //退出登錄 function logOut(){ clearCookie("token") //清除cookie token clearCookie("refreshToken") //清除cookie refreshToken clearCookie("sessionCode") //清除cookie 會話 //跳轉到SSO退出登錄 window.location.href=https://www.cnblogs.com/wei325/p/"https://sso.com:7000/SSO/LogOut?clientId=web1&redirectUrl=https://web1.com:7001/Account/LoginRedirect" } </script> }
sso登錄完要跳轉回web1的頁面
@* For more information on enabling MVC for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860 *@ @{ Layout = null; } <script src=https://www.cnblogs.com/wei325/p/"~/lib/jquery/dist/jquery.min.js"></script> <script src=https://www.cnblogs.com/wei325/p/"~/js/Common.js"></script> <script> GetAccessToken(); //根據code獲取token function GetAccessToken(){ var params=GetParam() //code var authCode=params["authCode"] var sessionCode=params["sessionCode"] console.log('authcode',authCode) var params={authCode,sessionCode} $.ajax({ url:'/Account/GetAccessCode', type:"POST", data:JSON.stringify(params), contentType:"application/json; charset=utf-8", dataType:"json", success: function(data){ console.log('token',data) if(data.code===0) //成功 { console.log('設定cookie') //把token存到 cookie,過期時間為token有效時間少一分鐘 setCookie("token",data.data.token,data.data.expires-60,"/") //重繪token,有效期1天 setCookie("refreshToken",data.data.refreshToken,24*60*60,"/") setCookie("SessionCode",sessionCode,24*60*60,"/") //跳轉到主頁 window.location.href=https://www.cnblogs.com/wei325/p/"/Home/Index" } }}) } </script>
到這里web1的核心代碼就完成了,web2的代碼跟web1除了配置里面的加密key,其他全部一樣,就不再貼出代碼了,后面原始碼有,
到這里,就實作了一處登錄,全部登錄了,
2、一處退出,全部退出實作
一處退出,處處退出的流程像實作目標中的流程圖,web1系統退出,跳轉到SSO,讓SSO發http請求退出其他的系統,跳轉回登錄頁,
退出有個核心的問題就是,SSO只能讓全部系統在當前瀏覽器上退出,比喻用戶A在電腦1的瀏覽器登錄了,在電腦2的瀏覽器也登錄了,在電腦1上退出只能退出電腦1瀏覽器的登錄,
電腦2的登錄不受影響,web1退出了,SSO中的http請求退出web2的時候是不經過瀏覽器請求的,web2怎么知道清除那個token呢?
這里需要在SSO登錄的時候生成了一個全域會話,SSO的cookie這時可以生成一個全域code,每個系統登錄的時候帶過去作為token的快取key,這樣就能保證全部系統的區域會話快取key是同一個了,
退出登錄的時候只需要洗掉這個快取key的token即可,
SSO的登錄頁面Login.cshtml
@* For more information on enabling MVC for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860 *@ @{ } <form id="form"> <div>用戶名:<input type="text" id=userName name="userName" /></div> <div>密碼:<input type="password" id="password" name="password" /></div> <div><input type="button" value=https://www.cnblogs.com/wei325/p/"提交" onclick="login()" /></div> </form> <script src=https://www.cnblogs.com/wei325/p/"~/lib/jquery/dist/jquery.min.js"></script> <script src=https://www.cnblogs.com/wei325/p/"~/js/Common.js"></script> <script> sessionCheck(); //會話檢查 function sessionCheck(){ //獲取引數集合 const urlParams=GetParam(); const clientId=urlParams['clientId']; const redirectUrl=urlParams['redirectUrl'] const sessionCode=getCookie("SessionCode") if(!sessionCode) { return; } //根據授權碼獲取code var params={clientId,sessionCode} $.ajax({ url:'/SSO/GetCodeBySessionCode', data:JSON.stringify(params), method:'post', dataType:'json', contentType:'application/json', success:function(data){ if(data.code===0) { const code=data.data window.location.href=redirectUrl+'?authCode='+code+"&sessionCode="+sessionCode } } }) } function login(){ //獲取引數集合 const urlParams=GetParam(); const clientId=urlParams['clientId']; const redirectUrl=urlParams['redirectUrl'] const userName=$("#userName").val() const password=$("#password").val() const params={clientId,userName,password} $.ajax({ url:'/SSO/GetCode', data:JSON.stringify(params), method:'post', dataType:'json', contentType:"application/json", success:function(data){ //獲得code,跳轉回客戶頁面 if(data.code===0) { const code=data.data //存盤會話,這里的時間最好減去幾分鐘,不然那邊的token過期,這里剛好多了幾秒沒過期又重新登錄了 setCookie("SessionCode",code,24*60*60,"/") window.location.href=redirectUrl+'?authCode='+code+'&sessionCode='+code } } }) } </script>
這里的SessionCode是關鍵,作為一個全域code,系統登錄會同步到個系統,用于統一退出登錄時用
SSO的退出登錄頁面LogOut.cshtml
@* For more information on enabling MVC for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860 *@ @{ } <p>退出登錄中...</p> <script src=https://www.cnblogs.com/wei325/p/"~/lib/jquery/dist/jquery.min.js"></script> <script src=https://www.cnblogs.com/wei325/p/"~/js/Common.js?v=1"></script> <script> logOut() function logOut() { var sessionCode=getCookie("SessionCode") //清除會話 clearCookie("SessionCode") //獲取引數集合 const urlParams=GetParam(); //跳轉到登錄 const clientId=urlParams['clientId']; const redirectUrl=urlParams['redirectUrl'] var params={sessionCode} //退出登錄 $.ajax({ url:'/SSO/LogOutApp', type:"POST", data:JSON.stringify(params), contentType:"application/json; charset=utf-8", dataType:"json", success: function(data){ console.log('token',data) if(data.code===0) //成功 { //跳轉到登錄頁面 window.location.href=https://www.cnblogs.com/wei325/p/'/SSO/Login'+'?clientId='+clientId+'&redirectUrl='+redirectUrl } }}) } </script>
退出登錄介面:
/// <summary> /// 退出登錄 /// </summary> /// <param name="request"></param> /// <returns></returns> [HttpPost] public async Task<ResponseModel> LogOutApp([FromBody] LogOutRequest request) { //洗掉全域會話 string sessionKey = $"SessionCode:{request.sessionCode}"; _cachelper.DeleteKey(sessionKey); var client = _httpClientFactory.CreateClient(); var param = new { sessionCode = request.sessionCode }; string jsonData =https://www.cnblogs.com/wei325/p/ System.Text.Json.JsonSerializer.Serialize(param); StringContent paramContent = new StringContent(jsonData); //這里實戰中是用資料庫或快取取 List<string> urls = new List<string>() { "https://localhost:7001/Account/LogOut", "https://localhost:7002/Account/LogOut" }; //這里可以異步mq處理,不阻塞回傳 foreach (var url in urls) { //web1退出登錄 var logOutResponse = await client.PostAsync(url, new StringContent(jsonData, Encoding.UTF8, "application/json")); string resultStr = await logOutResponse.Content.ReadAsStringAsync(); ResponseModel response = System.Text.Json.JsonSerializer.Deserialize<ResponseModel>(resultStr); if (response.code == 0) //成功 { Console.WriteLine($"url:{url},會話Id:{request.sessionCode},退出登錄成功"); } else { Console.WriteLine($"url:{url},會話Id:{request.sessionCode},退出登錄失敗"); } }; return new ResponseModel().SetSuccess(); }
web1,web2的退出登錄介面
/// <summary> /// 退出登錄 /// </summary> /// <param name="request"></param> /// <returns></returns> [HttpPost] public ResponseModel LogOut([FromBody] LogOutRequest request) { string key = $"SessionCode:{request.SessionCode}"; //根據會話取出token string token = _cachelper.StringGet<string>(key); if (!string.IsNullOrEmpty(token)) { //清除token string tokenKey = $"token:{token}"; _cachelper.DeleteKey(tokenKey); } Console.WriteLine($"會話Code:{request.SessionCode}退出登錄"); return new ResponseModel().SetSuccess(); }
到這里,一處退出,全部退出也完成了,
3、雙token機制實作
token和refresh_token生成演算法一樣就可以了,只是token的有效期短,refresh_token的有效期長,
那重繪token時怎么知道這個是重繪token呢,SSO生成重繪token的時候,把它保存到快取中,重繪token的時候判斷快取中有就是重繪token,
生成雙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, currentUserModel, 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; }
根據重繪token獲取token代碼:
/// <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; }
四、效果演示
這里專案的SSO地址是:https://localhost:7000 ,web1地址是:https://localhost:7001,web2地址是:https://localhost:7002
修改hosts檔案,讓他們在不同域名下,cookie不能共享,
win10路徑:C:\Windows\System32\drivers\etc\hosts 在最后加入
127.0.0.1 sso.com 127.0.0.1 web1.com 127.0.0.1 web2.com
這樣得到新的地址,SSO地址:https://sso.com:7000 ,web1地址是:https://web1.com,web2地址是:https://web2.com

1、 這里一開始,訪問https://web2.com沒登錄跳轉到https://sso.com,
2、然后訪問https://web1.com也沒登錄,也跳轉到了https://sso.com,證明web1,web2都沒登錄,
3、然后在跳轉的sso登錄后跳轉回web1,然后點https://web2.com的連接跳轉到https://web2.com,自動登錄了,
4、然后在web1退出登錄,web2重繪頁面,也退出了登錄,
再看一下這些操作下SSO日志列印的記錄,

到這里.NET6下基于JWT+OAuth2.0的SSO就完成了,
最后附原始碼:
百度云盤:https://pan.baidu.com/s/1MZbFC7KojIRT0LEL5HBkGg 提取碼:qcn1
github: https://github.com/weixiaolong325/SSO.Demo.SSO (github網路有點問題,還沒上傳完整,用百度云盤的,晚點上傳完整到github)
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/483268.html
標籤:.NET Core
上一篇:.NET CORE 基礎
下一篇:反應imgonError沒有觸發
