.net core 微服务之IdentityServer4
身份验证、授权
什么是身份认证
身份认证是指当客户端访问服务端资源时,验证客户端是否合法的一种机制
什么是授权
授权就是指当客户端经过身份认证后,能够有限的访问服务端资源的一种机制
为什么要使用身份验证和授权
为了保证服务端资源的安全,我们要理解必须从真实项目中去理解
身份认证和授权方式有哪些
1、Base认证
Base64编号认证 == https
2、Digest认证
MD5消息摘要认证 == https
3、Bearer认证
就是基于token(电子身份证)认证,讲用户信息等其他信息转换成为token,然后以token进行认证
token认证是一种无状态的认证方式,能够无限扩展,特别适合做单点登录
3.1 OAuth 2.0 ==== 授权方式的认证
3.2 JWT的也是一种身份认证
token 分两种类型
引用类型token == OAuth 2.0
无用户相关信息
自包含token
有用户相关信息 JWT === 地址,电话,id,等
在实际分布式项目中,大部分都是使用Bearer来进行身份认证和身份授权
在分布式项目或者微服务项目中,为了保证系统统一登录(SSO登录),
使用OpenID协议标准来规范身份认证功能 === 规范
同时使用OAuth 2.0协议标准来规范权限访问 === 规范
为了将身份认证(单点登录)和授权结合起来,所以出现了OpenID Connect协议标准 === 接口
OpenID Connect = OpenID + OAuth 2.0
SSO+OAuth 2.0可以实现单点登录和授权
IdentityServer4 也可以实现单点登录和授权
然后综合实现了这些框架就是今天要讲的IdentityServer4 身份认证服务器
IdentityServer4 是什么
IdentityServer是基于OpenID Connect协议标准的身份认证和授权程序,它实现了OpenID Connect 和 OAuth 2.0 协议。
这里只记录了简单使用,深入了解可以去官网地址
IdentityServer4 官网地址
中文地址:http://www.identityserver.com.cn/Home/Detail/Zhengtizongshu
英文地址:https://identityserver4.readthedocs.io/en/3.1.0/
IdentityServer4 功能
保护你的资源
使用本地帐户或通过外部身份提供程序对用户进行身份验证
提供会话管理和单点登录
管理和验证客户机
向客户发出标识和访问令牌
验证令牌
IdentityServer4 内部概念
**用户(User)** 用户是使用已注册的客户端(指在id4中已经注册)访问资源的人。
**客户端(Client)** 客户端就是从identityserver请求令牌的软件(你可以理解为一个app即可),既可以通过身份认证令牌来验证识别用户身份,又可以通过授权令牌来访问服务端的资源。但是客户端首先必须在申请令牌前已经在identityserver服务中注册过。 实际客户端不仅可以是Web应用程序,app或桌面应用程序(你就理解为pc端的软件即可),SPA,服务器进程等。 **资源(Resources)** 资源就是你想用identityserver保护的东东,可以是用户的身份数据或者api资源。 每一个资源都有一个唯一的名称,客户端使用这个唯一的名称来确定想访问哪一个资源(在访问之前,实际identityserver服务端已经配置好了哪个客户端可以访问哪个资源,所以你不必理解为客户端只要指定名称他们就可以随便访问任何一个资源)。 用户的身份信息实际由一组claim组成,例如姓名或者邮件都会包含在身份信息中(将来通过identityserver校验后都会返回给被调用的客户端)。 API资源就是客户端想要调用的功能(通常以json或xml的格式返回给客户端,例如webapi,wcf,webservice),通常通过webapi来建立模型,但是不一定是webapi,我刚才已经强调可以使其他类型的格式,这个要看具体的使用场景了。 **身份令牌(顾名思义用于做身份认证,例如sso其实主要就是用于身份认证)** id_token jwt 一个身份令牌指的就是对认证过程的描述。它至少要标识某个用户(Called the sub aka subject claim)的主身份信息,和该用户的认证时间和认证方式。但是身份令牌可以包含额外的身份数据,具体开发者可以自行设定,但是一般情况为了确保数据传输的效率,开发者一般不做过多额外的设置,大家也可以根据使用场景自行决定。 **访问令牌(用于做客户端访问授权)** access_token oauth 2.0 访问令牌允许客户端访问某个 API 资源。客户端请求到访问令牌,然后使用这个令牌来访问 API资源。访问令牌包含了客户端和用户(如果有的话,这取决于业务是否需要,但通常不必要)的相关信息,API通过这些令牌信息来授予客户端的数据访问权限。
项目实例
创建认证服务端
1. 新建一个MVC项目,安装nuget包
IdentityServer4
2. 创建配置中心类 MemoryConfigs.cs
public class MemoryConfigs { public static List<IdentityResource> GetIdentityResources() { return new List<IdentityResource> { new IdentityResource{ Name="openid", Enabled=true, Emphasize=true, Required=true, DisplayName="用户授权认证信息", Description="获取你的授权认证" }, new IdentityResource{ Name="profile", Enabled=true, Emphasize=false, Required=true, DisplayName="用户个人信息", Description="获取你的个人基本资料信息,如:姓名、性别、年龄等" } }; } public static List<ApiResource> GetApiResources() { return new List<ApiResource> { new ApiResource{ Name="userapi", DisplayName="用户服务", Description="用户服务", ApiSecrets={ new Secret("testcode".Sha256()) }, Scopes={ new Scope("userapi") } }, new ApiResource{ Name="signalrapi", DisplayName="WebSocket服务", Description="WebSocket服务", ApiSecrets={ new Secret("testcode".Sha256()) }, Scopes={ new Scope("signalrapi") } }, new ApiResource{ Name="fileapi", DisplayName="文件服务", Description="文件服务", ApiSecrets={ new Secret("testcode".Sha256()) }, Scopes={ new Scope("fileapi") } }, new ApiResource{ Name="physicalexamapi", DisplayName="体检服务", Description="体检服务", ApiSecrets={ new Secret("testcode".Sha256()) }, Scopes={ new Scope("physicalexamapi") } }, new ApiResource{ Name="printapi", DisplayName="打印服务", Description="打印服务", ApiSecrets={ new Secret("testcode".Sha256()) }, Scopes={ new Scope("printapi") } }, new ApiResource{ Name="reportapi", DisplayName="报表服务", Description="报表服务", ApiSecrets={ new Secret("testcode".Sha256()) }, Scopes={ new Scope("reportapi") } }, new ApiResource{ Name="vaccineapi", DisplayName="疫苗服务", Description="疫苗服务", ApiSecrets={ new Secret("testcode".Sha256()) }, Scopes={ new Scope("vaccineapi") } }, new ApiResource{ Name="authenticationapi", DisplayName="认证管理服务", Description="认证管理服务", ApiSecrets={ new Secret("testcode".Sha256()) }, Scopes={ new Scope("authenticationapi") } }, new ApiResource{ Name="configapi", DisplayName="配置中心服务", Description="配置中心服务", ApiSecrets={ new Secret("testcode".Sha256()) }, Scopes={ new Scope("configapi") } } }; } public static List<Client> GetClients() { return new List<Client> { new Client(){ ClientId="clientcode", ClientName="C/S客户端", ClientSecrets={ new Secret("clientcode".Sha256()) }, AllowedGrantTypes= GrantTypes.ClientCredentials, AccessTokenType= AccessTokenType.Reference, AllowedScopes={ "signalrapi" } }, new Client(){ ClientId="fileclient", ClientName="C/S客户端", ClientSecrets={ new Secret("fileclient".Sha256()) }, AllowedGrantTypes= GrantTypes.ClientCredentials, AccessTokenType= AccessTokenType.Reference, AllowedScopes={ "fileapi" } }, new Client(){ ClientId="configclient", ClientName="C/S客户端", ClientSecrets={ new Secret("configclient".Sha256()) }, AllowedGrantTypes= GrantTypes.ClientCredentials, AccessTokenType= AccessTokenType.Reference, AllowedScopes={ "configapi" } }, new Client(){ ClientId="testcode", ClientName="测试授权码", ClientSecrets={ new Secret("testcode".Sha256()) }, AllowedGrantTypes= GrantTypes.Code, AccessTokenType= AccessTokenType.Reference, RequireConsent=false, RequirePkce=true, AllowOfflineAccess=true, AllowAccessTokensViaBrowser=true, // IdToken过期时间 客户端可以设置UseTokenLifetime = true将客户端过期时间设置为IdToken过期时间 IdentityTokenLifetime=60*60*2, RedirectUris={ "http://localhost:2001/signin-oidc"}, FrontChannelLogoutUri= "http://localhost:2001/" , PostLogoutRedirectUris = { "http://localhost:2001/signout-callback-oidc" }, AllowedScopes={ "openid", "profile", "userapi", "signalrapi", "fileapi", "printapi", "reportapi", "vaccineapi", "authenticationapi" } }, new Client(){ ClientId="appclient", ClientName="App客户端", ClientSecrets={ new Secret("appclient".Sha256()) }, AllowedGrantTypes= GrantTypes.ResourceOwnerPassword, AccessTokenType= AccessTokenType.Reference, RequireConsent=false, RequirePkce=true, AllowOfflineAccess=true, AllowAccessTokensViaBrowser=true, AllowedScopes={ "openid", "profile", "vaccineapi", "offline_access", } }, new Client(){ ClientId="testcodeapi", ClientName="API授权", ClientSecrets={ new Secret("testcodeapi".Sha256()) }, AllowedGrantTypes= GrantTypes.Implicit, AccessTokenType= AccessTokenType.Reference, RequireConsent=false, AllowAccessTokensViaBrowser=true, AlwaysIncludeUserClaimsInIdToken=true, RedirectUris={ "http://localhost:3002/swagger/oauth2-redirect.html", "http://localhost:3004/swagger/oauth2-redirect.html", "http://localhost:3005/swagger/oauth2-redirect.html", "http://localhost:3006/swagger/oauth2-redirect.html", "http://localhost:3007/swagger/oauth2-redirect.html", "http://localhost:4000/swagger/oauth2-redirect.html", "http://localhost:2001/signin-oidc" }, AllowedScopes={ //必需的。通知授权服务器客户机正在发出一个OpenID连接请求。如果openid作用域值不存在,则行为完全没有指定。 IdentityServerConstants.StandardScopes.OpenId, //可选的。这个作用域值请求访问终端用户的默认配置文件声明,它们是:name、family_name、given_name、middle_name、昵称、preferred_username、profile、picture、website、性别、生日、zoneinfo、locale和updated_at。 IdentityServerConstants.StandardScopes.Profile, "userapi", "signalrapi", "fileapi", "physicalexamapi", "printapi", "reportapi", "vaccineapi", "authenticationapi" } }, new Client { ClientId = "mvc", ClientName = "MVC Client", AllowedGrantTypes = GrantTypes.Implicit, RequireConsent=false, //登录后重定向到哪里 RedirectUris = { "http://localhost:2001/signin-oidc" }, //注销后重定向到哪里 PostLogoutRedirectUris = { "http://localhost:2001/signout-callback-oidc" }, AllowedScopes = new List<string> { //必需的。通知授权服务器客户机正在发出一个OpenID连接请求。如果openid作用域值不存在,则行为完全没有指定。 IdentityServerConstants.StandardScopes.OpenId, //可选的。这个作用域值请求访问终端用户的默认配置文件声明,它们是:name、family_name、given_name、middle_name、昵称、preferred_username、profile、picture、website、性别、生日、zoneinfo、locale和updated_at。 IdentityServerConstants.StandardScopes.Profile } } }; } //添加用户(密码授权模式) public static List<TestUser> GetUsers() { return new List<TestUser> { new TestUser { SubjectId = "1", Username = "Darcy", Password = "123", Claims = new List<Claim> //可自定义存入Claim中,将一起添加到身份令牌中 { new Claim("name", "Darcy"), new Claim("website", "https://Darcy.com"), new Claim(JwtClaimTypes.Name, "Alice Smith"), new Claim(JwtClaimTypes.GivenName, "Alice"), new Claim(JwtClaimTypes.FamilyName, "Smith"), new Claim(JwtClaimTypes.Email, "AliceSmith@email.com"), new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), new Claim(JwtClaimTypes.WebSite, "http://alice.com"), new Claim(JwtClaimTypes.Address, @"{ 'street_address': 'One Hacker Way', 'locality': 'Heidelberg', 'postal_code': 69118, 'country': 'Germany' }", IdentityServerConstants.ClaimValueTypes.Json) } }, new TestUser { SubjectId = "2", Username = "Larry", Password = "123", Claims = new List<Claim> { new Claim("name", "Larry"), new Claim("website", "https://Larry.com"), new Claim(JwtClaimTypes.Name, "Bob Smith"), new Claim(JwtClaimTypes.GivenName, "Bob"), new Claim(JwtClaimTypes.FamilyName, "Smith"), new Claim(JwtClaimTypes.Email, "BobSmith@email.com"), new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), new Claim(JwtClaimTypes.WebSite, "http://bob.com"), new Claim(JwtClaimTypes.Address, @"{ 'street_address': 'One Hacker Way', 'locality': 'Heidelberg', 'postal_code': 69118, 'country': 'Germany' }", IdentityServerConstants.ClaimValueTypes.Json), new Claim("location", "somewhere") } } }; } }
3. 在ConfigureServices中注入服务
#region 通过配置使用idr4,测试时使用 services.AddIdentityServer() //依赖注入identityserver .AddDeveloperSigningCredential() //扩展在每次启动时,为令牌签名创建了一个临时密钥 .AddInMemoryIdentityResources(MemoryConfigs.GetIdentityResources())//添加身份资源 // 使用内存存储的密钥,客户端和API资源来配置ids4。 .AddInMemoryApiResources(MemoryConfigs.GetApiResources()) //添加api资源 .AddInMemoryClients(MemoryConfigs.GetClients()) //添加客户端 .AddTestUsers(MemoryConfigs.GetUsers()); //添加测试用户 #endregion
4. 在Configure中使用
app.UseIdentityServer(); app.UseAuthorization();
5. 创建account控制器及相关登录接口
/// <summary> /// 这个示例控制器为本地和外部帐户实现了一个典型的登录/注销/提供工作流。 /// 登录服务封装了与用户数据存储的交互。此数据存储仅在内存中,不能用于生产! /// 交互服务为UI提供了一种与identityserver通信的方式,用于验证和上下文检索 /// </summary> public class AccountController : Controller { private readonly TestUserStore _users; private readonly IIdentityServerInteractionService _interaction; private readonly IClientStore _clientStore; private readonly IAuthenticationSchemeProvider _schemeProvider; private readonly IEventService _events; public AccountController( IIdentityServerInteractionService interaction, IClientStore clientStore, IAuthenticationSchemeProvider schemeProvider, IEventService events, TestUserStore users = null) { //如果TestUserStore不在DI中,那么我们将只使用全局用户集合 //在这里,您可以插入自己的自定义身份管理库(例如, ASP.NET Identity) _users = users ?? new TestUserStore(MemoryConfigs.GetUsers()); _interaction = interaction; _clientStore = clientStore; _schemeProvider = schemeProvider; _events = events; } /// <summary> /// 展示登录页 /// </summary> [HttpGet] public async Task<IActionResult> Login(string backurl) { // 构建一个模型,以便我们知道在登录页面上显示什么 var vm = await BuildLoginViewModelAsync(backurl); if (vm.IsExternalLoginOnly) { //我们只有一个登录选项,它是一个外部提供者 return await ExternalLogin(vm.ExternalLoginScheme, backurl); } return View(vm); } public async Task<IActionResult> Login(LoginInputModel model, string button) { if (button != "login") { //用户点击“取消”按钮 var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); if (context != null) { //如果用户取消,则将结果发送回IdentityServer //拒绝同意(即使该客户不需要同意)。 //这将向客户端发送一个访问被拒绝的OIDC错误响应。 await _interaction.GrantConsentAsync(context, ConsentResponse.Denied); //我们可以信任模型。因为GetAuthorizationContextAsync返回非空 return Redirect(model.ReturnUrl); } else { //因为我们没有一个有效的上下文,所以我们只能返回主页 return Redirect("~/"); } } if (ModelState.IsValid) { //在内存存储中验证用户名/密码 if (_users.ValidateCredentials(model.Username, model.Password)) { var user = _users.FindByUsername(model.Username); await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username)); //只有当用户选择“记住我”时才设置显式过期。 //否则,我们依赖于在cookie中间件中配置的过期。 AuthenticationProperties props = null; if (AccountOptions.AllowRememberLogin && model.RememberLogin) { props = new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration) }; }; //List<Claim> customClaims = new List<Claim>(); //customClaims.Add(new Claim("custom", "自定义信息")); //使用主题ID和用户名发出身份验证cookie await HttpContext.SignInAsync(user.SubjectId, user.Username, props,new Claim("custom", "自定义Claim")); //确保returnUrl仍然有效,如果有效,则重定向回授权端点或本地页 //只有在需要支持其他本地页面时才需要进行IsLocalUrl检查,否则IsValidReturnUrl将更加严格 if (_interaction.IsValidReturnUrl(model.ReturnUrl) || Url.IsLocalUrl(model.ReturnUrl)) { return Redirect(model.ReturnUrl); } return Redirect("~/"); } await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials")); ModelState.AddModelError("", AccountOptions.InvalidCredentialsErrorMessage); } //出了问题,显示错误的形式 var vm = await BuildLoginViewModelAsync(model); return View(vm); } /// <summary> /// 启动到外部身份验证提供者的往返 /// </summary> [HttpGet] public async Task<IActionResult> ExternalLogin(string provider, string returnUrl) { if (AccountOptions.WindowsAuthenticationSchemeName == provider) { // windows身份验证需要特殊处理 return await ProcessWindowsLoginAsync(returnUrl); } else { //开始挑战和往返返回的URL和 var props = new AuthenticationProperties() { RedirectUri = Url.Action("ExternalLoginCallback"), Items = { { "returnUrl", returnUrl }, { "scheme", provider }, } }; return Challenge(props, provider); } } /// <summary> /// 外部认证的后处理 /// </summary> [HttpGet] public async Task<IActionResult> ExternalLoginCallback() { //从临时cookie读取外部标识 var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); if (result?.Succeeded != true) { throw new Exception("External authentication error"); } //查找我们的用户和外部提供商信息 var (user, provider, providerUserId, claims) = FindUserFromExternalProvider(result); if (user == null) { //这可能是您启动用户注册的自定义工作流的地方 //在这个示例中,作为示例实现,我们没有展示如何实现 //简单地自动提供新的外部用户 user = AutoProvisionUser(provider, providerUserId, claims); } //这允许我们收集任何附加的声明或属性 //用于特定的prtotocols,并将其存储在本地的auth cookie中。 //这通常用于存储从这些协议签出所需的数据。 var additionalLocalClaims = new List<Claim>(); var localSignInProps = new AuthenticationProperties(); ProcessLoginCallbackForOidc(result, additionalLocalClaims, localSignInProps); ProcessLoginCallbackForWsFed(result, additionalLocalClaims, localSignInProps); ProcessLoginCallbackForSaml2p(result, additionalLocalClaims, localSignInProps); //为用户发出认证cookie await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.SubjectId, user.Username)); await HttpContext.SignInAsync(user.SubjectId, user.Username, provider, localSignInProps, additionalLocalClaims.ToArray()); //删除外部身份验证期间使用的临时cookie await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); //验证返回的URL并将其重定向回授权端点或本地页面 var returnUrl = result.Properties.Items["returnUrl"]; if (_interaction.IsValidReturnUrl(returnUrl) || Url.IsLocalUrl(returnUrl)) { return Redirect(returnUrl); } return Redirect("~/"); } /// <summary> /// 显示注销页 /// </summary> [HttpGet] public async Task<IActionResult> Logout(string logoutId) { //建立一个模型,以便注销页面知道显示什么 var vm = await BuildLogoutViewModelAsync(logoutId); if (vm.ShowLogoutPrompt == false) { //如果从IdentityServer正确验证了注销请求,那么 //我们不需要显示提示,可以直接将用户登出即可。 return await Logout(vm); } return View(vm); } /// <summary> /// 处理注销页面回发 /// </summary> [HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Logout(LogoutInputModel model) { // 建立一个模型,以便注销页面知道显示什么 var vm = await BuildLoggedOutViewModelAsync(model.LogoutId); if (User?.Identity.IsAuthenticated == true) { //删除本地身份验证cookie await HttpContext.SignOutAsync(); //引发注销事件 await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); } //检查是否需要在上游身份提供商触发注销 if (vm.TriggerExternalSignout) { //构建一个返回URL,以便上游提供者将重定向回来 //在用户登出后发送给我们。这样我们就可以 //完成我们的单次签名处理。 string url = Url.Action("Logout", new { logoutId = vm.LogoutId }); //这会触发重定向到外部提供者以便注销 return SignOut(new AuthenticationProperties { RedirectUri = url }, vm.ExternalAuthenticationScheme); } return View("LoggedOut", vm); } /// <summary> /// 刷新Token /// </summary> /// <param name="refreshToken"></param> /// <returns></returns> [HttpGet] public async Task<IActionResult> RefreshToken(string refreshToken) { HttpClient client2 = new HttpClient(); DiscoveryDocumentResponse disco2 = await client2.GetDiscoveryDocumentAsync("http://localhost:2000"); if (disco2.IsError) { Console.WriteLine($"[DiscoveryDocumentResponse Error]: {disco2.Error}"); } //// 1.1、通过客户端获取AccessToken //TokenResponse tokenResponse2 = await client2.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest //{ // Address = disco2.TokenEndpoint, // 1、生成AccessToken中心 // ClientId = "clientcode", // 2、客户端编号 // ClientSecret = "clientcode",// 3、客户端密码 // Scope = "signalrapi" // 4、客户端需要访问的API //}); TokenResponse tokenResponse3 = await client2.RequestRefreshTokenAsync(new RefreshTokenRequest { Address = disco2.TokenEndpoint, RefreshToken = refreshToken, ClientId = "appclient", // 2、客户端编号 ClientSecret = "appclient",// 3、客户端密码 Scope = "openid profile offline_access signalrapi", //Scope = "signalrapi", }); return new JsonResult(JsonConvert.SerializeObject(tokenResponse3)); } /*****************************************/ /* AccountController API帮助类 */ /*****************************************/ private async Task<LoginViewModel> BuildLoginViewModelAsync(string returnUrl) { var context = await _interaction.GetAuthorizationContextAsync(returnUrl); if (context?.IdP != null) { // 这意味着将UI短路,只触发一个外部IdP return new LoginViewModel { EnableLocalLogin = false, ReturnUrl = returnUrl, Username = context?.LoginHint, ExternalProviders = new ExternalProvider[] { new ExternalProvider { AuthenticationScheme = context.IdP } } }; } var schemes = await _schemeProvider.GetAllSchemesAsync(); var providers = schemes .Where(x => x.DisplayName != null || (x.Name.Equals(AccountOptions.WindowsAuthenticationSchemeName, StringComparison.OrdinalIgnoreCase)) ) .Select(x => new ExternalProvider { DisplayName = x.DisplayName, AuthenticationScheme = x.Name }).ToList(); var allowLocal = true; if (context?.ClientId != null) { var client = await _clientStore.FindEnabledClientByIdAsync(context.ClientId); if (client != null) { allowLocal = client.EnableLocalLogin; if (client.IdentityProviderRestrictions != null && client.IdentityProviderRestrictions.Any()) { providers = providers.Where(provider => client.IdentityProviderRestrictions.Contains(provider.AuthenticationScheme)).ToList(); } } } return new LoginViewModel { AllowRememberLogin = AccountOptions.AllowRememberLogin, EnableLocalLogin = allowLocal && AccountOptions.AllowLocalLogin, ReturnUrl = returnUrl, Username = context?.LoginHint, ExternalProviders = providers.ToArray() }; } private async Task<LoginViewModel> BuildLoginViewModelAsync(LoginInputModel model) { var vm = await BuildLoginViewModelAsync(model.ReturnUrl); vm.Username = model.Username; vm.RememberLogin = model.RememberLogin; return vm; } private async Task<LogoutViewModel> BuildLogoutViewModelAsync(string logoutId) { var vm = new LogoutViewModel { LogoutId = logoutId, ShowLogoutPrompt = AccountOptions.ShowLogoutPrompt }; if (User?.Identity.IsAuthenticated != true) { //如果用户没有经过身份验证,那么只显示注销页面 vm.ShowLogoutPrompt = false; return vm; } var context = await _interaction.GetLogoutContextAsync(logoutId); if (context?.ShowSignoutPrompt == false) { //自动退出是安全的 vm.ShowLogoutPrompt = false; return vm; } //显示注销提示。这可以防止用户攻击 //被另一个恶意网页自动注销。 return vm; } private async Task<LoggedOutViewModel> BuildLoggedOutViewModelAsync(string logoutId) { // 获取上下文信息(用于联合注销的客户端名称、post logout重定向URI和iframe) var logout = await _interaction.GetLogoutContextAsync(logoutId); var vm = new LoggedOutViewModel { AutomaticRedirectAfterSignOut = AccountOptions.AutomaticRedirectAfterSignOut, PostLogoutRedirectUri = logout?.PostLogoutRedirectUri, ClientName = string.IsNullOrEmpty(logout?.ClientName) ? logout?.ClientId : logout?.ClientName, SignOutIframeUrl = logout?.SignOutIFrameUrl, LogoutId = logoutId }; if (User?.Identity.IsAuthenticated == true) { var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; if (idp != null && idp != IdentityServerConstants.LocalIdentityProvider) { var providerSupportsSignout = await HttpContext.GetSchemeSupportsSignOutAsync(idp); if (providerSupportsSignout) { if (vm.LogoutId == null) { //如果当前没有注销上下文,我们需要创建一个 //从当前登录的用户获取必要的信息 //在我们注销和重定向到外部IdP进行注销之前 vm.LogoutId = await _interaction.CreateLogoutContextAsync(); } vm.ExternalAuthenticationScheme = idp; } } } return vm; } private async Task<IActionResult> ProcessWindowsLoginAsync(string returnUrl) { // 看看windows auth是否已经被请求成功 var result = await HttpContext.AuthenticateAsync(AccountOptions.WindowsAuthenticationSchemeName); if (result?.Principal is WindowsPrincipal wp) { //我们将发出外部cookie,然后重定向 //用户返回到外部回调,本质上是tresting windows // auth与任何其他外部身份验证机制相同 var props = new AuthenticationProperties() { RedirectUri = Url.Action("ExternalLoginCallback"), Items = { { "returnUrl", returnUrl }, { "scheme", AccountOptions.WindowsAuthenticationSchemeName }, } }; var id = new ClaimsIdentity(AccountOptions.WindowsAuthenticationSchemeName); id.AddClaim(new Claim(JwtClaimTypes.Subject, wp.Identity.Name)); id.AddClaim(new Claim(JwtClaimTypes.Name, wp.Identity.Name)); // 添加组作为声明——如果组的数量太大,请小心 if (AccountOptions.IncludeWindowsGroups) { var wi = wp.Identity as WindowsIdentity; var groups = wi.Groups.Translate(typeof(NTAccount)); var roles = groups.Select(x => new Claim(JwtClaimTypes.Role, x.Value)); id.AddClaims(roles); } await HttpContext.SignInAsync( IdentityServerConstants.ExternalCookieAuthenticationScheme, new ClaimsPrincipal(id), props); return Redirect(props.RedirectUri); } else { //触发窗口 //由于windows auth不支持重定向uri, //当我们调用challenge时,这个URL被重新触发 return Challenge(AccountOptions.WindowsAuthenticationSchemeName); } } private (TestUser user, string provider, string providerUserId, IEnumerable<Claim> claims) FindUserFromExternalProvider(AuthenticateResult result) { var externalUser = result.Principal; //尝试确定外部用户的唯一id(由提供者发出) //最常见的索赔类型是子索赔和名称标识符 //根据外部提供者的不同,可能会使用其他一些索赔类型 var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ?? externalUser.FindFirst(ClaimTypes.NameIdentifier) ?? throw new Exception("Unknown userid"); //删除用户id声明,这样我们在提供用户时就不会将其作为额外的声明 var claims = externalUser.Claims.ToList(); claims.Remove(userIdClaim); var provider = result.Properties.Items["scheme"]; var providerUserId = userIdClaim.Value; //寻找外部用户 var user = _users.FindByExternalProvider(provider, providerUserId); return (user, provider, providerUserId, claims); } private TestUser AutoProvisionUser(string provider, string providerUserId, IEnumerable<Claim> claims) { var user = _users.AutoProvisionUser(provider, providerUserId, claims.ToList()); return user; } private void ProcessLoginCallbackForOidc(AuthenticateResult externalResult, List<Claim> localClaims, AuthenticationProperties localSignInProps) { //如果外部系统发送了会话id声明,请复制它 //这样我们就可以用它来做单点签到了 var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId); if (sid != null) { localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value)); } //如果外部提供者发出id_token,我们将保留它以便注销 var id_token = externalResult.Properties.GetTokenValue("id_token"); if (id_token != null) { localSignInProps.StoreTokens(new[] { new AuthenticationToken { Name = "id_token", Value = id_token } }); } } private void ProcessLoginCallbackForWsFed(AuthenticateResult externalResult, List<Claim> localClaims, AuthenticationProperties localSignInProps) { } private void ProcessLoginCallbackForSaml2p(AuthenticateResult externalResult, List<Claim> localClaims, AuthenticationProperties localSignInProps) { } }
6. 创建登录相关的model
public class AccountOptions { public static bool AllowLocalLogin = true; public static bool AllowRememberLogin = true; public static TimeSpan RememberMeLoginDuration = TimeSpan.FromDays(30); public static bool ShowLogoutPrompt = true; public static bool AutomaticRedirectAfterSignOut = false; // 指定正在使用的Windows身份验证方案 public static readonly string WindowsAuthenticationSchemeName = Microsoft.AspNetCore.Server.IISIntegration.IISDefaults.AuthenticationScheme; // 如果用户使用windows auth,是否应该从windows加载组 public static bool IncludeWindowsGroups = false; public static string InvalidCredentialsErrorMessage = "Invalid username or password"; } public class ExternalProvider { public string DisplayName { get; set; } public string AuthenticationScheme { get; set; } } public class LoggedOutViewModel { public string PostLogoutRedirectUri { get; set; } public string ClientName { get; set; } public string SignOutIframeUrl { get; set; } public bool AutomaticRedirectAfterSignOut { get; set; } public string LogoutId { get; set; } public bool TriggerExternalSignout => ExternalAuthenticationScheme != null; public string ExternalAuthenticationScheme { get; set; } } public class LoginInputModel { [Required] public string Username { get; set; } [Required] public string Password { get; set; } public bool RememberLogin { get; set; } public string ReturnUrl { get; set; } } public class LoginViewModel : LoginInputModel { public bool AllowRememberLogin { get; set; } public bool EnableLocalLogin { get; set; } public IEnumerable<ExternalProvider> ExternalProviders { get; set; } public IEnumerable<ExternalProvider> VisibleExternalProviders => ExternalProviders.Where(x => !String.IsNullOrWhiteSpace(x.DisplayName)); public bool IsExternalLoginOnly => EnableLocalLogin == false && ExternalProviders?.Count() == 1; public string ExternalLoginScheme => IsExternalLoginOnly ? ExternalProviders?.SingleOrDefault()?.AuthenticationScheme : null; } public class LogoutInputModel { public string LogoutId { get; set; } } public class LogoutViewModel : LogoutInputModel { public bool ShowLogoutPrompt { get; set; } }
完成了,直接运行访问 /.well-known/openid-configuration 查看效果
创建客户端
1. 新建一个MVC项目,安装nuget包
IdentityServer4
2. 在ConfigureServices中注入服务
//删除系统所有claims JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); //将认证服务依赖注入容器中 services.AddAuthentication(options => { //使用cookie作为验证用户的方法 options.DefaultScheme = "Cookies"; //当需要用户登录时,使用OpenID Connect方案 options.DefaultChallengeScheme = "oidc"; }) //添加可以处理Cookie的处理程序 .AddCookie("Cookies") //配置执行OpenID Connect协议的处理程序 .AddOpenIdConnect("oidc", options => { //在OpenID Connect协议完成后使用cookie处理程序发出cookie options.SignInScheme = "Cookies"; //id4地址 options.Authority = "http://localhost:2000"; //获取或设置元数据地址或权限是否需要HTTPS,默认为true,只能在开发环境设置 options.RequireHttpsMetadata = false; //用于识别客户端 options.ClientId = "mvc";
//options.ClientSecret = "testcode"; //code授权码模式才设置
//options.ResponseType = "code"; //code授权码模式才设置
//用于在Cookie中保存IdentityServer中的令牌 options.SaveTokens = true; });
3. 在Configure中新增认证服务
app.UseAuthentication();
app.UseAuthorization();
4. 在客户端中添加测试代码
[Authorize] public IActionResult Userinfo() { ViewBag.Userinfo = User.Claims; return View(); }
运行结果
如果出现输入账号密码后无法跳转,并且出现这种错误 “idr4 The cookie 'idsrv.session' has set 'SameSite=None' and must also set 'S” 那就是因为谷歌浏览器版本问题,需要手动设置 SameSite
在我们认证服务器中 ConfigureServices 手动设置服务
services.Configure<CookiePolicyOptions>(options => { options.MinimumSameSitePolicy = SameSiteMode.Lax; options.OnAppendCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions); options.OnDeleteCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions); }); private static void CheckSameSite(HttpContext httpContext, CookieOptions options) { if (options.SameSite == SameSiteMode.None) { if (options.SameSite == SameSiteMode.None) { if (httpContext.Request.Scheme != "https") { options.SameSite = SameSiteMode.Lax; } } } }
在Configure中添加中间件
app.UseCookiePolicy();
IdentityServer4 集成EF Core
1. 添加nuget包
Pomelo.EntityFrameworkCore.MySql
Microsoft.EntityFrameworkCore.Design
2. 在ConfigureServices中注入服务,在appsettings.json 配置数据库连接字符串
"ConnectionStrings": "server=localhost;port=3306;user=root;password=hua3182486;database=fcb_idr4;SslMode=none;"
#region 通过EF Core 使用idr4, 实际开发中使用 services.AddIdentityServer(options => { //options.Authentication.CookieSlidingExpiration = true; options.IssuerUri = "http://localhost:2000"; //var publicOrgin = options.PublicOrigin = Configuration.GetSection("PublicOrigin").Value; //if (!string.IsNullOrEmpty(publicOrgin)) //{ //options.PublicOrigin = publicOrgin; //} IdentityModelEventSource.ShowPII = true; //设置用户交互时路由 options.UserInteraction = new UserInteractionOptions { LoginUrl = "/account/login", LogoutUrl = "/account/logout", ConsentUrl = "/consent/index", ConsentReturnUrlParameter = "backurl", ErrorUrl = "/account/error", LoginReturnUrlParameter = "backurl", LogoutIdParameter = "logoutid", ErrorIdParameter = "errorid", CookieMessageThreshold = 5 }; options.Authentication.CookieLifetime = TimeSpan.FromDays(1); #region 事件设置 options.Events.RaiseErrorEvents = true; options.Events.RaiseFailureEvents = true; options.Events.RaiseInformationEvents = true; options.Events.RaiseSuccessEvents = true; #endregion }) .AddDeveloperSigningCredential() //.AddSigningCredential() .AddProfileService<CustomProfileService>() .AddResourceOwnerValidator<CustomResourceOwnerPasswordValidator>() //.AddSecretValidator<CustomSecretValidator>() .AddInMemoryCaching() .AddConfigurationStore(options => { options.ConfigureDbContext = builder => { builder.UseMySql(Configuration.GetSection("ConnectionStrings").Value, ServerVersion.AutoDetect(Configuration.GetSection("ConnectionStrings").Value), sqloption => { sqloption.MigrationsAssembly(typeof(Startup).GetTypeInfo().Assembly.GetName().Name); sqloption.UseRelationalNulls(); }); }; }) .AddOperationalStore(options => { options.ConfigureDbContext = builder => builder.UseMySql(Configuration.GetSection("ConnectionStrings").Value, ServerVersion.AutoDetect(Configuration.GetSection("ConnectionStrings").Value), sqloption => { sqloption.MigrationsAssembly(typeof(Startup).GetTypeInfo().Assembly.GetName().Name); sqloption.UseRelationalNulls(); }); options.EnableTokenCleanup = true; options.TokenCleanupInterval = 3600; }) ; #endregion
3. 生成 Migrations 文件
dotnet ef migrations add initdb -c ConfigurationDbContext -o Migrations/Configuration
dotnet ef migrations add initdb -c PersistedGrantDbContext -o Migrations/PersistedGrant
4. 加入初始化数据
//根据migration初始化数据库 public static void UseInitIdr4Data(this IApplicationBuilder app) { using (var scope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope()) { //初始化数据库结构 scope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>().Database.Migrate(); var configContext = scope.ServiceProvider.GetRequiredService<ConfigurationDbContext>(); configContext.Database.Migrate(); //初始数据 if (!configContext.Clients.Any()) { foreach (var client in MemoryConfigs.GetClients()) { configContext.Clients.Add(client.ToEntity()); } configContext.SaveChanges(); } if (!configContext.IdentityResources.Any()) { foreach (var resource in MemoryConfigs.GetIdentityResources()) { configContext.IdentityResources.Add(resource.ToEntity()); } configContext.SaveChanges(); } if (!configContext.ApiResources.Any()) { foreach (var resource in MemoryConfigs.GetApiResources()) { configContext.ApiResources.Add(resource.ToEntity()); } configContext.SaveChanges(); } } }
5. 在Configure 中使用
app.UseInitIdr4Data();
6. 添加一些自定义验证,例如账号密码验证等等 可以忽略
//自定义登录扩展 //这里是通过openid登录 public class CustomExtensionValidator : IExtensionGrantValidator { public string GrantType => "password"; private readonly IHttpClientFactory _httpClientFactory; private readonly IEventService _eventService; public CustomExtensionValidator(IHttpClientFactory httpClientFactory, IEventService eventService) { _httpClientFactory = httpClientFactory; _eventService = eventService; } public async Task ValidateAsync(ExtensionGrantValidationContext context) { #region 正常的微信登录示例 //var openid = context.Request.Raw["openid"]; //var clientapi = _httpClientFactory.CreateClient("你的服务名称ServicesName"); //var result = await clientapi.PostAsJsonAsync("/api/oauth/wxlogin", new { openid = openid}); //if (result.IsSuccessStatusCode) //{ // var operatorResult = await result.Content.ReadAsStringAsync(); // var dataresult = JsonConvert.DeserializeObject<OperatorResult>(operatorResult); // if (dataresult.Result == 0) // { // var data = JsonConvert.DeserializeObject<UserInfo>(JsonConvert.SerializeObject(dataresult.Data)); // #region 登录具体 // ClaimsIdentity claimsIdentity = new ClaimsIdentity("UserIdentity"); // claimsIdentity.AddClaim(new Claim("authcode", data.AuthCode + "")); // claimsIdentity.AddClaim(new Claim("username", data.UserName + "")); // claimsIdentity.AddClaim(new Claim("nickname", data.NickName + "")); // claimsIdentity.AddClaim(new Claim("oid", data.OId + "")); // claimsIdentity.AddClaim(new Claim("oname", data.OName + "")); // claimsIdentity.AddClaim(new Claim("usertype", data.UserType + "")); // context.Result = new GrantValidationResult(subject: data.Id.ToString(), authenticationMethod: "password", claims: claimsIdentity.Claims.ToArray()); // #endregion // } // else // { // context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, dataresult.Message); // } //} //else //{ // context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "登录失败"); //} #endregion await Task.CompletedTask; } }
public class CustomProfileService : IProfileService { public async Task GetProfileDataAsync(ProfileDataRequestContext context) { context.IssuedClaims = context.Subject.Claims.ToList(); await Task.CompletedTask; } public async Task IsActiveAsync(IsActiveContext context) { await Task.CompletedTask; } }
/// <summary> /// 手机 账号密码登录扩展 /// </summary> public class CustomResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator { private readonly IHttpClientFactory _httpClientFactory; private readonly IEventService _eventService; public CustomResourceOwnerPasswordValidator(IHttpClientFactory httpClientFactory, IEventService eventService) { _httpClientFactory = httpClientFactory; _eventService = eventService; } public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) { #region 自定义账号密码登录示例 //string username = context.UserName; //string userpwd = context.Password; //var clientapi = _httpClientFactory.CreateClient(ServicesUrl.UserServicesName); //var wxtype = context.Request.Raw["wxtype"]; //if (string.IsNullOrEmpty(wxtype)) //{ // #region 账户密码登录 // var result = await clientapi.PostAsJsonAsync("/api/oauth/login", new { username = username, userpwd = userpwd }); // if (result.IsSuccessStatusCode) // { // var operatorResult = await result.Content.ReadAsStringAsync(); // var dataresult = JsonConvert.DeserializeObject<OperatorResult>(operatorResult); // if (dataresult.Result == ResultType.Success) // { // var data = JsonConvert.DeserializeObject<UserInfo>(JsonConvert.SerializeObject(dataresult.Data)); // #region 登录具体 // ////通过事件 这里可以通过事件配置来设置通知事件 // //await _eventService.RaiseAsync(new UserLoginSuccessEvent(data.UserName, data.Id, data.NickName, clientId: client?.ClientId)); // //写入Claims // ClaimsIdentity claimsIdentity = new ClaimsIdentity("UserIdentity"); // //if (data.Scopes.Any()) // //{ // // var scopeslst = data.Scopes.Select(c => c.ClaimValue).ToList(); // // claimsIdentity.AddClaim(new Claim("operatorpermission", string.Join(",", scopeslst))); // // //data.Scopes.ForEach(c => // // //{ // // // claimsIdentity.AddClaim(new Claim(c.ClaimType, c.ClaimValue)); // // //}); // //} // ////写入数据权限 // //if (data.DataPermissions.Any()) // //{ // // claimsIdentity.AddClaim(new Claim("datapermission", JsonConvert.SerializeObject(data.DataPermissions))); // //} // claimsIdentity.AddClaim(new Claim("authcode", data.AuthCode + "")); // claimsIdentity.AddClaim(new Claim("username", data.UserName + "")); // claimsIdentity.AddClaim(new Claim("nickname", data.NickName + "")); // claimsIdentity.AddClaim(new Claim("oid", data.OId + "")); // claimsIdentity.AddClaim(new Claim("oname", data.OName + "")); // claimsIdentity.AddClaim(new Claim("usertype", data.UserType + "")); // context.Result = new GrantValidationResult(subject: data.Id.ToString(), authenticationMethod: "password", claims: claimsIdentity.Claims.ToArray()); // #endregion // } // else // { // context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, dataresult.Message); // } // } // else // { // context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "登录失败"); // } // #endregion //} //else { // #region 微信登录 // var result = await clientapi.PostAsJsonAsync("/api/oauth/wxlogin", new { openid = userpwd }); // if (result.IsSuccessStatusCode) // { // var operatorResult = await result.Content.ReadAsStringAsync(); // var dataresult = JsonConvert.DeserializeObject<OperatorResult>(operatorResult); // if (dataresult.Result == ResultType.Success) // { // var data = JsonConvert.DeserializeObject<UserInfo>(JsonConvert.SerializeObject(dataresult.Data)); // #region 登录具体 // ClaimsIdentity claimsIdentity = new ClaimsIdentity("UserIdentity"); // claimsIdentity.AddClaim(new Claim("authcode", data.AuthCode + "")); // claimsIdentity.AddClaim(new Claim("username", data.UserName + "")); // claimsIdentity.AddClaim(new Claim("nickname", data.NickName + "")); // claimsIdentity.AddClaim(new Claim("oid", data.OId + "")); // claimsIdentity.AddClaim(new Claim("oname", data.OName + "")); // claimsIdentity.AddClaim(new Claim("usertype", data.UserType + "")); // claimsIdentity.AddClaim(new Claim("openid", data.OpenId + "")); // context.Result = new GrantValidationResult(subject: data.Id.ToString(), authenticationMethod: "password", claims: claimsIdentity.Claims.ToArray()); // #endregion // } // else // { // context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, dataresult.Message); // } // } // else // { // context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "登录失败"); // } // #endregion //} #endregion await Task.CompletedTask; } }
public class CustomSecretValidator : ISecretValidator { public Task<SecretValidationResult> ValidateAsync(IEnumerable<Secret> secrets, ParsedSecret parsedSecret) { var jsonstr = JsonConvert.SerializeObject(parsedSecret.Properties); return Task.FromResult(new SecretValidationResult { Success = true, Confirmation = jsonstr }); } }
运行,可以看到生成了数据库
PostMan几种模式请求示例
创建API资源服务
这里创建一个API资源服务,方便下面postman几种模式请求
1. 创建一个API项目,引入nuget包
IdentityServer4.AccessTokenValidation
2. 注入服务
services.AddAuthentication("Bearer") .AddIdentityServerAuthentication(options => { options.Authority = "http://localhost:2000"; options.RequireHttpsMetadata = false; options.ApiSecret = "testcode"; options.ApiName = "signalrapi"; });
3. 管道中在授权中间件之前添加认证中间件
app.UseAuthentication();
app.UseAuthorization();
4. 添加测试接口
[Route("api/[controller]")] [ApiController] [Authorize] public class TestController : ControllerBase { public TestController() { } [HttpGet] [Route("[action]")] public new IActionResult Get() { var userCliam = User.Claims; return Ok("请求成功"); } [HttpGet] [Route("[action]")] [AllowAnonymous] public new IActionResult GetCode(string code) { return Ok("请求的code为:"+code); } }
这里就不演示效果,下面几种模式通过获取的token直接请求这里 /api/Test/get 就行了,如果
客户端模式
适用于和用户无关,机器与机器之间直接交互访问资源的场景,适用于受信任的设备访问。
POST https://api.oauth2server.com/token grant_type=client_credentials& client_id=CLIENT_ID& client_secret=CLIENT_SECRET
密码模式
适用于APP等第三方登录
(授权码)隐式模式
相对于授权码模式少了获取授权码,直接根据账号密码登录适用于web端
浏览器请求地址:
http://localhost:2000/connect/authorize?response_type=token&client_id=testcodeapi&redirect_uri=http://localhost:2002/signin-oidc&scope=signalrapi
授权码模式
先获取一个code授权码,授权码一般只有五分钟有效时间,并且用一次就会失效,再通过授权码加上账号和密码请求token。授权码模式通过后台传输Tokens,相对于简化模式会更安全一点。
但每当考虑使用授权码模式的时候,请使用混合模式。混合模式会首先返回一个可验证的ID Token并且有更多其他特性。
或者
通过浏览器获取token,请求地址:
http://localhost:2000/connect/authorize?response_type=code&client_id=testcode&redirect_uri=http://localhost:2002/api/Test/getcode&scope=signalrapi
再通过code获取token