diff options
| author | AlfaPC11 | 2026-07-26 21:01:15 +0300 |
|---|---|---|
| committer | AlfaPC11 | 2026-07-26 21:01:15 +0300 |
| commit | d4198c795c1038b61140d21ac500db26af573e82 (patch) | |
| tree | 8e01b50f09f7658279667f1c6720857a0d56da72 /backend/Auth/AuthCodeService.cs | |
| parent | d35c08a3ecf20de76ee4ee8f68066eafa934887c (diff) | |
| download | website-d4198c795c1038b61140d21ac500db26af573e82.tar.gz | |
add registry identity dashboard and transactional email
Diffstat (limited to 'backend/Auth/AuthCodeService.cs')
| -rw-r--r-- | backend/Auth/AuthCodeService.cs | 74 |
1 files changed, 74 insertions, 0 deletions
diff --git a/backend/Auth/AuthCodeService.cs b/backend/Auth/AuthCodeService.cs new file mode 100644 index 0000000..6223c27 --- /dev/null +++ b/backend/Auth/AuthCodeService.cs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Leitwolf <xs-lang.chess031@slmails.com> +// SPDX-License-Identifier: AGPL-3.0-or-later + +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using XSharp.Web.Api.Data; + +namespace XSharp.Web.Api.Auth; + +internal sealed class AuthCodeService(RegistryDbContext database, IOptions<AuthCodeOptions> options) +{ + private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private readonly AuthCodeOptions configuration = options.Value; + + public async Task<string> CreateAsync(Guid userId, string purpose, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(configuration.Pepper)) + { + throw new InvalidOperationException("AuthCodes:Pepper is required."); + } + + await database.AuthChallenges + .Where(item => item.UserId == userId && item.Purpose == purpose && item.ConsumedAt == null) + .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, DateTimeOffset.UtcNow), + cancellationToken); + + string code = string.Create(8, 0, static (buffer, _) => + { + for (int index = 0; index < buffer.Length; ++index) + { + buffer[index] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]; + } + }); + database.AuthChallenges.Add(new AuthChallenge + { + UserId = userId, + Purpose = purpose, + CodeDigest = Digest(code, configuration.Pepper), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(configuration.LifetimeMinutes), + }); + await database.SaveChangesAsync(cancellationToken); + return code; + } + + public async Task<bool> ConsumeAsync(Guid userId, string purpose, string code, CancellationToken cancellationToken) + { + AuthChallenge? challenge = await database.AuthChallenges + .Where(item => item.UserId == userId && item.Purpose == purpose && item.ConsumedAt == null) + .OrderByDescending(item => item.ExpiresAt) + .FirstOrDefaultAsync(cancellationToken); + if (challenge is null || challenge.ExpiresAt <= DateTimeOffset.UtcNow + || challenge.FailedAttempts >= configuration.MaximumAttempts) + { + return false; + } + + byte[] supplied = Digest(code.Trim().ToUpperInvariant(), configuration.Pepper); + if (!CryptographicOperations.FixedTimeEquals(challenge.CodeDigest, supplied)) + { + ++challenge.FailedAttempts; + await database.SaveChangesAsync(cancellationToken); + return false; + } + + challenge.ConsumedAt = DateTimeOffset.UtcNow; + await database.SaveChangesAsync(cancellationToken); + return true; + } + + private static byte[] Digest(string code, string pepper) => + SHA256.HashData(Encoding.UTF8.GetBytes($"{pepper}:{code}")); +} |