Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/backend/Auth/AuthCodeService.cs
diff options
context:
space:
mode:
authorAlfaPC112026-07-26 21:01:15 +0300
committerAlfaPC112026-07-26 21:01:15 +0300
commitd4198c795c1038b61140d21ac500db26af573e82 (patch)
tree8e01b50f09f7658279667f1c6720857a0d56da72 /backend/Auth/AuthCodeService.cs
parentd35c08a3ecf20de76ee4ee8f68066eafa934887c (diff)
downloadwebsite-d4198c795c1038b61140d21ac500db26af573e82.tar.gz
add registry identity dashboard and transactional email
Diffstat (limited to 'backend/Auth/AuthCodeService.cs')
-rw-r--r--backend/Auth/AuthCodeService.cs74
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}"));
+}