using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Caching.Memory; namespace Nuuru.Server.Services { public interface ICaptchaTokenService { string IssueToken(); bool ValidateToken(string token); } public sealed class CaptchaTokenService : ICaptchaTokenService { private readonly byte[] _key; private readonly int _tokenTtlSeconds; private readonly IMemoryCache _consumedTokens; public CaptchaTokenService(IConfiguration configuration, IMemoryCache cache) { var signingKey = configuration["Captcha:SigningKey"] ?? throw new InvalidOperationException("Captcha:SigningKey is not configured."); _key = Encoding.UTF8.GetBytes(signingKey); _tokenTtlSeconds = configuration.GetValue("Captcha:TokenTtlSeconds", 120); _consumedTokens = cache; } public string IssueToken() { var guid = Guid.NewGuid().ToString("N"); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var payload = $"{guid}:{timestamp}"; var hmac = HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(payload)); var sig = WebEncoders.Base64UrlEncode(hmac); return $"{guid}:{timestamp}:{sig}"; } public bool ValidateToken(string token) { if (string.IsNullOrWhiteSpace(token)) return false; var parts = token.Split(':'); if (parts.Length != 3) return false; var guid = parts[0]; if (!long.TryParse(parts[1], out var timestamp)) return false; var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (now - timestamp > _tokenTtlSeconds) return false; byte[] providedSig; try { providedSig = WebEncoders.Base64UrlDecode(parts[2]); } catch { return false; } var payload = $"{guid}:{timestamp}"; var expectedSig = HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(payload)); if (providedSig.Length != expectedSig.Length || !CryptographicOperations.FixedTimeEquals(providedSig, expectedSig)) return false; var cacheKey = $"captcha-used:{guid}"; if (_consumedTokens.TryGetValue(cacheKey, out _)) return false; _consumedTokens.Set(cacheKey, true, TimeSpan.FromSeconds(_tokenTtlSeconds)); return true; } } }