Repository navigation
Add initial MFA support with TOTP and recovery codes #1744
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
86a45ef
Add MFA persistence
StuartFerguson 4fc5bb6
Add management API MFA policies
StuartFerguson 9d7da19
Add TOTP enrollment and recovery codes
StuartFerguson f13563a
Add protected MFA login challenge
StuartFerguson 4f2831b
Enforce MFA for OIDC password grants
StuartFerguson 46e039a
Document MFA operations and lockout behavior
StuartFerguson f56eb7c
Test protected MFA sign-in transactions
StuartFerguson be09f9d
Cover MFA login page transitions
StuartFerguson df403b6
Cover MFA policy management integration
StuartFerguson 02fc563
Cover MFA password grant rejection
StuartFerguson 7a692ca
Cover browser MFA enrollment and login
StuartFerguson 3583fcd
Cover one-time MFA recovery codes
StuartFerguson c266861
Cover role MFA policy management
StuartFerguson 8057e80
Clear temporary MFA challenge after sign-in
StuartFerguson af94b87
Configure persistent MFA data protection
StuartFerguson ea627ba
Add MFA enrollment QR code
StuartFerguson 6c2155c
Load Kestrel certificates with ephemeral keys
StuartFerguson 167b15e
Configure persistent Data Protection key directory
StuartFerguson 73b1910
Register MFA policy service interface
StuartFerguson 7fbfe8e
Restore hybrid flow behind feature flag
StuartFerguson fac6295
Address MFA pull request feedback
StuartFerguson fbb66d5
fix codacy issue
StuartFerguson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| using System.Text.Json; | ||
| using Microsoft.AspNetCore.DataProtection; | ||
| using Microsoft.AspNetCore.WebUtilities; | ||
|
|
||
| namespace SecurityService.BusinessLogic.Mfa; | ||
|
|
||
| public abstract record LoginOutcome; | ||
| public sealed record LoginCompleted(string ReturnUrl = "/") : LoginOutcome; | ||
| public sealed record LoginRequiresMfa(string Transaction) : LoginOutcome; | ||
| public sealed record LoginRequiresMfaEnrollment(string Transaction) : LoginOutcome; | ||
| public sealed record LoginRejected(string Message) : LoginOutcome; | ||
|
|
||
| public sealed record MfaSignInTransaction(string UserId, string ReturnUrl, bool RememberLogin, DateTimeOffset ExpiresUtc); | ||
|
|
||
| public sealed class MfaSignInTransactionProtector | ||
| { | ||
| private readonly IDataProtector _protector; | ||
|
|
||
| public MfaSignInTransactionProtector(IDataProtectionProvider provider) | ||
| { | ||
| _protector = provider.CreateProtector("SecurityService.MfaSignInTransaction.v1"); | ||
| } | ||
|
|
||
| public string Protect(string userId, string returnUrl, bool rememberLogin) | ||
| { | ||
| var transaction = new MfaSignInTransaction(userId, returnUrl, rememberLogin, DateTimeOffset.UtcNow.AddMinutes(5)); | ||
| var payload = JsonSerializer.SerializeToUtf8Bytes(transaction); | ||
| return WebEncoders.Base64UrlEncode(_protector.Protect(payload)); | ||
| } | ||
|
|
||
| public bool TryUnprotect(string value, out MfaSignInTransaction? transaction) | ||
| { | ||
| transaction = null; | ||
| try | ||
| { | ||
| var payload = _protector.Unprotect(WebEncoders.Base64UrlDecode(value)); | ||
| transaction = JsonSerializer.Deserialize<MfaSignInTransaction>(payload); | ||
| return transaction is not null && transaction.ExpiresUtc > DateTimeOffset.UtcNow; | ||
| } | ||
| catch (Exception) when (value.Length > 0) | ||
| { | ||
| return false; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public sealed record MfaEnrollmentTransaction(string UserId, string ReturnUrl, DateTimeOffset ExpiresUtc); | ||
|
|
||
| public sealed class MfaEnrollmentTransactionProtector | ||
| { | ||
| private readonly IDataProtector _protector; | ||
|
|
||
| public MfaEnrollmentTransactionProtector(IDataProtectionProvider provider) | ||
| { | ||
| _protector = provider.CreateProtector("SecurityService.MfaEnrollmentTransaction.v1"); | ||
| } | ||
|
|
||
| public string Protect(string userId, string returnUrl) | ||
| { | ||
| var transaction = new MfaEnrollmentTransaction(userId, returnUrl, DateTimeOffset.UtcNow.AddMinutes(10)); | ||
| var payload = JsonSerializer.SerializeToUtf8Bytes(transaction); | ||
| return WebEncoders.Base64UrlEncode(_protector.Protect(payload)); | ||
| } | ||
|
|
||
| public bool TryUnprotect(string value, out MfaEnrollmentTransaction? transaction) | ||
| { | ||
| transaction = null; | ||
| try | ||
| { | ||
| var payload = _protector.Unprotect(WebEncoders.Base64UrlDecode(value)); | ||
| transaction = JsonSerializer.Deserialize<MfaEnrollmentTransaction>(payload); | ||
| return transaction is not null && transaction.ExpiresUtc > DateTimeOffset.UtcNow; | ||
| } | ||
| catch (Exception) when (value.Length > 0) | ||
| { | ||
| return false; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| using Microsoft.AspNetCore.Identity; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using SecurityService.Database.DbContexts; | ||
| using SecurityService.Database.Entities; | ||
|
|
||
| namespace SecurityService.BusinessLogic.Mfa; | ||
|
|
||
| public sealed record MfaEnrollment(string AuthenticatorKey, string OtpauthUri); | ||
|
|
||
| public sealed class MfaAccountService | ||
| { | ||
| private const string AuthenticatorProvider = "Authenticator"; | ||
| private readonly UserManager<ApplicationUser> _userManager; | ||
| private readonly IPasswordHasher<ApplicationUser> _passwordHasher; | ||
| private readonly SecurityServiceDbContext _dbContext; | ||
| private readonly RecoveryCodeGenerator _recoveryCodeGenerator; | ||
|
|
||
| public MfaAccountService( | ||
| UserManager<ApplicationUser> userManager, | ||
| IPasswordHasher<ApplicationUser> passwordHasher, | ||
| SecurityServiceDbContext dbContext, | ||
| RecoveryCodeGenerator recoveryCodeGenerator) | ||
| { | ||
| _userManager = userManager; | ||
| _passwordHasher = passwordHasher; | ||
| _dbContext = dbContext; | ||
| _recoveryCodeGenerator = recoveryCodeGenerator; | ||
| } | ||
|
|
||
| public async Task<MfaEnrollment> BeginEnrollmentAsync(ApplicationUser user, string issuer, CancellationToken cancellationToken) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| var resetResult = await _userManager.ResetAuthenticatorKeyAsync(user); | ||
| if (!resetResult.Succeeded) | ||
| { | ||
| throw new InvalidOperationException(string.Join("; ", resetResult.Errors.Select(error => error.Description))); | ||
| } | ||
|
|
||
| return await GetEnrollmentAsync(user, issuer, cancellationToken); | ||
| } | ||
|
|
||
| public async Task<MfaEnrollment> GetEnrollmentAsync(ApplicationUser user, string issuer, CancellationToken cancellationToken) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| var key = await _userManager.GetAuthenticatorKeyAsync(user); | ||
| if (string.IsNullOrWhiteSpace(key)) | ||
| { | ||
| throw new InvalidOperationException("The authenticator key could not be generated."); | ||
| } | ||
|
|
||
| var account = user.Email ?? user.UserName ?? user.Id; | ||
| var uri = $"otpauth://totp/{Uri.EscapeDataString(issuer)}:{Uri.EscapeDataString(account)}?secret={key}&issuer={Uri.EscapeDataString(issuer)}"; | ||
| return new MfaEnrollment(key, uri); | ||
| } | ||
|
|
||
| public async Task<bool> ConfirmEnrollmentAsync(ApplicationUser user, string code, CancellationToken cancellationToken) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| if (string.IsNullOrWhiteSpace(code)) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var valid = await _userManager.VerifyTwoFactorTokenAsync(user, AuthenticatorProvider, code); | ||
| if (valid == false) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| return (await _userManager.SetTwoFactorEnabledAsync(user, true)).Succeeded; | ||
| } | ||
|
|
||
| public async Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(ApplicationUser user, int count, CancellationToken cancellationToken) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| var existingCodes = await _dbContext.RecoveryCodes.Where(code => code.UserId == user.Id).ToListAsync(cancellationToken); | ||
| _dbContext.RecoveryCodes.RemoveRange(existingCodes); | ||
|
|
||
| var codes = _recoveryCodeGenerator.Generate(count); | ||
| foreach (var code in codes) | ||
| { | ||
| _dbContext.RecoveryCodes.Add(new RecoveryCode | ||
| { | ||
| Id = Guid.NewGuid(), | ||
| UserId = user.Id, | ||
| CodeHash = _passwordHasher.HashPassword(user, code), | ||
| CreatedUtc = DateTime.UtcNow | ||
| }); | ||
| } | ||
|
|
||
| await _dbContext.SaveChangesAsync(cancellationToken); | ||
| return codes; | ||
| } | ||
|
|
||
| public async Task<bool> RedeemRecoveryCodeAsync(ApplicationUser user, string code, CancellationToken cancellationToken) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(code)) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var candidates = await _dbContext.RecoveryCodes | ||
| .Where(recoveryCode => recoveryCode.UserId == user.Id && recoveryCode.ConsumedUtc == null) | ||
| .ToListAsync(cancellationToken); | ||
| foreach (var candidate in candidates) | ||
| { | ||
| var verification = _passwordHasher.VerifyHashedPassword(user, candidate.CodeHash, code); | ||
| if (verification == PasswordVerificationResult.Failed) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| candidate.ConcurrencyStamp = Guid.NewGuid().ToString("N"); | ||
| candidate.ConsumedUtc = DateTime.UtcNow; | ||
| try | ||
| { | ||
| await _dbContext.SaveChangesAsync(cancellationToken); | ||
| return true; | ||
| } | ||
| catch (DbUpdateConcurrencyException) | ||
| { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| public async Task<bool> RemoveAuthenticatorAsync(ApplicationUser user, string currentPassword, string? code, CancellationToken cancellationToken) | ||
| { | ||
| var reauthenticated = await _userManager.CheckPasswordAsync(user, currentPassword); | ||
| if (reauthenticated == false && string.IsNullOrWhiteSpace(code) == false) | ||
| { | ||
| reauthenticated = await _userManager.VerifyTwoFactorTokenAsync(user, AuthenticatorProvider, code); | ||
| } | ||
|
|
||
| if (reauthenticated == false) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var disableResult = await _userManager.SetTwoFactorEnabledAsync(user, false); | ||
| if (disableResult.Succeeded == false) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| await _userManager.ResetAuthenticatorKeyAsync(user); | ||
| var recoveryCodes = await _dbContext.RecoveryCodes.Where(recoveryCode => recoveryCode.UserId == user.Id).ToListAsync(cancellationToken); | ||
| _dbContext.RecoveryCodes.RemoveRange(recoveryCodes); | ||
| await _dbContext.SaveChangesAsync(cancellationToken); | ||
| return true; | ||
| } | ||
|
|
||
| public async Task RevokeRecoveryCodesAsync(ApplicationUser user, CancellationToken cancellationToken) | ||
| { | ||
| var recoveryCodes = await _dbContext.RecoveryCodes | ||
| .Where(recoveryCode => recoveryCode.UserId == user.Id) | ||
| .ToListAsync(cancellationToken); | ||
| _dbContext.RecoveryCodes.RemoveRange(recoveryCodes); | ||
| await _dbContext.SaveChangesAsync(cancellationToken); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| using Microsoft.AspNetCore.Identity; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using SecurityService.Database.DbContexts; | ||
| using SecurityService.Database.Entities; | ||
| using SimpleResults; | ||
|
|
||
| namespace SecurityService.BusinessLogic.Mfa; | ||
|
|
||
| public interface IMfaPolicyService | ||
| { | ||
| Task<bool> IsMfaRequiredAsync(ApplicationUser user, CancellationToken cancellationToken); | ||
| } | ||
|
|
||
| public sealed class MfaPolicyService : IMfaPolicyService | ||
| { | ||
| private readonly UserManager<ApplicationUser> _userManager; | ||
| private readonly RoleManager<IdentityRole> _roleManager; | ||
| private readonly SecurityServiceDbContext _dbContext; | ||
|
|
||
| public MfaPolicyService( | ||
| UserManager<ApplicationUser> userManager, | ||
| RoleManager<IdentityRole> roleManager, | ||
| SecurityServiceDbContext dbContext) | ||
| { | ||
| _userManager = userManager; | ||
| _roleManager = roleManager; | ||
| _dbContext = dbContext; | ||
| } | ||
|
|
||
| public async Task<bool> IsMfaRequiredAsync(ApplicationUser user, CancellationToken cancellationToken) | ||
| { | ||
| var hasUserPolicy = await _dbContext.MfaPolicies | ||
| .AnyAsync(policy => policy.TargetType == MfaPolicyTargetType.User && policy.TargetId == user.Id, cancellationToken); | ||
| if (hasUserPolicy) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| var roleNames = await _userManager.GetRolesAsync(user); | ||
| if (roleNames.Count == 0) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var roleIds = await _roleManager.Roles | ||
| .Where(role => role.Name != null && roleNames.Contains(role.Name)) | ||
| .Select(role => role.Id) | ||
| .ToListAsync(cancellationToken); | ||
|
|
||
| return await _dbContext.MfaPolicies | ||
| .AnyAsync(policy => policy.TargetType == MfaPolicyTargetType.Role && roleIds.Contains(policy.TargetId), cancellationToken); | ||
| } | ||
|
|
||
| public async Task<Result> RequireUserAsync(string userId, CancellationToken cancellationToken) | ||
| { | ||
| if (await _userManager.FindByIdAsync(userId) is null) | ||
| { | ||
| return Result.NotFound($"No user found with id '{userId}'."); | ||
| } | ||
|
|
||
| return await RequireAsync(MfaPolicyTargetType.User, userId, cancellationToken); | ||
| } | ||
|
|
||
| public Task<Result> RemoveUserAsync(string userId, CancellationToken cancellationToken) => | ||
| RemoveAsync(MfaPolicyTargetType.User, userId, cancellationToken); | ||
|
|
||
| public async Task<Result> RequireRoleAsync(string roleId, CancellationToken cancellationToken) | ||
| { | ||
| if (await _roleManager.FindByIdAsync(roleId) is null) | ||
| { | ||
| return Result.NotFound($"No role found with id '{roleId}'."); | ||
| } | ||
|
|
||
| return await RequireAsync(MfaPolicyTargetType.Role, roleId, cancellationToken); | ||
| } | ||
|
|
||
| public Task<Result> RemoveRoleAsync(string roleId, CancellationToken cancellationToken) => | ||
| RemoveAsync(MfaPolicyTargetType.Role, roleId, cancellationToken); | ||
|
|
||
| private async Task<Result> RequireAsync(MfaPolicyTargetType targetType, string targetId, CancellationToken cancellationToken) | ||
| { | ||
| var exists = await _dbContext.MfaPolicies.AnyAsync( | ||
| policy => policy.TargetType == targetType && policy.TargetId == targetId, | ||
| cancellationToken); | ||
| if (exists) | ||
| { | ||
| return Result.Success(); | ||
| } | ||
|
|
||
| _dbContext.MfaPolicies.Add(new MfaPolicy | ||
| { | ||
| Id = Guid.NewGuid(), | ||
| TargetType = targetType, | ||
| TargetId = targetId, | ||
| CreatedUtc = DateTime.UtcNow | ||
| }); | ||
| await _dbContext.SaveChangesAsync(cancellationToken); | ||
| return Result.Success(); | ||
| } | ||
|
|
||
| private async Task<Result> RemoveAsync(MfaPolicyTargetType targetType, string targetId, CancellationToken cancellationToken) | ||
| { | ||
| var policies = await _dbContext.MfaPolicies | ||
| .Where(policy => policy.TargetType == targetType && policy.TargetId == targetId) | ||
| .ToListAsync(cancellationToken); | ||
| if (policies.Count == 0) | ||
| { | ||
| return Result.Success(); | ||
| } | ||
|
|
||
| _dbContext.MfaPolicies.RemoveRange(policies); | ||
| await _dbContext.SaveChangesAsync(cancellationToken); | ||
| return Result.Success(); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.