單點登陸(二):功能實現詳解

上一篇 《單點登陸(一):思考》介紹了我在作單點登陸功能過程當中的一些思考,本篇內容將基於這些思考做代碼實現詳細的介紹。
 
票據的定義
票據是用戶登陸成功後發給用戶的憑據,在本篇博客中,票據可被理解爲登陸用戶身份信息的集合,相似於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;
        }
    }
}
 
票據的處理
票據的處理分爲:
  1. 登陸成功後將用戶信息生成票據並按一系列規則加密後返給用戶。
  2. 對用戶請求中的票據信息進行解密獲取登陸的用戶信息。
由此能夠發現,票據存在加密與解析的過程,而且這個加密與解析方式應該是能夠自定義的,因而有了票據處理接口:
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跨域同步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);

 

與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的過程,也記錄了過程當中的思考,思考是極其美妙的。前端

相關文章
相關標籤/搜索