add passkey & totp

This commit is contained in:
biss committed 2026-08-27 21:45:39 +08:00
1 parent 70f6b52577
commit 1d06bbd482
13 files changed
+9114 -4

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);
@@ -0,0 +1,20 @@
namespace Jiaowu.Api.Domain.Identity;
/// <summary>Public WebAuthn credential material. Private keys never leave the authenticator.</summary>
public sealed class PasskeyCredential
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public required string CredentialId { get; set; }
/// <summary>SHA-256 of the raw credential ID, used for a portable unique index.</summary>
public required string CredentialIdHash { get; set; }
public required byte[] PublicKey { get; set; }
public uint SignatureCounter { get; set; }
public required string FriendlyName { get; set; }
public string? Transports { get; set; }
public bool IsBackupEligible { get; set; }
public bool IsBackedUp { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastUsedAt { get; set; }
public ApplicationUser? User { get; set; }
}
Loaded 3 of 13 files, more files were not shown because too many files have changed in this diff. Show more