0.2.38: optional two-factor authentication (authenticator-app TOTP)

Per-user opt-in 2FA: enroll from the Two-factor option next to Change
password (QR + confirm code + 8 one-time recovery codes), login becomes
two-step for enrolled users, disable requires the account password.
Escape hatch for lost phones: reset-2fa CLI + Reset Two-Factor StartOS
action. Second-factor guesses share the login rate limiter; the pending
login window expires after 5 minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-11 22:08:04 +02:00
co-authored by Claude Fable 5
parent 053bfeab23
commit 0822eca887
20 changed files with 846 additions and 41 deletions
+28
View File
@@ -87,6 +87,28 @@ def reset_password(args: argparse.Namespace) -> None:
print(f"Password reset for {user.name} ({user.username}).")
def reset_2fa(args: argparse.Namespace) -> None:
"""Clear a user's two-factor enrollment so they can sign in with password alone."""
run_migrations()
with Session(engine) as session:
user = _find_user(session, args.username, args.email)
if user is None:
who = args.username or args.email
print(f"Error: no user found for '{who}'.", file=sys.stderr)
sys.exit(1)
if not user.totp_enabled and not user.totp_secret:
print(f"{user.name} ({user.username}) does not have two-factor enabled.")
return
user.totp_secret = None
user.totp_enabled = False
user.totp_recovery_codes = None
session.add(user)
session.commit()
print(f"Two-factor disabled for {user.name} ({user.username}). "
"They can sign in with just their password and re-enroll from the app.")
def enable_investor_logins(args: argparse.Namespace) -> None:
"""Give every no-login investor account the shared default password and enable sign-in.
@@ -325,6 +347,10 @@ def main() -> None:
reset.add_argument("--email", required=False, default=None)
reset.add_argument("--password", required=True)
r2fa = sub.add_parser("reset-2fa", help="Clear a user's two-factor enrollment (lost phone)")
r2fa.add_argument("--username", required=False, default=None)
r2fa.add_argument("--email", required=False, default=None)
sub.add_parser("list-users", help="List all user accounts")
sub.add_parser(
@@ -357,6 +383,8 @@ def main() -> None:
create_user(args)
elif args.command == "reset-password":
reset_password(args)
elif args.command == "reset-2fa":
reset_2fa(args)
elif args.command == "list-users":
list_users(args)
elif args.command == "enable-investor-logins":