mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 10:31:09 +00:00
[AZ-534] TOTP-based 2FA at credential login
Add RFC 6238 TOTP enrollment, two-step /login flow, recovery codes, and
the amr=["pwd","mfa"] claim that propagates through refresh-token rotation.
- New endpoints: /users/me/mfa/{enroll,confirm,disable} and /login/mfa.
- /login short-circuits to a 5-min ES256 step-1 token (audience-pinned
azaion-mfa-step2) when the user has MFA enabled; real access+refresh
pair is minted only after /login/mfa.
- mfa_secret encrypted at rest via ASP.NET Core IDataProtector
(purpose=Azaion.Mfa.Secret.v1; key folder configurable via
DataProtection:KeysFolder for production persistence).
- Recovery codes (10 single-use, base32, ~80-bit entropy) hashed with
SHA-256 and stored as JSONB; constant-time compare on lookup.
- RFC 6238 §5.2 replay defense via mfa_last_used_window per user.
- Sessions carry mfa_authenticated so /token/refresh re-stamps the
amr claim correctly across the entire 30-day refresh window.
- New audit events: enroll, confirm, disable, login-success/failed,
recovery-used.
- Schema: env/db/10_users_mfa.sql adds users.mfa_* columns and
sessions.mfa_authenticated; mfa_recovery_codes mapped as BinaryJson
in AzaionDbSchemaHolder; disable path uses raw parameterised SQL to
avoid LinqToDB null-literal type-inference on jsonb columns.
E2E: 6 new tests in MfaLoginTests cover all six AC; full suite
82 passed / 0 failed / 3 intentional skips.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,14 @@ public interface IAuditLog
|
||||
Task RecordLoginLockout(string email, CancellationToken ct = default);
|
||||
Task RecordLoginSuccess(string email, CancellationToken ct = default);
|
||||
|
||||
// AZ-534 — MFA lifecycle + login auth-event audit.
|
||||
Task RecordMfaEnroll (string email, CancellationToken ct = default);
|
||||
Task RecordMfaConfirm (string email, CancellationToken ct = default);
|
||||
Task RecordMfaDisable (string email, CancellationToken ct = default);
|
||||
Task RecordMfaLoginSuccess (string email, CancellationToken ct = default);
|
||||
Task RecordMfaLoginFailed (string email, CancellationToken ct = default);
|
||||
Task RecordMfaRecoveryUsed (string email, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Number of `login_failed` rows for the given email within the last <paramref name="windowSeconds"/>.
|
||||
/// Used by the per-account sliding-window rate limit (AZ-537 AC-2).
|
||||
@@ -29,6 +37,19 @@ public class AuditLog(IDbFactory dbFactory, IHttpContextAccessor httpContextAcce
|
||||
public Task RecordLoginSuccess(string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.LoginSuccess, email, ct);
|
||||
|
||||
public Task RecordMfaEnroll (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaEnroll, email, ct);
|
||||
public Task RecordMfaConfirm (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaConfirm, email, ct);
|
||||
public Task RecordMfaDisable (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaDisable, email, ct);
|
||||
public Task RecordMfaLoginSuccess (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaLoginSuccess, email, ct);
|
||||
public Task RecordMfaLoginFailed (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaLoginFailed, email, ct);
|
||||
public Task RecordMfaRecoveryUsed (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaRecoveryUsed, email, ct);
|
||||
|
||||
public async Task<int> CountRecentFailedLogins(string email, int windowSeconds, CancellationToken ct = default)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddSeconds(-windowSeconds);
|
||||
|
||||
@@ -16,8 +16,10 @@ public interface IAuthService
|
||||
/// AZ-531 / AZ-532 — mint a 15-minute ES256 access token. <paramref name="sessionId"/>
|
||||
/// is stamped as the <c>sid</c> claim (logout / family-revocation key in AZ-535)
|
||||
/// and <paramref name="jti"/> is the per-token unique id (AZ-535 access denylist).
|
||||
/// AZ-534 — <paramref name="amr"/> values are stamped as repeated <c>amr</c>
|
||||
/// claims so verifiers can require step-up MFA. Defaults to <c>["pwd"]</c>.
|
||||
/// </summary>
|
||||
AccessToken CreateToken(User user, Guid sessionId, Guid jti);
|
||||
AccessToken CreateToken(User user, Guid sessionId, Guid jti, IEnumerable<string>? amr = null);
|
||||
}
|
||||
|
||||
public sealed record AccessToken(string Jwt, DateTime ExpiresAt);
|
||||
@@ -42,22 +44,31 @@ public class AuthService(
|
||||
return await userService.GetByEmail(email);
|
||||
}
|
||||
|
||||
public AccessToken CreateToken(User user, Guid sessionId, Guid jti)
|
||||
public AccessToken CreateToken(User user, Guid sessionId, Guid jti, IEnumerable<string>? amr = null)
|
||||
{
|
||||
var active = signingKeys.Active;
|
||||
var signingCredentials = new SigningCredentials(active.SecurityKey, SecurityAlgorithms.EcdsaSha256);
|
||||
|
||||
var expires = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenLifetimeMinutes);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(ClaimTypes.Name, user.Email),
|
||||
new(ClaimTypes.Role, user.Role.ToString()),
|
||||
new(JwtRegisteredClaimNames.Sid, sessionId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, jti.ToString())
|
||||
};
|
||||
|
||||
// AZ-534 — stamp authentication-methods-reference per RFC 8176. Multi-valued:
|
||||
// password+TOTP login produces ["pwd","mfa"]; recovery-code login adds "recovery".
|
||||
var amrValues = amr?.ToArray() ?? ["pwd"];
|
||||
foreach (var v in amrValues)
|
||||
claims.Add(new Claim("amr", v));
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity([
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Email),
|
||||
new Claim(ClaimTypes.Role, user.Role.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Sid, sessionId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, jti.ToString())
|
||||
]),
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = expires,
|
||||
Issuer = _jwt.Issuer,
|
||||
Audience = _jwt.Audience,
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="LazyCache.AspNetCore" Version="2.4.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Configs;
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Common.Entities;
|
||||
using Azaion.Common.Requests;
|
||||
using LinqToDB;
|
||||
using LinqToDB.Data;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OtpNet;
|
||||
using QRCoder;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-534 — RFC 6238 TOTP enrollment + login validation, with single-use recovery codes.
|
||||
/// MfaSecret is encrypted at rest via <see cref="IDataProtector"/>; recovery codes are
|
||||
/// stored as SHA-256 hashes (high-entropy secrets need a fast hash, not Argon2id —
|
||||
/// same reasoning the refresh-token store uses).
|
||||
/// </summary>
|
||||
public interface IMfaService
|
||||
{
|
||||
Task<MfaEnrollResponse> Enroll(Guid userId, string password, CancellationToken ct = default);
|
||||
Task Confirm(Guid userId, string code, CancellationToken ct = default);
|
||||
Task Disable(Guid userId, string password, string code, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Issued at /login when the user has MFA enabled — a 5-minute JWT (aud=azaion-mfa-step2)
|
||||
/// the client carries to /login/mfa for the second-factor verification.
|
||||
/// </summary>
|
||||
string IssueMfaStepToken(Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// Decode the step-1 token, returning the userId. Throws BusinessException(InvalidMfaToken)
|
||||
/// on bad signature, audience mismatch, or expired token.
|
||||
/// </summary>
|
||||
Guid ValidateMfaStepToken(string token);
|
||||
|
||||
/// <summary>
|
||||
/// AZ-534 AC-3 + AC-4 — second-factor verification at login. Returns the
|
||||
/// <c>amr</c> values the access token should carry (always includes <c>"pwd"</c>
|
||||
/// and <c>"mfa"</c>; <c>"recovery"</c> is added when a recovery code was used).
|
||||
/// </summary>
|
||||
Task<string[]> VerifyForLogin(Guid userId, string code, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class MfaService(
|
||||
IDbFactory dbFactory,
|
||||
IUserService userService,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IJwtSigningKeyProvider signingKeys,
|
||||
IOptions<JwtConfig> jwtConfig,
|
||||
IAuditLog auditLog) : IMfaService
|
||||
{
|
||||
private const string MfaSecretPurpose = "Azaion.Mfa.Secret.v1";
|
||||
private const string MfaStepAudience = "azaion-mfa-step2";
|
||||
private const int MfaStepLifetimeSeconds = 300; // 5 min — matches AC-3
|
||||
private const int SecretBytes = 20; // 160 bits — RFC 6238 §3
|
||||
private const int RecoveryCodeCount = 10;
|
||||
private const int RecoveryCodeBytes = 10; // base32(10) = 16 chars (≥12 per AC-1)
|
||||
|
||||
private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector(MfaSecretPurpose);
|
||||
private readonly JwtConfig _jwt = jwtConfig.Value;
|
||||
|
||||
public async Task<MfaEnrollResponse> Enroll(Guid userId, string password, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userService.GetById(userId, ct)
|
||||
?? throw new BusinessException(ExceptionEnum.NoEmailFound);
|
||||
|
||||
// Re-auth with password — AC-1 requires this to defend a stolen access token
|
||||
// from being usable to silently flip the user into MFA.
|
||||
var verify = Security.VerifyPassword(password, user.PasswordHash);
|
||||
if (!verify.Valid)
|
||||
throw new BusinessException(ExceptionEnum.WrongPassword);
|
||||
|
||||
if (user.MfaEnabled)
|
||||
throw new BusinessException(ExceptionEnum.MfaAlreadyEnabled);
|
||||
|
||||
var secretBytes = KeyGeneration.GenerateRandomKey(SecretBytes);
|
||||
var secretBase32 = Base32Encoding.ToString(secretBytes); // 32 chars (per AC-1)
|
||||
|
||||
var otpAuthUrl = new OtpUri(
|
||||
schema: OtpType.Totp,
|
||||
secret: secretBase32,
|
||||
user: user.Email,
|
||||
issuer: _jwt.Issuer,
|
||||
algorithm: OtpHashMode.Sha1,
|
||||
digits: 6,
|
||||
period: 30).ToString();
|
||||
|
||||
var qrPng = GenerateQrPng(otpAuthUrl);
|
||||
|
||||
var recoveryPlain = new string[RecoveryCodeCount];
|
||||
var recoveryStore = new RecoveryCodeStore[RecoveryCodeCount];
|
||||
for (var i = 0; i < RecoveryCodeCount; i++)
|
||||
{
|
||||
var code = Base32Encoding.ToString(KeyGeneration.GenerateRandomKey(RecoveryCodeBytes));
|
||||
recoveryPlain[i] = code;
|
||||
recoveryStore[i] = new RecoveryCodeStore { Hash = HashRecoveryCode(code), UsedAt = null };
|
||||
}
|
||||
|
||||
var encryptedSecret = _protector.Protect(secretBase32);
|
||||
var recoveryJson = JsonSerializer.Serialize(recoveryStore);
|
||||
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == userId,
|
||||
u => new User
|
||||
{
|
||||
MfaSecret = encryptedSecret,
|
||||
MfaRecoveryCodes = recoveryJson,
|
||||
MfaEnabled = false, // confirm step flips this true
|
||||
MfaEnrolledAt = null
|
||||
},
|
||||
token: ct));
|
||||
|
||||
await auditLog.RecordMfaEnroll(user.Email, ct);
|
||||
|
||||
return new MfaEnrollResponse
|
||||
{
|
||||
Secret = secretBase32,
|
||||
OtpAuthUrl = otpAuthUrl,
|
||||
QrPngBase64 = qrPng,
|
||||
RecoveryCodes = recoveryPlain
|
||||
};
|
||||
}
|
||||
|
||||
public async Task Confirm(Guid userId, string code, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userService.GetById(userId, ct)
|
||||
?? throw new BusinessException(ExceptionEnum.NoEmailFound);
|
||||
|
||||
if (user.MfaEnabled)
|
||||
throw new BusinessException(ExceptionEnum.MfaAlreadyEnabled);
|
||||
|
||||
if (string.IsNullOrEmpty(user.MfaSecret))
|
||||
throw new BusinessException(ExceptionEnum.MfaNotEnrolling);
|
||||
|
||||
var secret = _protector.Unprotect(user.MfaSecret);
|
||||
if (!VerifyTotpCode(secret, code, lastUsedWindow: null, out _))
|
||||
throw new BusinessException(ExceptionEnum.InvalidMfaCode);
|
||||
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == userId,
|
||||
u => new User
|
||||
{
|
||||
MfaEnabled = true,
|
||||
MfaEnrolledAt = DateTime.UtcNow
|
||||
},
|
||||
token: ct));
|
||||
|
||||
await auditLog.RecordMfaConfirm(user.Email, ct);
|
||||
}
|
||||
|
||||
public async Task Disable(Guid userId, string password, string code, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userService.GetById(userId, ct)
|
||||
?? throw new BusinessException(ExceptionEnum.NoEmailFound);
|
||||
|
||||
if (!user.MfaEnabled)
|
||||
throw new BusinessException(ExceptionEnum.MfaNotEnabled);
|
||||
|
||||
var verify = Security.VerifyPassword(password, user.PasswordHash);
|
||||
if (!verify.Valid)
|
||||
throw new BusinessException(ExceptionEnum.WrongPassword);
|
||||
|
||||
var secret = _protector.Unprotect(user.MfaSecret!);
|
||||
if (!VerifyTotpCode(secret, code, lastUsedWindow: null, out _))
|
||||
throw new BusinessException(ExceptionEnum.InvalidMfaCode);
|
||||
|
||||
// Raw SQL: setting mfa_recovery_codes (jsonb) to NULL via the LinqToDB UPDATE
|
||||
// expression sends an untyped NULL literal that Postgres parses as text and
|
||||
// rejects (42804). A small parameterized SQL avoids the type-inference dance.
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.ExecuteAsync(
|
||||
@"UPDATE public.users
|
||||
SET mfa_enabled = false,
|
||||
mfa_secret = NULL,
|
||||
mfa_recovery_codes = NULL::jsonb,
|
||||
mfa_enrolled_at = NULL,
|
||||
mfa_last_used_window = NULL
|
||||
WHERE id = @id",
|
||||
new DataParameter("id", userId, DataType.Guid)));
|
||||
|
||||
await auditLog.RecordMfaDisable(user.Email, ct);
|
||||
}
|
||||
|
||||
public string IssueMfaStepToken(Guid userId)
|
||||
{
|
||||
var active = signingKeys.Active;
|
||||
var creds = new SigningCredentials(active.SecurityKey, SecurityAlgorithms.EcdsaSha256);
|
||||
var expires = DateTime.UtcNow.AddSeconds(MfaStepLifetimeSeconds);
|
||||
|
||||
var descriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity([
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim("token_use", "mfa_step")
|
||||
]),
|
||||
Expires = expires,
|
||||
Issuer = _jwt.Issuer,
|
||||
// AZ-534 — narrow audience: this token is ONLY usable at /login/mfa.
|
||||
// The main JwtBearer middleware accepts _jwt.Audience and rejects this one.
|
||||
Audience = MfaStepAudience,
|
||||
SigningCredentials = creds
|
||||
};
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
return handler.WriteToken(handler.CreateToken(descriptor));
|
||||
}
|
||||
|
||||
public Guid ValidateMfaStepToken(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = _jwt.Issuer,
|
||||
ValidAudience = MfaStepAudience,
|
||||
ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256],
|
||||
IssuerSigningKeyResolver = (_, _, _, _) =>
|
||||
signingKeys.All.Select(k => (SecurityKey)k.SecurityKey)
|
||||
}, out _);
|
||||
|
||||
var sub = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? throw new BusinessException(ExceptionEnum.InvalidMfaToken);
|
||||
return Guid.Parse(sub);
|
||||
}
|
||||
catch (BusinessException) { throw; }
|
||||
catch (Exception)
|
||||
{
|
||||
throw new BusinessException(ExceptionEnum.InvalidMfaToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string[]> VerifyForLogin(Guid userId, string code, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userService.GetById(userId, ct)
|
||||
?? throw new BusinessException(ExceptionEnum.NoEmailFound);
|
||||
|
||||
if (!user.MfaEnabled || string.IsNullOrEmpty(user.MfaSecret))
|
||||
throw new BusinessException(ExceptionEnum.MfaNotEnabled);
|
||||
|
||||
var secret = _protector.Unprotect(user.MfaSecret);
|
||||
if (VerifyTotpCode(secret, code, user.MfaLastUsedWindow, out var window))
|
||||
{
|
||||
// Persist last-used window so a re-presented code in the same 30 s
|
||||
// step is rejected even if the attacker presents it before the next step.
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == userId,
|
||||
u => new User { MfaLastUsedWindow = window },
|
||||
token: ct));
|
||||
await auditLog.RecordMfaLoginSuccess(user.Email, ct);
|
||||
return ["pwd", "mfa"];
|
||||
}
|
||||
|
||||
// TOTP failed — try recovery code (single-use)
|
||||
if (await TryConsumeRecoveryCode(user, code, ct))
|
||||
{
|
||||
await auditLog.RecordMfaRecoveryUsed(user.Email, ct);
|
||||
return ["pwd", "mfa", "recovery"];
|
||||
}
|
||||
|
||||
await auditLog.RecordMfaLoginFailed(user.Email, ct);
|
||||
throw new BusinessException(ExceptionEnum.InvalidMfaCode);
|
||||
}
|
||||
|
||||
private static bool VerifyTotpCode(string secretBase32, string code, long? lastUsedWindow, out long matchedWindow)
|
||||
{
|
||||
matchedWindow = 0;
|
||||
var totp = new Totp(Base32Encoding.ToBytes(secretBase32));
|
||||
if (!totp.VerifyTotp(code, out matchedWindow, VerificationWindow.RfcSpecifiedNetworkDelay))
|
||||
return false;
|
||||
if (lastUsedWindow.HasValue && matchedWindow <= lastUsedWindow.Value)
|
||||
return false; // replay within or before the last accepted window
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> TryConsumeRecoveryCode(User user, string code, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(user.MfaRecoveryCodes)) return false;
|
||||
|
||||
var codes = JsonSerializer.Deserialize<RecoveryCodeStore[]>(user.MfaRecoveryCodes)
|
||||
?? Array.Empty<RecoveryCodeStore>();
|
||||
var candidateHash = HashRecoveryCode(code);
|
||||
|
||||
var matchIdx = -1;
|
||||
for (var i = 0; i < codes.Length; i++)
|
||||
{
|
||||
if (codes[i].UsedAt != null) continue;
|
||||
if (CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.ASCII.GetBytes(codes[i].Hash),
|
||||
System.Text.Encoding.ASCII.GetBytes(candidateHash)))
|
||||
{
|
||||
matchIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchIdx < 0) return false;
|
||||
|
||||
codes[matchIdx] = codes[matchIdx] with { UsedAt = DateTime.UtcNow };
|
||||
var updated = JsonSerializer.Serialize(codes);
|
||||
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
// Conditional update on the prior JSON to avoid a race where two
|
||||
// concurrent /login/mfa calls both consume the same code.
|
||||
u => u.Id == user.Id && u.MfaRecoveryCodes == user.MfaRecoveryCodes,
|
||||
u => new User { MfaRecoveryCodes = updated },
|
||||
token: ct));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string GenerateQrPng(string text)
|
||||
{
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
|
||||
var pngBytes = new PngByteQRCode(data).GetGraphic(pixelsPerModule: 6);
|
||||
return Convert.ToBase64String(pngBytes);
|
||||
}
|
||||
|
||||
private static string HashRecoveryCode(string code)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(code);
|
||||
var digest = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(digest);
|
||||
}
|
||||
|
||||
private sealed record RecoveryCodeStore
|
||||
{
|
||||
public string Hash { get; init; } = "";
|
||||
public DateTime? UsedAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,10 @@ public interface IRefreshTokenService
|
||||
/// <summary>
|
||||
/// Mint a fresh refresh token at login; starts a new session family. Returns
|
||||
/// the opaque token (NEVER persisted; only its sha256 lands in the DB) and
|
||||
/// the session row that backs it.
|
||||
/// the session row that backs it. <paramref name="mfaAuthenticated"/> is pinned
|
||||
/// to the session so refresh-token rotation inherits the original AMR strength.
|
||||
/// </summary>
|
||||
Task<(string OpaqueToken, Session Session)> IssueForNewLogin(Guid userId, CancellationToken ct = default);
|
||||
Task<(string OpaqueToken, Session Session)> IssueForNewLogin(Guid userId, bool mfaAuthenticated = false, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Rotate <paramref name="opaqueToken"/>. On success returns the new token +
|
||||
@@ -37,20 +38,21 @@ public class RefreshTokenService(IDbFactory dbFactory, IOptions<SessionConfig> s
|
||||
|
||||
private readonly SessionConfig _cfg = sessionConfig.Value;
|
||||
|
||||
public async Task<(string OpaqueToken, Session Session)> IssueForNewLogin(Guid userId, CancellationToken ct = default)
|
||||
public async Task<(string OpaqueToken, Session Session)> IssueForNewLogin(Guid userId, bool mfaAuthenticated = false, CancellationToken ct = default)
|
||||
{
|
||||
var (opaque, hash) = GenerateToken();
|
||||
var now = DateTime.UtcNow;
|
||||
var session = new Session
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
RefreshHash = hash,
|
||||
FamilyId = Guid.NewGuid(), // self-rooted family
|
||||
IssuedAt = now,
|
||||
LastUsedAt = now,
|
||||
ExpiresAt = now.AddHours(_cfg.RefreshSlidingHours),
|
||||
FamilyStartedAt = now,
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
RefreshHash = hash,
|
||||
FamilyId = Guid.NewGuid(), // self-rooted family
|
||||
IssuedAt = now,
|
||||
LastUsedAt = now,
|
||||
ExpiresAt = now.AddHours(_cfg.RefreshSlidingHours),
|
||||
FamilyStartedAt = now,
|
||||
MfaAuthenticated = mfaAuthenticated,
|
||||
};
|
||||
// family_id should equal id for the root row so SELECT family_id from
|
||||
// any row returns a stable handle even if id is renamed later.
|
||||
@@ -104,15 +106,16 @@ public class RefreshTokenService(IDbFactory dbFactory, IOptions<SessionConfig> s
|
||||
var (newOpaque, newHash) = GenerateToken();
|
||||
var newSession = new Session
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = current.UserId,
|
||||
RefreshHash = newHash,
|
||||
FamilyId = current.FamilyId,
|
||||
IssuedAt = now,
|
||||
LastUsedAt = now,
|
||||
ExpiresAt = now.AddHours(_cfg.RefreshSlidingHours),
|
||||
FamilyStartedAt = current.FamilyStartedAt,
|
||||
ParentSessionId = current.Id,
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = current.UserId,
|
||||
RefreshHash = newHash,
|
||||
FamilyId = current.FamilyId,
|
||||
IssuedAt = now,
|
||||
LastUsedAt = now,
|
||||
ExpiresAt = now.AddHours(_cfg.RefreshSlidingHours),
|
||||
FamilyStartedAt = current.FamilyStartedAt,
|
||||
ParentSessionId = current.Id,
|
||||
MfaAuthenticated = current.MfaAuthenticated,
|
||||
};
|
||||
|
||||
await db.Sessions
|
||||
|
||||
Reference in New Issue
Block a user