diff --git a/CLAUDE.md b/CLAUDE.md index 788fdb1..5ebe2d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ PrivaPub/ ASP.NET Core Web API, net10.0 Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts) Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs - Cli/ AdminCommands (`PrivaPub admin promote|demote|create-root|move-persona|delete-root`, + Cli/ AdminCommands (`PrivaPub admin promote|demote|create-root|move-persona|delete-root|reset-totp`, `media audit`, `backup`, `backups`, `restore`) Backup/ ServerBackup, ServerRestore, ProtectiveMerge, MaintenanceLock, TransferStore Geo/ GeoLocator, GeoUpdater (DB-IP Lite), CdnCatalog, CdnUpdater @@ -185,6 +185,7 @@ echo '' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-ro ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin move-persona # its link moves, its Mastodon tokens end ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin delete-root # as the client deletes a login: its personas go too +ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin reset-totp # a login that lost its authenticator and codes ``` The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through @@ -420,6 +421,13 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. - Sign-up still says a login name is taken: uniqueness has to be told, and sign-up is reachable only while setup waits or with an invitation, rate-limited. - `AppConfigurationService` reads appsettings as they are now; the copy older builds saved in Mongo is not read. + - **Two-factor sign-in** (R3, `Services/TwoFactor.cs`, `RootUser.Totp`): TOTP (30 s, 6 digits, SHA-1, one step of + slack, Otp.NET), set up with the password (`/clientapi/user/totp/setup`: the secret, its `otpauth://` URI and a QR + code as an SVG data URI, QRCoder), turned on by the app's first code (`/totp/enable`: ten recovery codes shown + once, every other session ended) and off with the password and a code (`/totp/disable`). Each code is taken once + (`LastStep`, moved by a conditional update); a recovery code is kept as its SHA-256 and pulled when used. Sign-in + (`/clientapi/user/login`, the invitation login and `/oauth/login`) asks for `OneTimeCode` with a 428, and only after a + correct password. Recovery by email leaves two-factor on; `PrivaPub admin reset-totp ` turns it off. - **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete` with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer diff --git a/PrivaPub.ClientModels/LoginForm.cs b/PrivaPub.ClientModels/LoginForm.cs index e3fbc5e..49f9749 100644 --- a/PrivaPub.ClientModels/LoginForm.cs +++ b/PrivaPub.ClientModels/LoginForm.cs @@ -34,5 +34,10 @@ namespace PrivaPub.ClientModels public bool ThemeIsDarkMode { get; set; } = false; public string InvitationPassword { get; set; } + + // the authenticator app's code, or one of the recovery codes, for a login with two-factor sign-in; asked for with a + // 428 once the password was right + [StringLength(32, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))] + public string OneTimeCode { get; set; } } } \ No newline at end of file diff --git a/PrivaPub.ClientModels/User/AccountForms.cs b/PrivaPub.ClientModels/User/AccountForms.cs index 9beef18..e347a85 100644 --- a/PrivaPub.ClientModels/User/AccountForms.cs +++ b/PrivaPub.ClientModels/User/AccountForms.cs @@ -49,5 +49,46 @@ namespace PrivaPub.ClientModels.User public bool IsEmailValidated { get; set; } = false; public string PendingEmail { get; set; }//waiting for its code public DateTime CreatedAt { get; set; } + public bool TwoFactorEnabled { get; set; } = false; + public int RecoveryCodesLeft { get; set; } = 0; + } + + // a code from the authenticator app (six digits) or a recovery code + public class TotpCodeForm + { + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(32, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)), + Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))] + public string Code { get; set; } + } + + public class TotpDisableForm + { + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + DataType(DataType.Password), + PasswordPropertyText(true), + StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)), + Display(Name = "Password", ResourceType = typeof(FieldsNameResource))] + public string Password { get; set; } + + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(32, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)), + Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))] + public string Code { get; set; } + } + + // what an authenticator app needs: the secret, as text and as the otpauth:// URI its QR code holds + public class ViewTotpSetup + { + public string Secret { get; set; } + public string OtpAuthUri { get; set; } + public string QrDataUri { get; set; }//an SVG image, as a data: URI + } + + // shown once: the recovery codes, and this device's new session (turning two-factor on ends every other one) + public class ViewTotpEnabled + { + public List RecoveryCodes { get; set; } = []; + public JwtUser Session { get; set; } } } diff --git a/PrivaPub/Controllers/ClientToServer/RootUserController.cs b/PrivaPub/Controllers/ClientToServer/RootUserController.cs index 0d7e087..26f271e 100644 --- a/PrivaPub/Controllers/ClientToServer/RootUserController.cs +++ b/PrivaPub/Controllers/ClientToServer/RootUserController.cs @@ -362,6 +362,37 @@ namespace PrivaPub.Controllers.ClientToServer return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); } + [HttpPost, Route("/clientapi/user/totp/setup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] + public async Task TotpSetup(PasswordForm form) + { + if (!ModelState.IsValid) + return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."])); + var result = await UsersService.TotpSetupAsync(User.GetUserId(), form.Password); + return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); + } + + // on with the app's first code; every other session ends, and this device goes on with a new one + [HttpPost, Route("/clientapi/user/totp/enable"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] + public async Task TotpEnable(TotpCodeForm form) + { + if (!ModelState.IsValid) + return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."])); + var result = await UsersService.TotpEnableAsync(User.GetUserId(), form.Code); + if (!result.IsValid) + return StatusCode(result.StatusCode, result); + var (user, userSettings, codes) = ((RootUser, ViewUserSettings, List))result.Data; + return Ok(new ViewTotpEnabled { RecoveryCodes = codes, Session = AuthTokenManager.GenerateToken(user, userSettings) }); + } + + [HttpPost, Route("/clientapi/user/totp/disable"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] + public async Task TotpDisable(TotpDisableForm form) + { + if (!ModelState.IsValid) + return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."])); + var result = await UsersService.TotpDisableAsync(User.GetUserId(), form.Password, form.Code); + return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); + } + [HttpPost, Route("/clientapi/user/email/remove"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] public async Task RemoveEmail(PasswordForm form) { diff --git a/PrivaPub/Infrastructure/Cli/AdminCommands.cs b/PrivaPub/Infrastructure/Cli/AdminCommands.cs index b384cc5..f9edb71 100644 --- a/PrivaPub/Infrastructure/Cli/AdminCommands.cs +++ b/PrivaPub/Infrastructure/Cli/AdminCommands.cs @@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Cli PrivaPub admin create-root [--admin] the password is read from standard input PrivaPub admin move-persona gives a persona to another login; its Mastodon tokens end PrivaPub admin delete-root deletes a login and every persona it still has + PrivaPub admin reset-totp turns a login's two-factor sign-in off PrivaPub admin media audit [--fix] media files against what holds them; --fix (as www-data) trashes what nothing holds and gives today's pictures their rows PrivaPub admin backup [--kind manual|pre-deploy] [--db-only] @@ -52,6 +53,8 @@ namespace PrivaPub.Infrastructure.Cli return await MovePersona(services, persona, root, output); case ["delete-root", var root]: return await DeleteRoot(services, root, output); + case ["reset-totp", var root]: + return await ResetTotp(root, output); case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"): return await AuditMedia(services, flags.Contains("--fix"), output); case ["backup", "verify", var id]: @@ -267,6 +270,22 @@ namespace PrivaPub.Infrastructure.Cli return 0; } + // The way back for a login that lost its authenticator and its recovery codes: its two-factor sign-in goes, its + // password stays. + static async Task ResetTotp(string login, TextWriter output) + { + login = login.ToLowerInvariant(); + var updated = await DB.Default.Update().Match(u => u.UserName == login && u.DeletedAt == null) + .Modify(u => u.Totp, null).ExecuteAsync(); + if (updated.MatchedCount == 0) + { + Console.Error.WriteLine($"no root user '{login}'"); + return 1; + } + output.WriteLine($"{login}: two-factor sign-in is off"); + return 0; + } + // A login deleted from the box as the client deletes its own (RootRemoval): every persona it still has goes with it, // announced deleted, and its sessions end. static async Task DeleteRoot(IServiceProvider services, string login, TextWriter output) diff --git a/PrivaPub/Models/User/RootUser.cs b/PrivaPub/Models/User/RootUser.cs index 2d7b3bc..9f66368 100644 --- a/PrivaPub/Models/User/RootUser.cs +++ b/PrivaPub/Models/User/RootUser.cs @@ -22,6 +22,7 @@ namespace PrivaPub.Models.User public DateTime? CredentialsChangedAt { get; set; }//when RootSessions last ended every session public string SessionStamp { get; set; }//carried by every /clientapi token; a new one ends the tokens issued before public List EndedSessions { get; set; } = new();//sessions signed out one by one, until their tokens expire + public RootTotp Totp { get; set; }//two-factor sign-in, when the login set it up (Services/TwoFactor.cs) /// An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user. public void SetAdmin(bool admin) @@ -35,6 +36,18 @@ namespace PrivaPub.Models.User } } + // TOTP (RFC 6238: 30 s, 6 digits, SHA-1, as every authenticator app reads it). PendingSecret waits for its first code; + // LastStep refuses a code used before; a recovery code is kept as its SHA-256 and works once. + public class RootTotp + { + public string Secret { get; set; }//base32 + public DateTime? EnabledAt { get; set; } + public long LastStep { get; set; } + public List RecoveryCodeHashes { get; set; } = new(); + public string PendingSecret { get; set; } + public DateTime? PendingSince { get; set; } + } + // a session that signed out: its tokens carry Sid, and are refused until Until, when they would have expired anyway public class EndedSession { diff --git a/PrivaPub/PrivaPub.csproj b/PrivaPub/PrivaPub.csproj index 59d71ad..34cc923 100644 --- a/PrivaPub/PrivaPub.csproj +++ b/PrivaPub/PrivaPub.csproj @@ -19,6 +19,8 @@ + + diff --git a/PrivaPub/Services/IRootUsersService.cs b/PrivaPub/Services/IRootUsersService.cs index 3bc3e59..c323d8e 100644 --- a/PrivaPub/Services/IRootUsersService.cs +++ b/PrivaPub/Services/IRootUsersService.cs @@ -37,5 +37,11 @@ namespace PrivaPub.Services Task VerifyEmailAsync(string rootId, string code); Task RemoveEmailAsync(string rootId, string password); + + Task TotpSetupAsync(string rootId, string password); + + Task TotpEnableAsync(string rootId, string code); + + Task TotpDisableAsync(string rootId, string password, string code); } } \ No newline at end of file diff --git a/PrivaPub/Services/RootUsersService.cs b/PrivaPub/Services/RootUsersService.cs index 1aded5f..f06b524 100644 --- a/PrivaPub/Services/RootUsersService.cs +++ b/PrivaPub/Services/RootUsersService.cs @@ -113,6 +113,14 @@ namespace PrivaPub.Services return result.Invalidate(Localizer[NoMatch]); if (user.IsBanned) return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]); + // only a correct password learns that two-factor is on: it is asked for with a 428 + if (user.Totp?.EnabledAt != default) + { + if (string.IsNullOrWhiteSpace(loginForm.OneTimeCode)) + return result.Invalidate(Localizer[TwoFactor.CodeNeeded], StatusCodes.Status428PreconditionRequired); + if (!await TwoFactor.Consume(user, loginForm.OneTimeCode, CancellationToken.None)) + return result.Invalidate(Localizer[TwoFactor.CodeInvalid]); + } // hashed again at today's iterations; matched on the old hash, so two sign-ins at once never undo each other if (needsUpgrade) @@ -421,7 +429,9 @@ namespace PrivaPub.Services Email = user.Email, IsEmailValidated = user.IsEmailValidated, PendingEmail = pending?.Email, - CreatedAt = user.CreatedAt + CreatedAt = user.CreatedAt, + TwoFactorEnabled = user.Totp?.EnabledAt != default, + RecoveryCodesLeft = user.Totp?.EnabledAt != default ? user.Totp.RecoveryCodeHashes.Count : 0 }; return result; } @@ -500,6 +510,92 @@ namespace PrivaPub.Services } } + // Two-factor sign-in (TwoFactor): set up with the password, turned on by the app's first code (which also proves the + // app holds the secret), turned off with the password and a code. Turning it on ends every other session. + public async Task TotpSetupAsync(string rootId, string password) + { + var result = new WebResult(); + try + { + var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(); + var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty); + if (user?.HashedPassword == default || !verified) + return result.Invalidate(Localizer["Wrong password."], StatusCodes.Status403Forbidden); + if (user.Totp?.EnabledAt != default) + return result.Invalidate(Localizer["Two-factor sign-in is on already: turn it off first."], StatusCodes.Status409Conflict); + + var secret = TwoFactor.NewSecret(); + await DB.Default.Update().MatchID(rootId) + .Modify(u => u.Totp, new RootTotp { PendingSecret = secret, PendingSince = DateTime.UtcNow }) + .ExecuteAsync(); + var uri = TwoFactor.OtpAuthUri(secret, user.UserName); + result.Data = new ViewTotpSetup { Secret = secret, OtpAuthUri = uri, QrDataUri = TwoFactor.QrDataUri(uri) }; + return result; + } + catch (Exception ex) + { + Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpSetupAsync)}()"); + return result.Invalidate(Localizer["Something went wrong."], exception: ex); + } + } + + public async Task TotpEnableAsync(string rootId, string code) + { + var result = new WebResult(); + try + { + var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(); + if (user?.Totp is not { EnabledAt: null, PendingSecret: not null } pending || pending.PendingSince < DateTime.UtcNow.AddHours(-1)) + return result.Invalidate(Localizer["Set up two-factor sign-in again: that setup expired."], StatusCodes.Status409Conflict); + if (TwoFactor.Step(pending.PendingSecret, code) is not { } step) + return result.Invalidate(Localizer[TwoFactor.CodeInvalid]); + + var codes = TwoFactor.NewRecoveryCodes(); + await DB.Default.Update().MatchID(rootId) + .Modify(u => u.Totp, new RootTotp + { + Secret = pending.PendingSecret, + EnabledAt = DateTime.UtcNow, + LastStep = step, + RecoveryCodeHashes = codes.Select(TwoFactor.HashRecovery).ToList() + }) + .ExecuteAsync(); + await Sessions.Revoke(rootId, CancellationToken.None); + user = await DbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync(); + result.Data = (user, ToViewSettings(user.Settings), codes); + return result; + } + catch (Exception ex) + { + Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpEnableAsync)}()"); + return result.Invalidate(Localizer["Something went wrong."], exception: ex); + } + } + + public async Task TotpDisableAsync(string rootId, string password, string code) + { + var result = new WebResult(); + try + { + var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(); + var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty); + if (user?.HashedPassword == default || !verified) + return result.Invalidate(Localizer["Wrong password."], StatusCodes.Status403Forbidden); + if (user.Totp?.EnabledAt == default) + return await AccountAsync(rootId); + if (!await TwoFactor.Consume(user, code, CancellationToken.None)) + return result.Invalidate(Localizer[TwoFactor.CodeInvalid]); + + await DB.Default.Update().MatchID(rootId).Modify(u => u.Totp, null).ExecuteAsync(); + return await AccountAsync(rootId); + } + catch (Exception ex) + { + Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpDisableAsync)}()"); + return result.Invalidate(Localizer["Something went wrong."], exception: ex); + } + } + public async Task RemoveEmailAsync(string rootId, string password) { var result = new WebResult(); diff --git a/PrivaPub/Services/TwoFactor.cs b/PrivaPub/Services/TwoFactor.cs new file mode 100644 index 0000000..b60c7fa --- /dev/null +++ b/PrivaPub/Services/TwoFactor.cs @@ -0,0 +1,84 @@ +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; + } + } +} diff --git a/PrivaPub/Web/Pages/OAuth/Login.cshtml b/PrivaPub/Web/Pages/OAuth/Login.cshtml index 49cfb6f..149bdf4 100644 --- a/PrivaPub/Web/Pages/OAuth/Login.cshtml +++ b/PrivaPub/Web/Pages/OAuth/Login.cshtml @@ -10,6 +10,7 @@ application never sees the login.

+

@if (Model.Error != default) {

@Model.Error

diff --git a/PrivaPub/Web/Pages/OAuth/OAuthPages.cs b/PrivaPub/Web/Pages/OAuth/OAuthPages.cs index 94ccd91..b28e33d 100644 --- a/PrivaPub/Web/Pages/OAuth/OAuthPages.cs +++ b/PrivaPub/Web/Pages/OAuth/OAuthPages.cs @@ -40,7 +40,7 @@ namespace PrivaPub.Web.Pages.OAuth public void OnGet() => Harden(); - public async Task OnPostAsync([FromForm] string userName, [FromForm] string password) + public async Task OnPostAsync([FromForm] string userName, [FromForm] string password, [FromForm] string oneTimeCode) { Harden(); if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password)) @@ -48,10 +48,15 @@ namespace PrivaPub.Web.Pages.OAuth Error = "Enter your username and password."; return Page(); } - var result = await _users.LoginAsync(new LoginForm { UserName = userName, Password = password }); + var result = await _users.LoginAsync(new LoginForm { UserName = userName, Password = password, OneTimeCode = oneTimeCode }); + if (result.StatusCode == StatusCodes.Status428PreconditionRequired) + { + Error = "Enter the code from your authenticator app, or a recovery code, with your password."; + return Page(); + } if (!result.IsValid) { - Error = "That username and password do not match."; + Error = string.IsNullOrEmpty(oneTimeCode) ? "That username and password do not match." : "That username, password and code do not match."; return Page(); } var (root, _) = ((RootUser, ClientModels.User.ViewUserSettings))result.Data; diff --git a/tools/pasture/scenarios/accounts.sh b/tools/pasture/scenarios/accounts.sh index 365bc51..d43f996 100644 --- a/tools/pasture/scenarios/accounts.sh +++ b/tools/pasture/scenarios/accounts.sh @@ -96,3 +96,59 @@ iterations=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db [ "$iterations" != 10101 ] && ok "it was made again at $iterations iterations" || ko "the old hash was kept" ttl=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.EmailRecovery.getIndexes().some(i => i.expireAfterSeconds === 0) && db.EmailVerification.getIndexes().some(i => i.expireAfterSeconds === 0))") [ "$ttl" = true ] && ok "recovery and verification codes expire by TTL" || ko "no TTL on the codes" + +echo " two-factor sign-in" +# totp [step offset]: the authenticator's code now (or a step later) +totp() { python3 -c " +import base64,hmac,hashlib,struct,time,sys +s=sys.argv[1]; s+='='*((8-len(s)%8)%8) +k=base64.b32decode(s.upper()) +t=int(time.time())//30+int(sys.argv[2]) +h=hmac.new(k,struct.pack('>Q',t),hashlib.sha1).digest() +o=h[-1]&15 +print('%06d'%((struct.unpack('>I',h[o:o+4])[0]&0x7fffffff)%1000000))" "$1" "${2:-0}"; } +login2() { curl -s -o /dev/null -w '%{http_code}' -X POST "$P/clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\"$1\",\"password\":\"$2\",\"oneTimeCode\":\"$3\"}"; } +T=$(login "$user" "$pw") +[ "$(code_of "$T" POST /clientapi/user/totp/setup -d '{"password":"wrong-Pass-9"}')" = 403 ] && ok "setting it up takes the password" || ko "a wrong password set it up" +setup=$(api "$T" POST /clientapi/user/totp/setup -d "{\"password\":\"$pw\"}") +secret=$(echo "$setup" | j "print(d['secret'])") +[ -n "$secret" ] && echo "$setup" | j "print(d['otpAuthUri'].startswith('otpauth://totp/') and d['qrDataUri'].startswith('data:image/svg+xml;base64,'))" | grep -q True \ + && ok "the setup gives the secret, its URI and a QR code" || ko "the setup is incomplete" +[ "$(code_of "$T" POST /clientapi/user/totp/enable -d '{"code":"000000"}')" = 400 ] && ok "a wrong first code is refused" || ko "a wrong first code turned it on" +enabled=$(api "$T" POST /clientapi/user/totp/enable -d "{\"code\":\"$(totp "$secret")\"}") +recovery=$(echo "$enabled" | j "print(d['recoveryCodes'][0])"); spare=$(echo "$enabled" | j "print(d['recoveryCodes'][1])") +T2=$(echo "$enabled" | j "print(d['session']['token'])") +[ "$(echo "$enabled" | j "print(len(d['recoveryCodes']))")" = 10 ] && ok "turning it on shows ten recovery codes" || ko "no recovery codes" +[ "$(code_of "$T" GET /clientapi/user/account)" = 401 ] && [ "$(code_of "$T2" GET /clientapi/user/account)" = 200 ] \ + && ok "turning it on ends the other sessions and this device goes on" || ko "turning it on left the sessions as they were" +[ "$(login2 "$user" "$pw" "")" = 428 ] && ok "a correct password alone is asked for a code (428)" || ko "a password alone still signs in" +[ "$(login2 "$user" "wrong-Pass-9" "")" = 400 ] && ok "a wrong password is not told about two-factor" || ko "a wrong password learns of two-factor" +[ "$(login2 "$user" "$pw" "$(totp "$secret")")" = 400 ] && ok "the code that turned it on can't be used again" || ko "a used code signed in" +next=$(totp "$secret" 1) +[ "$(login2 "$user" "$pw" "$next")" = 200 ] && ok "the next code signs in" || ko "the next code was refused" +[ "$(login2 "$user" "$pw" "$next")" = 400 ] && ok "a code is taken once" || ko "a code signed in twice" +[ "$(login2 "$user" "$pw" "$recovery")" = 200 ] && ok "a recovery code signs in" || ko "a recovery code was refused" +[ "$(login2 "$user" "$pw" "$recovery")" = 400 ] && ok "a recovery code works once" || ko "a recovery code worked twice" +[ "$(api "$T2" GET /clientapi/user/account | j "print(d['twoFactorEnabled'] and d['recoveryCodesLeft'])")" = 9 ] \ + && ok "nine recovery codes are left" || ko "the recovery codes left are miscounted" +# oauth_login [code]: the OAuth sign-in page's answer (with its antiforgery token, as tools/smoke/oauth.sh) +oauth_login() { + local jar="$work/oauth-jar" xt + rm -f "$jar" + xt=$(curl -s -c "$jar" -b "$jar" "$P/oauth/login" | grep -o 'name="__RequestVerificationToken" type="hidden" value="[^"]*"' | sed 's/.*value="//;s/"//') + curl -s -o "$work/oauth-page" -w '%{http_code}' -c "$jar" -b "$jar" -X POST "$P/oauth/login" --data-urlencode "__RequestVerificationToken=$xt" \ + --data-urlencode "userName=$1" --data-urlencode "password=$2" --data-urlencode "oneTimeCode=${3:-}" +} +oauth=$(oauth_login "$user" "$pw") +[ "$oauth" = 200 ] && grep -q 'authenticator app' "$work/oauth-page" && ok "the OAuth sign-in page asks for the code" || ko "the OAuth sign-in page answered $oauth without a code" +oauth=$(oauth_login "$user" "$pw" "$spare") +[ "$oauth" = 302 ] && ok "the OAuth sign-in page takes a code" || ko "the OAuth sign-in page answered $oauth with a code" +[ "$(code_of "$T2" POST /clientapi/user/totp/disable -d "{\"password\":\"$pw\",\"code\":\"000000\"}")" = 400 ] \ + && ok "turning it off takes a code" || ko "a wrong code turned it off" +off=$(api "$T2" POST /clientapi/user/totp/disable -d "{\"password\":\"$pw\",\"code\":\"$(echo "$enabled" | j "print(d['recoveryCodes'][2])")\"}") +[ "$(echo "$off" | j "print(d['twoFactorEnabled'])")" = False ] && [ "$(login2 "$user" "$pw" "")" = 200 ] \ + && ok "turned off, the password alone signs in again" || ko "two-factor could not be turned off" +setup=$(api "$T2" POST /clientapi/user/totp/setup -d "{\"password\":\"$pw\"}"); secret=$(echo "$setup" | j "print(d['secret'])") +T3=$(api "$T2" POST /clientapi/user/totp/enable -d "{\"code\":\"$(totp "$secret")\"}" | j "print(d['session']['token'])") +podman exec -w /app pasture-privapub /app/PrivaPub admin reset-totp "$user" >/dev/null 2>&1 +[ "$(login2 "$user" "$pw" "")" = 200 ] && ok "admin reset-totp turns it off from the box" || ko "admin reset-totp did not turn it off"