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); } }
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()); } } }
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跨域同步javascript
sso服務器登陸成功後生成的token如何寫入到各個業務系統的cookie中去,思路我在上一篇博客中寫過,具體實現是登錄成功後生成加密後的票據信息,以及須要通知的業務系統地址,向前端返回javascript代碼並執行:html
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);
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的過程,也記錄了過程當中的思考,思考是極其美妙的。前端