Files
gatehouse-api/gatehouse_app/api/v1/auth.py
T
JamesBhattarai a0d4e59c24 Feat(Chore): Verify Flow, Invites, Suspend, Depart Cert Policy
feat: add password reset and email verification flow
feat: add org invite listing, cancellation, and invite link fallback
feat: add user suspend/unsuspend with audit logging
feat: add department certificate policy (expiry, extensions)
feat: enforce dept cert policy on SSH certificate signing
feat: wire up OIDC consent and token flow (replace mocks)
feat: rework CLI auth bridge to use frontend login flow
feat: add admin OAuth provider management (CRUD)
chore: refactor model import paths after module reorganisation
chore: clean up config, decorators, and dev tooling
2026-03-01 20:42:48 +05:45

1548 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Authentication endpoints."""
import json
import logging
from flask import request, session, g, jsonify, current_app
from marshmallow import ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.schemas.auth_schema import (
RegisterSchema,
LoginSchema,
TOTPVerifyEnrollmentSchema,
TOTPVerifySchema,
TOTPDisableSchema,
TOTPRegenerateBackupCodesSchema,
)
from gatehouse_app.schemas.webauthn_schema import (
WebAuthnRegistrationBeginSchema,
WebAuthnRegistrationCompleteSchema,
WebAuthnLoginBeginSchema,
WebAuthnLoginCompleteSchema,
WebAuthnCredentialRenameSchema,
)
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.webauthn_service import WebAuthnService
from gatehouse_app.services.user_service import UserService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.services.notification_service import NotificationService
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.exceptions.validation_exceptions import ConflictError, NotFoundError
@api_v1_bp.route("/auth/register", methods=["POST"])
def register():
"""
Register a new user.
Request body:
email: User email
password: User password
password_confirm: Password confirmation
full_name: Optional full name
Returns:
201: User created successfully
400: Validation error
409: Email already exists
"""
try:
# Validate request data
schema = RegisterSchema()
data = schema.load(request.json)
# Register user
user = AuthService.register_user(
email=data["email"],
password=data["password"],
full_name=data.get("full_name"),
)
# Send verification email
try:
from gatehouse_app.models import EmailVerificationToken
verify_token = EmailVerificationToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
subject = "Verify your Gatehouse email address"
body = (
f"Hi {user.full_name or user.email},\n\n"
f"Welcome to Gatehouse! Please verify your email address by clicking the link below (valid for 24 hours):\n"
f"{verify_link}\n\n"
f"Gatehouse Security Team"
)
NotificationService._send_email(to_address=user.email, subject=subject, body=body)
except Exception as exc:
logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}")
# Create session
user_session = AuthService.create_session(user)
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="Registration successful",
status=201,
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/auth/login", methods=["POST"])
def login():
"""
Login user.
Request body:
email: User email
password: User password
remember_me: Optional boolean for extended session
Returns:
200: Login successful or TOTP code required
400: Validation error
401: Invalid credentials
"""
import logging
logger = logging.getLogger(__name__)
try:
# Validate request data
schema = LoginSchema()
data = schema.load(request.json)
# Authenticate user with email and password
user = AuthService.authenticate(
email=data["email"],
password=data["password"],
)
# Check MFA enrollment status
has_totp = user.has_totp_enabled()
has_webauthn = user.has_webauthn_enabled()
logger.info(f"Login attempt for user {user.email} - TOTP enabled: {has_totp}, WebAuthn enabled: {has_webauthn}")
# MFA Enforcement: Check WebAuthn first (most secure), then TOTP fallback
# Priority: WebAuthn > TOTP > No MFA
if has_webauthn:
# User has WebAuthn enrolled - require WebAuthn verification
# Store user_id in session for WebAuthn verification
# The /auth/webauthn/login/complete endpoint will retrieve this user_id
session["webauthn_pending_user_id"] = user.id
# Return response indicating WebAuthn verification is required
return api_response(
data={
"requires_webauthn": True,
},
message="Passkey verification required. Please use your passkey to complete login.",
)
# Check if user has TOTP enabled for two-factor authentication
if has_totp:
# 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.",
)
# Evaluate MFA policy after primary authentication
remember_me = data.get("remember_me", False)
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me)
# Create session with appropriate duration based on remember_me preference
duration = 2592000 if remember_me else 86400 # 30 days vs 1 day
# Determine if this should be a compliance-only session
is_compliance_only = policy_result.create_compliance_only_session
user_session = AuthService.create_session(
user,
duration_seconds=duration,
is_compliance_only=is_compliance_only
)
# Build response data
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(),
}
# Add MFA compliance information
if policy_result.compliance_summary:
response_data["mfa_compliance"] = {
"overall_status": policy_result.compliance_summary.overall_status,
"missing_methods": policy_result.compliance_summary.missing_methods,
"deadline_at": policy_result.compliance_summary.deadline_at,
"orgs": [
{
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"effective_mode": org.effective_mode,
"deadline_at": org.deadline_at,
"applied_at": org.applied_at,
}
for org in policy_result.compliance_summary.orgs
],
}
# Add requires_mfa_enrollment flag if compliance-only session
if is_compliance_only:
response_data["requires_mfa_enrollment"] = True
return api_response(
data=response_data,
message="Login successful",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/auth/logout", methods=["POST"])
@login_required
def logout():
"""
Logout current user.
Returns:
200: Logout successful
401: Not authenticated
"""
# Revoke current session (g.current_session is set by login_required decorator)
if g.current_session:
AuthService.revoke_session(g.current_session.id, reason="User logout")
return api_response(
message="Logout successful",
)
@api_v1_bp.route("/auth/me", methods=["GET"])
@login_required
def get_current_user():
"""
Get current authenticated user.
Returns:
200: User data
401: Not authenticated
"""
user = g.current_user
return api_response(
data={
"user": user.to_dict(),
"organizations": [
{"id": org.id, "name": org.name, "slug": org.slug}
for org in user.get_organizations()
],
},
message="User retrieved successfully",
)
@api_v1_bp.route("/auth/sessions", methods=["GET"])
@login_required
def get_user_sessions():
"""
Get all active sessions for current user.
Returns:
200: List of active sessions
401: Not authenticated
"""
from gatehouse_app.services.session_service import SessionService
sessions = SessionService.get_user_sessions(g.current_user.id, active_only=True)
return api_response(
data={
"sessions": [session.to_dict() for session in sessions],
"count": len(sessions),
},
message="Sessions retrieved successfully",
)
@api_v1_bp.route("/auth/sessions/<session_id>", methods=["DELETE"])
@login_required
def revoke_session(session_id):
"""
Revoke a specific session.
Args:
session_id: ID of session to revoke
Returns:
200: Session revoked
401: Not authenticated
404: Session not found
"""
from gatehouse_app.models.user.session import Session
# Ensure session belongs to current user
user_session = Session.query.filter_by(
id=session_id, user_id=g.current_user.id, deleted_at=None
).first()
if not user_session:
return api_response(
success=False,
message="Session not found",
status=404,
error_type="NOT_FOUND",
)
AuthService.revoke_session(session_id, reason="Revoked by user")
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
client_timestamp: Optional client UTC timestamp in seconds since epoch
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"],
client_utc_timestamp=data.get("client_timestamp"),
)
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)
client_timestamp: Optional client UTC timestamp in seconds since epoch
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)
# Check totp_pending_user_id first, then fall back to webauthn_pending_user_id
# This allows TOTP to be used as a fallback when WebAuthn was the primary MFA method
user_id = session.get("totp_pending_user_id") or session.get("webauthn_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 gatehouse_app.models.user.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),
client_utc_timestamp=data.get("client_timestamp"),
)
# Evaluate MFA policy after primary authentication
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
# Determine if this should be a compliance-only session
is_compliance_only = policy_result.create_compliance_only_session
# Create session
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
# Clear temporary session - clear both pending user IDs
session.pop("totp_pending_user_id", None)
session.pop("webauthn_pending_user_id", None)
# Build response data
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(),
}
# Add MFA compliance information
if policy_result.compliance_summary:
response_data["mfa_compliance"] = {
"overall_status": policy_result.compliance_summary.overall_status,
"missing_methods": policy_result.compliance_summary.missing_methods,
"deadline_at": policy_result.compliance_summary.deadline_at,
"orgs": [
{
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"effective_mode": org.effective_mode,
"deadline_at": org.deadline_at,
"applied_at": org.applied_at,
}
for org in policy_result.compliance_summary.orgs
],
}
# Add requires_mfa_enrollment flag if compliance-only session
if is_compliance_only:
response_data["requires_mfa_enrollment"] = True
return api_response(
data=response_data,
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,
)
# =============================================================================
# WebAuthn Passkey Endpoints
# =============================================================================
@api_v1_bp.route("/auth/webauthn/register/begin", methods=["POST"])
@login_required
def begin_webauthn_registration():
"""
Begin WebAuthn passkey registration.
Returns:
200: PublicKeyCredentialCreationOptions (raw JSON, no wrapper)
401: Not authenticated
"""
user = g.current_user
# Generate registration challenge
options = WebAuthnService.generate_registration_challenge(user)
# Return unwrapped JSON for WebAuthn
return jsonify(options), 200
@api_v1_bp.route("/auth/webauthn/register/complete", methods=["POST"])
@login_required
def complete_webauthn_registration():
"""
Complete WebAuthn passkey registration.
Request body:
id: Credential ID
rawId: Base64URL-encoded credential ID
type: "public-key"
response: Attestation response data
transports: List of transport types
Returns:
200: Registration successful
400: Validation error
401: Not authenticated
409: Credential already exists
"""
import base64
import logging
logger = logging.getLogger(__name__)
user_email = g.current_user.email
logger.info(f"WebAuthn registration completion started for user: {user_email}")
try:
# Validate request data
schema = WebAuthnRegistrationCompleteSchema()
data = schema.load(request.json)
# Extract challenge from client data
client_data_json_b64 = data.get("response", {}).get("clientDataJSON", "")
if not client_data_json_b64:
logger.error(f"WebAuthn registration failed - missing clientDataJSON for user: {user_email}")
return api_response(
success=False,
message="Missing clientDataJSON in response",
status=400,
error_type="VALIDATION_ERROR",
)
try:
# Add padding if needed
padding = 4 - (len(client_data_json_b64) % 4)
if padding != 4:
client_data_json_b64_padded = client_data_json_b64 + '=' * padding
else:
client_data_json_b64_padded = client_data_json_b64
client_data_json = base64.urlsafe_b64decode(client_data_json_b64_padded)
client_data_dict = json.loads(client_data_json)
except Exception as e:
logger.error(f"WebAuthn registration failed - client data decode error for user {user_email}: {e}")
return api_response(
success=False,
message=f"Failed to decode client data JSON: {str(e)}",
status=400,
error_type="VALIDATION_ERROR",
)
challenge = client_data_dict.get("challenge")
if not challenge:
logger.error(f"WebAuthn registration failed - no challenge in client data for user: {user_email}")
return api_response(
success=False,
message="Invalid challenge in client data",
status=400,
error_type="VALIDATION_ERROR",
)
# Verify registration response
auth_method = WebAuthnService.verify_registration_response(
g.current_user,
data,
challenge
)
logger.info(f"WebAuthn registration completed successfully for user: {user_email}")
return api_response(
data={
"credential": auth_method.to_webauthn_dict(),
},
message="Passkey registered successfully",
status=201,
)
except ValidationError as e:
logger.error(f"WebAuthn registration validation error for user {user_email}: {e.messages}")
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
except InvalidCredentialsError as e:
logger.warning(f"WebAuthn registration failed for user {user_email}: {e.message}")
return api_response(
success=False,
message=e.message,
status=e.status_code,
error_type=e.error_type,
)
except Exception as e:
logger.exception(f"WebAuthn registration unexpected error for user {user_email}: {e}")
return api_response(
success=False,
message="An unexpected error occurred during registration",
status=500,
error_type="INTERNAL_ERROR",
)
@api_v1_bp.route("/auth/webauthn/login/begin", methods=["POST"])
def begin_webauthn_login():
"""
Begin WebAuthn passkey login.
Request body:
email: User email address
Returns:
200: PublicKeyCredentialRequestOptions (raw JSON, no wrapper)
400: Validation error
404: User not found
"""
import logging
logger = logging.getLogger(__name__)
try:
# Validate request data
schema = WebAuthnLoginBeginSchema()
data = schema.load(request.json)
# Find user by email
from gatehouse_app.models.user.user import User
user = User.query.filter_by(
email=data["email"].lower(),
deleted_at=None
).first()
if not user:
logger.warning(f"WebAuthn login begin - user not found: {data['email']}")
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
# Check if user has any WebAuthn credentials
if not user.has_webauthn_enabled():
logger.warning(f"WebAuthn login begin - no credentials for user: {user.email}")
return api_response(
success=False,
message="No passkeys found for this account",
status=404,
error_type="NOT_FOUND",
)
logger.info(f"WebAuthn login challenge generated for user: {user.email}")
# Generate authentication challenge
options = WebAuthnService.generate_authentication_challenge(user)
# Store user_id in Flask session for WebAuthn verification
session["webauthn_pending_user_id"] = user.id
# Return unwrapped JSON for WebAuthn
return jsonify(options), 200
except ValidationError as e:
logger.error(f"WebAuthn login begin validation error: {e.messages}")
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
except Exception as e:
logger.exception(f"WebAuthn login begin unexpected error: {e}")
raise
@api_v1_bp.route("/auth/webauthn/login/complete", methods=["POST"])
def complete_webauthn_login():
"""
Complete WebAuthn passkey login.
Request body:
id: Credential ID
rawId: Base64URL-encoded credential ID
type: "public-key"
response: Assertion response data
Returns:
200: Login successful with session token
400: Validation error
401: Authentication failed
"""
import logging
import base64
logger = logging.getLogger(__name__)
try:
# Get user from Flask session (stored by /begin endpoint)
user_id = session.get("webauthn_pending_user_id")
if not user_id:
logger.error("WebAuthn login complete - no pending verification in session")
return api_response(
success=False,
message="No pending WebAuthn verification. Please initiate login first.",
status=401,
error_type="AUTHENTICATION_ERROR",
)
# Validate request data
schema = WebAuthnLoginCompleteSchema()
data = schema.load(request.json)
# Get user from database
from gatehouse_app.models.user.user import User
user = User.query.get(user_id)
if not user:
logger.error(f"WebAuthn login complete - user not found: {user_id}")
return api_response(
success=False,
message="User not found",
status=401,
error_type="AUTHENTICATION_ERROR",
)
# Extract challenge from client data
client_data = data.get("response", {}).get("clientDataJSON", "")
client_data_json = base64.urlsafe_b64decode(client_data + "==")
client_data_dict = json.loads(client_data_json)
challenge = client_data_dict.get("challenge")
if not challenge:
logger.error(f"WebAuthn login complete - no challenge in client data for user: {user.email}")
return api_response(
success=False,
message="Invalid challenge in client data",
status=400,
error_type="VALIDATION_ERROR",
)
# Verify authentication response
WebAuthnService.verify_authentication_response(
user,
data,
challenge
)
# Evaluate MFA policy after primary authentication
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
# Determine if this should be a compliance-only session
is_compliance_only = policy_result.create_compliance_only_session
# Create session
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
# Clear pending session
session.pop("webauthn_pending_user_id", None)
logger.info(f"WebAuthn login completed successfully for user: {user.email}")
# Build response data
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(),
}
# Add MFA compliance information
if policy_result.compliance_summary:
response_data["mfa_compliance"] = {
"overall_status": policy_result.compliance_summary.overall_status,
"missing_methods": policy_result.compliance_summary.missing_methods,
"deadline_at": policy_result.compliance_summary.deadline_at,
"orgs": [
{
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"effective_mode": org.effective_mode,
"deadline_at": org.deadline_at,
"applied_at": org.applied_at,
}
for org in policy_result.compliance_summary.orgs
],
}
# Add requires_mfa_enrollment flag if compliance-only session
if is_compliance_only:
response_data["requires_mfa_enrollment"] = True
return api_response(
data=response_data,
message="Login successful",
)
except ValidationError as e:
logger.error(f"WebAuthn login complete validation error: {e.messages}")
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
except InvalidCredentialsError as e:
logger.warning(f"WebAuthn login complete authentication failed: {e.message}")
return api_response(
success=False,
message=e.message,
status=e.status_code,
error_type=e.error_type,
)
except Exception as e:
logger.exception(f"WebAuthn login complete unexpected error: {e}")
raise
@api_v1_bp.route("/auth/webauthn/credentials", methods=["GET"])
@login_required
def list_webauthn_credentials():
"""
List all WebAuthn passkey credentials for the current user.
Returns:
200: List of credentials
401: Not authenticated
"""
user = g.current_user
credentials = WebAuthnService.get_user_credentials(user)
return api_response(
data={
"credentials": [cred.to_webauthn_dict() for cred in credentials],
"count": len(credentials),
},
message="Credentials retrieved successfully",
)
@api_v1_bp.route("/auth/webauthn/credentials/<credential_id>", methods=["DELETE"])
@login_required
def delete_webauthn_credential(credential_id):
"""
Delete a WebAuthn passkey credential.
Args:
credential_id: ID of the credential to delete
Returns:
200: Credential deleted successfully
401: Not authenticated
404: Credential not found
"""
user = g.current_user
# Check if this is the last credential
credential_count = user.get_webauthn_credential_count()
if credential_count <= 1:
return api_response(
success=False,
message="Cannot delete the last passkey. Add another passkey first.",
status=400,
error_type="BAD_REQUEST",
)
# Delete the credential
success = WebAuthnService.delete_credential(credential_id, user)
if not success:
return api_response(
success=False,
message="Credential not found",
status=404,
error_type="NOT_FOUND",
)
return api_response(
message="Passkey deleted successfully",
)
@api_v1_bp.route("/auth/webauthn/credentials/<credential_id>", methods=["PATCH"])
@login_required
def rename_webauthn_credential(credential_id):
"""
Rename a WebAuthn passkey credential.
Args:
credential_id: ID of the credential to rename
Request body:
name: New name for the credential
Returns:
200: Credential renamed successfully
400: Validation error
401: Not authenticated
404: Credential not found
"""
try:
# Validate request data
schema = WebAuthnCredentialRenameSchema()
data = schema.load(request.json)
# Rename the credential
success = WebAuthnService.rename_credential(
credential_id,
g.current_user,
data["name"]
)
if not success:
return api_response(
success=False,
message="Credential not found",
status=404,
error_type="NOT_FOUND",
)
# Get updated credential
credential = WebAuthnService.get_credential_by_id(credential_id, g.current_user)
return api_response(
data={
"credential": credential.to_webauthn_dict() if credential else None,
},
message="Passkey renamed successfully",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/auth/webauthn/status", methods=["GET"])
@login_required
def get_webauthn_status():
"""
Get WebAuthn status for the current user.
Returns:
200: WebAuthn status with webauthn_enabled and credential_count
401: Not authenticated
"""
user = g.current_user
return api_response(
data={
"webauthn_enabled": user.has_webauthn_enabled(),
"credential_count": user.get_webauthn_credential_count(),
},
message="WebAuthn status retrieved successfully",
)
_pw_logger = logging.getLogger(__name__)
@api_v1_bp.route("/auth/forgot-password", methods=["POST"])
def forgot_password():
"""Request a password reset email.
Always returns 200 to avoid leaking account existence.
Request body:
email: User email address
Returns:
200: Password reset email sent (or silently no-op if email not found)
"""
from gatehouse_app.models import User, PasswordResetToken
data = request.get_json() or {}
email = (data.get("email") or "").strip().lower()
if not email:
return api_response(
success=False,
message="Email is required",
status=400,
error_type="VALIDATION_ERROR",
)
# Always return 200 — don't leak whether the email exists
user = User.query.filter_by(email=email, deleted_at=None).first()
if user:
try:
reset_token = PasswordResetToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
reset_link = f"{app_url}/reset-password?token={reset_token.token}"
subject = "Reset your Gatehouse password"
body = (
f"Hi {user.full_name or user.email},\n\n"
f"You requested a password reset for your Gatehouse account.\n\n"
f"Click the link below to reset your password (valid for 2 hours):\n"
f"{reset_link}\n\n"
f"If you did not request this, you can safely ignore this email.\n\n"
f"Gatehouse Security Team"
)
NotificationService._send_email(
to_address=user.email,
subject=subject,
body=body,
)
_pw_logger.info(f"Password reset token generated for user {user.id}")
except Exception as exc:
_pw_logger.exception(f"Error generating password reset token: {exc}")
return api_response(
data={},
message="If an account exists for this email, you will receive a password reset link shortly.",
)
@api_v1_bp.route("/auth/reset-password", methods=["POST"])
def reset_password():
"""Reset a user's password using a reset token.
Request body:
token: Password reset token from email
password: New password
password_confirm: Password confirmation
Returns:
200: Password reset successfully
400: Invalid or expired token / validation error
"""
import bcrypt as _bcrypt
from gatehouse_app.extensions import bcrypt
from gatehouse_app.models import PasswordResetToken, AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType
data = request.get_json() or {}
token_value = (data.get("token") or "").strip()
new_password = data.get("password") or ""
password_confirm = data.get("password_confirm") or ""
if not token_value or not new_password:
return api_response(
success=False,
message="Token and new password are required",
status=400,
error_type="VALIDATION_ERROR",
)
if new_password != password_confirm:
return api_response(
success=False,
message="Passwords do not match",
status=400,
error_type="VALIDATION_ERROR",
)
if len(new_password) < 8:
return api_response(
success=False,
message="Password must be at least 8 characters",
status=400,
error_type="VALIDATION_ERROR",
)
reset_token = PasswordResetToken.query.filter_by(token=token_value).first()
if not reset_token or not reset_token.is_valid:
return api_response(
success=False,
message="This password reset link is invalid or has expired.",
status=400,
error_type="INVALID_TOKEN",
)
try:
user = reset_token.user
# Update the password hash on the authentication method
auth_method = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.PASSWORD,
deleted_at=None,
).first()
if auth_method:
auth_method.password_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
from gatehouse_app.extensions import db
db.session.add(auth_method)
reset_token.consume()
_pw_logger.info(f"Password reset for user {user.id}")
return api_response(
data={},
message="Your password has been reset. You can now sign in with your new password.",
)
except Exception as exc:
_pw_logger.exception(f"Error resetting password: {exc}")
return api_response(
success=False,
message="An error occurred while resetting your password.",
status=500,
error_type="INTERNAL_ERROR",
)
@api_v1_bp.route("/auth/verify-email", methods=["POST"])
def verify_email():
"""Verify a user's email address using a verification token.
Request body:
token: Email verification token
Returns:
200: Email verified successfully
400: Invalid or expired token
"""
from gatehouse_app.models import EmailVerificationToken
data = request.get_json() or {}
token_value = (data.get("token") or "").strip()
if not token_value:
return api_response(
success=False,
message="Verification token is required",
status=400,
error_type="VALIDATION_ERROR",
)
verify_token = EmailVerificationToken.query.filter_by(token=token_value).first()
if not verify_token or not verify_token.is_valid:
return api_response(
success=False,
message="This verification link is invalid or has expired.",
status=400,
error_type="INVALID_TOKEN",
)
try:
user = verify_token.user
user.email_verified = True
from gatehouse_app.extensions import db
db.session.add(user)
verify_token.consume()
_pw_logger.info(f"Email verified for user {user.id}")
return api_response(
data={},
message="Your email has been verified. You can now sign in.",
)
except Exception as exc:
_pw_logger.exception(f"Error verifying email: {exc}")
return api_response(
success=False,
message="An error occurred while verifying your email.",
status=500,
error_type="INTERNAL_ERROR",
)
@api_v1_bp.route("/auth/resend-verification", methods=["POST"])
def resend_verification():
"""Resend email verification link.
Always returns 200 to avoid leaking account existence.
Request body:
email: User email address
Returns:
200: Verification email sent (or silently no-op)
"""
from gatehouse_app.models import User, EmailVerificationToken
data = request.get_json() or {}
email = (data.get("email") or "").strip().lower()
if not email:
return api_response(
success=False,
message="Email is required",
status=400,
error_type="VALIDATION_ERROR",
)
user = User.query.filter_by(email=email, deleted_at=None).first()
if user and not user.email_verified:
try:
verify_token = EmailVerificationToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
subject = "Verify your Gatehouse email address"
body = (
f"Hi {user.full_name or user.email},\n\n"
f"Please verify your email address by clicking the link below (valid for 24 hours):\n"
f"{verify_link}\n\n"
f"Gatehouse Security Team"
)
NotificationService._send_email(
to_address=user.email,
subject=subject,
body=body,
)
_pw_logger.info(f"Verification email sent for user {user.id}")
except Exception as exc:
_pw_logger.exception(f"Error sending verification email: {exc}")
return api_response(
data={},
message="If an account exists for this email and is not yet verified, you will receive a verification link shortly.",
)
# =============================================================================
# Account Activation (separate from email-verification)
# =============================================================================
@api_v1_bp.route("/auth/activate", methods=["POST"])
def activate_account():
"""Activate a user account via a one-time activation code.
Request body:
code the activation_key from the welcome email
Returns:
200: Account activated, session token returned
400: Missing code
404: Invalid or already-used code
"""
import secrets
from gatehouse_app.models.user.user import User
from gatehouse_app.extensions import db
data = request.get_json() or {}
code = (data.get("code") or "").strip()
if not code:
return api_response(success=False, message="Activation code is required", status=400, error_type="VALIDATION_ERROR")
user = User.query.filter_by(activation_key=code, deleted_at=None).first()
if not user:
return api_response(success=False, message="Invalid or expired activation code", status=404, error_type="NOT_FOUND")
user.activated = True
user.activation_key = None # one-time use
db.session.add(user)
db.session.commit()
user_session = AuthService.create_session(user)
_pw_logger.info(f"Account activated for user {user.id}")
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="Account activated successfully",
)
@api_v1_bp.route("/auth/resend-activation", methods=["POST"])
def resend_activation():
"""Re-send an account activation email.
Always returns 200 to avoid leaking whether an account exists.
Request body:
email user email address
"""
import secrets
from gatehouse_app.models.user.user import User
from gatehouse_app.extensions import db
data = request.get_json() or {}
email = (data.get("email") or "").strip().lower()
if not email:
return api_response(success=False, message="Email is required", status=400, error_type="VALIDATION_ERROR")
user = User.query.filter_by(email=email, deleted_at=None).first()
if user and not user.activated:
try:
code = secrets.token_urlsafe(32)
user.activation_key = code
db.session.add(user)
db.session.commit()
app_url = current_app.config.get("APP_URL", current_app.config.get("FRONTEND_URL", "http://localhost:8080"))
activate_link = f"{app_url}/activate?code={code}"
subject = "Activate your Gatehouse account"
body = (
f"Hi {user.full_name or user.email},\n\n"
f"Please activate your Gatehouse account by clicking the link below:\n"
f"{activate_link}\n\n"
f"If you did not create an account, you can safely ignore this email.\n\n"
f"Gatehouse Security Team"
)
NotificationService._send_email(to_address=user.email, subject=subject, body=body)
_pw_logger.info(f"Activation email re-sent to {user.id}")
except Exception as exc:
_pw_logger.exception(f"Error re-sending activation email: {exc}")
return api_response(
data={},
message="If an unactivated account exists for this email, you will receive a new activation link shortly.",
)
# =============================================================================
# Token retrieval / redirect (for CLI / external tools)
# =============================================================================
@api_v1_bp.route("/auth/token", methods=["GET"])
@login_required
def get_token():
"""Return the current session token, optionally redirecting to a URL.
Query parameters:
redirect optional URL to redirect to with the token appended as
a query param: ``<redirect>?token=<token>``
Returns:
200: JSON ``{"token": "<token>"}`` (no redirect given)
302: Redirect to ``<redirect>?token=<token>``
"""
from flask import redirect as flask_redirect
token = g.current_session.token
redirect_url = request.args.get("redirect", "").strip()
if redirect_url:
sep = "&" if "?" in redirect_url else "?"
return flask_redirect(f"{redirect_url}{sep}token={token}", code=302)
return api_response(data={"token": token}, message="Token retrieved")