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,
UserManager<ApplicationUser> userManager,
IAuthSessionService authSessionService,
ITwoFactorLoginTicketService twoFactorTickets,
IAppCache cache) : ControllerBase
{
[AllowAnonymous]
@@ -137,7 +138,7 @@ public sealed class AuthController(
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login(
public async Task<ActionResult> Login(
LoginRequest request,
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);
user.LastLoginAt = DateTime.UtcNow;
await userManager.UpdateAsync(user);
@@ -176,6 +182,36 @@ public sealed class AuthController(
? AuthenticationClientType.App
: AuthenticationClientType.Web,
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);
}
@@ -248,6 +284,12 @@ public sealed class AuthController(
Status = status
});
private static ProblemDetails LoginProblem() => new()
{
Title = "登录失败", Detail = "验证码无效、已过期或账号已停用。",
Status = StatusCodes.Status401Unauthorized
};
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
new(
session.AccessToken,
@@ -268,6 +310,15 @@ public sealed record LoginRequest(
[Required, MaxLength(100)] string Password,
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(
[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 NativeAppProperty = "sso-native-app";
private const string NativeAppStateProperty = "sso-native-app-state";
private readonly SsoOptions _options = options.Value;
[AllowAnonymous]
@@ -44,6 +45,7 @@ public sealed class SsoController(
[FromQuery] string? returnUrl = null,
[FromQuery] string? bindingIntent = null,
[FromQuery] bool nativeApp = false,
[FromQuery] string? nativeState = null,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
@@ -52,7 +54,12 @@ public sealed class SsoController(
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
var properties = new AuthenticationProperties();
if (nativeApp)
{
if (!IsValidNativeState(nativeState))
return SsoProblem("原生应用登录校验已失效,请返回应用重新发起登录。", StatusCodes.Status400BadRequest);
properties.Items[NativeAppProperty] = bool.TrueString;
properties.Items[NativeAppStateProperty] = nativeState!;
}
if (!string.IsNullOrWhiteSpace(bindingIntent))
{
var targetUserId = await cache.GetStringAsync(
@@ -116,6 +123,10 @@ public sealed class SsoController(
externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) &&
bool.TryParse(nativeAppValue, out var isNativeApp) &&
isNativeApp;
var nativeState = authentication.Properties is { } nativeProperties &&
nativeProperties.Items.TryGetValue(NativeAppStateProperty, out var storedNativeState)
? storedNativeState
: null;
if (!string.IsNullOrWhiteSpace(bindingIntent))
{
var targetUserId = await cache.GetStringAsync(
@@ -165,7 +176,7 @@ public sealed class SsoController(
subject;
await cache.SetStringAsync(
BindingCacheKey(bindingCode),
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)),
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName, nativeState)),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
@@ -190,7 +201,7 @@ public sealed class SsoController(
RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync(
ExchangeCacheKey(exchangeCode),
user.Id.ToString("D"),
JsonSerializer.Serialize(new SsoExchangeTicket(user.Id, nativeState)),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
@@ -215,14 +226,25 @@ public sealed class SsoController(
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var cacheKey = ExchangeCacheKey(request.Code);
var userId = await cache.GetStringAsync(cacheKey, cancellationToken);
if (userId is null)
var ticketJson = await cache.GetStringAsync(cacheKey, cancellationToken);
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(
"统一身份认证结果已失效,请重新登录。",
StatusCodes.Status401Unauthorized);
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))
return SsoProblem(
"本地账号不存在、已停用或已锁定。",
@@ -269,7 +291,7 @@ public sealed class SsoController(
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
if (ticket is null)
if (ticket is null || !NativeStateMatches(ticket.NativeState, request.NativeState))
return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized);
@@ -353,7 +375,8 @@ public sealed class SsoController(
[HttpPost("prepare-binding")]
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
CancellationToken cancellationToken,
[FromQuery] bool nativeApp = false)
[FromQuery] bool nativeApp = false,
[FromQuery] string? nativeState = null)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
@@ -383,7 +406,8 @@ public sealed class SsoController(
{
returnUrl = "/account",
bindingIntent = intentCode,
nativeApp
nativeApp,
nativeState
})!;
return new SsoBindingStartResponse(loginUrl);
}
@@ -427,20 +451,26 @@ public sealed class SsoController(
? returnUrl
: "/dashboard";
private string BuildFrontendUrl(string path, bool requireAbsoluteUrl = false)
private string BuildFrontendUrl(string path, bool nativeApp = false)
{
if (requireAbsoluteUrl && string.IsNullOrWhiteSpace(_options.FrontendBaseUrl))
{
throw new InvalidOperationException(
"原生单点登录需要配置 Sso:FrontendBaseUrl 为已验证的 HTTPS 地址。");
}
return
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
if (nativeApp)
return "mingxu://open" + path;
return string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
? 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(
string error,
string path = "/login") =>
@@ -543,7 +573,8 @@ public sealed record SsoSettingsResponse(
public sealed record SsoExchangeRequest(
[Required, MinLength(20), MaxLength(200)] string Code,
bool IsNativeApp = false);
bool IsNativeApp = false,
string? NativeState = null);
public sealed record SsoBindingInfoResponse(
string ProviderDisplayName,
@@ -553,9 +584,14 @@ public sealed record SsoBindRequest(
[Required, MinLength(20), MaxLength(200)] string Code,
[Required, MaxLength(100)] string UserName,
[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(
bool Enabled,
Loaded 3 of 21 files, more files were not shown because too many files have changed in this diff. Show more