84 lines
3.5 KiB
C#
84 lines
3.5 KiB
C#
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<string> 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));
|
||
|
|
|
||
|
|
/// <summary>The step a six-digit code matches in the secret's window, or null.</summary>
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Takes a code for a login with two-factor on: a fresh authenticator code, or an unused recovery code.</summary>
|
||
|
|
public static async Task<bool> 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<RootUser>()
|
||
|
|
.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<RootUser>()
|
||
|
|
.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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|