182 lines
5.2 KiB
C#
182 lines
5.2 KiB
C#
using System.Buffers.Binary;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using QRCoder;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public sealed class TwoFactorService(ILogger<TwoFactorService> logger) : ITwoFactorService
|
|
{
|
|
private const string Issuer = "PlotDirector";
|
|
private const int CodeDigits = 6;
|
|
private const int TimeStepSeconds = 30;
|
|
private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
|
|
public string GenerateSecretKey()
|
|
{
|
|
var bytes = RandomNumberGenerator.GetBytes(20);
|
|
return ToBase32(bytes);
|
|
}
|
|
|
|
public string GenerateQrCodeUri(string email, string secretKey)
|
|
{
|
|
var issuer = Uri.EscapeDataString(Issuer);
|
|
var account = Uri.EscapeDataString($"{Issuer}:{email}");
|
|
return $"otpauth://totp/{account}?secret={secretKey}&issuer={issuer}";
|
|
}
|
|
|
|
public bool ValidateCode(string secretKey, string code)
|
|
{
|
|
return ValidateCode(secretKey, code, lastUsedTimeStep: null, out _);
|
|
}
|
|
|
|
public bool ValidateCode(string secretKey, string code, long? lastUsedTimeStep, out long matchedTimeStep)
|
|
{
|
|
matchedTimeStep = 0;
|
|
var cleanCode = CleanCode(code);
|
|
if (cleanCode.Length != CodeDigits || cleanCode.Any(c => !char.IsDigit(c)))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var secretBytes = FromBase32(secretKey);
|
|
var currentStep = GetCurrentTimeStep();
|
|
for (var offset = -1; offset <= 1; offset++)
|
|
{
|
|
var step = currentStep + offset;
|
|
if (lastUsedTimeStep.HasValue && step <= lastUsedTimeStep.Value)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (GenerateTotp(secretBytes, step) == cleanCode)
|
|
{
|
|
matchedTimeStep = step;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public string? GenerateQrCodeDataUri(string qrCodeUri)
|
|
{
|
|
try
|
|
{
|
|
using var generator = new QRCodeGenerator();
|
|
using var data = generator.CreateQrCode(qrCodeUri, QRCodeGenerator.ECCLevel.Q);
|
|
var qrCode = new PngByteQRCode(data);
|
|
var bytes = qrCode.GetGraphic(8);
|
|
return $"data:image/png;base64,{Convert.ToBase64String(bytes)}";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "QR code generation failed for two-factor setup.");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public IReadOnlyList<string> GenerateRecoveryCodes(int count)
|
|
{
|
|
var codes = new List<string>(count);
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
codes.Add(GenerateRecoveryCode());
|
|
}
|
|
|
|
return codes;
|
|
}
|
|
|
|
private static long GetCurrentTimeStep()
|
|
{
|
|
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() / TimeStepSeconds;
|
|
}
|
|
|
|
private static string GenerateTotp(byte[] secretBytes, long timeStep)
|
|
{
|
|
Span<byte> counter = stackalloc byte[8];
|
|
BinaryPrimitives.WriteInt64BigEndian(counter, timeStep);
|
|
|
|
using var hmac = new HMACSHA1(secretBytes);
|
|
var hash = hmac.ComputeHash(counter.ToArray());
|
|
var offset = hash[^1] & 0x0f;
|
|
var binaryCode = ((hash[offset] & 0x7f) << 24)
|
|
| ((hash[offset + 1] & 0xff) << 16)
|
|
| ((hash[offset + 2] & 0xff) << 8)
|
|
| (hash[offset + 3] & 0xff);
|
|
|
|
var otp = binaryCode % 1_000_000;
|
|
return otp.ToString("D6");
|
|
}
|
|
|
|
private static string GenerateRecoveryCode()
|
|
{
|
|
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
var bytes = RandomNumberGenerator.GetBytes(10);
|
|
var builder = new StringBuilder(10);
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
builder.Append(alphabet[bytes[i] % alphabet.Length]);
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static string ToBase32(byte[] bytes)
|
|
{
|
|
var output = new StringBuilder((bytes.Length + 4) / 5 * 8);
|
|
var buffer = 0;
|
|
var bitsLeft = 0;
|
|
|
|
foreach (var b in bytes)
|
|
{
|
|
buffer = (buffer << 8) | b;
|
|
bitsLeft += 8;
|
|
while (bitsLeft >= 5)
|
|
{
|
|
output.Append(Base32Alphabet[(buffer >> (bitsLeft - 5)) & 31]);
|
|
bitsLeft -= 5;
|
|
}
|
|
}
|
|
|
|
if (bitsLeft > 0)
|
|
{
|
|
output.Append(Base32Alphabet[(buffer << (5 - bitsLeft)) & 31]);
|
|
}
|
|
|
|
return output.ToString();
|
|
}
|
|
|
|
private static byte[] FromBase32(string value)
|
|
{
|
|
var clean = value.Trim().Replace(" ", string.Empty).TrimEnd('=').ToUpperInvariant();
|
|
var bytes = new List<byte>();
|
|
var buffer = 0;
|
|
var bitsLeft = 0;
|
|
|
|
foreach (var c in clean)
|
|
{
|
|
var index = Base32Alphabet.IndexOf(c);
|
|
if (index < 0)
|
|
{
|
|
throw new FormatException("The two-factor secret is not valid Base32.");
|
|
}
|
|
|
|
buffer = (buffer << 5) | index;
|
|
bitsLeft += 5;
|
|
if (bitsLeft >= 8)
|
|
{
|
|
bytes.Add((byte)((buffer >> (bitsLeft - 8)) & 255));
|
|
bitsLeft -= 8;
|
|
}
|
|
}
|
|
|
|
return bytes.ToArray();
|
|
}
|
|
|
|
private static string CleanCode(string code)
|
|
{
|
|
return code.Trim().Replace(" ", string.Empty).Replace("-", string.Empty);
|
|
}
|
|
}
|