Feat(Chore, Fix): Refractor, Half Baked Deletion + Admin Privilege
Refractor Codes into sub file/folders Admin can remove users'/members mfa/2fa, unlink account from oauth provider Admin can add/reset password Different Email (OIDC + Manual)-Same Account; (Block Linking and authorize if available)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Auth blueprint subpackage."""
|
||||
from gatehouse_app.api.v1.auth import core, totp, webauthn, password
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Core auth endpoints: register, login, logout, sessions."""
|
||||
import logging
|
||||
from flask import request, session, g, current_app
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.extensions import limiter
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.schemas.auth_schema import RegisterSchema, LoginSchema
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
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
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/register", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_REGISTER"])
|
||||
def register():
|
||||
try:
|
||||
schema = RegisterSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
user = AuthService.register_user(
|
||||
email=data["email"],
|
||||
password=data["password"],
|
||||
full_name=data.get("full_name"),
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
user_session = AuthService.create_session(user)
|
||||
|
||||
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from datetime import datetime, timezone as _tz
|
||||
|
||||
now = datetime.now(_tz.utc)
|
||||
pending_invites = OrgInviteToken.query.filter(
|
||||
OrgInviteToken.email == user.email,
|
||||
OrgInviteToken.accepted_at.is_(None),
|
||||
OrgInviteToken.expires_at > now,
|
||||
OrgInviteToken.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
total_users = _User.query.filter(_User.deleted_at.is_(None)).count()
|
||||
is_first_user = total_users == 1
|
||||
|
||||
expires_str = user_session.expires_at.isoformat()
|
||||
if expires_str[-1] != "Z":
|
||||
expires_str += "Z"
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"user": user.to_dict(),
|
||||
"token": user_session.token,
|
||||
"expires_at": expires_str,
|
||||
"is_first_user": is_first_user,
|
||||
"pending_invites": [
|
||||
{
|
||||
"token": inv.token,
|
||||
"organization": {"id": str(inv.organization_id), "name": inv.organization.name},
|
||||
"role": inv.role,
|
||||
"expires_at": inv.expires_at.isoformat(),
|
||||
}
|
||||
for inv in pending_invites
|
||||
],
|
||||
},
|
||||
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"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_LOGIN"])
|
||||
def login():
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
schema = LoginSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
user = AuthService.authenticate(email=data["email"], password=data["password"])
|
||||
|
||||
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}")
|
||||
|
||||
if has_webauthn:
|
||||
session["webauthn_pending_user_id"] = user.id
|
||||
return api_response(data={"requires_webauthn": True}, message="Passkey verification required. Please use your passkey to complete login.")
|
||||
|
||||
if has_totp:
|
||||
session["totp_pending_user_id"] = user.id
|
||||
return api_response(data={"requires_totp": True}, message="TOTP code required. Please enter your 6-digit code from your authenticator app.")
|
||||
|
||||
remember_me = data.get("remember_me", False)
|
||||
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me)
|
||||
duration = 2592000 if remember_me else 86400
|
||||
is_compliance_only = policy_result.create_compliance_only_session
|
||||
|
||||
user_session = AuthService.create_session(user, duration_seconds=duration, is_compliance_only=is_compliance_only)
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
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
|
||||
],
|
||||
}
|
||||
|
||||
if is_compliance_only:
|
||||
response_data["requires_mfa_enrollment"] = True
|
||||
|
||||
user_orgs = user.get_organizations()
|
||||
if not user_orgs:
|
||||
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
|
||||
from datetime import datetime, timezone as _tz
|
||||
_now = datetime.now(_tz.utc)
|
||||
pending_invites = OrgInviteToken.query.filter(
|
||||
OrgInviteToken.email == user.email,
|
||||
OrgInviteToken.accepted_at.is_(None),
|
||||
OrgInviteToken.expires_at > _now,
|
||||
OrgInviteToken.deleted_at.is_(None),
|
||||
).all()
|
||||
response_data["pending_invites"] = [
|
||||
{
|
||||
"token": inv.token,
|
||||
"organization": {"id": str(inv.organization_id), "name": inv.organization.name},
|
||||
"role": inv.role,
|
||||
"expires_at": inv.expires_at.isoformat(),
|
||||
}
|
||||
for inv in pending_invites
|
||||
]
|
||||
response_data["requires_org_setup"] = 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():
|
||||
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():
|
||||
user = g.current_user
|
||||
return api_response(
|
||||
data={
|
||||
"user": user.to_dict(),
|
||||
"organizations": [
|
||||
{
|
||||
"id": membership.organization.id,
|
||||
"name": membership.organization.name,
|
||||
"slug": membership.organization.slug,
|
||||
"role": membership.role.value if hasattr(membership.role, "value") else str(membership.role),
|
||||
}
|
||||
for membership in user.organization_memberships
|
||||
if membership.deleted_at is None and membership.organization and not membership.organization.deleted_at
|
||||
],
|
||||
},
|
||||
message="User retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/sessions", methods=["GET"])
|
||||
@login_required
|
||||
def get_user_sessions():
|
||||
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": [s.to_dict() for s 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):
|
||||
from gatehouse_app.models.user.session import Session
|
||||
|
||||
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/token", methods=["GET"])
|
||||
@login_required
|
||||
def get_token():
|
||||
from flask import redirect as flask_redirect
|
||||
from urllib.parse import urlparse
|
||||
|
||||
token = g.current_session.token
|
||||
redirect_url = request.args.get("redirect", "").strip()
|
||||
|
||||
if redirect_url:
|
||||
allowed_origins = set(current_app.config.get("CORS_ORIGINS", []))
|
||||
frontend_url = current_app.config.get("FRONTEND_URL", "")
|
||||
if frontend_url:
|
||||
parsed = urlparse(frontend_url)
|
||||
allowed_origins.add(f"{parsed.scheme}://{parsed.netloc}")
|
||||
|
||||
parsed_redirect = urlparse(redirect_url)
|
||||
redirect_origin = f"{parsed_redirect.scheme}://{parsed_redirect.netloc}"
|
||||
|
||||
if redirect_origin not in allowed_origins:
|
||||
return api_response(success=False, message="Redirect URL is not allowed.", status=400, error_type="INVALID_REDIRECT")
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Password reset, email verification, and account activation endpoints."""
|
||||
import logging
|
||||
from flask import request, current_app
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.extensions import limiter
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
from gatehouse_app.services.notification_service import NotificationService
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/forgot-password", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_FORGOT_PASSWORD"])
|
||||
def forgot_password():
|
||||
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")
|
||||
|
||||
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}"
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
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"
|
||||
),
|
||||
)
|
||||
_logger.info(f"Password reset token generated for user {user.id}")
|
||||
except Exception as exc:
|
||||
_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"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_RESET_PASSWORD"])
|
||||
def reset_password():
|
||||
from gatehouse_app.extensions import bcrypt
|
||||
from gatehouse_app.models import PasswordResetToken, AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
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
|
||||
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")
|
||||
db.session.add(auth_method)
|
||||
reset_token.consume()
|
||||
_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:
|
||||
_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():
|
||||
from gatehouse_app.models import EmailVerificationToken
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
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
|
||||
db.session.add(user)
|
||||
verify_token.consume()
|
||||
_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:
|
||||
_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():
|
||||
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}"
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
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"
|
||||
),
|
||||
)
|
||||
_logger.info(f"Verification email sent for user {user.id}")
|
||||
except Exception as exc:
|
||||
_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.")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/activate", methods=["POST"])
|
||||
def activate_account():
|
||||
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
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
user_session = AuthService.create_session(user)
|
||||
_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():
|
||||
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}"
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
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"
|
||||
),
|
||||
)
|
||||
_logger.info(f"Activation email re-sent to {user.id}")
|
||||
except Exception as exc:
|
||||
_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.")
|
||||
@@ -0,0 +1,183 @@
|
||||
"""TOTP authentication endpoints."""
|
||||
from flask import request, session, g, current_app
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.extensions import limiter
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.schemas.auth_schema import (
|
||||
TOTPVerifyEnrollmentSchema,
|
||||
TOTPVerifySchema,
|
||||
TOTPDisableSchema,
|
||||
TOTPRegenerateBackupCodesSchema,
|
||||
)
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
from gatehouse_app.exceptions.validation_exceptions import ConflictError
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/enroll", methods=["POST"])
|
||||
@login_required
|
||||
def enroll_totp():
|
||||
try:
|
||||
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():
|
||||
try:
|
||||
schema = TOTPVerifyEnrollmentSchema()
|
||||
data = schema.load(request.json)
|
||||
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"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_TOTP_VERIFY"])
|
||||
def verify_totp():
|
||||
try:
|
||||
schema = TOTPVerifySchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED):
|
||||
session.pop("totp_pending_user_id", None)
|
||||
session.pop("webauthn_pending_user_id", None)
|
||||
return api_response(success=False, message="Account is suspended. Contact an administrator.", status=403, error_type="ACCOUNT_SUSPENDED")
|
||||
|
||||
AuthService.authenticate_with_totp(user, data["code"], data.get("is_backup_code", False), client_utc_timestamp=data.get("client_timestamp"))
|
||||
|
||||
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
|
||||
is_compliance_only = policy_result.create_compliance_only_session
|
||||
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
|
||||
|
||||
session.pop("totp_pending_user_id", None)
|
||||
session.pop("webauthn_pending_user_id", None)
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
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
|
||||
],
|
||||
}
|
||||
|
||||
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():
|
||||
try:
|
||||
schema = TOTPDisableSchema()
|
||||
data = schema.load(request.json)
|
||||
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():
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from datetime import datetime, timezone
|
||||
|
||||
user = g.current_user
|
||||
|
||||
stale = AuthenticationMethod.query.filter_by(user_id=user.id, method_type=AuthMethodType.TOTP, verified=False, deleted_at=None).all()
|
||||
for s in stale:
|
||||
secret = (s.provider_data or {}).get("secret") if s.provider_data else None
|
||||
if not secret:
|
||||
s.deleted_at = datetime.now(timezone.utc)
|
||||
_db.session.add(s)
|
||||
if stale:
|
||||
_db.session.commit()
|
||||
|
||||
totp_enabled = user.has_totp_enabled()
|
||||
backup_codes_remaining = 0
|
||||
verified_at = None
|
||||
|
||||
if totp_enabled:
|
||||
totp_method = AuthenticationMethod.query.filter_by(
|
||||
user_id=user.id, method_type=AuthMethodType.TOTP, verified=True, deleted_at=None
|
||||
).order_by(AuthenticationMethod.created_at.desc()).first()
|
||||
|
||||
if totp_method and totp_method.provider_data:
|
||||
backup_codes_remaining = len(totp_method.provider_data.get("backup_codes", []))
|
||||
if totp_method and totp_method.totp_verified_at:
|
||||
ts = totp_method.totp_verified_at.isoformat()
|
||||
verified_at = ts if ts.endswith("Z") else ts + "Z"
|
||||
|
||||
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():
|
||||
try:
|
||||
schema = TOTPRegenerateBackupCodesSchema()
|
||||
data = schema.load(request.json)
|
||||
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)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""WebAuthn passkey authentication endpoints."""
|
||||
import json
|
||||
import base64
|
||||
import logging
|
||||
from flask import request, session, g, jsonify
|
||||
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.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.mfa_policy_service import MfaPolicyService
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/webauthn/register/begin", methods=["POST"])
|
||||
@login_required
|
||||
def begin_webauthn_registration():
|
||||
options = WebAuthnService.generate_registration_challenge(g.current_user)
|
||||
return jsonify(options), 200
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/webauthn/register/complete", methods=["POST"])
|
||||
@login_required
|
||||
def complete_webauthn_registration():
|
||||
user_email = g.current_user.email
|
||||
logger.info(f"WebAuthn registration completion started for user: {user_email}")
|
||||
|
||||
try:
|
||||
schema = WebAuthnRegistrationCompleteSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
client_data_json_b64 = data.get("response", {}).get("clientDataJSON", "")
|
||||
if not client_data_json_b64:
|
||||
return api_response(success=False, message="Missing clientDataJSON in response", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
padding = 4 - (len(client_data_json_b64) % 4)
|
||||
padded = client_data_json_b64 + ("=" * padding if padding != 4 else "")
|
||||
client_data_dict = json.loads(base64.urlsafe_b64decode(padded))
|
||||
except Exception as 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:
|
||||
return api_response(success=False, message="Invalid challenge in client data", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
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:
|
||||
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)
|
||||
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():
|
||||
try:
|
||||
schema = WebAuthnLoginBeginSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
from gatehouse_app.models.user.user import User
|
||||
user = User.query.filter_by(email=data["email"].lower(), deleted_at=None).first()
|
||||
if not user:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED):
|
||||
return api_response(success=False, message="Account is suspended. Contact an administrator.", status=403, error_type="ACCOUNT_SUSPENDED")
|
||||
|
||||
if not user.has_webauthn_enabled():
|
||||
return api_response(success=False, message="No passkeys found for this account", status=404, error_type="NOT_FOUND")
|
||||
|
||||
options = WebAuthnService.generate_authentication_challenge(user)
|
||||
session["webauthn_pending_user_id"] = user.id
|
||||
return jsonify(options), 200
|
||||
except ValidationError as e:
|
||||
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():
|
||||
try:
|
||||
user_id = session.get("webauthn_pending_user_id")
|
||||
if not user_id:
|
||||
return api_response(success=False, message="No pending WebAuthn verification. Please initiate login first.", status=401, error_type="AUTHENTICATION_ERROR")
|
||||
|
||||
schema = WebAuthnLoginCompleteSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
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")
|
||||
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED):
|
||||
session.pop("webauthn_pending_user_id", None)
|
||||
return api_response(success=False, message="Account is suspended. Contact an administrator.", status=403, error_type="ACCOUNT_SUSPENDED")
|
||||
|
||||
client_data = data.get("response", {}).get("clientDataJSON", "")
|
||||
client_data_dict = json.loads(base64.urlsafe_b64decode(client_data + "=="))
|
||||
challenge = client_data_dict.get("challenge")
|
||||
|
||||
if not challenge:
|
||||
return api_response(success=False, message="Invalid challenge in client data", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
WebAuthnService.verify_authentication_response(user, data, challenge)
|
||||
|
||||
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
|
||||
is_compliance_only = policy_result.create_compliance_only_session
|
||||
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
|
||||
session.pop("webauthn_pending_user_id", None)
|
||||
|
||||
logger.info(f"WebAuthn login completed successfully for user: {user.email}")
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
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
|
||||
],
|
||||
}
|
||||
|
||||
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)
|
||||
except InvalidCredentialsError as e:
|
||||
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():
|
||||
credentials = WebAuthnService.get_user_credentials(g.current_user)
|
||||
return api_response(data={"credentials": [c.to_webauthn_dict() for c 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):
|
||||
user = g.current_user
|
||||
|
||||
if not WebAuthnService.credential_belongs_to_user(credential_id, user):
|
||||
return api_response(success=False, message="Credential not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if user.get_webauthn_credential_count() <= 1:
|
||||
return api_response(success=False, message="Cannot delete the last passkey. Add another passkey first.", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
if not WebAuthnService.delete_credential(credential_id, user):
|
||||
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):
|
||||
try:
|
||||
schema = WebAuthnCredentialRenameSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
if not WebAuthnService.rename_credential(credential_id, g.current_user, data["name"]):
|
||||
return api_response(success=False, message="Credential not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
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():
|
||||
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",
|
||||
)
|
||||
Reference in New Issue
Block a user