.NET Core SSO同域实现实践

前言

SSO的系列还是以.Net Core作为实践例子与大家分享,SSO在Web方面复杂度分同域与跨域。本篇先分享同域的设计与实现,跨域将在下篇与大家分享。

如有需要调试demo的,可把SSO项目部署为域名http://sso.cg.com/,Web1项目部署为http://web1.cg.com,http://web2.cg.com,可以减少配置修改量

源码地址:https://github.com/SkyChenSky/Core.SSO

效果图

.net core实践系列之SSO-同域实现_用户信息

SSO简介

单点登录,全称为Single Sign On,在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

它是一个解决方案,目的是为了整合企业内多个应用系统,仅由一组账号只需进行一次登录,就可被授权访问多个应用系统。

.net core实践系列之SSO-同域实现_ide_02

流程描述

未登录状态访问业务Web应用会引导到认证中心。

用户在认证中心输入账号信息通过登录后,认证中心会根据用户信息生成一个具有安全性的token,将以任何方式持久化在浏览器。

此后访问其他Web应用的时候,必须携带此token进行访问,业务Web应用会通过本地认证或者转发认证而对token进行校验。

从上图可以简单的分析出三个关键点:

  • Token的生成
  • Token的共享
  • Token校验

Token的生成

方式有多种:

可以通过Web框架对用户信息加密成Token。

Token编码方式也可以为JSON WEB TOKEN(JWT)

也可以是一段MD5,通过字典匹配保存在服务器用户信息与MD5值



Token的共享

浏览器存储有三种方式:

  • Cookie
  • 容量4KB限制过期时间
  • localStorage
  • 容量5MB限制生命周期永久
  • sessionStorage
  • 容量5MB限制生命周期当前会话,关闭浏览器则失效无法与服务端交互

作为拥有会失效的会话状态,更因选择Cookie存储。那么Cookie的使用是可以在同域共享的,因此在实现SSO的时候复杂度又分为同域跨域

同域的共享比较简单,在应用设置Cookie的Domain属性进行设置,就可以完美的解决。



Token校验

校验分两种情况:

  • 转发给认证中心认证
  • 由谁授权,就由谁进行身份认证。授权与认证是成对的。如果是以Cookie认证,那就是服务端对token进行解密。如果是服务端保存用户信息,则匹配token值。
  • 业务应用自身认证
  • 不需要转发,那就意味着业务应用认证规则与认证中心的认证规则必须是一致的。



设计要点

原则上来讲,只要统一Token的产生和校验方式,无论授权与认证的在哪(认证系统或业务系统),也无论用户信息存储在哪(浏览器、服务器),其实都可以实现单点登录的效果。

此次使用.NET Core MVC框架,以Cookie认证通过业务应用自身认证的方式进行同父域的SSO实现。

为什么要使用Cookie认证方式?

1.会话状态分布在客户浏览器,避免大量用户同时在线对服务端内存容量的压力。

2.横向扩展良好性,可按需增减节点。

统一应用授权认证

将以Core的Cookie认证进行实现,那么意味着每个应用对用户信息的加解密方式需要一致。

因此对AddCookie的设置属性DataProtectionProvider或者TicketDataFormat的加密方式进行重写实现。

.NET Core的SSO实现

Cookie认证

认证中心AddCookie的设置

登录后复制

public void ConfigureServices(IServiceCollection services)        {            services.AddMvc();            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)                .AddCookie(options =>               {                   options.Cookie.Name = "Token";                   options.Cookie.Domain = ".cg.com";                   options.Cookie.HttpOnly = true;                   options.ExpireTimeSpan = TimeSpan.FromMinutes(30);                   options.LoginPath = "/Account/Login";                   options.LogoutPath = "/Account/Logout";                   options.SlidingExpiration = true;                   //options.DataProtectionProvider = DataProtectionProvider.Create(new DirectoryInfo(@"D:\sso\key"));                   options.TicketDataFormat = new TicketDataFormat(new AesDataProtector());               });        }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.


业务应用AddCookie的设置

登录后复制

public void ConfigureServices(IServiceCollection services)        {            services.AddMvc();            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)                .AddCookie(options =>               {                   options.Cookie.Name = "Token";                   options.Cookie.Domain = ".cg.com";                   options.Events.OnRedirectToLogin = BuildRedirectToLogin;                   options.Events.OnSigningOut = BuildSigningOut;                   options.Cookie.HttpOnly = true;                   options.ExpireTimeSpan = TimeSpan.FromMinutes(30);                   options.LoginPath = "/Account/Login";                   options.LogoutPath = "/Account/Logout";                   options.SlidingExpiration = true;                   options.TicketDataFormat = new TicketDataFormat(new AesDataProtector());               });        }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.


基于设计要点的“统一应用授权认证”这一点,两者的区别不大,ticket的加密方式统一使用了AES,都指定Cookie.Domain = ".cg.com",保证了Cookie同域共享,设置了HttpOnly避免XSS攻击。

两者区别在于:

登录后复制

options.Events.OnRedirectToLogin = BuildRedirectToLogin;options.Events.OnSigningOut = BuildSigningOut;1.2.


这是为了让业务应用引导跳转到认证中心登录页面。OnRedirectToLogin是认证失败跳转。OnSigningOut是注销跳转。

登录后复制

/// <summary>        /// 未登录下,引导跳转认证中心登录页面        /// </summary>        /// <param name="context"></param>        /// <returns></returns>        private static Task BuildRedirectToLogin(RedirectContext<CookieAuthenticationOptions> context)        {            var currentUrl = new UriBuilder(context.RedirectUri);            var returnUrl = new UriBuilder            {                Host = currentUrl.Host,                Port = currentUrl.Port,                Path = context.Request.Path            };            var redirectUrl = new UriBuilder            {                Host = "sso.cg.com",                Path = currentUrl.Path,                Query = QueryString.Create(context.Options.ReturnUrlParameter, returnUrl.Uri.ToString()).Value            };            context.Response.Redirect(redirectUrl.Uri.ToString());            return Task.CompletedTask;        }        /// <summary>        /// 注销,引导跳转认证中心登录页面        /// </summary>        /// <param name="context"></param>        /// <returns></returns>        private static Task BuildSigningOut(CookieSigningOutContext context)        {            var returnUrl = new UriBuilder            {                Host = context.Request.Host.Host,                Port = context.Request.Host.Port ?? 80,            };            var redirectUrl = new UriBuilder            {                Host = "sso.cg.com",                Path = context.Options.LoginPath,                Query = QueryString.Create(context.Options.ReturnUrlParameter, returnUrl.Uri.ToString()).Value            };            context.Response.Redirect(redirectUrl.Uri.ToString());            return Task.CompletedTask;        }    }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.


登录注销

认证中心与业务应用两者的登录注册基本一致。

登录后复制

private async Task<IActionResult> SignIn(User user)        {            var claims = new List<Claim>            {                new Claim(JwtClaimTypes.Id,user.UserId),                new Claim(JwtClaimTypes.Name,user.UserName),                new Claim(JwtClaimTypes.NickName,user.RealName),            };            var userPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims, "Basic"));            var returnUrl = HttpContext.Request.Cookies[ReturnUrlKey];            await HttpContext.SignInAsync(userPrincipal,                new AuthenticationProperties                {                    IsPersistent = true,                    RedirectUri = returnUrl                });            HttpContext.Response.Cookies.Delete(ReturnUrlKey);            return Redirect(returnUrl ?? "/");        }        private async Task SignOut()        {            await HttpContext.SignOutAsync();        }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.


HttpContext.SignInAsync的原理

使用的是Cookie认证那么就是通过Microsoft.AspNetCore.Authentication.Cookies库的CookieAuthenticationHandler类的HandleSignInAsync方法进行处理的。

源码地址:https://github.com/aspnet/Security/blob/master/src/Microsoft.AspNetCore.Authentication.Cookies/CookieAuthenticationHandler.cs

.net core实践系列之SSO-同域实现_ide_03.net core实践系列之SSO-同域实现_ide_04

登录后复制

protected async override Task HandleSignInAsync(ClaimsPrincipal user, AuthenticationProperties properties)        {            if (user == null)            {                throw new ArgumentNullException(nameof(user));            }            properties = properties ?? new AuthenticationProperties();            _signInCalled = true;            // Process the request cookie to initialize members like _sessionKey.            await EnsureCookieTicket();            var cookieOptions = BuildCookieOptions();            var signInContext = new CookieSigningInContext(                Context,                Scheme,                Options,                user,                properties,                cookieOptions);            DateTimeOffset issuedUtc;            if (signInContext.Properties.IssuedUtc.HasValue)            {                issuedUtc = signInContext.Properties.IssuedUtc.Value;            }            else            {                issuedUtc = Clock.UtcNow;                signInContext.Properties.IssuedUtc = issuedUtc;            }            if (!signInContext.Properties.ExpiresUtc.HasValue)            {                signInContext.Properties.ExpiresUtc = issuedUtc.Add(Options.ExpireTimeSpan);            }            await Events.SigningIn(signInContext);            if (signInContext.Properties.IsPersistent)            {                var expiresUtc = signInContext.Properties.ExpiresUtc ?? issuedUtc.Add(Options.ExpireTimeSpan);                signInContext.CookieOptions.Expires = expiresUtc.ToUniversalTime();            }            var ticket = new AuthenticationTicket(signInContext.Principal, signInContext.Properties, signInContext.Scheme.Name);            if (Options.SessionStore != null)            {                if (_sessionKey != null)                {                    await Options.SessionStore.RemoveAsync(_sessionKey);                }                _sessionKey = await Options.SessionStore.StoreAsync(ticket);                var principal = new ClaimsPrincipal(                    new ClaimsIdentity(                        new[] { new Claim(SessionIdClaim, _sessionKey, ClaimValueTypes.String, Options.ClaimsIssuer) },                        Options.ClaimsIssuer));                ticket = new AuthenticationTicket(principal, null, Scheme.Name);            }            var cookieValue = Options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding());            Options.CookieManager.AppendResponseCookie(                Context,                Options.Cookie.Name,                cookieValue,                signInContext.CookieOptions);            var signedInContext = new CookieSignedInContext(                Context,                Scheme,                signInContext.Principal,                signInContext.Properties,                Options);            await Events.SignedIn(signedInContext);            // Only redirect on the login path            var shouldRedirect = Options.LoginPath.HasValue && OriginalPath == Options.LoginPath;            await ApplyHeaders(shouldRedirect, signedInContext.Properties);            Logger.SignedIn(Scheme.Name);        }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.

View Code

从源码我们可以分析出流程:

根据ClaimsPrincipal的用户信息序列化后通过加密方式进行加密获得ticket。(默认加密方式是的KeyRingBasedDataProtecto。源码地址:https://github.com/aspnet/DataProtection)

再通过之前的初始化好的CookieOption再AppendResponseCookie方法进行设置Cookie

最后通过Events.RedirectToReturnUrl进行重定向到ReturnUrl。

Ticket加密

两种设置方式

  • CookieAuthenticationOptions.DataProtectionProvider
  • CookieAuthenticationOptions.TicketDataFormat

DataProtectionProvider

如果做了集群可以设置到共享文件夹,在第一个启动的应用则会创建如下图的文件

登录后复制

options.DataProtectionProvider = DataProtectionProvider.Create(new DirectoryInfo(@"D:\sso\key"));1.


.net core实践系列之SSO-同域实现_用户信息_05

TicketDataFormat

重写数据加密方式,本次demo使用了是AES.

登录后复制

options.TicketDataFormat = new TicketDataFormat(new AesDataProtector());1.


登录后复制

internal class AesDataProtector : IDataProtector    {        private const string Key = "!@#13487";        public IDataProtector CreateProtector(string purpose)        {            return this;        }        public byte[] Protect(byte[] plaintext)        {            return AESHelper.Encrypt(plaintext, Key);        }        public byte[] Unprotect(byte[] protectedData)        {            return AESHelper.Decrypt(protectedData, Key);        }    }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.


结尾

以上为.NET Core MVC的同域SSO实现思路与细节 。因编写demo的原因代码复用率并不好,冗余代码比较多,大家可以根据情况进行抽离封装。下篇会继续分享跨域SSO的实现。如果对本篇有任何建议与疑问,可以在下方评论反馈给我。

免责声明:本文系网络转载或改编,未找到原创作者,版权归原作者所有。如涉及版权,请联系删

QR Code
微信扫一扫,欢迎咨询~

联系我们
武汉格发信息技术有限公司
湖北省武汉市经开区科技园西路6号103孵化器
电话:155-2731-8020 座机:027-59821821
邮件:tanzw@gofarlic.com
Copyright © 2023 Gofarsoft Co.,Ltd. 保留所有权利
遇到许可问题?该如何解决!?
评估许可证实际采购量? 
不清楚软件许可证使用数据? 
收到软件厂商律师函!?  
想要少购买点许可证,节省费用? 
收到软件厂商侵权通告!?  
有正版license,但许可证不够用,需要新购? 
联系方式 155-2731-8020
预留信息,一起解决您的问题
* 姓名:
* 手机:

* 公司名称:

姓名不为空

手机不正确

公司不为空