5 Commits
21 changed files with 9243 additions and 48 deletions

No files matched your search

@@ -0,0 +1,84 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Jiaowu.Api.Domain.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/auth/security")]
public sealed class AccountSecurityController(UserManager<ApplicationUser> userManager) : ControllerBase
{
[HttpGet("totp")]
public async Task<ActionResult> GetTotpStatus()
{
var user = await CurrentUserAsync();
return user is null ? Unauthorized() : Ok(new { enabled = user.TwoFactorEnabled });
}
[HttpPost("totp/setup")]
public async Task<ActionResult<TotpSetupResponse>> SetupTotp(PasswordConfirmationRequest request)
{
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
if (!await userManager.CheckPasswordAsync(user, request.CurrentPassword))
return Unauthorized(PasswordProblem());
await userManager.ResetAuthenticatorKeyAsync(user);
var key = await userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrWhiteSpace(key)) return Problem("无法创建验证器密钥。");
var issuer = "明序教务";
var account = Uri.EscapeDataString(user.UserName ?? user.Id.ToString("D"));
var label = Uri.EscapeDataString($"{issuer}:{user.UserName}");
var uri = $"otpauth://totp/{label}?secret={key}&issuer={Uri.EscapeDataString(issuer)}&digits=6";
return Ok(new TotpSetupResponse(key, uri));
}
[HttpPost("totp/enable")]
public async Task<ActionResult<RecoveryCodesResponse>> EnableTotp(TotpEnableRequest request)
{
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
if (!await userManager.VerifyTwoFactorTokenAsync(user,
TokenOptions.DefaultAuthenticatorProvider, request.Code.Replace(" ", string.Empty)))
{
ModelState.AddModelError("code", "验证码不正确或已过期。");
return ValidationProblem(ModelState);
}
await userManager.SetTwoFactorEnabledAsync(user, true);
var codes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
return Ok(new RecoveryCodesResponse((codes ?? []).ToArray()));
}
[HttpPost("totp/disable")]
public async Task<IActionResult> DisableTotp(PasswordConfirmationRequest request)
{
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
if (!await userManager.CheckPasswordAsync(user, request.CurrentPassword))
return Unauthorized(PasswordProblem());
await userManager.SetTwoFactorEnabledAsync(user, false);
await userManager.ResetAuthenticatorKeyAsync(user);
return NoContent();
}
private async Task<ApplicationUser?> CurrentUserAsync()
{
var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
return id is null ? null : await userManager.FindByIdAsync(id);
}
private static ProblemDetails PasswordProblem() => new()
{
Title = "验证失败", Detail = "当前密码不正确。", Status = StatusCodes.Status401Unauthorized,
};
}
public sealed record PasswordConfirmationRequest([Required, MaxLength(100)] string CurrentPassword);
public sealed record TotpEnableRequest([Required, MinLength(6), MaxLength(12)] string Code);
public sealed record TotpSetupResponse(string Secret, string OtpauthUri);
public sealed record RecoveryCodesResponse(string[] RecoveryCodes);
+52 -1
View File
@@ -19,6 +19,7 @@ public sealed class AuthController(
AppDbContext db, AppDbContext db,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IAuthSessionService authSessionService, IAuthSessionService authSessionService,
ITwoFactorLoginTicketService twoFactorTickets,
IAppCache cache) : ControllerBase IAppCache cache) : ControllerBase
{ {
[AllowAnonymous] [AllowAnonymous]
@@ -137,7 +138,7 @@ public sealed class AuthController(
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting("public-auth")] [EnableRateLimiting("public-auth")]
[HttpPost("login")] [HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login( public async Task<ActionResult> Login(
LoginRequest request, LoginRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -164,6 +165,11 @@ public sealed class AuthController(
}); });
} }
if (user.TwoFactorEnabled)
{
return Ok(new TwoFactorRequiredResponse(twoFactorTickets.Create(user.Id, request.IsNativeApp)));
}
await userManager.ResetAccessFailedCountAsync(user); await userManager.ResetAccessFailedCountAsync(user);
user.LastLoginAt = DateTime.UtcNow; user.LastLoginAt = DateTime.UtcNow;
await userManager.UpdateAsync(user); await userManager.UpdateAsync(user);
@@ -176,6 +182,36 @@ public sealed class AuthController(
? AuthenticationClientType.App ? AuthenticationClientType.App
: AuthenticationClientType.Web, : AuthenticationClientType.Web,
cancellationToken); cancellationToken);
return Ok(CreateLoginResponse(session));
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("login/totp")]
public async Task<ActionResult<LoginResponse>> CompleteTotpLogin(
TotpLoginRequest request,
CancellationToken cancellationToken)
{
if (!twoFactorTickets.TryRead(request.TwoFactorTicket, out var userId, out var isNativeApp))
return Unauthorized(LoginProblem());
var user = await userManager.FindByIdAsync(userId.ToString("D"));
var code = request.Code.Replace(" ", string.Empty);
var isValid = user is not null && (await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, code) ||
(await userManager.RedeemTwoFactorRecoveryCodeAsync(user, code)).Succeeded);
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user) || !isValid)
{
if (user is not null) await userManager.AccessFailedAsync(user);
return Unauthorized(LoginProblem());
}
await userManager.ResetAccessFailedCountAsync(user);
user.LastLoginAt = DateTime.UtcNow;
await userManager.UpdateAsync(user);
var roles = await userManager.GetRolesAsync(user);
var session = await authSessionService.CreateAsync(user, roles,
isNativeApp ? AuthenticationClientType.App : AuthenticationClientType.Web,
cancellationToken);
return CreateLoginResponse(session); return CreateLoginResponse(session);
} }
@@ -248,6 +284,12 @@ public sealed class AuthController(
Status = status Status = status
}); });
private static ProblemDetails LoginProblem() => new()
{
Title = "登录失败", Detail = "验证码无效、已过期或账号已停用。",
Status = StatusCodes.Status401Unauthorized
};
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) => internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
new( new(
session.AccessToken, session.AccessToken,
@@ -268,6 +310,15 @@ public sealed record LoginRequest(
[Required, MaxLength(100)] string Password, [Required, MaxLength(100)] string Password,
bool IsNativeApp = false); bool IsNativeApp = false);
public sealed record TotpLoginRequest(
[Required, MinLength(20), MaxLength(2048)] string TwoFactorTicket,
[Required, MinLength(6), MaxLength(12)] string Code);
public sealed record TwoFactorRequiredResponse(string TwoFactorTicket)
{
public bool RequiresTotp => true;
}
public sealed record RefreshTokenRequest( public sealed record RefreshTokenRequest(
[Required, MinLength(40), MaxLength(200)] string RefreshToken); [Required, MinLength(40), MaxLength(200)] string RefreshToken);
+56 -20
View File
@@ -27,6 +27,7 @@ public sealed class SsoController(
{ {
private const string BindingIntentProperty = "sso-binding-intent"; private const string BindingIntentProperty = "sso-binding-intent";
private const string NativeAppProperty = "sso-native-app"; private const string NativeAppProperty = "sso-native-app";
private const string NativeAppStateProperty = "sso-native-app-state";
private readonly SsoOptions _options = options.Value; private readonly SsoOptions _options = options.Value;
[AllowAnonymous] [AllowAnonymous]
@@ -44,6 +45,7 @@ public sealed class SsoController(
[FromQuery] string? returnUrl = null, [FromQuery] string? returnUrl = null,
[FromQuery] string? bindingIntent = null, [FromQuery] string? bindingIntent = null,
[FromQuery] bool nativeApp = false, [FromQuery] bool nativeApp = false,
[FromQuery] string? nativeState = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!_options.Enabled) if (!_options.Enabled)
@@ -52,7 +54,12 @@ public sealed class SsoController(
var safeReturnUrl = NormalizeReturnUrl(returnUrl); var safeReturnUrl = NormalizeReturnUrl(returnUrl);
var properties = new AuthenticationProperties(); var properties = new AuthenticationProperties();
if (nativeApp) if (nativeApp)
{
if (!IsValidNativeState(nativeState))
return SsoProblem("原生应用登录校验已失效,请返回应用重新发起登录。", StatusCodes.Status400BadRequest);
properties.Items[NativeAppProperty] = bool.TrueString; properties.Items[NativeAppProperty] = bool.TrueString;
properties.Items[NativeAppStateProperty] = nativeState!;
}
if (!string.IsNullOrWhiteSpace(bindingIntent)) if (!string.IsNullOrWhiteSpace(bindingIntent))
{ {
var targetUserId = await cache.GetStringAsync( var targetUserId = await cache.GetStringAsync(
@@ -116,6 +123,10 @@ public sealed class SsoController(
externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) && externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) &&
bool.TryParse(nativeAppValue, out var isNativeApp) && bool.TryParse(nativeAppValue, out var isNativeApp) &&
isNativeApp; isNativeApp;
var nativeState = authentication.Properties is { } nativeProperties &&
nativeProperties.Items.TryGetValue(NativeAppStateProperty, out var storedNativeState)
? storedNativeState
: null;
if (!string.IsNullOrWhiteSpace(bindingIntent)) if (!string.IsNullOrWhiteSpace(bindingIntent))
{ {
var targetUserId = await cache.GetStringAsync( var targetUserId = await cache.GetStringAsync(
@@ -165,7 +176,7 @@ public sealed class SsoController(
subject; subject;
await cache.SetStringAsync( await cache.SetStringAsync(
BindingCacheKey(bindingCode), BindingCacheKey(bindingCode),
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)), JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName, nativeState)),
new DistributedCacheEntryOptions new DistributedCacheEntryOptions
{ {
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
@@ -190,7 +201,7 @@ public sealed class SsoController(
RandomNumberGenerator.GetBytes(32)); RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync( await cache.SetStringAsync(
ExchangeCacheKey(exchangeCode), ExchangeCacheKey(exchangeCode),
user.Id.ToString("D"), JsonSerializer.Serialize(new SsoExchangeTicket(user.Id, nativeState)),
new DistributedCacheEntryOptions new DistributedCacheEntryOptions
{ {
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
@@ -215,14 +226,25 @@ public sealed class SsoController(
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var cacheKey = ExchangeCacheKey(request.Code); var cacheKey = ExchangeCacheKey(request.Code);
var userId = await cache.GetStringAsync(cacheKey, cancellationToken); var ticketJson = await cache.GetStringAsync(cacheKey, cancellationToken);
if (userId is null) SsoExchangeTicket? ticket;
try
{
ticket = ticketJson is null
? null
: JsonSerializer.Deserialize<SsoExchangeTicket>(ticketJson);
}
catch (JsonException)
{
ticket = null;
}
if (ticket is null || !NativeStateMatches(ticket.NativeState, request.NativeState))
return SsoProblem( return SsoProblem(
"统一身份认证结果已失效,请重新登录。", "统一身份认证结果已失效,请重新登录。",
StatusCodes.Status401Unauthorized); StatusCodes.Status401Unauthorized);
await cache.RemoveAsync(cacheKey, cancellationToken); await cache.RemoveAsync(cacheKey, cancellationToken);
var user = await userManager.FindByIdAsync(userId); var user = await userManager.FindByIdAsync(ticket.UserId.ToString("D"));
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user)) if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return SsoProblem( return SsoProblem(
"本地账号不存在、已停用或已锁定。", "本地账号不存在、已停用或已锁定。",
@@ -269,7 +291,7 @@ public sealed class SsoController(
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken); var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
if (ticket is null) if (ticket is null || !NativeStateMatches(ticket.NativeState, request.NativeState))
return SsoProblem( return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。", "账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized); StatusCodes.Status401Unauthorized);
@@ -353,7 +375,8 @@ public sealed class SsoController(
[HttpPost("prepare-binding")] [HttpPost("prepare-binding")]
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding( public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
CancellationToken cancellationToken, CancellationToken cancellationToken,
[FromQuery] bool nativeApp = false) [FromQuery] bool nativeApp = false,
[FromQuery] string? nativeState = null)
{ {
if (!_options.Enabled) if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
@@ -383,7 +406,8 @@ public sealed class SsoController(
{ {
returnUrl = "/account", returnUrl = "/account",
bindingIntent = intentCode, bindingIntent = intentCode,
nativeApp nativeApp,
nativeState
})!; })!;
return new SsoBindingStartResponse(loginUrl); return new SsoBindingStartResponse(loginUrl);
} }
@@ -427,20 +451,26 @@ public sealed class SsoController(
? returnUrl ? returnUrl
: "/dashboard"; : "/dashboard";
private string BuildFrontendUrl(string path, bool requireAbsoluteUrl = false) private string BuildFrontendUrl(string path, bool nativeApp = false)
{ {
if (requireAbsoluteUrl && string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)) if (nativeApp)
{ return "mingxu://open" + path;
throw new InvalidOperationException( return string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
"原生单点登录需要配置 Sso:FrontendBaseUrl 为已验证的 HTTPS 地址。");
}
return
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
? path ? path
: _options.FrontendBaseUrl.TrimEnd('/') + path; : _options.FrontendBaseUrl.TrimEnd('/') + path;
} }
private static bool IsValidNativeState(string? value) =>
!string.IsNullOrWhiteSpace(value) && value.Length is >= 32 and <= 200;
private static bool NativeStateMatches(string? expected, string? actual) =>
expected is null
? string.IsNullOrWhiteSpace(actual)
: IsValidNativeState(actual) &&
CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(expected),
System.Text.Encoding.UTF8.GetBytes(actual!));
private RedirectResult RedirectToFrontendError( private RedirectResult RedirectToFrontendError(
string error, string error,
string path = "/login") => string path = "/login") =>
@@ -543,7 +573,8 @@ public sealed record SsoSettingsResponse(
public sealed record SsoExchangeRequest( public sealed record SsoExchangeRequest(
[Required, MinLength(20), MaxLength(200)] string Code, [Required, MinLength(20), MaxLength(200)] string Code,
bool IsNativeApp = false); bool IsNativeApp = false,
string? NativeState = null);
public sealed record SsoBindingInfoResponse( public sealed record SsoBindingInfoResponse(
string ProviderDisplayName, string ProviderDisplayName,
@@ -553,9 +584,14 @@ public sealed record SsoBindRequest(
[Required, MinLength(20), MaxLength(200)] string Code, [Required, MinLength(20), MaxLength(200)] string Code,
[Required, MaxLength(100)] string UserName, [Required, MaxLength(100)] string UserName,
[Required, MaxLength(100)] string Password, [Required, MaxLength(100)] string Password,
bool IsNativeApp = false); bool IsNativeApp = false,
string? NativeState = null);
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName); internal sealed record SsoBindingTicket(
string Subject,
string ExternalUserName,
string? NativeState = null);
internal sealed record SsoExchangeTicket(Guid UserId, string? NativeState);
public sealed record SsoAccountResponse( public sealed record SsoAccountResponse(
bool Enabled, bool Enabled,
Loaded 3 of 21 files, more files were not shown because too many files have changed in this diff. Show more