IdentityServer4+.net core+Docker认证授权和单点登录
操作环境:Centos7.8+.net Core3.1+Docker
由于IdentityServer4的认证授权功能太过强大和复杂,实现了OAuth2.0的四种授权模式——隐式模式(implicit)、授权码模式(Authorization Code)、密码凭证模式(Resource Owner Password Credentials)、客户端凭证模式(Client Credentials),本文仅以其中的客户端凭证模式和隐式模式,实现两种不同场景作为示例,供大家参考学习。
一、客户端凭证模式——实现接口资源认证授权
示例场景:Web应用(MVC)请求微服务接口资源(WebAPI)需要通过idetityserver认证中心授权(采用客户端凭证模式实现)
项目整体结构:
1、认证中心IdentityServer项目
使用Nuget引入IdentityServer4组件
创建Config配置类
using IdentityServer4; using IdentityServer4.Models; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace HPM.IdentityServer4 { public class Config { /// <summary> /// 获取自定义API资源列表 /// </summary> /// <returns></returns> public static IEnumerable<ApiResource> GetApiResources() { List<ApiResource> apiResources = new List<ApiResource>(); apiResources.Add(new ApiResource() { Name = "ProductMicroService", DisplayName = "产品微服务",Scopes = { "ProductMicroService" } });//定义资源名称 apiResources.Add(new ApiResource() { Name = "UserMicroService", DisplayName = "用户微服务", Scopes = { "UserMicroService" } });//定义资源名称 return apiResources; } /// <summary> /// 获取自定义客户端配置列表 /// 支持 授权码,隐藏式,密码式,凭证式、混合式 /// </summary> /// <returns></returns> public static IEnumerable<Client> GetClients() { List<Client> clients = new List<Client>(); //客户端凭证授权模式(用于客户端访问微服务API资源授权认证) clients.Add(new Client() { ClientId="client",//定义客户端获取Token时指定的client_id值 AllowedGrantTypes=GrantTypes.ClientCredentials,//指定grant_type为client_credentials客户端授权模式 ClientSecrets = {new Secret("Secret".Sha256())},//定义客户端获取token时指定的client_secret值 AllowOfflineAccess = true,//如果要获取refresh_tokens ,必须把AllowOfflineAccess设置为true AccessTokenLifetime = 3600,//token有效期,默认3600秒 AllowedScopes = { "ProductMicroService", "UserMicroService" }//设置可访问范围的资源名称 });return clients; } /// <summary> /// 获取IdentityServer4自身API资源列表 /// </summary> /// <returns></returns> public static IEnumerable<IdentityResource> GetIdentityResources() { return new List<IdentityResource> { new IdentityResources.OpenId(), new IdentityResources.Profile() }; } public static IEnumerable<ApiScope> GetApiScopes() { return new List<ApiScope> { new ApiScope("ProductMicroService"), new ApiScope("UserMicroService") }; } } }
修改StartUp类
在ConfigureServices方法中添加IdentityServer的依赖注入
services.AddIdentityServer().AddDeveloperSigningCredential()
.AddInMemoryIdentityResources(Config.GetIdentityResources())
.AddInMemoryApiScopes(Config.GetApiScopes())
.AddInMemoryApiResources(Config.GetApiResources())
.AddInMemoryClients(Config.GetClients());
在Configure方法中启用IdentityServer中间件及认证授权
// 配置管道中间件 // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } //暂时禁用Https //app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); //身份认证中间件(身份验证必须在授权的前面) app.UseAuthentication(); //授权中间件 app.UseAuthorization(); //这个必须在UseRouting和UseEndpoints中间。如果IdentityServer服务端和API端要写在一起, //那么这个必须在UseAuthorization和UseAuthentication的上面。 //通过访问https://localhost:端口/.well-known/openid-configuration默认配置地址可以查看IdentityServer提供的endpoint app.UseIdentityServer();//使用IdentityServer中间件 app.UseEndpoints(endpoints => { //endpoints.MapControllers(); endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); }); }
2、微服务ProductMicroService接口资源项目(其他微服务同样调整)
修改StartUp类
在ConfigureServices方法中添加IdentityServer的认证授权依赖注入
//指定使用IdentityServer作为API资源的授权模式 services.AddAuthentication("Bearer").AddIdentityServerAuthentication(options => { options.Authority = "https://www.aaa.vip:5050";//设置IdentityServer授权端口地址 options.ApiName = "ProductMicroService";//设置要开放访问的资源名称 options.RequireHttpsMetadata = false; });
在Configure方法中启用认证授权中间件
//身份验证中间件 (身份验证必须在授权的前面) app.UseAuthentication(); //授权验证中间件 app.UseAuthorization();
然后在需要通过授权验证才能访问的API加上[Authorize]特性保护起来
3、Web应用客户端项目
Web客户端应用想要请求到微服务中带有[Authorize]特性标识的API资源,则必须在请求的同时,提供IdentityServer认证中心颁发的令牌,因此最好封装一个获取Token令牌的方法,如下
public async Task<string> GetAccessToken(string client_id,string client_secret,string scope) { //获取Redis中缓存的Token string accessToken = _redisService.GetTokenValue(scope); //string accessToken = _redisService.GetValue(scope + "_access_token"); if (string.IsNullOrEmpty(accessToken)) { //重新从Identityserver中获取token var tokenClient = new HttpClient(); var tokenResponse = await tokenClient.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { Address = "https://www.aaa.vip:5100" + "/connect/token", ClientId = client_id, ClientSecret = client_secret, Scope = scope }); accessToken = tokenResponse.AccessToken; //设置redis中的token字符串600秒过期 _redisService.SetTokenValue(scope, accessToken); //_redisService.SetValue(scope + "_access_token", accessToken, 600); } ////获取Refresh_Token //tokenResponse = await tokenClient.RequestRefreshTokenAsync(new RefreshTokenRequest //{ // Address = identityServerUrl + "connect/token", // ClientId = client_id, // ClientSecret = client_secret, // Scope = scope //}); return accessToken; }
其中client_id、client_secret、scope参数分别是IdentityServer项目中配置类Config中定义的Client客户端信息,讲这些客户端ID和密钥信息保持一致传入即可获取AccessToken授权令牌。
然后在请求微服务接口资源时,将令牌带入请求头部即可,如下
二、隐式模式——实现单点登录
首先要想使用IdentityServer实现单点登录,必须要满足一个条件,那就是应用站点和认证站点必须是HTTPS,重要的事情说三遍,必须是HTTPS!必须是HTTPS!必须是HTTPS!否则登录认证回调环节会有问题,过不去。
至于怎么搭建.net core的HTTPS站点,我在之后其他的文章中会讲解,并不复杂,主要是要花钱,没有氪金心理准备的就别玩IdentityServer的单点登录了。
示例场景:多个Web应用(mvc)通过请求IdentityServer认证中心实现单点登录(本文暂时仅以单个客户端应用进行演示)
项目整体结构:
1、认证中心IdentityServer项目
使用Nuget引入IdentityServer4组件
创建Config配置类
using IdentityServer4; using IdentityServer4.Models; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace HPM.IdentityServer4 { public class Config { /// <summary> /// 获取自定义客户端配置列表 /// 支持 授权码,隐藏式,密码式,凭证式、混合式 /// </summary> /// <returns></returns> public static IEnumerable<Client> GetClients() { List<Client> clients = new List<Client>(); //隐藏式授权模式(用于多客户端单点登录) clients.Add(new Client() { ClientId = "hpm_mvc_imp", ClientName = "hpm", AllowedGrantTypes = GrantTypes.Implicit, //设置是否显示授权提示界面 //RequireConsent=true, //指定允许令牌或授权码返回的地址(URL)-登录成功后重定向地址 //RedirectUris = { "http://www.b.net:5001/signin-oidc", "http://www.a.cn:5002/signin-oidc" }, RedirectUris = { "https://www.aaa.vip:5050/signin-oidc" }, //指定允许注销后返回的地址(URL),这里写两个客户端-注销成功后的重定向地址 //PostLogoutRedirectUris = { "http://www.b.net:5001/signout-callback-oidc", "http://www.a.cn:5002/signout-callback-oidc" }, PostLogoutRedirectUris = { "https://www.aaa.vip:5050/signout-callback-oidc" }, ClientSecrets = { new Secret("SSOSecret".Sha256()) }, AllowedScopes = new List<string> { IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile, } }); return clients; } /// <summary> /// 获取IdentityServer4自身API资源列表 /// </summary> /// <returns></returns> public static IEnumerable<IdentityResource> GetIdentityResources() { return new List<IdentityResource> { new IdentityResources.OpenId(), new IdentityResources.Profile() }; } } }
修改StartUp类
在ConfigureServices方法中添加IdentityServer的依赖注入
#region 添加IdentityServer单点登录依赖注入,要使用IdentityServer的单点登录,则必须启用https //我们使用的IdentityModel这个组件,它默认限制了当 Ids4 非localhost地址时,必须启用HTTPS。详情见https://ask.csdn.net/questions/753220 //同时谷歌浏览器,对于cookies的写入,也必须要求https services.AddIdentityServer(options => { //可以通过此设置来指定登录路径,默认的登陆路径是/account/login options.UserInteraction.LoginUrl = "/Account/Login";//【必备】登录地址 options.UserInteraction.LogoutUrl = "/Account/Logout";//【必备】退出地址 //options.UserInteraction.ConsentUrl = "/Account/Consent";//【必备】允许授权同意页面地址 //options.UserInteraction.ErrorUrl = "/Account/Error";//【必备】错误页面地址 options.UserInteraction.LoginReturnUrlParameter = "ReturnUrl";//【必备】设置传递给登录页面的返回URL参数的名称。默认为returnUrl options.UserInteraction.LogoutIdParameter = "logoutId"; //【必备】设置传递给注销页面的注销消息ID参数的名称。缺省为logoutId //options.UserInteraction.ConsentReturnUrlParameter = "ReturnUrl"; //【必备】设置传递给同意页面的返回URL参数的名称。默认为returnUrl //options.UserInteraction.ErrorIdParameter = "errorId"; //【必备】设置传递给错误页面的错误消息ID参数的名称。缺省为errorId //options.UserInteraction.CustomRedirectReturnUrlParameter = "ReturnUrl"; //【必备】设置从授权端点传递给自定义重定向的返回URL参数的名称。默认为returnUrl //options.UserInteraction.CookieMessageThreshold = 5; //【必备】由于浏览器对Cookie的大小有限制,设置Cookies数量的限制,有效的保证了浏览器打开多个选项卡,一旦超出了Cookies限制就会清除以前的Cookies值 }).AddDeveloperSigningCredential() .AddInMemoryIdentityResources(Config.GetIdentityResources()) .AddInMemoryClients(Config.GetClients()); services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { // all your options options.Cookie.HttpOnly = false; // in dev options.Cookie.SecurePolicy = CookieSecurePolicy.Always; //options.Cookie.SecurePolicy = CookieSecurePolicy.None; //谷歌浏览器对SameSite有安全要求,否则无法写入Cookie,详情见https://stackoverflow.com/questions/51912757/identity-server-is-keep-showing-showing-login-user-is-not-authenticated-in-c //options.Cookie.SameSite = SameSiteMode.None; options.Cookie.SameSite = SameSiteMode.Lax; }); #endregion
在Configure方法中启用IdentityServer中间件及认证授权
// 配置管道中间件 // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } //暂时禁用Https //app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); //身份认证中间件(身份验证必须在授权的前面) app.UseAuthentication(); //授权中间件 app.UseAuthorization(); //这个必须在UseRouting和UseEndpoints中间。如果IdentityServer服务端和API端要写在一起, //那么这个必须在UseAuthorization和UseAuthentication的上面。 //通过访问https://localhost:端口/.well-known/openid-configuration默认配置地址可以查看IdentityServer提供的endpoint app.UseIdentityServer();//使用IdentityServer中间件 app.UseEndpoints(endpoints => { //endpoints.MapControllers(); endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); }); }
创建登录及注销控制器,其中/Account/Login和/Account/Logout分别是StartUp的ConfigureServices中给IdentityServer设定好的登入和登出时自动回调的地址(即MVC里的Action)。这2个地址(Action)非常重要。除此之外,一般还会有一个处理登录信息提交的Action,也很重要。由于太过重要,我就直接将整段代码贴出来
public class AccountController : Controller { private readonly IIdentityServerInteractionService _interaction; private readonly IUserServiceClient _userServiceClient;//自定义用户信息服务 public AccountController(IIdentityServerInteractionService interaction, IUserServiceClient userServiceClient) { this._interaction = interaction; this._userServiceClient = userServiceClient; } /// <summary> /// 登录页加载 /// 登录时IdentityServer自动跳转到此登录页 /// </summary> /// <param name="ReturnUrl"></param> /// <returns></returns> public IActionResult Login(string ReturnUrl) { if (HttpContext.User.Identity.IsAuthenticated) { return Redirect(ReturnUrl); } LoginInputViewModel vm = new LoginInputViewModel() { ReturnUrl = ReturnUrl }; return View(vm); } /// <summary> /// 提交登录信息操作 /// </summary> /// <param name="model"></param> /// <returns></returns> [HttpPost] public async Task<IActionResult> Login(LoginInputViewModel model) { //当登录提交给后台的model为null,则返回错误信息给前台 if (model == null) { ViewData["TipMessage"] = "登录失败,数据为空!"; return View(model); } //这里同理,当信息不完整的时候,返回错误信息给前台 if (string.IsNullOrEmpty(model.Username) || string.IsNullOrEmpty(model.Password)) { //这里只是简单处理了 ViewData["TipMessage"] = "登录失败,用户名和密码不能为空!"; return View(model); } //自定义的方法获取数据库信息验证用户名和密码 BaseResultModel<UserInfoForLoginDto> loginResult = await _userServiceClient.GetLoginUserInfo(model.Username, model.Password); if (loginResult.success) { //登录用户信息 UserInfoForLoginDto loginUserInfo = loginResult.data; //配置Cookie AuthenticationProperties properties = new AuthenticationProperties() { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.Add(TimeSpan.FromMinutes(30)) //ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration) //记住登录 }; //使用IdentityServerUser来进行SignInAsync // issue authentication cookie with subject ID and username var isuser = new IdentityServerUser(loginUserInfo.Id.ToString()) { DisplayName = loginUserInfo.UserName }; await HttpContext.SignInAsync(isuser, properties); //使用IIdentityServerInteractionService的IsValidReturnUrl来验证ReturnUrl是否有问题 if (_interaction.IsValidReturnUrl(model.ReturnUrl)) { return Redirect(model.ReturnUrl); } else { ViewData["TipMessage"] = "跳转地址有误:" + model.ReturnUrl; return View(model); } } else { //return Content("<script >alert('"+ loginResult.message + "');</script>", "text/html"); ViewData["TipMessage"] = loginResult.message; return View(model); } return View(); } /// <summary> /// 登录注销 /// 注销登录时IdentityServer会自动回调此路径 /// </summary> /// <param name="logoutId"></param> /// <returns></returns> [HttpGet] public async Task<IActionResult> Logout(string logoutId) { //var logoutId = await _interaction.CreateLogoutContextAsync(); Console.WriteLine("准备注销登录,logoutId为:" + logoutId); var logout= await _interaction.GetLogoutContextAsync(logoutId); await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); if (logout.PostLogoutRedirectUri != null) { Console.WriteLine("注销登录,跳转PostLogoutRedirectUri地址:" + logout.PostLogoutRedirectUri); return Redirect(logout.PostLogoutRedirectUri); } var refererUrl = Request.Headers["Referer"].ToString(); return Redirect(refererUrl); } }
这其中登录Login的ReturnUrl参数,以及注销Logout的logoutId参数,可以不用管,因为它们是IdentityServer回调地址,IdentityServer会自动带上这2个参数跳转到这2个地址。
至于是从何处触发的IdentityServer回调跳转?当然是在客户端Web应用的控制器中触发,下面会慢慢讲到。
为了便于大家理解,这里我将登录页、登录页的视图模型类ViewModel也一并贴出来
/// <summary> /// 登录页的视图模型 /// </summary> public class LoginInputViewModel { [Required] public string Username { get; set; } [Required] public string Password { get; set; } public bool RememberLogin { get; set; } public string ReturnUrl { get; set; } }
<div class="login_con"> <h1>登录/LOGIN</h1> @using (Html.BeginForm("Login", "Account")) { <div class="text_box"> <div> <input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" /> <div><i><img src="/login/images/icon01.png"></i><input id="name" type="text" name="Username" placeholder="请输入注册邮箱"></div> <div><i><img src="/login/images/icon02.png"></i><input id="pwd" type="password" name="Password" placeholder="请输入密码"></div> <button type="submit">登 录</button> </div> </div> } </div>
2、Web应用客户端项目
修改StartUp类
在ConfigureServices方法中添加IdentityServer的依赖注入
//注入IdentityServer单点登录授权认证 //DefaultChallengeScheme的名字要和下面AddOpenIdConnect方法第一个参数名字保持一致 services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;//默认的身份验证方案名 options.DefaultChallengeScheme = "oidc";//用来跳转到ids登录页的身份验证方案名 //注意这俩配置与下面注册的身份验证方案的名字对应 }) .AddCookie(options => { options.ExpireTimeSpan = TimeSpan.FromMinutes(60); options.Cookie.Name = CookieAuthenticationDefaults.AuthenticationScheme;//注册asp.net core 默认的基于cookie的身份验证方案 }) .AddOpenIdConnect("oidc", options =>//注册ids为我们提供的oidc身份验证方案 { options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.Authority = "https://www.aaa.vip:5100"; options.RequireHttpsMetadata = false; //指定允许服务端返回的地址,默认是new PathString("/signin-oidc") //如果这里地址进行了自定义,那么服务端也要进行修改 //options.CallbackPath = new PathString("/signin-oidc"); //指定用户注销后,服务端可以调用客户端注销的地址,默认是new PathString("signout-callback-oidc") //options.SignedOutCallbackPath = new PathString("/signout-callback-oidc"); options.ClientId = "hpm_mvc_imp"; options.ClientSecret = "SSOSecret"; //options.ResponseType = "code id_token";//授权模式 options.SaveTokens = true;//是否将最后获取的idToken和accessToken存储到默认身份验证方案中 //布尔值来设置处理程序是否应该转到用户信息端点检索。额外索赔或不在id_token创建一个身份收到令牌端点。默认为“false” //options.GetClaimsFromUserInfoEndpoint = true; //事件 options.Events = new Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectEvents() { //远程故障 OnRemoteFailure = context => { context.Response.Redirect("/SSO/Error"); context.HandleResponse(); return Task.FromResult(0); }, //访问拒绝 OnAccessDenied = context => { //重定向到指定页面 context.Response.Redirect("/SSO/Denied"); //停止此请求的所有处理并返回给客户端 context.HandleResponse(); return Task.FromResult(0); }, }; });
在Configure方法中启用认证授权中间件及Cookie策略中间件
//使用Cookie app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Lax }); app.UseRouting(); //身份认证中间件(身份验证必须在授权的前面) app.UseAuthentication(); app.UseAuthorization();
接着,在任何需要登录验证的Action操作上,添加[Authorize]特性标识,则未登录状态下系统会触发认证,自动跳转到identityServer项目的登录页/Account/Login并带上相应ReturnUrl参数,如下
如果不想这样被动的触发登录跳转,想要实现主动点击【登录】按钮跳转到登录页,可以创建一个用户控制器,将登录和注销功能都写在里面,其中登录Action带上[Authorize]特性标识,这样就能巧妙实现触发登录页跳转。
public class UserController : Controller {/// <summary> /// 登录 /// </summary> /// <param name="pageUrl"></param> /// <returns></returns> [Authorize] public IActionResult Login(string pageUrl=null) { //在认证中心登录页登录后,回到此处再跳转到其他页面 if(pageUrl == null) { return RedirectToAction("Index", "Home"); } else { return Redirect(pageUrl); } } /// <summary> /// 注销登录 /// </summary> /// <returns></returns> [HttpGet] public IActionResult Logout() { //this.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); //this.HttpContext.SignOutAsync("oidc"); //return RedirectToAction("Index", "Home"); //这里会触发identityServer对/Account/Logout的回调,并带上logoutId参数 return SignOut("Cookies", "oidc"); } }
客户端主动触发登录注销操作的位置
@if (string.IsNullOrEmpty(Model.UserName)) { <div id="loginBox"> <a style="cursor: pointer;" href="/User/Login">登录</a><span> | </span><a style="cursor: pointer;" href="login/register.html">注册</a> </div> } else { <span>Hi @Model.UserName 欢迎回来~<br /><a style="cursor: pointer;" href="/User/Logout">退出登录</a></span> }
当在IdentityServer认证中心的登录页登录成功后,会自动跳转回Web客户端项目,这时客户端需要获取登录后的用户信息,获取方式如下:
if(HttpContext.User.Claims.SingleOrDefault(s => s.Type == ClaimTypes.NameIdentifier)!=null) { //Type:http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier Value:1 viewModel.UserId = Convert.ToInt32(HttpContext.User.Claims.SingleOrDefault(s => s.Type == ClaimTypes.NameIdentifier).Value);//获取用户ID //Type:name Value:一人之下 viewModel.UserName = HttpContext.User.Claims.SingleOrDefault(s => s.Type == "name").Value;//获取用户姓名 }
直接在客户端的控制器中获取即可,这里由于我在登录时设定的IdentityServerUser参数Type是这2个,所以就用这2个Type来获取对应的信息,实际开发过程中大家可以根据自己的需要去设置,不一定要按照我的Type来取。
至此,费了我九牛二虎之力,苦心钻研一周的IdentityServer单点登录算是讲完了,最后还是要提醒大家一句,玩IdentityServer单点登录的前提是,你的站点必须是HTTPS!!!
本人虽然在钻研过程中参考了不少资料,但此文基本也算是原创,麻烦大家转载时注明出处,祝各位工作顺利,少死脑细胞。