sso优化
This commit is contained in:
@@ -12,40 +12,74 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/auth/sso")]
|
||||
public sealed class SsoController(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IAuthSessionService authSessionService,
|
||||
IDistributedCache cache,
|
||||
IOptions<SsoOptions> options) : ControllerBase
|
||||
IOptions<SsoOptions> options,
|
||||
ILogger<SsoController> logger) : ControllerBase
|
||||
{
|
||||
private const string BindingIntentProperty = "sso-binding-intent";
|
||||
private readonly SsoOptions _options = options.Value;
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("settings")]
|
||||
public ActionResult<SsoSettingsResponse> Settings() =>
|
||||
new SsoSettingsResponse(_options.Enabled, _options.DisplayName);
|
||||
new SsoSettingsResponse(
|
||||
_options.Enabled,
|
||||
_options.DisplayName,
|
||||
EffectiveCallbackUrl());
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpGet("login")]
|
||||
public ActionResult Login([FromQuery] string? returnUrl = null)
|
||||
public async Task<IActionResult> Login(
|
||||
[FromQuery] string? returnUrl = null,
|
||||
[FromQuery] string? bindingIntent = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||
var properties = new AuthenticationProperties();
|
||||
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
||||
{
|
||||
var targetUserId = await cache.GetStringAsync(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
if (targetUserId is null)
|
||||
return RedirectToFrontendError("binding_intent_expired", "/account");
|
||||
properties.Items[BindingIntentProperty] = bindingIntent;
|
||||
}
|
||||
var completeUrl = Url.Action(
|
||||
nameof(Complete),
|
||||
values: new { returnUrl = safeReturnUrl })!;
|
||||
return Challenge(
|
||||
new AuthenticationProperties { RedirectUri = completeUrl },
|
||||
SsoAuthSchemes.Keycloak);
|
||||
properties.RedirectUri = completeUrl;
|
||||
try
|
||||
{
|
||||
await HttpContext.ChallengeAsync(SsoAuthSchemes.Keycloak, properties);
|
||||
return new EmptyResult();
|
||||
}
|
||||
catch (OpenIdConnectProtocolException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Keycloak 拒绝了 OIDC 授权请求。当前回调地址为 {CallbackUrl}",
|
||||
EffectiveCallbackUrl());
|
||||
return RedirectToFrontendError(
|
||||
"configuration_error",
|
||||
string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account");
|
||||
}
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[HttpGet("complete")]
|
||||
public async Task<ActionResult> Complete(
|
||||
@@ -66,7 +100,36 @@ public sealed class SsoController(
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
return RedirectToFrontendError("missing_subject");
|
||||
|
||||
var user = await userManager.FindByLoginAsync(
|
||||
ApplicationUser? user = null;
|
||||
var bindingIntent =
|
||||
authentication.Properties is { } authenticationProperties &&
|
||||
authenticationProperties.Items.TryGetValue(
|
||||
BindingIntentProperty,
|
||||
out var storedBindingIntent)
|
||||
? storedBindingIntent
|
||||
: null;
|
||||
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
||||
{
|
||||
var targetUserId = await cache.GetStringAsync(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
user = targetUserId is null
|
||||
? null
|
||||
: await userManager.FindByIdAsync(targetUserId);
|
||||
if (user is null)
|
||||
return RedirectToFrontendError("binding_intent_expired", "/account");
|
||||
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return RedirectToFrontendError("account_disabled", "/account");
|
||||
|
||||
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||
if (linkError is not null)
|
||||
return RedirectToFrontendError(linkError, "/account");
|
||||
await cache.RemoveAsync(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
user ??= await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject);
|
||||
if (user is null && _options.LinkExistingUsersByUserName)
|
||||
@@ -77,14 +140,9 @@ public sealed class SsoController(
|
||||
user = await userManager.FindByNameAsync(userName);
|
||||
if (user is not null)
|
||||
{
|
||||
var linkResult = await userManager.AddLoginAsync(
|
||||
user,
|
||||
new UserLoginInfo(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject,
|
||||
_options.DisplayName));
|
||||
if (!linkResult.Succeeded)
|
||||
return RedirectToFrontendError("account_link_failed");
|
||||
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||
if (linkError is not null)
|
||||
return RedirectToFrontendError(linkError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +196,7 @@ public sealed class SsoController(
|
||||
return Redirect(callback);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("exchange")]
|
||||
public async Task<ActionResult<LoginResponse>> Exchange(
|
||||
@@ -172,6 +231,7 @@ public sealed class SsoController(
|
||||
return AuthController.CreateLoginResponse(session);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpGet("binding")]
|
||||
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
|
||||
@@ -190,6 +250,7 @@ public sealed class SsoController(
|
||||
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("bind")]
|
||||
public async Task<ActionResult<LoginResponse>> Bind(
|
||||
@@ -263,6 +324,92 @@ public sealed class SsoController(
|
||||
return AuthController.CreateLoginResponse(session);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("account")]
|
||||
public async Task<ActionResult<SsoAccountResponse>> Account()
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null)
|
||||
return Unauthorized();
|
||||
|
||||
var login = (await userManager.GetLoginsAsync(user))
|
||||
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
|
||||
return new SsoAccountResponse(
|
||||
_options.Enabled,
|
||||
_options.DisplayName,
|
||||
login is not null,
|
||||
EffectiveCallbackUrl());
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("prepare-binding")]
|
||||
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return Unauthorized();
|
||||
if ((await userManager.GetLoginsAsync(user))
|
||||
.Any(x => x.LoginProvider == SsoAuthSchemes.LoginProvider))
|
||||
{
|
||||
return BindingConflict("当前账号已绑定统一身份账号,请先解绑后再更换绑定。");
|
||||
}
|
||||
|
||||
var intentCode = WebEncoders.Base64UrlEncode(
|
||||
RandomNumberGenerator.GetBytes(32));
|
||||
await cache.SetStringAsync(
|
||||
BindingIntentCacheKey(intentCode),
|
||||
user.Id.ToString("D"),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||
},
|
||||
cancellationToken);
|
||||
var loginUrl = Url.Action(
|
||||
nameof(Login),
|
||||
values: new
|
||||
{
|
||||
returnUrl = "/account",
|
||||
bindingIntent = intentCode
|
||||
})!;
|
||||
return new SsoBindingStartResponse(loginUrl);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("unbind")]
|
||||
public async Task<IActionResult> Unbind(SsoUnbindRequest request)
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return Unauthorized();
|
||||
if (!await userManager.HasPasswordAsync(user))
|
||||
return BindingConflict("当前账号没有本地密码,不能自行解绑,请联系管理员处理。");
|
||||
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||
{
|
||||
await userManager.AccessFailedAsync(user);
|
||||
return InvalidLocalCredentials();
|
||||
}
|
||||
|
||||
var login = (await userManager.GetLoginsAsync(user))
|
||||
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
|
||||
if (login is null)
|
||||
return NoContent();
|
||||
|
||||
var result = await userManager.RemoveLoginAsync(
|
||||
user,
|
||||
login.LoginProvider,
|
||||
login.ProviderKey);
|
||||
if (!result.Succeeded)
|
||||
return SsoProblem("解除统一身份绑定失败,请稍后重试。", StatusCodes.Status500InternalServerError);
|
||||
|
||||
await userManager.ResetAccessFailedCountAsync(user);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
internal static string NormalizeReturnUrl(string? returnUrl) =>
|
||||
!string.IsNullOrWhiteSpace(returnUrl) &&
|
||||
returnUrl.StartsWith('/') &&
|
||||
@@ -275,14 +422,64 @@ public sealed class SsoController(
|
||||
? path
|
||||
: _options.FrontendBaseUrl.TrimEnd('/') + path;
|
||||
|
||||
private RedirectResult RedirectToFrontendError(string error) =>
|
||||
Redirect(BuildFrontendUrl("/login") +
|
||||
private RedirectResult RedirectToFrontendError(
|
||||
string error,
|
||||
string path = "/login") =>
|
||||
Redirect(BuildFrontendUrl(path) +
|
||||
$"?ssoError={Uri.EscapeDataString(error)}");
|
||||
|
||||
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
|
||||
|
||||
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
|
||||
|
||||
private static string BindingIntentCacheKey(string code) =>
|
||||
$"sso:binding-intent:{code}";
|
||||
|
||||
private async Task<ApplicationUser?> CurrentUserAsync()
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return userId is null ? null : await userManager.FindByIdAsync(userId);
|
||||
}
|
||||
|
||||
private async Task<string?> LinkSsoIdentityAsync(
|
||||
ApplicationUser user,
|
||||
string subject)
|
||||
{
|
||||
var subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject);
|
||||
if (subjectOwner is not null)
|
||||
return subjectOwner.Id == user.Id ? null : "identity_already_bound";
|
||||
|
||||
if ((await userManager.GetLoginsAsync(user)).Any(x =>
|
||||
x.LoginProvider == SsoAuthSchemes.LoginProvider &&
|
||||
x.ProviderKey != subject))
|
||||
{
|
||||
return "account_already_bound";
|
||||
}
|
||||
|
||||
var result = await userManager.AddLoginAsync(
|
||||
user,
|
||||
new UserLoginInfo(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject,
|
||||
_options.DisplayName));
|
||||
if (result.Succeeded)
|
||||
return null;
|
||||
|
||||
subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject);
|
||||
return subjectOwner?.Id == user.Id ? null : "account_link_failed";
|
||||
}
|
||||
|
||||
private string EffectiveCallbackUrl()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.CallbackUrl))
|
||||
return _options.CallbackUrl;
|
||||
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}/signin-keycloak";
|
||||
}
|
||||
|
||||
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -320,7 +517,10 @@ public sealed class SsoController(
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record SsoSettingsResponse(bool Enabled, string DisplayName);
|
||||
public sealed record SsoSettingsResponse(
|
||||
bool Enabled,
|
||||
string DisplayName,
|
||||
string CallbackUrl);
|
||||
|
||||
public sealed record SsoExchangeRequest(
|
||||
[Required, MinLength(20), MaxLength(200)] string Code,
|
||||
@@ -337,3 +537,14 @@ public sealed record SsoBindRequest(
|
||||
bool IsNativeApp = false);
|
||||
|
||||
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
|
||||
|
||||
public sealed record SsoAccountResponse(
|
||||
bool Enabled,
|
||||
string ProviderDisplayName,
|
||||
bool IsBound,
|
||||
string CallbackUrl);
|
||||
|
||||
public sealed record SsoBindingStartResponse(string LoginUrl);
|
||||
|
||||
public sealed record SsoUnbindRequest(
|
||||
[Required, MaxLength(100)] string Password);
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class SsoOptions
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
public bool LinkExistingUsersByUserName { get; set; } = true;
|
||||
public string FrontendBaseUrl { get; set; } = string.Empty;
|
||||
public string CallbackUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class SsoAuthSchemes
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>2.2.0</Version>
|
||||
<Version>2.3.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
|
||||
|
||||
@@ -104,12 +104,18 @@ if (ssoOptions.Enabled &&
|
||||
ssoAuthority.Scheme is not ("http" or "https") ||
|
||||
(ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") ||
|
||||
string.IsNullOrWhiteSpace(ssoOptions.UserNameClaim) ||
|
||||
(!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl) &&
|
||||
(!Uri.TryCreate(ssoOptions.CallbackUrl, UriKind.Absolute, out var callbackUrl) ||
|
||||
callbackUrl.Scheme is not ("http" or "https") ||
|
||||
!callbackUrl.AbsolutePath.EndsWith(
|
||||
"/signin-keycloak",
|
||||
StringComparison.OrdinalIgnoreCase))) ||
|
||||
(!string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) &&
|
||||
(!Uri.TryCreate(ssoOptions.FrontendBaseUrl, UriKind.Absolute, out var frontendBaseUrl) ||
|
||||
frontendBaseUrl.Scheme is not ("http" or "https")))))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim 和 FrontendBaseUrl;生产元数据地址必须使用 HTTPS。");
|
||||
"启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim、CallbackUrl 和 FrontendBaseUrl;CallbackUrl 必须以 /signin-keycloak 结尾,生产元数据地址必须使用 HTTPS。");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) ||
|
||||
@@ -490,6 +496,12 @@ if (ssoOptions.Enabled)
|
||||
ssoOptions.UserNameClaim,
|
||||
ssoOptions.UserNameClaim);
|
||||
options.TokenValidationParameters.NameClaimType = ssoOptions.UserNameClaim;
|
||||
options.Events.OnRedirectToIdentityProvider = context =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl))
|
||||
context.ProtocolMessage.RedirectUri = ssoOptions.CallbackUrl;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
options.Events.OnRemoteFailure = context =>
|
||||
{
|
||||
context.HandleResponse();
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"AppIdleMinutes": 4320
|
||||
},
|
||||
"Sso": {
|
||||
"FrontendBaseUrl": "http://localhost:5173"
|
||||
"FrontendBaseUrl": "http://localhost:5173",
|
||||
"CallbackUrl": "http://localhost:5255/signin-keycloak"
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
|
||||
@@ -90,7 +90,8 @@
|
||||
"UserNameClaim": "preferred_username",
|
||||
"RequireHttpsMetadata": true,
|
||||
"LinkExistingUsersByUserName": true,
|
||||
"FrontendBaseUrl": ""
|
||||
"FrontendBaseUrl": "",
|
||||
"CallbackUrl": ""
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
|
||||
Reference in New Issue
Block a user