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
+1 -1
View File
@@ -224,7 +224,7 @@ Phase BTOTP,可选配)
- **Reviewer checklist**: `totp_required` 时确未发 cookie;恢复码消费后不可复用;通用错误不泄露细节。
### M4-T07 — CLI disable-totp / reissue-totp
- **Status**: `todo` · **Depends**: M4-T04(逻辑上配合 T03 的 CLI 骨架)
- **Status**: `done` · **Depends**: M4-T04(逻辑上配合 T03 的 CLI 骨架)
- **Files**: `modify scripts/admin_cli.py``modify tests/test_admin_cli.py`
- **Steps**: `disable-totp <username>``totp_enabled=false` + 清 secret + 删恢复码(**不需任何恢复码即可执行**);`reissue-totp <username>`:生成新 secret(可选打印新 URI)。
- **Out of scope / 不要碰**: 不碰用户数据表。
+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",
+203 -2
View File
@@ -1,8 +1,10 @@
"""Tests for M4-T03: scripts/admin_cli.py — CLI escape hatch.
"""Tests for M4-T03 + M4-T07: scripts/admin_cli.py — CLI escape hatch.
Covers:
- reset-password: new password verifies, old password fails.
- unlock: clears specified/all throttle rows; prints count; idempotent.
- disable-totp: clears TOTP state + recovery codes without requiring any credential.
- reissue-totp: writes new secret; prints otpauth URI; secret changes.
- list-admin: prints user table without crashing.
- Non-existent user → friendly error + SystemExit with non-zero code.
- Data-safety: CLI must never reference user-data tables in delete/drop.
@@ -26,13 +28,15 @@ from sqlalchemy.orm import Session, sessionmaker
from app.db import reset_db_caches
from app.config import get_settings
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
from scripts.admin_cli import (
build_parser,
cmd_disable_totp,
cmd_list_admin,
cmd_reset_password,
cmd_reissue_totp,
cmd_unlock,
)
@@ -129,6 +133,25 @@ def _throttle_count(session: Session) -> int:
return session.query(LoginThrottle).count()
def _enable_totp_for_user(session: Session, username: str, secret: str = "JBSWY3DPEHPK3PXP") -> None:
"""Helper: set TOTP enabled + secret for a user and add some recovery codes."""
user = session.scalar(select(AuthUser).where(AuthUser.username == username))
assert user is not None, f"User '{username}' not found in _enable_totp_for_user"
user.totp_secret = secret
user.totp_enabled = True
# Add two fake recovery codes so we can verify they get deleted.
session.add(RecoveryCode(user_id=user.id, code_hash=hash_password("aaaa-bbbb")))
session.add(RecoveryCode(user_id=user.id, code_hash=hash_password("cccc-dddd")))
session.commit()
session.expire_all()
def _recovery_code_count(session: Session, username: str) -> int:
user = session.scalar(select(AuthUser).where(AuthUser.username == username))
assert user is not None
return session.query(RecoveryCode).filter(RecoveryCode.user_id == user.id).count()
# ---------------------------------------------------------------------------
# reset-password tests
# ---------------------------------------------------------------------------
@@ -342,6 +365,182 @@ class TestListAdmin:
reset_db_caches()
# ---------------------------------------------------------------------------
# disable-totp tests
# ---------------------------------------------------------------------------
class TestDisableTotp:
"""disable-totp sub-command (M4-T07)."""
def test_disable_totp_clears_state(self, cli_db: str, cli_session: Session):
"""After disable-totp: totp_enabled=False, totp_secret=None, recovery codes gone."""
_enable_totp_for_user(cli_session, "admin")
# Verify preconditions.
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user is not None
assert user.totp_enabled is True
assert user.totp_secret is not None
assert _recovery_code_count(cli_session, "admin") == 2
# Run disable-totp — NO credential supplied (escape hatch semantics).
parser = build_parser()
args = parser.parse_args(["disable-totp", "admin"])
cmd_disable_totp(args)
# Verify state.
cli_session.expire_all()
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user is not None
assert user.totp_enabled is False
assert user.totp_secret is None
assert _recovery_code_count(cli_session, "admin") == 0
def test_disable_totp_no_credential_required(self, cli_db: str, cli_session: Session):
"""disable-totp must succeed without any recovery code or password argument."""
_enable_totp_for_user(cli_session, "admin")
# The argparse spec for disable-totp takes only username — no credential flags.
# Parsing must succeed with just the username.
parser = build_parser()
args = parser.parse_args(["disable-totp", "admin"])
# No exception means no credential was needed.
cmd_disable_totp(args) # must not raise
def test_disable_totp_already_disabled_is_noop(self, cli_db: str, cli_session: Session):
"""disable-totp on a user who never had TOTP is a harmless no-op."""
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user is not None
assert user.totp_enabled is False
parser = build_parser()
args = parser.parse_args(["disable-totp", "admin"])
cmd_disable_totp(args) # must not raise
cli_session.expire_all()
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user is not None
assert user.totp_enabled is False
assert user.totp_secret is None
def test_disable_totp_nonexistent_user_exits_nonzero(self, cli_db: str):
"""disable-totp for an unknown user must SystemExit with code != 0."""
parser = build_parser()
args = parser.parse_args(["disable-totp", "ghost-user"])
with pytest.raises(SystemExit) as exc_info:
cmd_disable_totp(args)
assert exc_info.value.code != 0
def test_disable_totp_prints_confirmation(
self, cli_db: str, cli_session: Session, capsys
):
"""disable-totp must print a confirmation message mentioning the user."""
_enable_totp_for_user(cli_session, "admin")
parser = build_parser()
args = parser.parse_args(["disable-totp", "admin"])
cmd_disable_totp(args)
captured = capsys.readouterr()
assert "admin" in captured.out
def test_disable_totp_does_not_touch_password(
self, cli_db: str, cli_session: Session
):
"""disable-totp must not alter the user's password_hash."""
_enable_totp_for_user(cli_session, "admin")
user_before = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user_before is not None
pw_hash_before = user_before.password_hash
parser = build_parser()
args = parser.parse_args(["disable-totp", "admin"])
cmd_disable_totp(args)
cli_session.expire_all()
user_after = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user_after is not None
assert user_after.password_hash == pw_hash_before
# ---------------------------------------------------------------------------
# reissue-totp tests
# ---------------------------------------------------------------------------
class TestReissueTotp:
"""reissue-totp sub-command (M4-T07)."""
def test_reissue_totp_changes_secret(self, cli_db: str, cli_session: Session):
"""After reissue-totp, totp_secret must be a new non-empty value."""
old_secret = "JBSWY3DPEHPK3PXP"
_enable_totp_for_user(cli_session, "admin", secret=old_secret)
parser = build_parser()
args = parser.parse_args(["reissue-totp", "admin"])
cmd_reissue_totp(args)
cli_session.expire_all()
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user is not None
assert user.totp_secret is not None
assert user.totp_secret != old_secret, "Secret must be regenerated"
def test_reissue_totp_prints_uri(self, cli_db: str, cli_session: Session, capsys):
"""reissue-totp must print an otpauth:// URI and the secret."""
_enable_totp_for_user(cli_session, "admin")
parser = build_parser()
args = parser.parse_args(["reissue-totp", "admin"])
cmd_reissue_totp(args)
captured = capsys.readouterr()
assert "otpauth://" in captured.out
assert "Secret" in captured.out
def test_reissue_totp_totp_enabled_unchanged(self, cli_db: str, cli_session: Session):
"""reissue-totp must leave totp_enabled in its current state (True stays True)."""
_enable_totp_for_user(cli_session, "admin")
user_before = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user_before is not None
assert user_before.totp_enabled is True
parser = build_parser()
args = parser.parse_args(["reissue-totp", "admin"])
cmd_reissue_totp(args)
cli_session.expire_all()
user_after = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user_after is not None
assert user_after.totp_enabled is True, "totp_enabled must remain unchanged"
def test_reissue_totp_disabled_user_stays_disabled(
self, cli_db: str, cli_session: Session
):
"""reissue-totp on a user with totp_enabled=False leaves it False."""
# Don't enable TOTP — user is in disabled state.
parser = build_parser()
args = parser.parse_args(["reissue-totp", "admin"])
cmd_reissue_totp(args)
cli_session.expire_all()
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user is not None
assert user.totp_enabled is False, "totp_enabled must remain False"
assert user.totp_secret is not None, "A new secret should have been written"
def test_reissue_totp_nonexistent_user_exits_nonzero(self, cli_db: str):
"""reissue-totp for an unknown user must SystemExit with code != 0."""
parser = build_parser()
args = parser.parse_args(["reissue-totp", "ghost-user"])
with pytest.raises(SystemExit) as exc_info:
cmd_reissue_totp(args)
assert exc_info.value.code != 0
# ---------------------------------------------------------------------------
# Argument-parser edge-cases
# ---------------------------------------------------------------------------
@@ -387,4 +586,6 @@ def test_help_flag_exits_zero():
assert result.returncode == 0
assert "reset-password" in result.stdout
assert "unlock" in result.stdout
assert "disable-totp" in result.stdout
assert "reissue-totp" in result.stdout
assert "list-admin" in result.stdout