using MongoDB.Entities; using OtpNet; using PrivaPub.Models.User; using QRCoder; using System.Security.Cryptography; namespace PrivaPub.Services { // Two-factor sign-in by TOTP (completion program R3): the authenticator app's six digits, a step of 30 seconds and one // step of slack either way, each code taken once (RootTotp.LastStep, moved forward only by a conditional update, so two // sign-ins with one code can't both pass); or one of ten recovery codes, each kept as its SHA-256 and pulled when used. public static class TwoFactor { public const string CodeNeeded = "Enter the code from your authenticator app, or a recovery code."; public const string CodeInvalid = "That code is not valid."; public const string Issuer = "PrivaPub"; const string RecoveryAlphabet = "abcdefghjkmnpqrstuvwxyz23456789";//no 0/o, 1/l/i public static string NewSecret() => Base32Encoding.ToString(KeyGeneration.GenerateRandomKey(20)).TrimEnd('='); public static string OtpAuthUri(string secret, string userName) => $"otpauth://totp/{Uri.EscapeDataString($"{Issuer}:{userName}")}?secret={secret}&issuer={Issuer}&algorithm=SHA1&digits=6&period=30"; public static string QrDataUri(string text) { using var generator = new QRCodeGenerator(); using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M); var svg = new SvgQRCode(data).GetGraphic(6); return "data:image/svg+xml;base64," + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(svg)); } public static List NewRecoveryCodes(int count = 10) => Enumerable.Range(0, count).Select(_ => { var chars = new char[10]; for (var i = 0; i < chars.Length; i++) chars[i] = RecoveryAlphabet[RandomNumberGenerator.GetInt32(RecoveryAlphabet.Length)]; return $"{new string(chars, 0, 5)}-{new string(chars, 5, 5)}"; }).ToList(); public static string Normalize(string code) => (code ?? string.Empty).Trim().Replace(" ", "").Replace("-", "").ToLowerInvariant(); public static string HashRecovery(string code) => RecoveryJob.Hash(Normalize(code)); /// The step a six-digit code matches in the secret's window, or null. public static long? Step(string secret, string code) { var digits = Normalize(code); if (digits.Length != 6 || !digits.All(char.IsAsciiDigit) || string.IsNullOrEmpty(secret)) return null; var totp = new Totp(Base32Encoding.ToBytes(secret)); return totp.VerifyTotp(digits, out var step, VerificationWindow.RfcSpecifiedNetworkDelay) ? step : null; } /// Takes a code for a login with two-factor on: a fresh authenticator code, or an unused recovery code. public static async Task Consume(RootUser user, string code, CancellationToken token) { if (user?.Totp is not { EnabledAt: not null } totp) return false; if (Step(totp.Secret, code) is { } step) { if (step <= totp.LastStep) return false;//used already var moved = await DB.Default.Update() .Match(u => u.ID == user.ID && u.Totp.LastStep < step) .Modify(u => u.Totp.LastStep, step) .ExecuteAsync(token); return moved.ModifiedCount == 1; } var hash = HashRecovery(code); if (!totp.RecoveryCodeHashes.Contains(hash)) return false; var pulled = await DB.Default.Update() .Match(u => u.ID == user.ID && u.Totp.RecoveryCodeHashes.Contains(hash)) .Modify(b => b.Pull(u => u.Totp.RecoveryCodeHashes, hash)) .ExecuteAsync(token); return pulled.ModifiedCount == 1; } } }