上一篇
《單點登錄(一):思考》介紹了我在做單點登錄功能過程中的一些思考,本篇內容將基於這些思考作代碼實現詳細的介紹。
票據的定義
票據是用戶登錄成功后發給用戶的憑據,在本篇博客中,票據可被理解為登錄用戶身份信息的集合,類似於ClaimsIdentity。而由於sso系統本身的平台語言無關性,我希望票據能夠去除ClaimsIdentity內部的一些復雜定義。於是有了票據的定義:
using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; namespace sso.Ticket { public class TicketInfo { public string UserId { get; set; } public string Name { get; set; } public string AuthenticationType { get; set; } = "sso.cookie"; public DateTime CreationTime { get; set; } public DateTime? LastRefreshTime { get; set; } public DateTime ExpireTime { get; set; } public List<NameValue> Claims { get; set; } public TicketInfo() { CreationTime = DateTime.Now; ExpireTime = CreationTime.AddHours(2);//默認有效期:2小時 Claims = new List<NameValue>(); } public TicketInfo(ClaimsIdentity identity) : this() { UserId = identity.FindFirst(ClaimTypes.NameIdentifier).Value; Name = identity.Name; Claims = identity.Claims.Select(p => new NameValue(p.Type, p.Value)).ToList(); } public ClaimsIdentity ToClaimsIdentity() { var claims = Claims.Select(p => new Claim(p.Name, p.Value)); var identity = new ClaimsIdentity(claims, AuthenticationType); return identity; } } }
票據的處理
票據的處理分為:
- 登錄成功后將用戶信息生成票據並按一系列規則加密后返給用戶。
- 對用戶請求中的票據信息進行解密獲取登錄的用戶信息。
由此可以發現,票據存在加密與解析的過程,並且這個加密與解析方式應該是可以自定義的,於是有了票據處理接口:
namespace sso.Ticket { public interface ITicketInfoProtector { string Protect(TicketInfo ticket); TicketInfo UnProtect(string token); } }
對於懶得自己實現加密解析方式的小伙伴們,系統也提供默認實現。票據的處理有了,那應該在什么時候進行處理呢,票據的解析應該是在進方法之前,但是進方法之前如何判斷該方法是否需要登錄呢?這里參考了owin的cookie登錄的實現:
using Microsoft.Owin; using Microsoft.Owin.Security.Infrastructure; using sso.Ticket; namespace sso.Authentication { public class SsoAuthenticationMiddleware : AuthenticationMiddleware<SsoAuthenticationOptions> { public SsoAuthenticationMiddleware(OwinMiddleware next, SsoAuthenticationOptions options) : base(next, options) { if (string.IsNullOrEmpty(Options.CookieName)) { Options.CookieName = SsoAuthenticationOptions.DefaultCookieName; } if (Options.TicketInfoProtector == null) { Options.TicketInfoProtector = new DesTicketInfoProtector(); } } protected override AuthenticationHandler<SsoAuthenticationOptions> CreateHandler() { return new SsoAuthenticationHandler(); } } }
using System; using System.Linq; using System.Threading.Tasks; using Microsoft.Owin.Security; using Microsoft.Owin.Security.Infrastructure; using sso.Ticket; using sso.Utils; namespace sso.Authentication { internal class SsoAuthenticationHandler : AuthenticationHandler<SsoAuthenticationOptions> { protected override async Task<AuthenticationTicket> AuthenticateCoreAsync() { string requestCookie = Context.Request.Cookies[Options.CookieName]; if (requestCookie.IsNullOrWhiteSpace()) return null; TicketInfo ticketInfo; if (Options.SessionStore != null) { ticketInfo = await Options.SessionStore.RetrieveAsync(requestCookie); if (!CheckAllowHost(ticketInfo)) return null; //如果超過一半的有效期,則刷新 DateTime now = DateTime.Now; DateTime issuedTime = ticketInfo.LastRefreshTime ?? ticketInfo.CreationTime; DateTime expireTime = ticketInfo.ExpireTime; TimeSpan t1 = now - issuedTime; TimeSpan t2 = expireTime - now; if (t1 > t2) { ticketInfo.LastRefreshTime = now; ticketInfo.ExpireTime = now.Add(t1 + t2); await Options.SessionStore.RenewAsync(requestCookie, ticketInfo); } } else { //未啟用分布式存儲器,需要前端定時請求刷新token ticketInfo = Options.TicketInfoProtector.UnProtect(requestCookie); if (!CheckAllowHost(ticketInfo)) return null; } if (ticketInfo != null && !ticketInfo.UserId.IsNullOrWhiteSpace()) { var identity = ticketInfo.ToClaimsIdentity(); AuthenticationTicket ticket = new AuthenticationTicket(identity, new AuthenticationProperties()); return ticket; } return null; } protected override Task ApplyResponseChallengeAsync() { if (Response.StatusCode != 401 || Options.LoginPath.IsNullOrWhiteSpace()) { return Task.FromResult(0); } var loginUrl = $"{Options.LoginPath}?{Options.ReturnUrlParameter}={Request.Uri}"; Response.Redirect(loginUrl); return Task.FromResult<object>(null); } private bool CheckAllowHost(TicketInfo ticketInfo) { var claim = ticketInfo.Claims.FirstOrDefault(p => p.Name == SsoClaimTypes.AllowHosts); if (claim == null) return false; var allowHosts = claim.Value.Split(",", StringSplitOptions.RemoveEmptyEntries); return allowHosts.Contains(Request.Host.ToString()); } } }
票據的存儲
票據經過一系列加密處理后形成的加密字符串到底是不是應該直接返給瀏覽器,直接返給瀏覽器會不會有不可描述的安全隱患,如果加密方式泄露確實存在票據被竄改的可能,比較好的做法是將票據存儲在一個共享的存儲器(如:redis)中,向瀏覽器返回該票據的key即可,於是有了票據存儲器的設計:
using System.Threading.Tasks; namespace sso.Ticket { /// <summary> /// 票據共享存儲器 /// </summary> public interface ITicketInfoSessionStore { Task<string> StoreAsync(TicketInfo ticket); Task RenewAsync(string key, TicketInfo ticket); Task<TicketInfo> RetrieveAsync(string key); Task RemoveAsync(string key); } }
Cookie跨域同步
sso服務器登錄成功后生成的token如何寫入到各個業務系統的cookie中去,思路我在上一篇博客中寫過,具體實現是登陸成功后生成加密后的票據信息,以及需要通知的業務系統地址,向前端返回javascript代碼並執行:
using System.Collections.Generic; using sso.Utils; namespace sso.Authentication { public class JavascriptCodeGenerator { /// <summary> /// 執行通知的Javascript方法 /// </summary> public string NotifyFuncName => "sso.notify"; /// <summary> /// 執行錯誤提示的Javascript方法 /// </summary> public string ErrorFuncName => "sso.error"; public string GetLoginCode(string token, List<string> notifyUrls, string redirectUrl) { notifyUrls.Insert(0, redirectUrl); //第一個元素是登陸成功后跳轉的地址,不加token參數 for (int i = 1; i < notifyUrls.Count; i++) { notifyUrls[i] = $"{notifyUrls[i]}?token={token}"; } var strUrls = notifyUrls.ExpandAndToString("','"); return $"{NotifyFuncName}('{strUrls}');"; } public string GetLogoutCode(List<string> notifyUrls) { notifyUrls.Insert(0, "refresh"); var strUrls = notifyUrls.ExpandAndToString("','"); return $"{NotifyFuncName}('{strUrls}');"; } public string GetErrorCode(int code,string message) { return $"sso.error({code},'{message}')"; } } }
var sso = sso || {}; (function ($) { ...... /** * sso服務器登陸成功后jsonp回調 * @param {string[]}需要通知的Url集合 */ sso.notify = function () { var createScript = function (src) { $("<script><//script>").attr("src", src).appendTo("body"); }; var urlList = arguments; for (var i = 1; i < urlList.length; i++) { createScript(urlList[i]); } //延時執行,避免跳轉時cookie還未寫入成功 setTimeout(function () { if (urlList[0] === "refresh") { window.location.reload(); } else { window.location.href = urlList[0]; } }, 1000); }; /** * sso服務器登陸失敗后jsonp回調 * @param {code}錯誤碼 * @param {msg}錯誤消息 */ sso.error= function(code, msg) { alert(msg); } })(jQuery);
與OWIN的集成
因為票據解析使用了owin中間件,所以本項目以及sso服務端是強耦合owin的,owin的擴展類:
using System; using Microsoft.Owin.Extensions; using Owin; namespace sso.Authentication { public static class SsoAuthenticationExtensions { public static IAppBuilder UseSsoCookieAuthentication(this IAppBuilder app, SsoAuthenticationOptions options) { return app.UseSsoCookieAuthentication(options, PipelineStage.Authenticate); } public static IAppBuilder UseSsoCookieAuthentication(this IAppBuilder app, SsoAuthenticationOptions options, PipelineStage stage) { if (app == null) { throw new ArgumentNullException(nameof(app)); } app.Use<SsoAuthenticationMiddleware>(options); app.UseStageMarker(stage); return app; } } }
using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.Owin.Security; using sso.Client; using sso.Ticket; using sso.Utils; namespace sso.Authentication { public class SsoAuthenticationOptions : AuthenticationOptions { public const string DefaultCookieName = "sso.cookie"; public string CookieName { get; set; } public string LoginPath { get; set; } public string ReturnUrlParameter { get; set; } private JavascriptCodeGenerator Javascript { get; } public ITicketInfoProtector TicketInfoProtector { get; set; } public ITicketInfoSessionStore SessionStore { get; set; } public IUserClientStore UserClientStore { get; set; } public SsoAuthenticationOptions() : base(DefaultCookieName) { CookieName = DefaultCookieName; ReturnUrlParameter = "ReturnUrl"; Javascript = new JavascriptCodeGenerator(); } public async Task<string> GetLoginJavascriptCode(ClaimsIdentity identity, string returnUrl) { identity.CheckNotNull(nameof(identity)); UserClientStore.CheckNotNull(nameof(UserClientStore)); var userClients = UserClientStore.GetUserClients(identity.Name); var allowHosts = userClients.Where(p => !p.Host.IsNullOrWhiteSpace()).Select(p => p.Host).ToList(); identity = identity.InitializeWithAllowHosts(allowHosts); var token = await GenerateToken(identity); var loginNotifyUrls = userClients.Where(p => !p.LoginNotifyUrl.IsNullOrWhiteSpace()).Select(p => p.LoginNotifyUrl).ToList(); return Javascript.GetLoginCode(token, loginNotifyUrls, returnUrl); } public string GetLogoutJavascriptCode(string userName) { UserClientStore.CheckNotNull(nameof(UserClientStore)); var userClients = UserClientStore.GetUserClients(userName); var logoutNotifyUrls = userClients.Where(p => !p.LogoutNotifyUrl.IsNullOrWhiteSpace()).Select(p => p.LogoutNotifyUrl).ToList(); return Javascript.GetLogoutCode(logoutNotifyUrls); } private async Task<string> GenerateToken(ClaimsIdentity identity) { var ticket = new TicketInfo(identity); if (SessionStore != null) { return await SessionStore.StoreAsync(ticket); } return TicketInfoProtector.Protect(ticket); } } }
到這里,我的整個sso系統設計的核心代碼就說的差不多了,具體使用示例與源碼在https://github.com/liuxx001/sso.git。寫在最后,看了園子里“百寶門”的sso解決方案的介紹,深知要做一套完整的sso解決方案絕非一日之功,而我目前的sso項目也只是針對web端(可跨域)。兩篇博文記錄了我在做sso系統由0到1的過程,也記錄了過程中的思考,思考是極其美妙的。