M4-T07: add admin_cli disable-totp and reissue-totp escape commands

This commit is contained in:
2026-06-21 22:27:07 +02:00
parent 81bd32f613
commit 51da8c5716
3 changed files with 345 additions and 7 deletions
+141 -4
View File
@@ -18,6 +18,22 @@ unlock [--all | --ip <ip> | --username <u>]
--username clear the row for a specific username (scope='user')
Prints the number of rows deleted.
disable-totp <username>
Disable TOTP for the given user without requiring any existing credential
(recovery codes, current TOTP code, or password). Sets totp_enabled=False,
clears totp_secret, and deletes ALL recovery codes for that user.
This is the final escape hatch when all recovery codes are lost.
reissue-totp <username>
Generate a fresh TOTP secret for the given user and print the new
otpauth:// URI so the user can re-enroll in their Authenticator app.
totp_enabled is LEFT UNCHANGED (caller must re-run enable flow if needed).
Existing recovery codes are NOT deleted. Recovery codes are independent
random values with no cryptographic binding to the TOTP secret, so they
remain valid after reissue and continue to serve as backup login credentials.
Silently removing them would unexpectedly strip the user of that backup;
use disable-totp for a full cleanup (secret + recovery codes).
list-admin
Print all users with their auth status columns (id, username,
is_active, force_password_change).
@@ -25,9 +41,9 @@ list-admin
Design contract
---------------
- Direct DB access via ``get_session_local()`` — no HTTP, no network.
- Only touches ``auth_users`` (password_hash column) and
``auth_login_throttle`` (delete rows). Never touches any user-data
table (location, poo, public_ip, app_config, etc.).
- Only touches ``auth_users`` (totp_secret/totp_enabled/password_hash columns)
and ``auth_login_throttle`` / ``auth_recovery_code`` (delete rows).
Never touches any user-data table (location, poo, public_ip, app_config, etc.).
- Non-zero exit code on user-facing errors (missing user, bad args, …).
"""
@@ -44,11 +60,13 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import pyotp
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db import get_session_local
from app.models.auth import AuthUser
from app.models.auth import AuthUser, RecoveryCode
from app.models.auth_throttle import LoginThrottle
from app.services.auth import hash_password, verify_password # noqa: F401 (used below)
@@ -148,6 +166,103 @@ def cmd_unlock(args: argparse.Namespace) -> None:
session.close()
def cmd_disable_totp(args: argparse.Namespace) -> None:
"""Disable TOTP for <username> — no credential required (escape hatch).
Sets totp_enabled=False, clears totp_secret, and deletes ALL recovery
codes for this user. This is the final escape route when all recovery
codes have been lost: whoever holds server CLI access can always turn off
TOTP, restoring plain-password login.
"""
username: str = args.username
session = _get_session()
try:
user = _find_user(session, username)
if user is None:
_die(f"User '{username}' not found.")
assert user.id is not None
# Delete all recovery codes for this user.
deleted = session.execute(
delete(RecoveryCode).where(RecoveryCode.user_id == user.id)
)
rc_count = deleted.rowcount
# Clear TOTP state.
user.totp_enabled = False
user.totp_secret = None
session.commit()
print(f"TOTP disabled for '{username}'. {rc_count} recovery code(s) deleted.")
print("The user can now log in with password only.")
finally:
session.close()
def cmd_reissue_totp(args: argparse.Namespace) -> None:
"""Generate a fresh TOTP secret for <username> and print the otpauth URI.
Design choices:
- ``totp_enabled`` is LEFT UNCHANGED. If the user currently has TOTP
enabled, the new secret takes effect immediately on the next login
(old code invalid, new code accepted) — no web enable step required.
If TOTP was disabled, the secret is updated but login remains
password-only until the web setup/enable flow is completed.
- Existing recovery codes are NOT deleted here. Recovery codes are
independent random values stored as Argon2 hashes in
``auth_recovery_code.code_hash``; login verification compares only those
hashes and has no dependency on ``totp_secret``. Reissuing the secret
therefore leaves all existing recovery codes fully valid — they remain
the user's backup login path. Silently deleting them would
unexpectedly strip that backup; ``disable-totp`` is the explicit full
cleanup path (secret + all recovery codes).
- This command's purpose is to hand the new secret URI to the admin so
they can re-enroll their Authenticator — useful when the device is lost
but the account credentials are still intact.
"""
username: str = args.username
session = _get_session()
try:
user = _find_user(session, username)
if user is None:
_die(f"User '{username}' not found.")
# Generate a new base-32 secret using pyotp (same library as the web TOTP flow).
new_secret = pyotp.random_base32()
# Build the provisioning URI using the same issuer logic as the web service.
settings = get_settings()
issuer = settings.effective_totp_issuer
otpauth_uri = pyotp.TOTP(new_secret).provisioning_uri(
name=user.username,
issuer_name=issuer,
)
user.totp_secret = new_secret
session.commit()
print(f"New TOTP secret issued for '{username}'.")
print(f"Secret : {new_secret}")
print(f"URI : {otpauth_uri}")
print()
if user.totp_enabled:
print(
"NOTE: TOTP is still ENABLED. Scan the URI above in your Authenticator app;"
" the new code is active immediately on next login — the old code is now invalid."
" To fully disable TOTP instead, use 'disable-totp'."
)
else:
print(
"NOTE: TOTP is currently DISABLED for this user — login remains password-only."
" The new secret has been saved, but will only take effect once TOTP is"
" enabled via the web settings page."
)
finally:
session.close()
def cmd_list_admin(args: argparse.Namespace) -> None: # noqa: ARG001
"""List all admin users with their status columns."""
session = _get_session()
@@ -225,6 +340,28 @@ def build_parser() -> argparse.ArgumentParser:
)
ul.set_defaults(func=cmd_unlock)
# -- disable-totp --
dt = subparsers.add_parser(
"disable-totp",
help=(
"Disable TOTP for a user (escape hatch — no credential required). "
"Clears totp_secret, sets totp_enabled=False, deletes all recovery codes."
),
)
dt.add_argument("username", help="Username whose TOTP to disable.")
dt.set_defaults(func=cmd_disable_totp)
# -- reissue-totp --
rt = subparsers.add_parser(
"reissue-totp",
help=(
"Generate a new TOTP secret for a user and print the otpauth:// URI. "
"totp_enabled is left unchanged; re-enroll via the web UI."
),
)
rt.add_argument("username", help="Username for whom to reissue the TOTP secret.")
rt.set_defaults(func=cmd_reissue_totp)
# -- list-admin --
la = subparsers.add_parser(
"list-admin",