0

identity4 系列————案例篇[三] - 敖毛毛

 2 years ago
source link: https://www.cnblogs.com/aoximin/p/16590293.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

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

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

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

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

1289794-20220821113237450-1265122422.png

看着项目是一个标准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" 对应的,前面是配置,这个是具体实现。

1289794-20220821120612660-354512877.png

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

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

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

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

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

默认就是使用这种方案。

/// <summary>
/// Used as the default scheme by <see cref="IAuthenticationService.ChallengeAsync(HttpContext, string, AuthenticationProperties)"/>.
/// </summary>
public string DefaultChallengeScheme { get; set; }

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

/// <summary>
/// Challenge the specified authentication scheme.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/>.</param>
/// <param name="scheme">The name of the authentication scheme.</param>
/// <param name="properties">The <see cref="AuthenticationProperties"/>.</param>
/// <returns>A task.</returns>
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 配置。

1289794-20220821125736313-306310407.png

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

这样子client 就注册了。

  1. 登录,一般模式是需要账户密码,那么要账户密码就需要用户,这个用户怎么注册进去呢?
public static List<TestUser> Users
{
	get
	{
		var address = new
		{
			street_address = "One Hacker Way",
			locality = "Heidelberg",
			postal_code = 69118,
			country = "Germany"
		};
		
		return new List<TestUser>
		{
			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, "[email protected]"),
					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, "[email protected]"),
					new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean),
					new Claim(JwtClaimTypes.WebSite, "http://bob.com"),
					new Claim(JwtClaimTypes.Address, JsonSerializer.Serialize(address), IdentityServerConstants.ClaimValueTypes.Json)
				}
			}
		};	
	}
}

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

1289794-20220821131339192-1142201994.png
  1. 这个时候还得处理identity Server的逻辑
/// <summary>
/// Entry point into the login workflow
/// </summary>
[HttpGet]
public async Task<IActionResult> 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客户端的时候,那么:

1289794-20220821132727410-633987028.png

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

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

然后又转到了account login

1289794-20220821133042565-196569106.png

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

1289794-20220821133309987-652417551.png

1289794-20220821133333495-1033796964.png

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

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

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

1289794-20220821133830974-1933635800.png

这是参数。

// 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("~/");
	}
}
1289794-20220821134440609-1709341959.png

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

然后看下正常登录逻辑。

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了,很多人还不了解里面做了啥,看下源码。

1289794-20220821143259592-1275297768.png

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

1289794-20220821143546890-1233399559.png

然后看一下_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<AuthenticationOptions> 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);
}
1289794-20220821144106369-379219068.png

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

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

1289794-20220821144836499-1433727912.png

然后就可以看到结果了。

1289794-20220821150652647-219733585.png

这里值得注意的是一定要使用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<ILoggerFactory>();
	var logger = loggerFactory.CreateLogger<SignOutResult>();

	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<IAuthenticationService>().SignOutAsync(context, scheme, properties);

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

那么IAuthenticationService 注入的是什么呢?

1289794-20220824062329086-179096263.png

那么会执行:

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<AuthenticationOptions> 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);
}
1289794-20220824063032662-1269454536.png

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

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

  1. 调用自身的logout
1289794-20220824063409841-1587467586.png
  1. 调用identityserver 封装的logout。

1289794-20220824063447077-1460368289.png

  1. 调用identityserver 自己封装的logout
1289794-20220824063609291-968166201.png
  1. 调用identityserver 封装的logout 回调

1289794-20220824064341241-1627050953.png

  1. 客户可以回调回去。
1289794-20220824072019535-1619978628.png

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

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

1289794-20220824072238977-1712898000.png

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

1289794-20220824070126200-531143823.png

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

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

1289794-20220824070213861-92999015.png
1289794-20220824072808296-552345350.png

客户端可以选择方式。

1289794-20220824073019294-2073800409.png

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK