M4-T05: add TOTP service and setup/enable/disable/status API
This commit is contained in:
@@ -14,6 +14,12 @@ from app.schemas.session import (
|
||||
SessionResponse,
|
||||
SessionUser,
|
||||
)
|
||||
from app.schemas.totp import (
|
||||
TotpDisableRequest,
|
||||
TotpEnableRequest,
|
||||
TotpSetupResponse,
|
||||
TotpStatusResponse,
|
||||
)
|
||||
from app.services.auth import (
|
||||
AuthPasswordChangeError,
|
||||
AuthenticatedSession,
|
||||
@@ -23,6 +29,7 @@ from app.services.auth import (
|
||||
revoke_session,
|
||||
)
|
||||
from app.services import login_throttle
|
||||
from app.services import totp as totp_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -179,3 +186,109 @@ def post_change_password(
|
||||
|
||||
logger.info("Password updated for user '%s'", auth.user.username)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TOTP endpoints (M4-T05)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/auth/totp/setup", response_model=TotpSetupResponse)
|
||||
def post_totp_setup(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> TotpSetupResponse:
|
||||
"""
|
||||
Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.
|
||||
|
||||
The secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays
|
||||
False until the user confirms with POST /api/auth/totp/enable).
|
||||
|
||||
Recovery codes are returned here as plaintext exactly once; their Argon2 hashes
|
||||
are persisted immediately so enable only needs to flip the enabled flag.
|
||||
|
||||
Repeating this call replaces any prior pending secret and regenerates codes.
|
||||
|
||||
Requires: session cookie + X-CSRF-Token.
|
||||
"""
|
||||
secret, otpauth_uri, recovery_codes = totp_service.setup(
|
||||
db,
|
||||
user=auth.user,
|
||||
issuer=settings.effective_totp_issuer,
|
||||
)
|
||||
return TotpSetupResponse(
|
||||
secret=secret,
|
||||
otpauth_uri=otpauth_uri,
|
||||
recovery_codes=recovery_codes,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/totp/enable", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def post_totp_enable(
|
||||
body: TotpEnableRequest,
|
||||
db: Session = Depends(get_db),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""
|
||||
Enable TOTP by confirming with the current 6-digit code from the authenticator app.
|
||||
|
||||
Requires a prior call to POST /api/auth/totp/setup (so that a pending secret
|
||||
exists). On success, totp_enabled becomes True.
|
||||
|
||||
Returns 400 if the code is wrong or there is no pending secret.
|
||||
Requires: session cookie + X-CSRF-Token.
|
||||
"""
|
||||
ok = totp_service.enable(db, user=auth.user, code=body.code)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="invalid TOTP code or no pending setup",
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/auth/totp/disable", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def post_totp_disable(
|
||||
body: TotpDisableRequest,
|
||||
db: Session = Depends(get_db),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""
|
||||
Disable TOTP. The caller must provide exactly one of:
|
||||
- ``password``: the user's current login password, OR
|
||||
- ``code``: the current 6-digit TOTP code.
|
||||
|
||||
On success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.
|
||||
Returns 400 if neither credential matches or neither is provided.
|
||||
Requires: session cookie + X-CSRF-Token.
|
||||
"""
|
||||
ok = totp_service.disable(
|
||||
db,
|
||||
user=auth.user,
|
||||
password=body.password,
|
||||
code=body.code,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="invalid credential; provide a valid password or TOTP code",
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/auth/totp", response_model=TotpStatusResponse)
|
||||
def get_totp_status(
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> TotpStatusResponse:
|
||||
"""
|
||||
Return the current TOTP status for the authenticated user.
|
||||
|
||||
Response contains only ``{"enabled": bool}``.
|
||||
Secret and recovery codes are NEVER returned here.
|
||||
Requires: session cookie only (no CSRF — read-only).
|
||||
"""
|
||||
return TotpStatusResponse(enabled=auth.user.totp_enabled)
|
||||
|
||||
Reference in New Issue
Block a user