feat(auth): implement TOTP two-factor authentication with enrollment and verification
Adds TOTP (Time-based One-Time Password) two-factor authentication support including: - New TOTP service with secret generation, QR code provisioning, and code verification - New auth endpoints for enrollment, verification, status, and backup code management - New TOTP authentication method type and user methods for TOTP management - Backup codes generation and verification for account recovery - Updated OIDC endpoints with timezone-aware datetime handling and RFC-compliant responses - Added "roles" scope support for OIDC userinfo and ID tokens - New pyotp dependency for TOTP operations - Comprehensive unit tests for TOTP service
This commit is contained in:
+321
-4
@@ -3,11 +3,20 @@ from flask import request, session, g
|
||||
from marshmallow import ValidationError
|
||||
from app.api.v1 import api_v1_bp
|
||||
from app.utils.response import api_response
|
||||
from app.schemas.auth_schema import RegisterSchema, LoginSchema
|
||||
from app.schemas.auth_schema import (
|
||||
RegisterSchema,
|
||||
LoginSchema,
|
||||
TOTPVerifyEnrollmentSchema,
|
||||
TOTPVerifySchema,
|
||||
TOTPDisableSchema,
|
||||
TOTPRegenerateBackupCodesSchema,
|
||||
)
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.user_service import UserService
|
||||
from app.utils.decorators import login_required
|
||||
from app.utils.constants import AuditAction
|
||||
from app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
from app.exceptions.validation_exceptions import ConflictError
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/register", methods=["POST"])
|
||||
@@ -72,7 +81,7 @@ def login():
|
||||
remember_me: Optional boolean for extended session
|
||||
|
||||
Returns:
|
||||
200: Login successful
|
||||
200: Login successful or TOTP code required
|
||||
400: Validation error
|
||||
401: Invalid credentials
|
||||
"""
|
||||
@@ -81,13 +90,29 @@ def login():
|
||||
schema = LoginSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Authenticate user
|
||||
# Authenticate user with email and password
|
||||
user = AuthService.authenticate(
|
||||
email=data["email"],
|
||||
password=data["password"],
|
||||
)
|
||||
|
||||
# Create session
|
||||
# Check if user has TOTP enabled for two-factor authentication
|
||||
if user.has_totp_enabled():
|
||||
# TOTP is enabled - store user_id in session for TOTP verification
|
||||
# The /auth/totp/verify endpoint will retrieve this user_id
|
||||
session["totp_pending_user_id"] = user.id
|
||||
|
||||
# Return response indicating TOTP code is required
|
||||
# Do NOT create session or return token yet - wait for TOTP verification
|
||||
return api_response(
|
||||
data={
|
||||
"requires_totp": True,
|
||||
},
|
||||
message="TOTP code required. Please enter your 6-digit code from your authenticator app.",
|
||||
)
|
||||
|
||||
# TOTP is NOT enabled - proceed with normal login flow
|
||||
# Create session with appropriate duration based on remember_me preference
|
||||
duration = 2592000 if data.get("remember_me") else 86400 # 30 days vs 1 day
|
||||
user_session = AuthService.create_session(user, duration_seconds=duration)
|
||||
|
||||
@@ -210,3 +235,295 @@ def revoke_session(session_id):
|
||||
return api_response(
|
||||
message="Session revoked successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/enroll", methods=["POST"])
|
||||
@login_required
|
||||
def enroll_totp():
|
||||
"""
|
||||
Initiate TOTP enrollment for the current user.
|
||||
|
||||
Returns:
|
||||
201: TOTP enrollment initiated with secret, provisioning_uri, qr_code, and backup_codes
|
||||
401: Not authenticated
|
||||
409: TOTP already enabled
|
||||
"""
|
||||
try:
|
||||
# Initiate TOTP enrollment
|
||||
result = AuthService.enroll_totp(g.current_user)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"secret": result["secret"],
|
||||
"provisioning_uri": result["provisioning_uri"],
|
||||
"qr_code": result["qr_code"],
|
||||
"backup_codes": result["backup_codes"],
|
||||
},
|
||||
message="TOTP enrollment initiated. Please verify with your authenticator app.",
|
||||
status=201,
|
||||
)
|
||||
|
||||
except ConflictError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=e.message,
|
||||
status=e.status_code,
|
||||
error_type=e.error_type,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/verify-enrollment", methods=["POST"])
|
||||
@login_required
|
||||
def verify_totp_enrollment():
|
||||
"""
|
||||
Complete TOTP enrollment by verifying the first TOTP code.
|
||||
|
||||
Request body:
|
||||
code: 6-digit TOTP code from authenticator app
|
||||
|
||||
Returns:
|
||||
200: TOTP enrollment completed successfully
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
401: Invalid TOTP code
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = TOTPVerifyEnrollmentSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Verify TOTP enrollment
|
||||
AuthService.verify_totp_enrollment(g.current_user, data["code"])
|
||||
|
||||
return api_response(
|
||||
message="TOTP enrollment completed successfully",
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation failed",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages,
|
||||
)
|
||||
|
||||
except InvalidCredentialsError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=e.message,
|
||||
status=e.status_code,
|
||||
error_type=e.error_type,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/verify", methods=["POST"])
|
||||
def verify_totp():
|
||||
"""
|
||||
Verify TOTP code during login.
|
||||
|
||||
Request body:
|
||||
code: 6-digit TOTP code or backup code
|
||||
is_backup_code: True if code is a backup code, False if TOTP code (default: False)
|
||||
|
||||
Returns:
|
||||
200: TOTP code verified successfully with session token
|
||||
400: Validation error
|
||||
401: Invalid TOTP code or session not found
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = TOTPVerifySchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Get user from temporary session (stored in Flask session by login endpoint)
|
||||
user_id = session.get("totp_pending_user_id")
|
||||
if not user_id:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="No pending TOTP verification. Please login first.",
|
||||
status=401,
|
||||
error_type="AUTHENTICATION_ERROR",
|
||||
)
|
||||
|
||||
# Get user from database
|
||||
from app.models.user import User
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User not found",
|
||||
status=401,
|
||||
error_type="AUTHENTICATION_ERROR",
|
||||
)
|
||||
|
||||
# Verify TOTP code
|
||||
AuthService.authenticate_with_totp(
|
||||
user, data["code"], data.get("is_backup_code", False)
|
||||
)
|
||||
|
||||
# Create full session
|
||||
user_session = AuthService.create_session(user)
|
||||
|
||||
# Clear temporary session
|
||||
session.pop("totp_pending_user_id", None)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"user": user.to_dict(),
|
||||
"token": user_session.token,
|
||||
"expires_at": user_session.expires_at.isoformat() + "Z"
|
||||
if user_session.expires_at.isoformat()[-1] != "Z"
|
||||
else user_session.expires_at.isoformat(),
|
||||
},
|
||||
message="TOTP verification successful",
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation failed",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages,
|
||||
)
|
||||
|
||||
except InvalidCredentialsError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=e.message,
|
||||
status=e.status_code,
|
||||
error_type=e.error_type,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/disable", methods=["DELETE"])
|
||||
@login_required
|
||||
def disable_totp():
|
||||
"""
|
||||
Disable TOTP for the current user.
|
||||
|
||||
Request body:
|
||||
password: User's current password for verification
|
||||
|
||||
Returns:
|
||||
200: TOTP disabled successfully
|
||||
400: Validation error
|
||||
401: Not authenticated or invalid password
|
||||
401: TOTP not enabled
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = TOTPDisableSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Disable TOTP
|
||||
AuthService.disable_totp(g.current_user, data["password"])
|
||||
|
||||
return api_response(
|
||||
message="TOTP disabled successfully",
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation failed",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages,
|
||||
)
|
||||
|
||||
except InvalidCredentialsError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=e.message,
|
||||
status=e.status_code,
|
||||
error_type=e.error_type,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/status", methods=["GET"])
|
||||
@login_required
|
||||
def get_totp_status():
|
||||
"""
|
||||
Get TOTP status for the current user.
|
||||
|
||||
Returns:
|
||||
200: TOTP status with totp_enabled, verified_at, and backup_codes_remaining
|
||||
401: Not authenticated
|
||||
"""
|
||||
user = g.current_user
|
||||
|
||||
# Check if TOTP is enabled
|
||||
totp_enabled = user.has_totp_enabled()
|
||||
|
||||
# Get TOTP method to check backup codes remaining
|
||||
backup_codes_remaining = 0
|
||||
verified_at = None
|
||||
|
||||
if totp_enabled:
|
||||
totp_method = user.get_totp_method()
|
||||
if totp_method and totp_method.provider_data:
|
||||
backup_codes = totp_method.provider_data.get("backup_codes", [])
|
||||
backup_codes_remaining = len(backup_codes)
|
||||
if totp_method and totp_method.totp_verified_at:
|
||||
verified_at = totp_method.totp_verified_at.isoformat() + "Z" if totp_method.totp_verified_at.isoformat()[-1] != "Z" else totp_method.totp_verified_at.isoformat()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"totp_enabled": totp_enabled,
|
||||
"verified_at": verified_at,
|
||||
"backup_codes_remaining": backup_codes_remaining,
|
||||
},
|
||||
message="TOTP status retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/regenerate-backup-codes", methods=["POST"])
|
||||
@login_required
|
||||
def regenerate_totp_backup_codes():
|
||||
"""
|
||||
Generate new backup codes for TOTP.
|
||||
|
||||
Request body:
|
||||
password: User's current password for verification
|
||||
|
||||
Returns:
|
||||
200: New backup codes generated successfully
|
||||
400: Validation error
|
||||
401: Not authenticated or invalid password
|
||||
401: TOTP not enabled
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = TOTPRegenerateBackupCodesSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Regenerate backup codes
|
||||
backup_codes = AuthService.regenerate_totp_backup_codes(
|
||||
g.current_user, data["password"]
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"backup_codes": backup_codes,
|
||||
},
|
||||
message="Backup codes regenerated successfully",
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation failed",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages,
|
||||
)
|
||||
|
||||
except InvalidCredentialsError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=e.message,
|
||||
status=e.status_code,
|
||||
error_type=e.error_type,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user