我们有一个 ASP.NET API 服务器,旨在通过用 Angular 和 React 编写的前端为多个租户提供服务。我们已经使用 OpenIdDict 授权代码流程实现了身份验证和授权,它运行良好,但有一个大问题。我们的客户希望编写自己的自定义登录页面,以便与其他网页的设计融为一体。虽然可以覆盖服务器上登录页面的外观和风格,但我们必须为每个客户端维护一组,并且每次客户端决定更改其网页的外观和风格时都需要更改它。我们曾考虑过使用 Duende Identity Server,但它似乎有相同的限制。
我还没有找到任何允许使用授权代码流程完全可定制的登录页面的示例文档。我想知道这里的开发者社区是否已经找到了一个好的解决方案。
更新 - 2023 年 10 月 21 日 客户很乐意只使用外部身份验证提供商 Google 和 Facebook。然而,他们希望“使用 Google 登录”按钮出现在他们的 Web 应用程序上(用 React 编写)。我尝试通过在 ASP.NET Core 服务器身份验证上公开端点、设置重定向 URI 并返回挑战来实现此目的。它取得了部分成功,因为我能够看到用户通过外部提供商的身份验证,并且可以将登录响应返回给客户端。我的回调主要基于 OpenIddict Velusia 示例的回调/登录/{provider},但没有使用 GoogleDefaults.AuthenticationScheme
React Web 应用程序
<form method='GET' action={`/api/v1/auth/externalLogin`} >
<input type="hidden" name="scheme" value="Google"/>
<input type="hidden" name="returnUrl" value="/info" />
<input type="hidden" name="tenantId" value="REDACTED" />
<input type="hidden" name="client_id" value="REDACTED" />
<Button className={styles.googleButton} variant="outlined" type="submit">
<div className={styles.googleButtonContent}>
<GoogleIcon className={styles.googleIcon} />
<Typography className={styles.googleText}>Continue with Google</Typography>
</div>
</Button>
</form>
程序.cs
builder.Services.AddAuthentication()
.AddGoogle(options =>
{
IConfigurationSection googleAuthNSection =
builder.Configuration.GetSection("Authentication:Google");
options.ClientId = googleAuthNSection["ClientId"];
options.ClientSecret = googleAuthNSection["ClientSecret"];
options.SignInScheme = IdentityConstants.ExternalScheme; // OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; // - A sign-in/Challenge response cannot be returned from this endpoint.
});
身份验证控制器 - 客户端登录端点
public override async Task<IActionResult> ExternalLogin(string scheme, string returnUrl, string tenantId)
{
var properties = new AuthenticationProperties
{
RedirectUri = Url.Action(nameof(ExternalLoginCallback)),
Items =
{
{ "scheme", scheme },
{ "returnUrl", returnUrl },
{ "tenantId", tenantId },
}
};
return Challenge(props, scheme);
}
身份验证控制器 - 回调
public override async Task<IActionResult> ExternalLoginCallback(string returnUrl = null)
{
var result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
if (result.Principal is not ClaimsPrincipal { Identity.IsAuthenticated: true })
{
throw new InvalidOperationException("The external authorization data cannot be used for authentication.");
}
string returnUri = result.Properties?.Items["returnUrl"];
string tenantId = result.Properties?.Items["tenantId"];
string scheme = result.Properties?.Items["scheme"];
var identity = new ClaimsIdentity(authenticationType: "ExternalLogin");
identity.SetClaim(ClaimTypes.Email, result.Principal.GetClaim(ClaimTypes.Email))
.SetClaim(ClaimTypes.Name, result.Principal.GetClaim(ClaimTypes.Name))
.SetClaim(ClaimTypes.NameIdentifier, result.Principal.GetClaim(ClaimTypes.NameIdentifier));
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info == null)
{
return NotFound();
return new ObjectResult("No account exists for the user. Please register first!"){StatusCode = StatusCodes.Status418ImATeapot};
// Error occurred
}
// Sign in the user with this external login provider if the user already has a login.
var extResult = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
if (extResult.IsLockedOut)
{
return new ForbidResult("User account is locked out");
}
else if (!extResult.Succeeded)
{
return new ObjectResult("No account exists for the user. Please register first!"){StatusCode = StatusCodes.Status418ImATeapot};
}
_logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name,
info.LoginProvider);
var user = await _userManager.FindByLoginAsync(scheme, result.Principal.GetClaim(ClaimTypes.NameIdentifier));
var user = _userManager.GetUserAsync(new ClaimsPrincipal(identity)).Result;
if (user == null)
{
// User is authenticated ok but needs to register first
return new ObjectResult("No account exists for the user. Please register first!")
{ StatusCode = StatusCodes.Status418ImATeapot };
}
// Build the authentication properties based on the properties that were added when the challenge was triggered.
var properties = new AuthenticationProperties(result.Properties.Items)
{
RedirectUri = result.Properties.RedirectUri ?? "/"
};
properties.StoreTokens(result.Properties.GetTokens());
return SignIn(new ClaimsPrincipal(identity), properties);
}
但是,有一些问题 -
社区是否可以在此处看到任何其他潜在问题或我面临的问题的解决方案。理想情况下,我想在调用的登录端点中使用 OpenIddictClientAspNetCoreDefaults.AuthenticationScheme,创建一个挑战并将流程定向到 OpenIddict 的“~/connect/authorize”,就像在他们的示例中一样。
不幸的是,不可能允许 SPA 客户端使用推荐用于新应用程序的 OAuth2 授权代码授予工作流程来自定义登录页面。但是,我们仍然可以自定义与 ASP.NET Identity 捆绑在一起的登录页面。可以完全替换它们,但我发现自定义它们更容易。以下是步骤 -
脚手架 ASP.NET 身份页面 - https://learn.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity?view=aspnetcore-8.0&viewFallbackFrom=aspnetcore-2.1&tabs=visual-工作室
根据需要更改这些页面的外观和感觉/代码。默认位置位于 ../Areas/Identity/
设置 openiddict 客户端 https://github.com/openiddict/openiddict-samples(我使用 Velusia 客户端)并注册外部 openidconnect 提供程序(外部或您自己使用 openiddict 的实现)。 https://andreyka26.com/openid-connect-authorization-code-using-openiddict-and-dot-net对此提供了很好的指导。
对于多租户场景,我依赖于创建多个布局。这个想法是拥有相同的 CSS 类名并为每个租户创建覆盖。如果页面仅在外观和感觉上有很大差异,则此方法将起作用。将 ../Areas/Identity/Pages/_ViewStart.cshtml 中的代码更改为如下所示。我正在从查询字符串中检索布局以进行说明,但可以通过使用任何其他方法来检索布局来发现调用租户。
@{
string layoutRequested = Context.Request.Query.ContainsKey("layout") ? Convert.ToString(Context.Request.Query["layout"]).ToLower() : "default";
switch (layoutRequested)
{
case "tenant1":
Layout = "~/Views/Shared/_Layout-t1.cshtml";
break;
case "tenant2":
default:
Layout = "~/Views/Shared/_Layout.cshtml";
break;
}
}
根据需要为每个租户添加布局 ../Views/Shared/