244 lines
8.1 KiB
C#
244 lines
8.1 KiB
C#
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
|
||
}
|