Initial commit: Lagerbestand Blazor Projekt

This commit is contained in:
spaethf committed 2026-09-21 21:28:23 +02:00
commit cba9414e92
105 files changed
+36431

No files matched your search

+243
View File
@@ -0,0 +1,243 @@
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
}