Initial commit: Lagerbestand Blazor Projekt
This commit is contained in:
commit
cba9414e92
105 files changed
+36431
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user