单点登录(二):功能实现详解
上一篇《单点登录(一):思考》介绍了我在做单点登录功能过程中的一些思考,本篇内容将基于这些思考作代码实现详细的介绍。
票据的定义
票据是用户登录成功后发给用户的凭据,在本篇博客中,票据可被理解为登录用户身份信息的集合,类似于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的过程,也记录了过程中的思考,思考是极其美妙的。