• identity4 系列————案例篇[三]


    前言

    前文介绍了identity的用法,同时介绍了什么是identitySourece、apiSource、client 这几个概念,和具体案例,那么下面继续介绍案例了。

    正文

    这里用官网的案例,因为学习一门技术最好的就是看官网了,所以不会去夹杂个人的自我编辑的案例,当然后面实战中怎么处理,遇到的问题是会展示开来的。

    官网给的第二个例子是这个: https://identityserver4.readthedocs.io/en/latest/quickstarts/2_interactive_aspnetcore.html

    首先来看下与identityServer 对接的客户端是怎么样的。

    看着项目是一个标准mvc。

    JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
    
    services.AddAuthentication(options =>
    {
    	options.DefaultScheme = "Cookies";
    	options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie("Cookies")
    .AddOpenIdConnect("oidc", options =>
    {
    	options.Authority = "https://localhost:5001";
    
    	options.ClientId = "mvc";
    	options.ClientSecret = "secret";
    	options.ResponseType = "code";
    
    	options.SaveTokens = true;
    });
    

    上面的意思是使用方案认证方案是cookies,然后查问方案使用oidc。

    AddCookie("Cookies") 就是注入cookies 方案,这个要和前面设置的options.DefaultScheme = "Cookies" 对应的,前面是配置,这个是具体实现。

    我写过认证这块源码的,可以去看下,这里就不多介绍了。

    然后下面AddOpenIdConnect 注册了查问访问oidc。

    public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions)
    {
    	builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdConnectPostConfigureOptions>());
    	return builder.AddRemoteScheme(authenticationScheme, displayName, configureOptions);
    }
    

    这里再介绍一下DefaultScheme 和 DefaultChallengeScheme 分别是什么哈。

    /// 
    /// Used as the fallback default scheme for all the other defaults.
    /// 
    public string DefaultScheme { get; set; }
    

    默认就是使用这种方案。

    /// 
    /// Used as the default scheme by .
    /// 
    public string DefaultChallengeScheme { get; set; }
    

    这个就是IAuthenticationService.ChallengeAsync 会使用到这个。

    /// 
    /// Challenge the specified authentication scheme.
    /// 
    /// The .
    /// The name of the authentication scheme.
    /// The .
    /// A task.
    Task ChallengeAsync(HttpContext context, string scheme, AuthenticationProperties properties);
    

    这个方案确认了是否能通过,有兴趣的可以看下源码。

    我们知道使用了AddAuthentication 是添加这个服务,我们需要在中间件中注册进去。

    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();
    

    那么这里mvc 客户端就算完成了。

    那么identityServer 怎么该做些什么呢?

    1. 肯定是要注册客户端的嘛
    new Client
    {
    	ClientId = "mvc",
    	ClientSecrets = { new Secret("secret".Sha256()) },
    
    	AllowedGrantTypes = GrantTypes.Code,
    	
    	// where to redirect to after login
    	RedirectUris = { "https://localhost:5002/signin-oidc" },
    
    	// where to redirect to after logout
    	PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc" },
    
    	AllowedScopes = new List<string>
    	{
    		IdentityServerConstants.StandardScopes.OpenId,
    		IdentityServerConstants.StandardScopes.Profile
    	}
    }
    

    这里解释一下。

    RedirectUris 是登录完成之后会跳转的地址。

    PostLogoutRedirectUris 是登录失败后会跳转的位置。

    有人就会问了,为什么登录完成之后的地址为什么不是跳转过来的地址呢。

    这里的流程是这样的,如果没有登录,那么就会跳转到identity Server的登录页面,然后再跳转回客户端的接收token 或者code 的路径,然后这个路径再跳转到一开始未登录的页面,有些直接到首页的。

    然后可以看到这两个路径signin-oidc 和 signout-callback-oidc 发现我们mvc 中根本就没有写这两个路由,这个是由AddOpenIdConnect 提供的。

    我们看下OpenIdConnectOptions 配置。

    拦截到这两个路由,会进入OpenIdConnectHandler 做相应的处理。

    这样子client 就注册了。

    1. 登录,一般模式是需要账户密码,那么要账户密码就需要用户,这个用户怎么注册进去呢?
    public static List Users
    {
    	get
    	{
    		var address = new
    		{
    			street_address = "One Hacker Way",
    			locality = "Heidelberg",
    			postal_code = 69118,
    			country = "Germany"
    		};
    		
    		return new List
    		{
    			new TestUser
    			{
    				SubjectId = "818727",
    				Username = "alice",
    				Password = "alice",
    				Claims =
    				{
    					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, JsonSerializer.Serialize(address), IdentityServerConstants.ClaimValueTypes.Json)
    				}
    			},
    			new TestUser
    			{
    				SubjectId = "88421113",
    				Username = "bob",
    				Password = "bob",
    				Claims =
    				{
    					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, JsonSerializer.Serialize(address), IdentityServerConstants.ClaimValueTypes.Json)
    				}
    			}
    		};	
    	}
    }
    

    那么需要将用户注册进去。

    1. 这个时候还得处理identity Server的逻辑
    /// 
    /// Entry point into the login workflow
    /// 
    [HttpGet]
    public async Task Login(string returnUrl)
    {
    	// build a model so we know what to show on the login page
    	var vm = await BuildLoginViewModelAsync(returnUrl);
    
    	if (vm.IsExternalLoginOnly)
    	{
    		// we only have one option for logging in and it's an external provider
    		return RedirectToAction("Challenge", "External", new { scheme = vm.ExternalLoginScheme, returnUrl });
    	}
    
    	return View(vm);
    }
    

    这样不好看,直接debug调试下。

    当我访问5002客户端的时候,那么:

    这里跳转到5001 identity server 服务中去。

    同样设置了返回的地址,红框中标明了。

    然后又转到了account login

    然后我们看到account login 接收到了什么。

    这里可以看到如果login action 结束会进入到/connect/authorize/callback。

    /connect/authorize -> account/login -> /connect/authorize/callback, 中间account/login就是用来验证是否通过的。

    那么看一下登录的处理逻辑。

    这是参数。

    // check if we are in the context of an authorization request
    var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl);
    
    // the user clicked the "cancel" button
    if (button != "login")
    {
    	if (context != null)
    	{
    		// if the user cancels, send a result back into IdentityServer as if they 
    		// denied the consent (even if this client does not require consent).
    		// this will send back an access denied OIDC error response to the client.
    		await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied);
    
    		// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
    		if (context.IsNativeClient())
    		{
    			// The client is native, so this change in how to
    			// return the response is for better UX for the end user.
    			return this.LoadingPage("Redirect", model.ReturnUrl);
    		}
    
    		return Redirect(model.ReturnUrl);
    	}
    	else
    	{
    		// since we don't have a valid context, then we just go back to the home page
    		return Redirect("~/");
    	}
    }
    

    然后就会回到原先的进来的页面了。

    然后看下正常登录逻辑。

    if (ModelState.IsValid)
    {
    	// validate username/password against in-memory store
    	if (_users.ValidateCredentials(model.Username, model.Password))
    	{
    		var user = _users.FindByUsername(model.Username);
    		await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username, clientId: context?.Client.ClientId));
    
    		// only set explicit expiration here if user chooses "remember me". 
    		// otherwise we rely upon expiration configured in cookie middleware.
    		AuthenticationProperties props = null;
    		if (AccountOptions.AllowRememberLogin && model.RememberLogin)
    		{
    			props = new AuthenticationProperties
    			{
    				IsPersistent = true,
    				ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration)
    			};
    		};
    
    		// issue authentication cookie with subject ID and username
    		var isuser = new IdentityServerUser(user.SubjectId)
    		{
    			DisplayName = user.Username
    		};
    
    		await HttpContext.SignInAsync(isuser, props);
    
    		if (context != null)
    		{
    			if (context.IsNativeClient())
    			{
    				// The client is native, so this change in how to
    				// return the response is for better UX for the end user.
    				return this.LoadingPage("Redirect", model.ReturnUrl);
    			}
    
    			// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
    			return Redirect(model.ReturnUrl);
    		}
    
    		// request for a local page
    		if (Url.IsLocalUrl(model.ReturnUrl))
    		{
    			return Redirect(model.ReturnUrl);
    		}
    		else if (string.IsNullOrEmpty(model.ReturnUrl))
    		{
    			return Redirect("~/");
    		}
    		else
    		{
    			// user might have clicked on a malicious link - should be logged
    			throw new Exception("invalid return URL");
    		}
    	
    	}
    }
    

    大体逻辑就是验证账户密码是否正确,如果正确设置cookie。

    await HttpContext.SignInAsync(isuser, props); 这个就是设置cookie了,很多人还不了解里面做了啥,看下源码。

    经过这个方法后的结果为:

    然后看一下_inner.SignInasync 做了什么。

    这里放下源码,然后这个innser 就是 AuthenticationService。

    public virtual async Task SignInAsync(HttpContext context, string scheme, ClaimsPrincipal principal, AuthenticationProperties properties)
    {
    	if (principal == null)
    	{
    		throw new ArgumentNullException(nameof(principal));
    	}
    
    	if (Options.RequireAuthenticatedSignIn)
    	{
    		if (principal.Identity == null)
    		{
    			throw new InvalidOperationException("SignInAsync when principal.Identity == null is not allowed when AuthenticationOptions.RequireAuthenticatedSignIn is true.");
    		}
    		if (!principal.Identity.IsAuthenticated)
    		{
    			throw new InvalidOperationException("SignInAsync when principal.Identity.IsAuthenticated is false is not allowed when AuthenticationOptions.RequireAuthenticatedSignIn is true.");
    		}
    	}
    
    	if (scheme == null)
    	{
    		var defaultScheme = await Schemes.GetDefaultSignInSchemeAsync();
    		scheme = defaultScheme?.Name;
    		if (scheme == null)
    		{
    			throw new InvalidOperationException($"No authenticationScheme was specified, and there was no DefaultSignInScheme found. The default schemes can be set using either AddAuthentication(string defaultScheme) or AddAuthentication(Action configureOptions).");
    		}
    	}
    
    	var handler = await Handlers.GetHandlerAsync(context, scheme);
    	if (handler == null)
    	{
    		throw await CreateMissingSignInHandlerException(scheme);
    	}
    
    	var signInHandler = handler as IAuthenticationSignInHandler;
    	if (signInHandler == null)
    	{
    		throw await CreateMismatchedSignInHandlerException(scheme, handler);
    	}
    
    	await signInHandler.SignInAsync(principal, properties);
    }
    

    最后处理结果如上。后面就不继续看了,有兴趣可以看下CookieAuthenticationHandler的HandleSignInAsync。

    然后处理完成后就可以进行交替给/connect/authorize/callback处理。

    然后就可以看到结果了。

    这里值得注意的是一定要使用https,不然会报错的。

    这样登录就完成了,那么登出怎么处理呢?

    public IActionResult Logout()
    {
    	return SignOut("Cookies", "oidc");
    }
    

    这样就可以了,那么登出做了什么事情呢?

    这个肯定是清除了cookie,并通知了identity server 进行清除cookie。

    public virtual SignOutResult SignOut(params string[] authenticationSchemes)
    => new SignOutResult(authenticationSchemes);
    
    public SignOutResult(IList<string> authenticationSchemes)
    	: this(authenticationSchemes, properties: null)
    {
    }
    

    SignOutResult : ActionResult 是一个actionResult,那么actionResult 会做什么呢?

    An <see cref="ActionResult"/> that on execution invokes <see cref="M:HttpContext.SignOutAsync"/>.
    

    那么SignOutResult 其会执行下面这一段。

    public override async Task ExecuteResultAsync(ActionContext context)
    {
    	if (context == null)
    	{
    		throw new ArgumentNullException(nameof(context));
    	}
    
    	if (AuthenticationSchemes == null)
    	{
    		throw new InvalidOperationException(
    			Resources.FormatPropertyOfTypeCannotBeNull(
    				/* property: */ nameof(AuthenticationSchemes),
    				/* type: */ nameof(SignOutResult)));
    	}
    
    	var loggerFactory = context.HttpContext.RequestServices.GetRequiredService();
    	var logger = loggerFactory.CreateLogger();
    
    	logger.SignOutResultExecuting(AuthenticationSchemes);
    
    	if (AuthenticationSchemes.Count == 0)
    	{
    		await context.HttpContext.SignOutAsync(Properties);
    	}
    	else
    	{
    		for (var i = 0; i < AuthenticationSchemes.Count; i++)
    		{
    			await context.HttpContext.SignOutAsync(AuthenticationSchemes[i], Properties);
    		}
    	}
    }
    

    重点看context.HttpContext.SignOutAsync 做了什么。AuthenticationSchemes 我们传递了SignOut("Cookies", "oidc")。

    public static Task SignOutAsync(this HttpContext context, string scheme, AuthenticationProperties properties) =>
                context.RequestServices.GetRequiredService().SignOutAsync(context, scheme, properties);
    

    那么就会掉我们注入的IAuthenticationService的SignOutAsync方法。

    那么IAuthenticationService 注入的是什么呢?

    那么会执行:

    public virtual async Task SignOutAsync(HttpContext context, string scheme, AuthenticationProperties properties)
    {
    	if (scheme == null)
    	{
    		var defaultScheme = await Schemes.GetDefaultSignOutSchemeAsync();
    		scheme = defaultScheme?.Name;
    		if (scheme == null)
    		{
    			throw new InvalidOperationException($"No authenticationScheme was specified, and there was no DefaultSignOutScheme found. The default schemes can be set using either AddAuthentication(string defaultScheme) or AddAuthentication(Action configureOptions).");
    		}
    	}
    
    	var handler = await Handlers.GetHandlerAsync(context, scheme);
    	if (handler == null)
    	{
    		throw await CreateMissingSignOutHandlerException(scheme);
    	}
    
    	var signOutHandler = handler as IAuthenticationSignOutHandler;
    	if (signOutHandler == null)
    	{
    		throw await CreateMismatchedSignOutHandlerException(scheme, handler);
    	}
    
    	await signOutHandler.SignOutAsync(properties);
    }
    

    那么其实就是分为两步,一步是清除自身的cookie,自身退出登录,然后通知identityserver 退出登录(清除cookie)

    cookie 自身的就不看了,看identity相关处理逻辑。

    public async virtual Task SignOutAsync(AuthenticationProperties properties)
    {
    	var target = ResolveTarget(Options.ForwardSignOut);
    	if (target != null)
    	{
    		await Context.SignOutAsync(target, properties);
    		return;
    	}
    
    	properties = properties ?? new AuthenticationProperties();
    
    	Logger.EnteringOpenIdAuthenticationHandlerHandleSignOutAsync(GetType().FullName);
    
    	if (_configuration == null && Options.ConfigurationManager != null)
    	{
    		_configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted);
    	}
    
    	var message = new OpenIdConnectMessage()
    	{
    		EnableTelemetryParameters = !Options.DisableTelemetry,
    		IssuerAddress = _configuration?.EndSessionEndpoint ?? string.Empty,
    
    		// Redirect back to SigneOutCallbackPath first before user agent is redirected to actual post logout redirect uri
    		PostLogoutRedirectUri = BuildRedirectUriIfRelative(Options.SignedOutCallbackPath)
    	};
    
    	// Get the post redirect URI.
    	if (string.IsNullOrEmpty(properties.RedirectUri))
    	{
    		properties.RedirectUri = BuildRedirectUriIfRelative(Options.SignedOutRedirectUri);
    		if (string.IsNullOrWhiteSpace(properties.RedirectUri))
    		{
    			properties.RedirectUri = OriginalPathBase + OriginalPath + Request.QueryString;
    		}
    	}
    	Logger.PostSignOutRedirect(properties.RedirectUri);
    
    	// Attach the identity token to the logout request when possible.
    	message.IdTokenHint = await Context.GetTokenAsync(Options.SignOutScheme, OpenIdConnectParameterNames.IdToken);
    
    	var redirectContext = new RedirectContext(Context, Scheme, Options, properties)
    	{
    		ProtocolMessage = message
    	};
    
    	await Events.RedirectToIdentityProviderForSignOut(redirectContext);
    	if (redirectContext.Handled)
    	{
    		Logger.RedirectToIdentityProviderForSignOutHandledResponse();
    		return;
    	}
    
    	message = redirectContext.ProtocolMessage;
    
    	if (!string.IsNullOrEmpty(message.State))
    	{
    		properties.Items[OpenIdConnectDefaults.UserstatePropertiesKey] = message.State;
    	}
    
    	message.State = Options.StateDataFormat.Protect(properties);
    
    	if (string.IsNullOrEmpty(message.IssuerAddress))
    	{
    		throw new InvalidOperationException("Cannot redirect to the end session endpoint, the configuration may be missing or invalid.");
    	}
    
    	if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.RedirectGet)
    	{
    		var redirectUri = message.CreateLogoutRequestUrl();
    		if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute))
    		{
    			Logger.InvalidLogoutQueryStringRedirectUrl(redirectUri);
    		}
    
    		Response.Redirect(redirectUri);
    	}
    	else if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.FormPost)
    	{
    		var content = message.BuildFormPost();
    		var buffer = Encoding.UTF8.GetBytes(content);
    
    		Response.ContentLength = buffer.Length;
    		Response.ContentType = "text/html;charset=UTF-8";
    
    		// Emit Cache-Control=no-cache to prevent client caching.
    		Response.Headers[HeaderNames.CacheControl] = "no-cache, no-store";
    		Response.Headers[HeaderNames.Pragma] = "no-cache";
    		Response.Headers[HeaderNames.Expires] = HeaderValueEpocDate;
    
    		await Response.Body.WriteAsync(buffer, 0, buffer.Length);
    	}
    	else
    	{
    		throw new NotImplementedException($"An unsupported authentication method has been configured: {Options.AuthenticationMethod}");
    	}
    
    	Logger.AuthenticationSchemeSignedOut(Scheme.Name);
    }
    

    会发送请求,然后调用identity 登出通知。

    那么抓包看一下,一共4步。

    1. 调用自身的logout

    1. 调用identityserver 封装的logout。

    1. 调用identityserver 自己封装的logout

    1. 调用identityserver 封装的logout 回调

    1. 客户可以回调回去。

    这个源码倒是挺简单的,就不把源码贴出来了。

    然后这里很多人就有问题了。

    这里我们明明传了回调地址了,为什么我们还有填一次呢?

    其实一般情况下真的可以不填,但是需求可以填一下,比如有多个回调地址的时候。

    然后我们可以选择登出的方式有get 和post,post的情况下是这样的。

    客户端可以选择方式。

    这个案例就先到这,后面介绍单页面客户端。

  • 相关阅读:
    IMX6ULL ARM Linux开发板SD卡启动,SD卡的分区与分区格式化创建
    【Python机器学习】零基础掌握CCA交叉分解
    【运维】Ubuntu换硬盘扩容
    Wgpu图文详解(01)窗口与基本渲染
    新增测试场景:方言
    博客的评论与回复功能的实现
    API接口采集商品详情页面数据(H5端和PC端)item_get-获得淘宝商品详情
    系统方向学习9--android 10.0 去掉未知来源弹窗 默认授予安装未知来源权限
    Golang--channel+waitGroup控制并发量
    Android使用osmdroid加载在线地图,离线地图以及各种填坑姿势
  • 原文地址:https://www.cnblogs.com/aoximin/p/16590293.html