add passkey & totp
This commit is contained in:
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);
|
||||
@@ -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
Reference in new issue
Block a user