move app to gatehouse-app
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
"""API package."""
|
||||
from flask import Blueprint
|
||||
from gatehouse_app.utils.response import api_response
|
||||
|
||||
# Create main API blueprint
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
|
||||
@api_bp.route("/health", methods=["GET"])
|
||||
def health_check():
|
||||
"""Health check endpoint."""
|
||||
return api_response(
|
||||
data={"status": "healthy", "service": "authy2-backend"},
|
||||
message="Service is running",
|
||||
)
|
||||
|
||||
|
||||
def register_api_blueprints(app):
|
||||
"""Register all API blueprints."""
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
|
||||
# Register versioned API blueprints
|
||||
app.register_blueprint(api_bp, url_prefix="/api")
|
||||
app.register_blueprint(api_v1_bp, url_prefix="/api/v1")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
"""API v1 blueprint."""
|
||||
from flask import Blueprint
|
||||
|
||||
# Create v1 API blueprint
|
||||
api_v1_bp = Blueprint("api_v1", __name__)
|
||||
|
||||
# Import route modules to register them
|
||||
from gatehouse_app.api.v1 import auth, users, organizations
|
||||
@@ -0,0 +1,937 @@
|
||||
"""Authentication endpoints."""
|
||||
import json
|
||||
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.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.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"),
|
||||
)
|
||||
|
||||
# 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
|
||||
"""
|
||||
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 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)
|
||||
|
||||
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="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.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
|
||||
|
||||
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 gatehouse_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,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = WebAuthnRegistrationCompleteSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Extract challenge from client data
|
||||
client_data = data.get("response", {}).get("clientDataJSON", "")
|
||||
import base64
|
||||
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:
|
||||
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
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = WebAuthnLoginBeginSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Find user by email
|
||||
from gatehouse_app.models.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",
|
||||
)
|
||||
|
||||
# Check if user has any WebAuthn credentials
|
||||
if not user.has_webauthn_enabled():
|
||||
return api_response(
|
||||
success=False,
|
||||
message="No passkeys found for this account",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Generate authentication challenge
|
||||
options = WebAuthnService.generate_authentication_challenge(user)
|
||||
|
||||
# Store user_id in session for verification
|
||||
session["webauthn_pending_user_id"] = user.id
|
||||
|
||||
# Return unwrapped JSON for WebAuthn
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
"""
|
||||
try:
|
||||
# Get user from session
|
||||
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",
|
||||
)
|
||||
|
||||
# Validate request data
|
||||
schema = WebAuthnLoginCompleteSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Get user from database
|
||||
from gatehouse_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",
|
||||
)
|
||||
|
||||
# Extract challenge from client data
|
||||
client_data = data.get("response", {}).get("clientDataJSON", "")
|
||||
import base64
|
||||
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:
|
||||
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
|
||||
)
|
||||
|
||||
# Create session
|
||||
user_session = AuthService.create_session(user)
|
||||
|
||||
# Clear pending session
|
||||
session.pop("webauthn_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="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,
|
||||
)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Organization endpoints."""
|
||||
from flask import g, request
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, require_admin, require_owner
|
||||
from gatehouse_app.schemas.organization_schema import (
|
||||
OrganizationCreateSchema,
|
||||
OrganizationUpdateSchema,
|
||||
InviteMemberSchema,
|
||||
UpdateMemberRoleSchema,
|
||||
)
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
from gatehouse_app.services.user_service import UserService
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations", methods=["POST"])
|
||||
@login_required
|
||||
def create_organization():
|
||||
"""
|
||||
Create a new organization.
|
||||
|
||||
Request body:
|
||||
name: Organization name
|
||||
slug: Organization slug (unique)
|
||||
description: Optional description
|
||||
logo_url: Optional logo URL
|
||||
|
||||
Returns:
|
||||
201: Organization created successfully
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
409: Slug already exists
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = OrganizationCreateSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Create organization
|
||||
org = OrganizationService.create_organization(
|
||||
name=data["name"],
|
||||
slug=data["slug"],
|
||||
owner_user_id=g.current_user.id,
|
||||
description=data.get("description"),
|
||||
logo_url=data.get("logo_url"),
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={"organization": org.to_dict()},
|
||||
message="Organization created 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,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>", methods=["GET"])
|
||||
@login_required
|
||||
def get_organization(org_id):
|
||||
"""
|
||||
Get organization by ID.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
|
||||
Returns:
|
||||
200: Organization data
|
||||
401: Not authenticated
|
||||
403: Not a member
|
||||
404: Organization not found
|
||||
"""
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
# Check if user is a member
|
||||
if not org.is_member(g.current_user.id):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="You are not a member of this organization",
|
||||
status=403,
|
||||
error_type="AUTHORIZATION_ERROR",
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"organization": org.to_dict(),
|
||||
"member_count": org.get_member_count(),
|
||||
},
|
||||
message="Organization retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def update_organization(org_id):
|
||||
"""
|
||||
Update organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
|
||||
Request body:
|
||||
name: Optional organization name
|
||||
description: Optional description
|
||||
logo_url: Optional logo URL
|
||||
|
||||
Returns:
|
||||
200: Organization updated successfully
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
403: Not an admin
|
||||
404: Organization not found
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = OrganizationUpdateSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
# Update organization
|
||||
org = OrganizationService.update_organization(
|
||||
org=org,
|
||||
user_id=g.current_user.id,
|
||||
**data
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={"organization": org.to_dict()},
|
||||
message="Organization updated 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("/organizations/<org_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@require_owner
|
||||
def delete_organization(org_id):
|
||||
"""
|
||||
Delete organization (soft delete).
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
|
||||
Returns:
|
||||
200: Organization deleted successfully
|
||||
401: Not authenticated
|
||||
403: Not the owner
|
||||
404: Organization not found
|
||||
"""
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
OrganizationService.delete_organization(
|
||||
org=org,
|
||||
user_id=g.current_user.id,
|
||||
soft=True,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
message="Organization deleted successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/members", methods=["GET"])
|
||||
@login_required
|
||||
def get_organization_members(org_id):
|
||||
"""
|
||||
Get all members of an organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
|
||||
Returns:
|
||||
200: List of members
|
||||
401: Not authenticated
|
||||
403: Not a member
|
||||
404: Organization not found
|
||||
"""
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
# Check if user is a member
|
||||
if not org.is_member(g.current_user.id):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="You are not a member of this organization",
|
||||
status=403,
|
||||
error_type="AUTHORIZATION_ERROR",
|
||||
)
|
||||
|
||||
members_data = []
|
||||
for member in org.members:
|
||||
if member.deleted_at is None:
|
||||
member_dict = member.to_dict()
|
||||
member_dict["user"] = member.user.to_dict()
|
||||
members_data.append(member_dict)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"members": members_data,
|
||||
"count": len(members_data),
|
||||
},
|
||||
message="Members retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/members", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def add_organization_member(org_id):
|
||||
"""
|
||||
Add a member to the organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
|
||||
Request body:
|
||||
email: User email to invite
|
||||
role: Member role (owner, admin, member, guest)
|
||||
|
||||
Returns:
|
||||
201: Member added successfully
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
403: Not an admin
|
||||
404: Organization or user not found
|
||||
409: User already a member
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = InviteMemberSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
# Find user by email
|
||||
user = UserService.get_user_by_email(data["email"])
|
||||
if not user:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Add member
|
||||
role = OrganizationRole(data["role"])
|
||||
member = OrganizationService.add_member(
|
||||
org=org,
|
||||
user_id=user.id,
|
||||
role=role,
|
||||
inviter_id=g.current_user.id,
|
||||
)
|
||||
|
||||
member_dict = member.to_dict()
|
||||
member_dict["user"] = user.to_dict()
|
||||
|
||||
return api_response(
|
||||
data={"member": member_dict},
|
||||
message="Member added 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,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/members/<user_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def remove_organization_member(org_id, user_id):
|
||||
"""
|
||||
Remove a member from the organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
user_id: User ID to remove
|
||||
|
||||
Returns:
|
||||
200: Member removed successfully
|
||||
401: Not authenticated
|
||||
403: Not an admin
|
||||
404: Organization or member not found
|
||||
"""
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
OrganizationService.remove_member(
|
||||
org=org,
|
||||
user_id=user_id,
|
||||
remover_id=g.current_user.id,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
message="Member removed successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/members/<user_id>/role", methods=["PATCH"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def update_member_role(org_id, user_id):
|
||||
"""
|
||||
Update a member's role.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
user_id: User ID
|
||||
|
||||
Request body:
|
||||
role: New role (owner, admin, member, guest)
|
||||
|
||||
Returns:
|
||||
200: Role updated successfully
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
403: Not an admin
|
||||
404: Organization or member not found
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = UpdateMemberRoleSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
# Update role
|
||||
new_role = OrganizationRole(data["role"])
|
||||
member = OrganizationService.update_member_role(
|
||||
org=org,
|
||||
user_id=user_id,
|
||||
new_role=new_role,
|
||||
updater_id=g.current_user.id,
|
||||
)
|
||||
|
||||
member_dict = member.to_dict()
|
||||
member_dict["user"] = member.user.to_dict()
|
||||
|
||||
return api_response(
|
||||
data={"member": member_dict},
|
||||
message="Member role updated successfully",
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation failed",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages,
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""User endpoints."""
|
||||
from flask import g, request
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
|
||||
from gatehouse_app.services.user_service import UserService
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me", methods=["GET"])
|
||||
@login_required
|
||||
def get_me():
|
||||
"""
|
||||
Get current user profile.
|
||||
|
||||
Returns:
|
||||
200: User profile data
|
||||
401: Not authenticated
|
||||
"""
|
||||
user = g.current_user
|
||||
|
||||
return api_response(
|
||||
data={"user": user.to_dict()},
|
||||
message="User profile retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me", methods=["PATCH"])
|
||||
@login_required
|
||||
def update_me():
|
||||
"""
|
||||
Update current user profile.
|
||||
|
||||
Request body:
|
||||
full_name: Optional full name
|
||||
avatar_url: Optional avatar URL
|
||||
|
||||
Returns:
|
||||
200: User updated successfully
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = UserUpdateSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Update user
|
||||
user = UserService.update_user(g.current_user, **data)
|
||||
|
||||
return api_response(
|
||||
data={"user": user.to_dict()},
|
||||
message="Profile updated 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("/users/me", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_me():
|
||||
"""
|
||||
Delete current user account (soft delete).
|
||||
|
||||
Returns:
|
||||
200: Account deleted successfully
|
||||
401: Not authenticated
|
||||
"""
|
||||
UserService.delete_user(g.current_user, soft=True)
|
||||
|
||||
return api_response(
|
||||
message="Account deleted successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/password", methods=["POST"])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""
|
||||
Change current user password.
|
||||
|
||||
Request body:
|
||||
current_password: Current password
|
||||
new_password: New password
|
||||
new_password_confirm: New password confirmation
|
||||
|
||||
Returns:
|
||||
200: Password changed successfully
|
||||
400: Validation error
|
||||
401: Not authenticated or invalid current password
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = ChangePasswordSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Verify passwords match
|
||||
if data["new_password"] != data["new_password_confirm"]:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="New passwords do not match",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details={"new_password_confirm": ["Passwords do not match"]},
|
||||
)
|
||||
|
||||
# Change password
|
||||
AuthService.change_password(
|
||||
user=g.current_user,
|
||||
current_password=data["current_password"],
|
||||
new_password=data["new_password"],
|
||||
)
|
||||
|
||||
return api_response(
|
||||
message="Password changed 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("/users/me/organizations", methods=["GET"])
|
||||
@login_required
|
||||
def get_my_organizations():
|
||||
"""
|
||||
Get all organizations current user is a member of.
|
||||
|
||||
Returns:
|
||||
200: List of organizations
|
||||
401: Not authenticated
|
||||
"""
|
||||
organizations = UserService.get_user_organizations(g.current_user)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"organizations": [org.to_dict() for org in organizations],
|
||||
"count": len(organizations),
|
||||
},
|
||||
message="Organizations retrieved successfully",
|
||||
)
|
||||
Reference in New Issue
Block a user