551 lines
21 KiB
C#
551 lines
21 KiB
C#
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<ApplicationUser> userManager,
|
|
IAuthSessionService authSessionService,
|
|
IDistributedCache cache,
|
|
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,
|
|
EffectiveCallbackUrl());
|
|
|
|
[AllowAnonymous]
|
|
[EnableRateLimiting("public-auth")]
|
|
[HttpGet("login")]
|
|
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 })!;
|
|
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(
|
|
[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<ActionResult<LoginResponse>> 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<ActionResult<SsoBindingInfoResponse>> 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<ActionResult<LoginResponse>> 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<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('/') &&
|
|
!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<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)
|
|
{
|
|
var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken);
|
|
if (json is null)
|
|
return null;
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<SsoBindingTicket>(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);
|