M4-T07: add admin_cli disable-totp and reissue-totp escape commands
This commit is contained in:
+141
-4
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user