using System.ComponentModel.DataAnnotations; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; 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] [Route("api/auth/sso")] public sealed class SsoController( UserManager userManager, IAuthSessionService authSessionService, IDistributedCache cache, IOptions options, ILogger logger) : ControllerBase { private const string BindingIntentProperty = "sso-binding-intent"; private readonly SsoOptions _options = options.Value; [AllowAnonymous] [HttpGet("settings")] public ActionResult Settings() => new SsoSettingsResponse( _options.Enabled, _options.DisplayName, EffectiveCallbackUrl()); [AllowAnonymous] [EnableRateLimiting("public-auth")] [HttpGet("login")] public async Task 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 })!; 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 Complete( [FromQuery] string? returnUrl, CancellationToken cancellationToken) { if (!_options.Enabled) return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); var authentication = await HttpContext.AuthenticateAsync( SsoAuthSchemes.ExternalCookie); if (!authentication.Succeeded || authentication.Principal is null) return RedirectToFrontendError("authentication_failed"); var principal = authentication.Principal; var subject = principal.FindFirstValue("sub") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(subject)) return RedirectToFrontendError("missing_subject"); 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) { var userName = principal.FindFirstValue(_options.UserNameClaim)?.Trim(); if (!string.IsNullOrWhiteSpace(userName)) { user = await userManager.FindByNameAsync(userName); if (user is not null) { var linkError = await LinkSsoIdentityAsync(user, subject); if (linkError is not null) return RedirectToFrontendError(linkError); } } } if (user is null) { var bindingCode = WebEncoders.Base64UrlEncode( RandomNumberGenerator.GetBytes(32)); var externalUserName = principal.FindFirstValue(_options.UserNameClaim)?.Trim() ?? principal.FindFirstValue("name")?.Trim() ?? subject; await cache.SetStringAsync( BindingCacheKey(bindingCode), JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, cancellationToken); await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie); var bindingPage = BuildFrontendUrl("/sso/bind") + $"?code={Uri.EscapeDataString(bindingCode)}" + $"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}"; return Redirect(bindingPage); } if (!user.IsEnabled || await userManager.IsLockedOutAsync(user)) return RedirectToFrontendError("account_disabled"); user.LastLoginAt = DateTime.UtcNow; var updateResult = await userManager.UpdateAsync(user); if (!updateResult.Succeeded) return RedirectToFrontendError("account_update_failed"); var exchangeCode = WebEncoders.Base64UrlEncode( RandomNumberGenerator.GetBytes(32)); await cache.SetStringAsync( ExchangeCacheKey(exchangeCode), user.Id.ToString("D"), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) }, cancellationToken); await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie); var callback = BuildFrontendUrl("/sso/callback") + $"?code={Uri.EscapeDataString(exchangeCode)}" + $"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}"; return Redirect(callback); } [AllowAnonymous] [EnableRateLimiting("public-auth")] [HttpPost("exchange")] public async Task> Exchange( SsoExchangeRequest request, CancellationToken cancellationToken) { if (!_options.Enabled) return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); var cacheKey = ExchangeCacheKey(request.Code); var userId = await cache.GetStringAsync(cacheKey, cancellationToken); if (userId is null) return SsoProblem( "统一身份认证结果已失效,请重新登录。", StatusCodes.Status401Unauthorized); await cache.RemoveAsync(cacheKey, cancellationToken); var user = await userManager.FindByIdAsync(userId); if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user)) return SsoProblem( "本地账号不存在、已停用或已锁定。", StatusCodes.Status401Unauthorized); var roles = await userManager.GetRolesAsync(user); var session = await authSessionService.CreateAsync( user, roles, request.IsNativeApp ? AuthenticationClientType.App : AuthenticationClientType.Web, cancellationToken); return AuthController.CreateLoginResponse(session); } [AllowAnonymous] [EnableRateLimiting("public-auth")] [HttpGet("binding")] public async Task> BindingInfo( [FromQuery, Required, MinLength(20), MaxLength(200)] string code, CancellationToken cancellationToken) { if (!_options.Enabled) return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); var ticket = await ReadBindingTicketAsync(code, cancellationToken); if (ticket is null) return SsoProblem( "账户绑定请求已失效,请重新使用统一身份认证登录。", StatusCodes.Status401Unauthorized); return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName); } [AllowAnonymous] [EnableRateLimiting("public-auth")] [HttpPost("bind")] public async Task> Bind( SsoBindRequest request, CancellationToken cancellationToken) { if (!_options.Enabled) return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken); if (ticket is null) return SsoProblem( "账户绑定请求已失效,请重新使用统一身份认证登录。", StatusCodes.Status401Unauthorized); var user = await userManager.FindByNameAsync(request.UserName.Trim()); if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user)) return InvalidLocalCredentials(); if (!await userManager.CheckPasswordAsync(user, request.Password)) { await userManager.AccessFailedAsync(user); return InvalidLocalCredentials(); } var subjectOwner = await userManager.FindByLoginAsync( SsoAuthSchemes.LoginProvider, ticket.Subject); if (subjectOwner is not null && subjectOwner.Id != user.Id) return BindingConflict("该统一身份账号已绑定其他教务系统账号。"); var keycloakLogins = (await userManager.GetLoginsAsync(user)) .Where(x => x.LoginProvider == SsoAuthSchemes.LoginProvider) .ToList(); if (keycloakLogins.Any(x => x.ProviderKey != ticket.Subject)) return BindingConflict("该教务系统账号已绑定其他统一身份账号。"); if (subjectOwner is null) { var linkResult = await userManager.AddLoginAsync( user, new UserLoginInfo( SsoAuthSchemes.LoginProvider, ticket.Subject, _options.DisplayName)); if (!linkResult.Succeeded) { subjectOwner = await userManager.FindByLoginAsync( SsoAuthSchemes.LoginProvider, ticket.Subject); if (subjectOwner?.Id != user.Id) return BindingConflict("账户绑定失败,请重新发起统一身份认证。"); } } await userManager.ResetAccessFailedCountAsync(user); user.LastLoginAt = DateTime.UtcNow; var updateResult = await userManager.UpdateAsync(user); if (!updateResult.Succeeded) return SsoProblem("本地账号状态更新失败,请稍后重试。", StatusCodes.Status500InternalServerError); await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken); var roles = await userManager.GetRolesAsync(user); var session = await authSessionService.CreateAsync( user, roles, request.IsNativeApp ? AuthenticationClientType.App : AuthenticationClientType.Web, cancellationToken); return AuthController.CreateLoginResponse(session); } [Authorize] [HttpGet("account")] public async Task> 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> 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 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('/') && !returnUrl.StartsWith("//", StringComparison.Ordinal) ? returnUrl : "/dashboard"; private string BuildFrontendUrl(string path) => string.IsNullOrWhiteSpace(_options.FrontendBaseUrl) ? path : _options.FrontendBaseUrl.TrimEnd('/') + path; 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 CurrentUserAsync() { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); return userId is null ? null : await userManager.FindByIdAsync(userId); } private async Task 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 ReadBindingTicketAsync( string code, CancellationToken cancellationToken) { var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken); if (json is null) return null; try { return JsonSerializer.Deserialize(json); } catch (JsonException) { return null; } } private UnauthorizedObjectResult InvalidLocalCredentials() => Unauthorized(new ProblemDetails { Title = "账户绑定失败", Detail = "教务系统账号或密码不正确,或账号已停用。", Status = StatusCodes.Status401Unauthorized }); private ObjectResult BindingConflict(string detail) => SsoProblem(detail, StatusCodes.Status409Conflict); private ObjectResult SsoProblem(string detail, int status) => StatusCode(status, new ProblemDetails { Title = "统一身份认证失败", Detail = detail, Status = status }); } public sealed record SsoSettingsResponse( bool Enabled, string DisplayName, string CallbackUrl); public sealed record SsoExchangeRequest( [Required, MinLength(20), MaxLength(200)] string Code, bool IsNativeApp = false); public sealed record SsoBindingInfoResponse( string ProviderDisplayName, string ExternalUserName); public sealed record SsoBindRequest( [Required, MinLength(20), MaxLength(200)] string Code, [Required, MaxLength(100)] string UserName, [Required, MaxLength(100)] string Password, 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);