using System.Net; using System.Net.Mail; using System.Security.Cryptography; using System.Text; using Lagerbestand.Models; using QRCoder; namespace Lagerbestand.Services; public interface ITwoFactorService { /// /// Generates a new random Base32-encoded TOTP secret key. /// string GenerateSecretKey(); /// /// Generates the otpauth:// URI for authenticator app setup. /// string GenerateOtpAuthUri(string secret, string username, string issuer); /// /// Validates a 6-digit TOTP code against a secret (±1 time step tolerance). /// bool ValidateTotpCode(string secret, string code); /// /// Generates a random 6-digit email verification code. /// string GenerateEmailCode(); /// /// Sends a 2FA code via email using configured SMTP settings. /// Task SendEmailCodeAsync(string toEmail, string code, string username); /// /// Generates a QR code as a base64 PNG data URL from an otpauth URI. /// string GenerateQrCodeDataUrl(string otpAuthUri); } public class TwoFactorService : ITwoFactorService { private const int SecretKeyLength = 20; // 160 bits private const int TotpCodeLength = 6; private const int TotpTimeStepSeconds = 30; private const int TotpTimeTolerance = 1; // ±1 step (30 seconds each side) private static readonly char[] Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray(); private readonly ISiteSettingsService _siteSettingsService; private readonly ILogger _logger; public TwoFactorService(ISiteSettingsService siteSettingsService, ILogger logger) { _siteSettingsService = siteSettingsService; _logger = logger; } public string GenerateSecretKey() { var secretBytes = RandomNumberGenerator.GetBytes(SecretKeyLength); return Base32Encode(secretBytes); } public string GenerateOtpAuthUri(string secret, string username, string issuer) { var encodedIssuer = Uri.EscapeDataString(issuer); var encodedUsername = Uri.EscapeDataString(username); return $"otpauth://totp/{encodedIssuer}:{encodedUsername}?secret={secret}&issuer={encodedIssuer}&digits={TotpCodeLength}&period={TotpTimeStepSeconds}"; } public bool ValidateTotpCode(string secret, string code) { if (string.IsNullOrWhiteSpace(code) || code.Length != TotpCodeLength) return false; var secretBytes = Base32Decode(secret); var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // Check current time step and ±tolerance steps for (int i = -TotpTimeTolerance; i <= TotpTimeTolerance; i++) { var timeStep = (unixTimestamp / TotpTimeStepSeconds) + i; var generatedCode = ComputeTotp(secretBytes, timeStep); if (CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(generatedCode), Encoding.UTF8.GetBytes(code))) { return true; } } return false; } public string GenerateEmailCode() { return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"); } public async Task SendEmailCodeAsync(string toEmail, string code, string username) { try { var smtpSettings = await _siteSettingsService.GetSmtpSettingsAsync(); if (!smtpSettings.IsEnabled || !smtpSettings.IsConfigured) { _logger.LogWarning("SMTP is not configured. Cannot send 2FA email to {Email}", toEmail); return false; } using var client = new SmtpClient(smtpSettings.Host, smtpSettings.Port) { Credentials = new NetworkCredential(smtpSettings.Username, smtpSettings.Password), EnableSsl = smtpSettings.UseSsl, Timeout = 15_000 }; using var message = new MailMessage { From = new MailAddress(smtpSettings.FromEmail, smtpSettings.FromName), Subject = $"Ihr Anmeldecode – {code}", IsBodyHtml = true, Body = $"""

Reifen Späth – Anmeldecode

Hallo {username},

Ihr Einmal-Code für die Anmeldung lautet:

{code}

Dieser Code ist 5 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.

""" }; message.To.Add(toEmail); await client.SendMailAsync(message); _logger.LogInformation("2FA email code sent to {Email} for user {Username}", toEmail, username); return true; } catch (Exception ex) { _logger.LogError(ex, "Failed to send 2FA email to {Email}", toEmail); return false; } } public string GenerateQrCodeDataUrl(string otpAuthUri) { using var qrGenerator = new QRCodeGenerator(); using var qrCodeData = qrGenerator.CreateQrCode(otpAuthUri, QRCodeGenerator.ECCLevel.M); using var qrCode = new PngByteQRCode(qrCodeData); var pngBytes = qrCode.GetGraphic(8, new byte[] { 30, 30, 32 }, new byte[] { 255, 255, 255 }); return $"data:image/png;base64,{Convert.ToBase64String(pngBytes)}"; } #region TOTP Internals private static string ComputeTotp(byte[] secret, long timeStep) { var counterBytes = BitConverter.GetBytes(timeStep); if (BitConverter.IsLittleEndian) Array.Reverse(counterBytes); var hash = HMACSHA1.HashData(secret, counterBytes); var offset = hash[^1] & 0x0F; var code = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16) | ((hash[offset + 2] & 0xFF) << 8) | (hash[offset + 3] & 0xFF); return (code % 1_000_000).ToString("D6"); } #endregion #region Base32 private static string Base32Encode(byte[] data) { var sb = new StringBuilder((data.Length * 8 + 4) / 5); int buffer = data[0]; int next = 1; int bitsLeft = 8; while (bitsLeft > 0 || next < data.Length) { if (bitsLeft < 5) { if (next < data.Length) { buffer <<= 8; buffer |= data[next++] & 0xFF; bitsLeft += 8; } else { int pad = 5 - bitsLeft; buffer <<= pad; bitsLeft += pad; } } int index = 0x1F & (buffer >> (bitsLeft - 5)); bitsLeft -= 5; sb.Append(Base32Chars[index]); } return sb.ToString(); } private static byte[] Base32Decode(string base32) { var cleanInput = base32.TrimEnd('=').ToUpperInvariant(); var output = new byte[cleanInput.Length * 5 / 8]; int buffer = 0; int bitsLeft = 0; int outputIndex = 0; foreach (var c in cleanInput) { var val = Array.IndexOf(Base32Chars, c); if (val < 0) continue; buffer <<= 5; buffer |= val & 0x1F; bitsLeft += 5; if (bitsLeft >= 8) { output[outputIndex++] = (byte)(buffer >> (bitsLeft - 8)); bitsLeft -= 8; } } return output; } #endregion }