Files

244 lines
8.1 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// Generates a new random Base32-encoded TOTP secret key.
/// </summary>
string GenerateSecretKey();
/// <summary>
/// Generates the otpauth:// URI for authenticator app setup.
/// </summary>
string GenerateOtpAuthUri(string secret, string username, string issuer);
/// <summary>
/// Validates a 6-digit TOTP code against a secret (±1 time step tolerance).
/// </summary>
bool ValidateTotpCode(string secret, string code);
/// <summary>
/// Generates a random 6-digit email verification code.
/// </summary>
string GenerateEmailCode();
/// <summary>
/// Sends a 2FA code via email using configured SMTP settings.
/// </summary>
Task<bool> SendEmailCodeAsync(string toEmail, string code, string username);
/// <summary>
/// Generates a QR code as a base64 PNG data URL from an otpauth URI.
/// </summary>
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<TwoFactorService> _logger;
public TwoFactorService(ISiteSettingsService siteSettingsService, ILogger<TwoFactorService> 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<bool> 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 = $"""
<div style="font-family: Arial, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<h2 style="color: #dc2626;">Reifen Späth – Anmeldecode</h2>
<p>Hallo <strong>{username}</strong>,</p>
<p>Ihr Einmal-Code für die Anmeldung lautet:</p>
<div style="background: #f3f4f6; border-radius: 8px; padding: 16px; text-align: center; margin: 16px 0;">
<span style="font-size: 32px; font-weight: bold; letter-spacing: 6px; color: #111;">{code}</span>
</div>
<p style="color: #666; font-size: 14px;">Dieser Code ist 5 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.</p>
</div>
"""
};
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
}