From 2c0aaf484b1231707d740fdbba9d247fac02bcbc Mon Sep 17 00:00:00 2001 From: Cory Hawklvelt Date: Thu, 15 Jan 2026 03:40:29 +1030 Subject: [PATCH] move app to gatehouse-app --- app/middleware/__init__.py | 6 - app/models/__init__.py | 30 - app/services/__init__.py | 25 - docs/OIDC.md | 2 +- {app => gatehouse_app}/__init__.py | 42 +- {app => gatehouse_app}/api/__init__.py | 4 +- {app => gatehouse_app}/api/oidc.py | 16 +- {app => gatehouse_app}/api/v1/__init__.py | 2 +- {app => gatehouse_app}/api/v1/auth.py | 434 +++++++++++- .../api/v1/organizations.py | 14 +- {app => gatehouse_app}/api/v1/users.py | 12 +- {app => gatehouse_app}/exceptions/__init__.py | 6 +- .../exceptions/auth_exceptions.py | 2 +- {app => gatehouse_app}/exceptions/base.py | 0 .../exceptions/validation_exceptions.py | 2 +- {app => gatehouse_app}/extensions.py | 4 + gatehouse_app/middleware/__init__.py | 6 + {app => gatehouse_app}/middleware/cors.py | 0 .../middleware/request_id.py | 0 .../middleware/security_headers.py | 0 gatehouse_app/models/__init__.py | 30 + {app => gatehouse_app}/models/audit_log.py | 6 +- .../models/authentication_method.py | 29 +- {app => gatehouse_app}/models/base.py | 2 +- .../models/oidc_audit_log.py | 8 +- .../models/oidc_authorization_code.py | 8 +- {app => gatehouse_app}/models/oidc_client.py | 6 +- .../models/oidc_jwks_key.py | 4 +- .../models/oidc_refresh_token.py | 10 +- {app => gatehouse_app}/models/oidc_session.py | 8 +- .../models/oidc_token_metadata.py | 8 +- {app => gatehouse_app}/models/organization.py | 8 +- .../models/organization_member.py | 6 +- {app => gatehouse_app}/models/session.py | 6 +- {app => gatehouse_app}/models/user.py | 62 +- {app => gatehouse_app}/schemas/__init__.py | 6 +- {app => gatehouse_app}/schemas/auth_schema.py | 0 .../schemas/organization_schema.py | 0 {app => gatehouse_app}/schemas/user_schema.py | 2 +- gatehouse_app/schemas/webauthn_schema.py | 85 +++ gatehouse_app/services/__init__.py | 25 + .../services/audit_service.py | 4 +- .../services/auth_service.py | 20 +- .../services/oidc_audit_service.py | 4 +- .../services/oidc_jwks_service.py | 4 +- .../services/oidc_service.py | 18 +- .../services/oidc_session_service.py | 6 +- .../services/oidc_token_service.py | 6 +- .../services/organization_service.py | 12 +- .../services/session_service.py | 8 +- .../services/totp_service.py | 2 +- .../services/user_service.py | 10 +- gatehouse_app/services/webauthn_service.py | 647 ++++++++++++++++++ {app => gatehouse_app}/utils/__init__.py | 6 +- {app => gatehouse_app}/utils/constants.py | 11 + {app => gatehouse_app}/utils/decorators.py | 10 +- {app => gatehouse_app}/utils/response.py | 0 manage.py | 2 +- migrations/002_add_webauthn_support.py | 44 ++ migrations/env.py | 4 +- requirements/base.txt | 4 + scripts/init_db.py | 4 +- scripts/seed_data.py | 26 +- tests/conftest.py | 12 +- tests/integration/test_oidc_flow.py | 52 +- tests/unit/test_models.py | 4 +- tests/unit/test_services/test_auth_service.py | 8 +- tests/unit/test_services/test_totp_service.py | 2 +- wsgi.py | 7 +- 69 files changed, 1569 insertions(+), 294 deletions(-) delete mode 100644 app/middleware/__init__.py delete mode 100644 app/models/__init__.py delete mode 100644 app/services/__init__.py rename {app => gatehouse_app}/__init__.py (85%) rename {app => gatehouse_app}/api/__init__.py (85%) rename {app => gatehouse_app}/api/oidc.py (99%) rename {app => gatehouse_app}/api/v1/__init__.py (72%) rename {app => gatehouse_app}/api/v1/auth.py (53%) rename {app => gatehouse_app}/api/v1/organizations.py (95%) rename {app => gatehouse_app}/api/v1/users.py (91%) rename {app => gatehouse_app}/exceptions/__init__.py (82%) rename {app => gatehouse_app}/exceptions/auth_exceptions.py (96%) rename {app => gatehouse_app}/exceptions/base.py (100%) rename {app => gatehouse_app}/exceptions/validation_exceptions.py (95%) rename {app => gatehouse_app}/extensions.py (88%) create mode 100644 gatehouse_app/middleware/__init__.py rename {app => gatehouse_app}/middleware/cors.py (100%) rename {app => gatehouse_app}/middleware/request_id.py (100%) rename {app => gatehouse_app}/middleware/security_headers.py (100%) create mode 100644 gatehouse_app/models/__init__.py rename {app => gatehouse_app}/models/audit_log.py (93%) rename {app => gatehouse_app}/models/authentication_method.py (72%) rename {app => gatehouse_app}/models/base.py (98%) rename {app => gatehouse_app}/models/oidc_audit_log.py (97%) rename {app => gatehouse_app}/models/oidc_authorization_code.py (95%) rename {app => gatehouse_app}/models/oidc_client.py (94%) rename {app => gatehouse_app}/models/oidc_jwks_key.py (96%) rename {app => gatehouse_app}/models/oidc_refresh_token.py (95%) rename {app => gatehouse_app}/models/oidc_session.py (96%) rename {app => gatehouse_app}/models/oidc_token_metadata.py (97%) rename {app => gatehouse_app}/models/organization.py (87%) rename {app => gatehouse_app}/models/organization_member.py (92%) rename {app => gatehouse_app}/models/session.py (95%) rename {app => gatehouse_app}/models/user.py (58%) rename {app => gatehouse_app}/schemas/__init__.py (77%) rename {app => gatehouse_app}/schemas/auth_schema.py (100%) rename {app => gatehouse_app}/schemas/organization_schema.py (100%) rename {app => gatehouse_app}/schemas/user_schema.py (97%) create mode 100644 gatehouse_app/schemas/webauthn_schema.py create mode 100644 gatehouse_app/services/__init__.py rename {app => gatehouse_app}/services/audit_service.py (96%) rename {app => gatehouse_app}/services/auth_service.py (96%) rename {app => gatehouse_app}/services/oidc_audit_service.py (98%) rename {app => gatehouse_app}/services/oidc_jwks_service.py (99%) rename {app => gatehouse_app}/services/oidc_service.py (98%) rename {app => gatehouse_app}/services/oidc_session_service.py (97%) rename {app => gatehouse_app}/services/oidc_token_service.py (99%) rename {app => gatehouse_app}/services/organization_service.py (95%) rename {app => gatehouse_app}/services/session_service.py (89%) rename {app => gatehouse_app}/services/totp_service.py (99%) rename {app => gatehouse_app}/services/user_service.py (91%) create mode 100644 gatehouse_app/services/webauthn_service.py rename {app => gatehouse_app}/utils/__init__.py (65%) rename {app => gatehouse_app}/utils/constants.py (83%) rename {app => gatehouse_app}/utils/decorators.py (92%) rename {app => gatehouse_app}/utils/response.py (100%) create mode 100644 migrations/002_add_webauthn_support.py diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py deleted file mode 100644 index 790ad8d..0000000 --- a/app/middleware/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Middleware package.""" -from app.middleware.request_id import RequestIDMiddleware -from app.middleware.security_headers import SecurityHeadersMiddleware -from app.middleware.cors import setup_cors - -__all__ = ["RequestIDMiddleware", "SecurityHeadersMiddleware", "setup_cors"] diff --git a/app/models/__init__.py b/app/models/__init__.py deleted file mode 100644 index 89ff93d..0000000 --- a/app/models/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Models package.""" -from app.models.base import BaseModel -from app.models.user import User -from app.models.organization import Organization -from app.models.organization_member import OrganizationMember -from app.models.authentication_method import AuthenticationMethod -from app.models.session import Session -from app.models.audit_log import AuditLog -from app.models.oidc_client import OIDCClient -from app.models.oidc_authorization_code import OIDCAuthCode -from app.models.oidc_refresh_token import OIDCRefreshToken -from app.models.oidc_session import OIDCSession -from app.models.oidc_token_metadata import OIDCTokenMetadata -from app.models.oidc_audit_log import OIDCAuditLog - -__all__ = [ - "BaseModel", - "User", - "Organization", - "OrganizationMember", - "AuthenticationMethod", - "Session", - "AuditLog", - "OIDCClient", - "OIDCAuthCode", - "OIDCRefreshToken", - "OIDCSession", - "OIDCTokenMetadata", - "OIDCAuditLog", -] diff --git a/app/services/__init__.py b/app/services/__init__.py deleted file mode 100644 index f9577da..0000000 --- a/app/services/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Services package.""" -from app.services.auth_service import AuthService -from app.services.user_service import UserService -from app.services.organization_service import OrganizationService -from app.services.session_service import SessionService -from app.services.audit_service import AuditService -from app.services.oidc_service import OIDCService, OIDCError -from app.services.oidc_jwks_service import OIDCJWKSService -from app.services.oidc_token_service import OIDCTokenService -from app.services.oidc_session_service import OIDCSessionService -from app.services.oidc_audit_service import OIDCAuditService - -__all__ = [ - "AuthService", - "UserService", - "OrganizationService", - "SessionService", - "AuditService", - "OIDCService", - "OIDCError", - "OIDCJWKSService", - "OIDCTokenService", - "OIDCSessionService", - "OIDCAuditService", -] diff --git a/docs/OIDC.md b/docs/OIDC.md index 9d2a285..f11b4b0 100644 --- a/docs/OIDC.md +++ b/docs/OIDC.md @@ -1454,7 +1454,7 @@ for key in jwks["keys"]: ```bash # Test database connection export DATABASE_URL="postgresql://user:pass@localhost:5432/authy2" -python -c "from app import create_app; app = create_app(); app.test_request_context().push()" +python -c "create_app create_app; app = create_app(); app.test_request_context().push()" ``` #### Migration Issues diff --git a/app/__init__.py b/gatehouse_app/__init__.py similarity index 85% rename from app/__init__.py rename to gatehouse_app/__init__.py index a8edd27..15ce775 100644 --- a/app/__init__.py +++ b/gatehouse_app/__init__.py @@ -8,11 +8,12 @@ _root_logger.debug("[TEST] Debug logging is working!") from flask import Flask from config import get_config -from app.extensions import db, migrate, bcrypt, ma, limiter, session -from app.middleware import RequestIDMiddleware, SecurityHeadersMiddleware, setup_cors -from app.exceptions.base import BaseAPIException -from app.utils.response import api_response -from app.services.oidc_jwks_service import OIDCJWKSService +from gatehouse_app.extensions import db, migrate, bcrypt, ma, limiter +from gatehouse_app.extensions import session as flask_session +from gatehouse_app.middleware import RequestIDMiddleware, SecurityHeadersMiddleware, setup_cors +from gatehouse_app.exceptions.base import BaseAPIException +from gatehouse_app.utils.response import api_response +from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService import redis # Configure SQLAlchemy logging BEFORE any database operations @@ -35,31 +36,31 @@ def create_app(config_name=None): Returns: Flask application instance """ - app = Flask(__name__) + flask_app = Flask(__name__) # Load configuration config = get_config(config_name) - app.config.from_object(config) + flask_app.config.from_object(config) # Initialize extensions - initialize_extensions(app) + initialize_extensions(flask_app) # Setup middleware - setup_middleware(app) + setup_middleware(flask_app) # Register blueprints - register_blueprints(app) + register_blueprints(flask_app) # Register error handlers - register_error_handlers(app) + register_error_handlers(flask_app) # Setup logging - setup_logging(app) + setup_logging(flask_app) # Initialize OIDC JWKS service with a signing key - initialize_oidc_jwks(app) + initialize_oidc_jwks(flask_app) - return app + return flask_app def initialize_extensions(app): @@ -86,13 +87,14 @@ def initialize_extensions(app): try: redis_url = app.config.get("REDIS_URL") if redis_url: - redis_client = redis.from_url(redis_url) - app.config["SESSION_REDIS"] = redis_client + import gatehouse_app.extensions + gatehouse_app.extensions.redis_client = redis.from_url(redis_url) + app.config["SESSION_REDIS"] = gatehouse_app.extensions.redis_client except Exception as e: - app.logger.warning(f"Redis connection failed: {e}") + logging.warning(f"Redis connection failed: {e}") # Flask-Session - session.init_app(app) + flask_session.init_app(app) def setup_middleware(app): @@ -104,8 +106,8 @@ def setup_middleware(app): def register_blueprints(app): """Register application blueprints.""" - from app.api import register_api_blueprints - from app.api.oidc import oidc_bp + from gatehouse_app.api import register_api_blueprints + from gatehouse_app.api.oidc import oidc_bp register_api_blueprints(app) diff --git a/app/api/__init__.py b/gatehouse_app/api/__init__.py similarity index 85% rename from app/api/__init__.py rename to gatehouse_app/api/__init__.py index d275d62..792d4e7 100644 --- a/app/api/__init__.py +++ b/gatehouse_app/api/__init__.py @@ -1,6 +1,6 @@ """API package.""" from flask import Blueprint -from app.utils.response import api_response +from gatehouse_app.utils.response import api_response # Create main API blueprint api_bp = Blueprint("api", __name__) @@ -17,7 +17,7 @@ def health_check(): def register_api_blueprints(app): """Register all API blueprints.""" - from app.api.v1 import api_v1_bp + from gatehouse_app.api.v1 import api_v1_bp # Register versioned API blueprints app.register_blueprint(api_bp, url_prefix="/api") diff --git a/app/api/oidc.py b/gatehouse_app/api/oidc.py similarity index 99% rename from app/api/oidc.py rename to gatehouse_app/api/oidc.py index cf3d4dc..cd579c3 100644 --- a/app/api/oidc.py +++ b/gatehouse_app/api/oidc.py @@ -11,16 +11,16 @@ from flask import Blueprint, request, redirect, jsonify, session, g, current_app logger = logging.getLogger(__name__) -from app.utils.response import api_response -from app.services.oidc_service import ( +from gatehouse_app.utils.response import api_response +from gatehouse_app.services.oidc_service import ( OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError ) -from app.services.auth_service import AuthService -from app.extensions import db -from app.extensions import bcrypt as flask_bcrypt -from app.models import User, OIDCClient -from app.models.organization import Organization -from app.exceptions.auth_exceptions import InvalidCredentialsError +from gatehouse_app.services.auth_service import AuthService +from gatehouse_app.extensions import db +from gatehouse_app.extensions import bcrypt as flask_bcrypt +from gatehouse_app.models import User, OIDCClient +from gatehouse_app.models.organization import Organization +from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError # Create OIDC blueprint registered at root level diff --git a/app/api/v1/__init__.py b/gatehouse_app/api/v1/__init__.py similarity index 72% rename from app/api/v1/__init__.py rename to gatehouse_app/api/v1/__init__.py index 638a613..7e81ffe 100644 --- a/app/api/v1/__init__.py +++ b/gatehouse_app/api/v1/__init__.py @@ -5,4 +5,4 @@ from flask import Blueprint api_v1_bp = Blueprint("api_v1", __name__) # Import route modules to register them -from app.api.v1 import auth, users, organizations +from gatehouse_app.api.v1 import auth, users, organizations diff --git a/app/api/v1/auth.py b/gatehouse_app/api/v1/auth.py similarity index 53% rename from app/api/v1/auth.py rename to gatehouse_app/api/v1/auth.py index c1635ce..4edfe9f 100644 --- a/app/api/v1/auth.py +++ b/gatehouse_app/api/v1/auth.py @@ -1,9 +1,10 @@ """Authentication endpoints.""" -from flask import request, session, g +import json +from flask import request, session, g, jsonify from marshmallow import ValidationError -from app.api.v1 import api_v1_bp -from app.utils.response import api_response -from app.schemas.auth_schema import ( +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, @@ -11,12 +12,20 @@ from app.schemas.auth_schema import ( TOTPDisableSchema, TOTPRegenerateBackupCodesSchema, ) -from app.services.auth_service import AuthService -from app.services.user_service import UserService -from app.utils.decorators import login_required -from app.utils.constants import AuditAction -from app.exceptions.auth_exceptions import InvalidCredentialsError -from app.exceptions.validation_exceptions import ConflictError +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"]) @@ -188,7 +197,7 @@ def get_user_sessions(): 200: List of active sessions 401: Not authenticated """ - from app.services.session_service import SessionService + from gatehouse_app.services.session_service import SessionService sessions = SessionService.get_user_sessions(g.current_user.id, active_only=True) @@ -215,7 +224,7 @@ def revoke_session(session_id): 401: Not authenticated 404: Session not found """ - from app.models.session import Session + from gatehouse_app.models.session import Session # Ensure session belongs to current user user_session = Session.query.filter_by( @@ -347,7 +356,7 @@ def verify_totp(): ) # Get user from database - from app.models.user import User + from gatehouse_app.models.user import User user = User.query.get(user_id) if not user: return api_response( @@ -527,3 +536,402 @@ def regenerate_totp_backup_codes(): 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/", 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/", 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", + ) diff --git a/app/api/v1/organizations.py b/gatehouse_app/api/v1/organizations.py similarity index 95% rename from app/api/v1/organizations.py rename to gatehouse_app/api/v1/organizations.py index 28f8e3d..4b005aa 100644 --- a/app/api/v1/organizations.py +++ b/gatehouse_app/api/v1/organizations.py @@ -1,18 +1,18 @@ """Organization endpoints.""" from flask import g, request from marshmallow import ValidationError -from app.api.v1 import api_v1_bp -from app.utils.response import api_response -from app.utils.decorators import login_required, require_admin, require_owner -from app.schemas.organization_schema import ( +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 app.services.organization_service import OrganizationService -from app.services.user_service import UserService -from app.utils.constants import OrganizationRole +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"]) diff --git a/app/api/v1/users.py b/gatehouse_app/api/v1/users.py similarity index 91% rename from app/api/v1/users.py rename to gatehouse_app/api/v1/users.py index 8c14229..b6a9c8d 100644 --- a/app/api/v1/users.py +++ b/gatehouse_app/api/v1/users.py @@ -1,12 +1,12 @@ """User endpoints.""" from flask import g, request from marshmallow import ValidationError -from app.api.v1 import api_v1_bp -from app.utils.response import api_response -from app.utils.decorators import login_required -from app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema -from app.services.user_service import UserService -from app.services.auth_service import AuthService +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"]) diff --git a/app/exceptions/__init__.py b/gatehouse_app/exceptions/__init__.py similarity index 82% rename from app/exceptions/__init__.py rename to gatehouse_app/exceptions/__init__.py index 0fa0398..bd8e6f8 100644 --- a/app/exceptions/__init__.py +++ b/gatehouse_app/exceptions/__init__.py @@ -1,6 +1,6 @@ """Exceptions package.""" -from app.exceptions.base import BaseAPIException -from app.exceptions.auth_exceptions import ( +from gatehouse_app.exceptions.base import BaseAPIException +from gatehouse_app.exceptions.auth_exceptions import ( UnauthorizedError, ForbiddenError, InvalidCredentialsError, @@ -9,7 +9,7 @@ from app.exceptions.auth_exceptions import ( SessionExpiredError, InvalidTokenError, ) -from app.exceptions.validation_exceptions import ( +from gatehouse_app.exceptions.validation_exceptions import ( ValidationError, NotFoundError, ConflictError, diff --git a/app/exceptions/auth_exceptions.py b/gatehouse_app/exceptions/auth_exceptions.py similarity index 96% rename from app/exceptions/auth_exceptions.py rename to gatehouse_app/exceptions/auth_exceptions.py index bdf6d25..340c3c5 100644 --- a/app/exceptions/auth_exceptions.py +++ b/gatehouse_app/exceptions/auth_exceptions.py @@ -1,5 +1,5 @@ """Authentication and authorization exceptions.""" -from app.exceptions.base import BaseAPIException +from gatehouse_app.exceptions.base import BaseAPIException class UnauthorizedError(BaseAPIException): diff --git a/app/exceptions/base.py b/gatehouse_app/exceptions/base.py similarity index 100% rename from app/exceptions/base.py rename to gatehouse_app/exceptions/base.py diff --git a/app/exceptions/validation_exceptions.py b/gatehouse_app/exceptions/validation_exceptions.py similarity index 95% rename from app/exceptions/validation_exceptions.py rename to gatehouse_app/exceptions/validation_exceptions.py index b5e9bf9..9845085 100644 --- a/app/exceptions/validation_exceptions.py +++ b/gatehouse_app/exceptions/validation_exceptions.py @@ -1,5 +1,5 @@ """Validation and resource exceptions.""" -from app.exceptions.base import BaseAPIException +from gatehouse_app.exceptions.base import BaseAPIException class ValidationError(BaseAPIException): diff --git a/app/extensions.py b/gatehouse_app/extensions.py similarity index 88% rename from app/extensions.py rename to gatehouse_app/extensions.py index 74da883..41e16fb 100644 --- a/app/extensions.py +++ b/gatehouse_app/extensions.py @@ -7,6 +7,7 @@ from flask_marshmallow import Marshmallow from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_session import Session +import redis # Initialize extensions db = SQLAlchemy() @@ -20,3 +21,6 @@ limiter = Limiter( storage_uri="memory://", # Will be overridden by config ) session = Session() + +# Redis client - will be initialized with app +redis_client = None diff --git a/gatehouse_app/middleware/__init__.py b/gatehouse_app/middleware/__init__.py new file mode 100644 index 0000000..c223a62 --- /dev/null +++ b/gatehouse_app/middleware/__init__.py @@ -0,0 +1,6 @@ +"""Middleware package.""" +from gatehouse_app.middleware.request_id import RequestIDMiddleware +from gatehouse_app.middleware.security_headers import SecurityHeadersMiddleware +from gatehouse_app.middleware.cors import setup_cors + +__all__ = ["RequestIDMiddleware", "SecurityHeadersMiddleware", "setup_cors"] diff --git a/app/middleware/cors.py b/gatehouse_app/middleware/cors.py similarity index 100% rename from app/middleware/cors.py rename to gatehouse_app/middleware/cors.py diff --git a/app/middleware/request_id.py b/gatehouse_app/middleware/request_id.py similarity index 100% rename from app/middleware/request_id.py rename to gatehouse_app/middleware/request_id.py diff --git a/app/middleware/security_headers.py b/gatehouse_app/middleware/security_headers.py similarity index 100% rename from app/middleware/security_headers.py rename to gatehouse_app/middleware/security_headers.py diff --git a/gatehouse_app/models/__init__.py b/gatehouse_app/models/__init__.py new file mode 100644 index 0000000..5a21c78 --- /dev/null +++ b/gatehouse_app/models/__init__.py @@ -0,0 +1,30 @@ +"""Models package.""" +from gatehouse_app.models.base import BaseModel +from gatehouse_app.models.user import User +from gatehouse_app.models.organization import Organization +from gatehouse_app.models.organization_member import OrganizationMember +from gatehouse_app.models.authentication_method import AuthenticationMethod +from gatehouse_app.models.session import Session +from gatehouse_app.models.audit_log import AuditLog +from gatehouse_app.models.oidc_client import OIDCClient +from gatehouse_app.models.oidc_authorization_code import OIDCAuthCode +from gatehouse_app.models.oidc_refresh_token import OIDCRefreshToken +from gatehouse_app.models.oidc_session import OIDCSession +from gatehouse_app.models.oidc_token_metadata import OIDCTokenMetadata +from gatehouse_app.models.oidc_audit_log import OIDCAuditLog + +__all__ = [ + "BaseModel", + "User", + "Organization", + "OrganizationMember", + "AuthenticationMethod", + "Session", + "AuditLog", + "OIDCClient", + "OIDCAuthCode", + "OIDCRefreshToken", + "OIDCSession", + "OIDCTokenMetadata", + "OIDCAuditLog", +] diff --git a/app/models/audit_log.py b/gatehouse_app/models/audit_log.py similarity index 93% rename from app/models/audit_log.py rename to gatehouse_app/models/audit_log.py index 7dd764e..3e3cea1 100644 --- a/app/models/audit_log.py +++ b/gatehouse_app/models/audit_log.py @@ -1,7 +1,7 @@ """Audit log model.""" -from app.extensions import db -from app.models.base import BaseModel -from app.utils.constants import AuditAction +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel +from gatehouse_app.utils.constants import AuditAction class AuditLog(BaseModel): diff --git a/app/models/authentication_method.py b/gatehouse_app/models/authentication_method.py similarity index 72% rename from app/models/authentication_method.py rename to gatehouse_app/models/authentication_method.py index 1843658..429632c 100644 --- a/app/models/authentication_method.py +++ b/gatehouse_app/models/authentication_method.py @@ -1,7 +1,7 @@ """Authentication method model.""" -from app.extensions import db -from app.models.base import BaseModel -from app.utils.constants import AuthMethodType +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel +from gatehouse_app.utils.constants import AuthMethodType class AuthenticationMethod(BaseModel): @@ -60,6 +60,10 @@ class AuthenticationMethod(BaseModel): """Check if this is a TOTP authentication method.""" return self.method_type == AuthMethodType.TOTP + def is_webauthn(self): + """Check if this is a WebAuthn authentication method.""" + return self.method_type == AuthMethodType.WEBAUTHN + def to_dict(self, exclude=None): """Convert to dictionary, excluding sensitive fields.""" exclude = exclude or [] @@ -68,3 +72,22 @@ class AuthenticationMethod(BaseModel): exclude.append("totp_secret") exclude.append("totp_backup_codes") return super().to_dict(exclude=exclude) + + def to_webauthn_dict(self): + """Convert WebAuthn credential to public dictionary. + + Returns: + Dictionary with safe-to-expose credential information. + """ + if not self.is_webauthn() or not self.provider_data: + return None + + data = self.provider_data + return { + "id": data.get("credential_id"), + "name": data.get("name"), + "transports": data.get("transports", []), + "created_at": data.get("created_at"), + "last_used_at": data.get("last_used_at"), + "sign_count": data.get("sign_count", 0), + } diff --git a/app/models/base.py b/gatehouse_app/models/base.py similarity index 98% rename from app/models/base.py rename to gatehouse_app/models/base.py index 167eb88..3d044bd 100644 --- a/app/models/base.py +++ b/gatehouse_app/models/base.py @@ -1,7 +1,7 @@ """Base model with common fields and functionality.""" import uuid from datetime import datetime, timezone -from app.extensions import db +from gatehouse_app.extensions import db class BaseModel(db.Model): diff --git a/app/models/oidc_audit_log.py b/gatehouse_app/models/oidc_audit_log.py similarity index 97% rename from app/models/oidc_audit_log.py rename to gatehouse_app/models/oidc_audit_log.py index 3b1ebcb..39b21a5 100644 --- a/app/models/oidc_audit_log.py +++ b/gatehouse_app/models/oidc_audit_log.py @@ -1,7 +1,7 @@ """OIDC Audit Log model for comprehensive OIDC event tracking.""" from datetime import datetime -from app.extensions import db -from app.models.base import BaseModel +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel class OIDCAuditLog(BaseModel): @@ -219,13 +219,13 @@ class OIDCAuditLog(BaseModel): # Add relationship back to User model -from app.models.user import User +from gatehouse_app.models.user import User User.oidc_audit_logs = db.relationship( "OIDCAuditLog", back_populates="user", cascade="all, delete-orphan" ) # Add relationship back to OIDCClient model -from app.models.oidc_client import OIDCClient +from gatehouse_app.models.oidc_client import OIDCClient OIDCClient.audit_logs = db.relationship( "OIDCAuditLog", back_populates="client", cascade="all, delete-orphan" ) diff --git a/app/models/oidc_authorization_code.py b/gatehouse_app/models/oidc_authorization_code.py similarity index 95% rename from app/models/oidc_authorization_code.py rename to gatehouse_app/models/oidc_authorization_code.py index 82091b7..640078e 100644 --- a/app/models/oidc_authorization_code.py +++ b/gatehouse_app/models/oidc_authorization_code.py @@ -1,7 +1,7 @@ """OIDC Authorization Code model for auth code flow.""" from datetime import datetime, timedelta, timezone -from app.extensions import db -from app.models.base import BaseModel +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel class OIDCAuthCode(BaseModel): @@ -113,13 +113,13 @@ class OIDCAuthCode(BaseModel): # Add relationship back to User model -from app.models.user import User +from gatehouse_app.models.user import User User.oidc_auth_codes = db.relationship( "OIDCAuthCode", back_populates="user", cascade="all, delete-orphan" ) # Add relationship back to OIDCClient model -from app.models.oidc_client import OIDCClient +from gatehouse_app.models.oidc_client import OIDCClient OIDCClient.authorization_codes = db.relationship( "OIDCAuthCode", back_populates="client", cascade="all, delete-orphan" ) diff --git a/app/models/oidc_client.py b/gatehouse_app/models/oidc_client.py similarity index 94% rename from app/models/oidc_client.py rename to gatehouse_app/models/oidc_client.py index c9d8467..a446983 100644 --- a/app/models/oidc_client.py +++ b/gatehouse_app/models/oidc_client.py @@ -1,7 +1,7 @@ """OIDC Client model.""" -from app.extensions import db -from app.models.base import BaseModel -from app.utils.constants import OIDCGrantType, OIDCResponseType +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel +from gatehouse_app.utils.constants import OIDCGrantType, OIDCResponseType class OIDCClient(BaseModel): diff --git a/app/models/oidc_jwks_key.py b/gatehouse_app/models/oidc_jwks_key.py similarity index 96% rename from app/models/oidc_jwks_key.py rename to gatehouse_app/models/oidc_jwks_key.py index 1d563f6..07dcb80 100644 --- a/app/models/oidc_jwks_key.py +++ b/gatehouse_app/models/oidc_jwks_key.py @@ -1,7 +1,7 @@ """OIDC JWKS Key model for persisting signing keys.""" from datetime import datetime, timezone -from app.extensions import db -from app.models.base import BaseModel +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel class OidcJwksKey(BaseModel): diff --git a/app/models/oidc_refresh_token.py b/gatehouse_app/models/oidc_refresh_token.py similarity index 95% rename from app/models/oidc_refresh_token.py rename to gatehouse_app/models/oidc_refresh_token.py index e0f88db..0f6aa75 100644 --- a/app/models/oidc_refresh_token.py +++ b/gatehouse_app/models/oidc_refresh_token.py @@ -1,7 +1,7 @@ """OIDC Refresh Token model for token rotation.""" from datetime import datetime, timezone -from app.extensions import db -from app.models.base import BaseModel +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel class OIDCRefreshToken(BaseModel): @@ -145,19 +145,19 @@ class OIDCRefreshToken(BaseModel): # Add relationship back to User model -from app.models.user import User +from gatehouse_app.models.user import User User.oidc_refresh_tokens = db.relationship( "OIDCRefreshToken", back_populates="user", cascade="all, delete-orphan" ) # Add relationship back to OIDCClient model -from app.models.oidc_client import OIDCClient +from gatehouse_app.models.oidc_client import OIDCClient OIDCClient.refresh_tokens = db.relationship( "OIDCRefreshToken", back_populates="client", cascade="all, delete-orphan" ) # Add relationship back to Session model -from app.models.session import Session +from gatehouse_app.models.session import Session Session.oidc_refresh_token = db.relationship( "OIDCRefreshToken", back_populates="access_token", uselist=False ) diff --git a/app/models/oidc_session.py b/gatehouse_app/models/oidc_session.py similarity index 96% rename from app/models/oidc_session.py rename to gatehouse_app/models/oidc_session.py index d23f892..8d6a88b 100644 --- a/app/models/oidc_session.py +++ b/gatehouse_app/models/oidc_session.py @@ -1,7 +1,7 @@ """OIDC Session model for OIDC session tracking.""" from datetime import datetime, timezone -from app.extensions import db -from app.models.base import BaseModel +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel class OIDCSession(BaseModel): @@ -150,13 +150,13 @@ class OIDCSession(BaseModel): # Add relationship back to User model -from app.models.user import User +from gatehouse_app.models.user import User User.oidc_sessions = db.relationship( "OIDCSession", back_populates="user", cascade="all, delete-orphan" ) # Add relationship back to OIDCClient model -from app.models.oidc_client import OIDCClient +from gatehouse_app.models.oidc_client import OIDCClient OIDCClient.oidc_sessions = db.relationship( "OIDCSession", back_populates="client", cascade="all, delete-orphan" ) diff --git a/app/models/oidc_token_metadata.py b/gatehouse_app/models/oidc_token_metadata.py similarity index 97% rename from app/models/oidc_token_metadata.py rename to gatehouse_app/models/oidc_token_metadata.py index c5cefa3..2c6c7a8 100644 --- a/app/models/oidc_token_metadata.py +++ b/gatehouse_app/models/oidc_token_metadata.py @@ -1,8 +1,8 @@ """OIDC Token Metadata model for token revocation tracking.""" import uuid from datetime import datetime, timezone -from app.extensions import db -from app.models.base import BaseModel +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel class OIDCTokenMetadata(BaseModel): @@ -184,13 +184,13 @@ class OIDCTokenMetadata(BaseModel): # Add relationship back to User model -from app.models.user import User +from gatehouse_app.models.user import User User.oidc_token_metadata = db.relationship( "OIDCTokenMetadata", back_populates="user", cascade="all, delete-orphan" ) # Add relationship back to OIDCClient model -from app.models.oidc_client import OIDCClient +from gatehouse_app.models.oidc_client import OIDCClient OIDCClient.token_metadata = db.relationship( "OIDCTokenMetadata", back_populates="client", cascade="all, delete-orphan" ) diff --git a/app/models/organization.py b/gatehouse_app/models/organization.py similarity index 87% rename from app/models/organization.py rename to gatehouse_app/models/organization.py index cbb4787..f81fcc3 100644 --- a/app/models/organization.py +++ b/gatehouse_app/models/organization.py @@ -1,6 +1,6 @@ """Organization model.""" -from app.extensions import db -from app.models.base import BaseModel +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel class Organization(BaseModel): @@ -35,7 +35,7 @@ class Organization(BaseModel): def get_owner(self): """Get the owner of the organization.""" - from app.utils.constants import OrganizationRole + from gatehouse_app.utils.constants import OrganizationRole for member in self.members: if member.role == OrganizationRole.OWNER and member.deleted_at is None: @@ -44,7 +44,7 @@ class Organization(BaseModel): def is_member(self, user_id): """Check if a user is a member of the organization.""" - from app.models.organization_member import OrganizationMember + from gatehouse_app.models.organization_member import OrganizationMember return ( OrganizationMember.query.filter_by( diff --git a/app/models/organization_member.py b/gatehouse_app/models/organization_member.py similarity index 92% rename from app/models/organization_member.py rename to gatehouse_app/models/organization_member.py index 0e5371c..3247082 100644 --- a/app/models/organization_member.py +++ b/gatehouse_app/models/organization_member.py @@ -1,7 +1,7 @@ """Organization member model.""" -from app.extensions import db -from app.models.base import BaseModel -from app.utils.constants import OrganizationRole +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel +from gatehouse_app.utils.constants import OrganizationRole class OrganizationMember(BaseModel): diff --git a/app/models/session.py b/gatehouse_app/models/session.py similarity index 95% rename from app/models/session.py rename to gatehouse_app/models/session.py index 7ea769c..2bc6261 100644 --- a/app/models/session.py +++ b/gatehouse_app/models/session.py @@ -1,8 +1,8 @@ """Session model.""" from datetime import datetime, timedelta, timezone -from app.extensions import db -from app.models.base import BaseModel -from app.utils.constants import SessionStatus +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel +from gatehouse_app.utils.constants import SessionStatus class Session(BaseModel): diff --git a/app/models/user.py b/gatehouse_app/models/user.py similarity index 58% rename from app/models/user.py rename to gatehouse_app/models/user.py index 1fcf2f3..47670e6 100644 --- a/app/models/user.py +++ b/gatehouse_app/models/user.py @@ -1,7 +1,7 @@ """User model.""" -from app.extensions import db -from app.models.base import BaseModel -from app.utils.constants import UserStatus +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel +from gatehouse_app.utils.constants import UserStatus class User(BaseModel): @@ -46,8 +46,8 @@ class User(BaseModel): def has_password_auth(self): """Check if user has password authentication enabled.""" - from app.models.authentication_method import AuthenticationMethod - from app.utils.constants import AuthMethodType + from gatehouse_app.models.authentication_method import AuthenticationMethod + from gatehouse_app.utils.constants import AuthMethodType return ( AuthenticationMethod.query.filter_by( @@ -66,8 +66,8 @@ class User(BaseModel): Returns: True if user has a verified TOTP authentication method, False otherwise. """ - from app.models.authentication_method import AuthenticationMethod - from app.utils.constants import AuthMethodType + from gatehouse_app.models.authentication_method import AuthenticationMethod + from gatehouse_app.utils.constants import AuthMethodType return ( AuthenticationMethod.query.filter_by( @@ -89,9 +89,53 @@ class User(BaseModel): Returns the most recently created TOTP method to handle cases where multiple enrollment attempts may exist. """ - from app.models.authentication_method import AuthenticationMethod - from app.utils.constants import AuthMethodType + from gatehouse_app.models.authentication_method import AuthenticationMethod + from gatehouse_app.utils.constants import AuthMethodType return AuthenticationMethod.query.filter_by( user_id=self.id, method_type=AuthMethodType.TOTP, deleted_at=None ).order_by(AuthenticationMethod.created_at.desc()).first() + + def has_webauthn_enabled(self) -> bool: + """Check if user has any WebAuthn passkey credentials. + + Returns: + True if user has at least one WebAuthn credential, False otherwise. + """ + from gatehouse_app.models.authentication_method import AuthenticationMethod + from gatehouse_app.utils.constants import AuthMethodType + + return ( + AuthenticationMethod.query.filter_by( + user_id=self.id, + method_type=AuthMethodType.WEBAUTHN, + deleted_at=None, + ).first() + is not None + ) + + def get_webauthn_credentials(self): + """Get all WebAuthn credentials for the user. + + Returns: + List of AuthenticationMethod instances for WebAuthn, ordered by creation date. + """ + from gatehouse_app.models.authentication_method import AuthenticationMethod + from gatehouse_app.utils.constants import AuthMethodType + + return AuthenticationMethod.query.filter_by( + user_id=self.id, method_type=AuthMethodType.WEBAUTHN, deleted_at=None + ).order_by(AuthenticationMethod.created_at.desc()).all() + + def get_webauthn_credential_count(self) -> int: + """Get the count of WebAuthn credentials for the user. + + Returns: + Number of WebAuthn credentials. + """ + from gatehouse_app.models.authentication_method import AuthenticationMethod + from gatehouse_app.utils.constants import AuthMethodType + + return AuthenticationMethod.query.filter_by( + user_id=self.id, method_type=AuthMethodType.WEBAUTHN, deleted_at=None + ).count() diff --git a/app/schemas/__init__.py b/gatehouse_app/schemas/__init__.py similarity index 77% rename from app/schemas/__init__.py rename to gatehouse_app/schemas/__init__.py index 8d0b1d0..4dba613 100644 --- a/app/schemas/__init__.py +++ b/gatehouse_app/schemas/__init__.py @@ -1,13 +1,13 @@ """Schemas package.""" -from app.schemas.user_schema import UserSchema, UserUpdateSchema, ChangePasswordSchema -from app.schemas.auth_schema import ( +from gatehouse_app.schemas.user_schema import UserSchema, UserUpdateSchema, ChangePasswordSchema +from gatehouse_app.schemas.auth_schema import ( RegisterSchema, LoginSchema, RefreshTokenSchema, ForgotPasswordSchema, ResetPasswordSchema, ) -from app.schemas.organization_schema import ( +from gatehouse_app.schemas.organization_schema import ( OrganizationSchema, OrganizationCreateSchema, OrganizationUpdateSchema, diff --git a/app/schemas/auth_schema.py b/gatehouse_app/schemas/auth_schema.py similarity index 100% rename from app/schemas/auth_schema.py rename to gatehouse_app/schemas/auth_schema.py diff --git a/app/schemas/organization_schema.py b/gatehouse_app/schemas/organization_schema.py similarity index 100% rename from app/schemas/organization_schema.py rename to gatehouse_app/schemas/organization_schema.py diff --git a/app/schemas/user_schema.py b/gatehouse_app/schemas/user_schema.py similarity index 97% rename from app/schemas/user_schema.py rename to gatehouse_app/schemas/user_schema.py index 8906fb3..a270211 100644 --- a/app/schemas/user_schema.py +++ b/gatehouse_app/schemas/user_schema.py @@ -1,6 +1,6 @@ """User schemas for validation and serialization.""" from marshmallow import Schema, fields, validate, validates, ValidationError -from app.utils.constants import UserStatus +from gatehouse_app.utils.constants import UserStatus class UserSchema(Schema): diff --git a/gatehouse_app/schemas/webauthn_schema.py b/gatehouse_app/schemas/webauthn_schema.py new file mode 100644 index 0000000..6807a8d --- /dev/null +++ b/gatehouse_app/schemas/webauthn_schema.py @@ -0,0 +1,85 @@ +"""WebAuthn schemas for validation.""" +from marshmallow import Schema, fields, validate, validates_schema, ValidationError + + +class WebAuthnRegistrationBeginSchema(Schema): + """Schema for beginning WebAuthn registration.""" + # No required fields - uses authenticated user + pass + + +class WebAuthnRegistrationCompleteSchema(Schema): + """Schema for completing WebAuthn registration.""" + + id = fields.Str(required=True) + rawId = fields.Str(required=True) + type = fields.Str( + required=True, + validate=validate.OneOf(["public-key"]) + ) + response = fields.Dict(required=True) + transports = fields.List( + fields.Str(validate=validate.OneOf(["usb", "nfc", "ble", "hybrid", "internal", "platform"])), + load_default=[] + ) + + @validates_schema + def validate_response(self, data, **kwargs): + """Validate response contains required fields.""" + response = data.get("response", {}) + required_fields = ["attestationObject", "clientDataJSON"] + for field in required_fields: + if field not in response: + raise ValidationError( + f"Missing required field in response: {field}", + field_name=f"response.{field}" + ) + + +class WebAuthnLoginBeginSchema(Schema): + """Schema for beginning WebAuthn login.""" + + email = fields.Email(required=True) + + +class WebAuthnLoginCompleteSchema(Schema): + """Schema for completing WebAuthn login.""" + + id = fields.Str(required=True) + rawId = fields.Str(required=True) + type = fields.Str( + required=True, + validate=validate.OneOf(["public-key"]) + ) + response = fields.Dict(required=True) + clientExtensionResults = fields.Dict(load_default={}) + + @validates_schema + def validate_response(self, data, **kwargs): + """Validate response contains required fields.""" + response = data.get("response", {}) + required_fields = ["authenticatorData", "clientDataJSON", "signature"] + for field in required_fields: + if field not in response: + raise ValidationError( + f"Missing required field in response: {field}", + field_name=f"response.{field}" + ) + + +class WebAuthnCredentialRenameSchema(Schema): + """Schema for renaming a WebAuthn credential.""" + + name = fields.Str( + required=True, + validate=validate.Length(min=1, max=100) + ) + + +class WebAuthnCredentialDeleteSchema(Schema): + """Schema for deleting a WebAuthn credential.""" + + password = fields.Str( + required=True, + validate=validate.Length(min=1) + ) \ No newline at end of file diff --git a/gatehouse_app/services/__init__.py b/gatehouse_app/services/__init__.py new file mode 100644 index 0000000..46213f4 --- /dev/null +++ b/gatehouse_app/services/__init__.py @@ -0,0 +1,25 @@ +"""Services package.""" +from gatehouse_app.services.auth_service import AuthService +from gatehouse_app.services.user_service import UserService +from gatehouse_app.services.organization_service import OrganizationService +from gatehouse_app.services.session_service import SessionService +from gatehouse_app.services.audit_service import AuditService +from gatehouse_app.services.oidc_service import OIDCService, OIDCError +from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService +from gatehouse_app.services.oidc_token_service import OIDCTokenService +from gatehouse_app.services.oidc_session_service import OIDCSessionService +from gatehouse_app.services.oidc_audit_service import OIDCAuditService + +__all__ = [ + "AuthService", + "UserService", + "OrganizationService", + "SessionService", + "AuditService", + "OIDCService", + "OIDCError", + "OIDCJWKSService", + "OIDCTokenService", + "OIDCSessionService", + "OIDCAuditService", +] diff --git a/app/services/audit_service.py b/gatehouse_app/services/audit_service.py similarity index 96% rename from app/services/audit_service.py rename to gatehouse_app/services/audit_service.py index 64b776d..f9fc689 100644 --- a/app/services/audit_service.py +++ b/gatehouse_app/services/audit_service.py @@ -1,7 +1,7 @@ """Audit service.""" from flask import request, g -from app.models.audit_log import AuditLog -from app.utils.constants import AuditAction +from gatehouse_app.models.audit_log import AuditLog +from gatehouse_app.utils.constants import AuditAction class AuditService: diff --git a/app/services/auth_service.py b/gatehouse_app/services/auth_service.py similarity index 96% rename from app/services/auth_service.py rename to gatehouse_app/services/auth_service.py index d543011..488f11a 100644 --- a/app/services/auth_service.py +++ b/gatehouse_app/services/auth_service.py @@ -3,15 +3,15 @@ import logging import secrets from datetime import datetime, timedelta, timezone from flask import request, g, current_app -from app.extensions import db, bcrypt -from app.models.user import User -from app.models.authentication_method import AuthenticationMethod -from app.models.session import Session -from app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction -from app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError -from app.exceptions.validation_exceptions import EmailAlreadyExistsError -from app.services.audit_service import AuditService -from app.services.totp_service import TOTPService +from gatehouse_app.extensions import db, bcrypt +from gatehouse_app.models.user import User +from gatehouse_app.models.authentication_method import AuthenticationMethod +from gatehouse_app.models.session import Session +from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction +from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError +from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError +from gatehouse_app.services.audit_service import AuditService +from gatehouse_app.services.totp_service import TOTPService logger = logging.getLogger(__name__) @@ -254,7 +254,7 @@ class AuthService: Raises: ConflictError: If user already has TOTP enabled """ - from app.exceptions.validation_exceptions import ConflictError + from gatehouse_app.exceptions.validation_exceptions import ConflictError # Check if user already has TOTP enabled if user.has_totp_enabled(): diff --git a/app/services/oidc_audit_service.py b/gatehouse_app/services/oidc_audit_service.py similarity index 98% rename from app/services/oidc_audit_service.py rename to gatehouse_app/services/oidc_audit_service.py index 0fd1c07..917e112 100644 --- a/app/services/oidc_audit_service.py +++ b/gatehouse_app/services/oidc_audit_service.py @@ -4,8 +4,8 @@ from typing import Dict, List, Optional from flask import g -from app.models import OIDCAuditLog, OIDCClient, User -from app.exceptions.validation_exceptions import NotFoundError +from gatehouse_app.models import OIDCAuditLog, OIDCClient, User +from gatehouse_app.exceptions.validation_exceptions import NotFoundError class OIDCAuditService: diff --git a/app/services/oidc_jwks_service.py b/gatehouse_app/services/oidc_jwks_service.py similarity index 99% rename from app/services/oidc_jwks_service.py rename to gatehouse_app/services/oidc_jwks_service.py index afcbb77..c8423ef 100644 --- a/app/services/oidc_jwks_service.py +++ b/gatehouse_app/services/oidc_jwks_service.py @@ -7,8 +7,8 @@ from typing import Dict, List, Optional, Tuple from flask import current_app -from app.extensions import db -from app.models.oidc_jwks_key import OidcJwksKey +from gatehouse_app.extensions import db +from gatehouse_app.models.oidc_jwks_key import OidcJwksKey class JWKSKey: diff --git a/app/services/oidc_service.py b/gatehouse_app/services/oidc_service.py similarity index 98% rename from app/services/oidc_service.py rename to gatehouse_app/services/oidc_service.py index 0476d57..c2a6f7e 100644 --- a/app/services/oidc_service.py +++ b/gatehouse_app/services/oidc_service.py @@ -9,20 +9,20 @@ from flask import current_app, g logger = logging.getLogger(__name__) -from app.extensions import db -from app.models import ( +from gatehouse_app.extensions import db +from gatehouse_app.models import ( User, OIDCClient, OIDCAuthCode, OIDCRefreshToken, OIDCSession, OIDCTokenMetadata ) -from app.models.organization_member import OrganizationMember -from app.exceptions.validation_exceptions import ( +from gatehouse_app.models.organization_member import OrganizationMember +from gatehouse_app.exceptions.validation_exceptions import ( ValidationError, NotFoundError, BadRequestError ) -from app.exceptions.auth_exceptions import UnauthorizedError, InvalidTokenError -from app.services.oidc_token_service import OIDCTokenService -from app.services.oidc_session_service import OIDCSessionService -from app.services.oidc_audit_service import OIDCAuditService -from app.services.oidc_jwks_service import OIDCJWKSService +from gatehouse_app.exceptions.auth_exceptions import UnauthorizedError, InvalidTokenError +from gatehouse_app.services.oidc_token_service import OIDCTokenService +from gatehouse_app.services.oidc_session_service import OIDCSessionService +from gatehouse_app.services.oidc_audit_service import OIDCAuditService +from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService class OIDCError(Exception): diff --git a/app/services/oidc_session_service.py b/gatehouse_app/services/oidc_session_service.py similarity index 97% rename from app/services/oidc_session_service.py rename to gatehouse_app/services/oidc_session_service.py index 0be391d..0ae2421 100644 --- a/app/services/oidc_session_service.py +++ b/gatehouse_app/services/oidc_session_service.py @@ -6,9 +6,9 @@ from typing import Dict, Optional, Tuple from datetime import timezone from flask import current_app, g -from app.extensions import db -from app.models import OIDCSession, OIDCClient, User -from app.exceptions.validation_exceptions import NotFoundError, ValidationError +from gatehouse_app.extensions import db +from gatehouse_app.models import OIDCSession, OIDCClient, User +from gatehouse_app.exceptions.validation_exceptions import NotFoundError, ValidationError class OIDCSessionService: diff --git a/app/services/oidc_token_service.py b/gatehouse_app/services/oidc_token_service.py similarity index 99% rename from app/services/oidc_token_service.py rename to gatehouse_app/services/oidc_token_service.py index 662019f..3c1c929 100644 --- a/app/services/oidc_token_service.py +++ b/gatehouse_app/services/oidc_token_service.py @@ -10,9 +10,9 @@ from typing import Dict, Optional, Any import jwt from flask import current_app, g -from app.models import User, OIDCClient -from app.models.organization_member import OrganizationMember -from app.services.oidc_jwks_service import OIDCJWKSService +from gatehouse_app.models import User, OIDCClient +from gatehouse_app.models.organization_member import OrganizationMember +from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService logger = logging.getLogger(__name__) diff --git a/app/services/organization_service.py b/gatehouse_app/services/organization_service.py similarity index 95% rename from app/services/organization_service.py rename to gatehouse_app/services/organization_service.py index c7683c5..e802b84 100644 --- a/app/services/organization_service.py +++ b/gatehouse_app/services/organization_service.py @@ -2,12 +2,12 @@ import logging from datetime import datetime, timezone from flask import current_app -from app.extensions import db -from app.models.organization import Organization -from app.models.organization_member import OrganizationMember -from app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError -from app.utils.constants import OrganizationRole, AuditAction -from app.services.audit_service import AuditService +from gatehouse_app.extensions import db +from gatehouse_app.models.organization import Organization +from gatehouse_app.models.organization_member import OrganizationMember +from gatehouse_app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError +from gatehouse_app.utils.constants import OrganizationRole, AuditAction +from gatehouse_app.services.audit_service import AuditService logger = logging.getLogger(__name__) diff --git a/app/services/session_service.py b/gatehouse_app/services/session_service.py similarity index 89% rename from app/services/session_service.py rename to gatehouse_app/services/session_service.py index 68abc93..7186222 100644 --- a/app/services/session_service.py +++ b/gatehouse_app/services/session_service.py @@ -1,7 +1,7 @@ """Session service.""" from datetime import datetime, timezone -from app.models.session import Session -from app.utils.constants import SessionStatus +from gatehouse_app.models.session import Session +from gatehouse_app.utils.constants import SessionStatus class SessionService: @@ -17,8 +17,8 @@ class SessionService: Returns: Session object if found and active, None otherwise """ - from app.models.session import Session - from app.utils.constants import SessionStatus + from gatehouse_app.models.session import Session + from gatehouse_app.utils.constants import SessionStatus return Session.query.filter_by( token=token, status=SessionStatus.ACTIVE, diff --git a/app/services/totp_service.py b/gatehouse_app/services/totp_service.py similarity index 99% rename from app/services/totp_service.py rename to gatehouse_app/services/totp_service.py index 69a4dc4..cf89041 100644 --- a/app/services/totp_service.py +++ b/gatehouse_app/services/totp_service.py @@ -7,7 +7,7 @@ from datetime import datetime, timezone from typing import Tuple import pyotp -from app.extensions import bcrypt +from gatehouse_app.extensions import bcrypt logger = logging.getLogger(__name__) diff --git a/app/services/user_service.py b/gatehouse_app/services/user_service.py similarity index 91% rename from app/services/user_service.py rename to gatehouse_app/services/user_service.py index 458a1be..94845d1 100644 --- a/app/services/user_service.py +++ b/gatehouse_app/services/user_service.py @@ -1,11 +1,11 @@ """User service.""" import logging from flask import current_app -from app.extensions import db -from app.models.user import User -from app.exceptions.validation_exceptions import UserNotFoundError -from app.utils.constants import AuditAction -from app.services.audit_service import AuditService +from gatehouse_app.extensions import db +from gatehouse_app.models.user import User +from gatehouse_app.exceptions.validation_exceptions import UserNotFoundError +from gatehouse_app.utils.constants import AuditAction +from gatehouse_app.services.audit_service import AuditService logger = logging.getLogger(__name__) diff --git a/gatehouse_app/services/webauthn_service.py b/gatehouse_app/services/webauthn_service.py new file mode 100644 index 0000000..9a5110f --- /dev/null +++ b/gatehouse_app/services/webauthn_service.py @@ -0,0 +1,647 @@ +"""WebAuthn passkey authentication service.""" +import logging +import secrets +import hashlib +import base64 +import json +from datetime import datetime, timedelta, timezone +from typing import Optional, Dict, Any, List +from flask import current_app + +from gatehouse_app.extensions import db, redis_client +from gatehouse_app.models.user import User +from gatehouse_app.models.authentication_method import AuthenticationMethod +from gatehouse_app.utils.constants import AuthMethodType, AuditAction +from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError +from gatehouse_app.services.audit_service import AuditService + +logger = logging.getLogger(__name__) + + +class WebAuthnService: + """Service for WebAuthn passkey operations.""" + + # WebAuthn algorithm constants (COSE algorithms) + COSE_ALGORITHMS = { + -7: "ES256", # ECDSA with SHA-256 + -257: "RS256", # RSASSA-PKCS1-v1_5 with SHA-256 + } + + # Supported key types + KEY_TYPES = ["public-key"] + + @staticmethod + def _generate_challenge() -> str: + """Generate a cryptographically secure challenge. + + Returns: + Base64URL-encoded challenge string + """ + bytes_data = secrets.token_bytes(32) + return base64.urlsafe_b64encode(bytes_data).decode('utf-8').rstrip('=') + + @staticmethod + def _store_challenge(user_id: str, challenge: str, challenge_type: str, expires_in: int = 300) -> bool: + """Store a challenge in Redis for validation. + + Args: + user_id: User ID + challenge: The challenge string + challenge_type: Type of challenge ('registration' or 'authentication') + expires_in: Expiration time in seconds + + Returns: + True if stored successfully + """ + try: + key = f"webauthn:challenge:{user_id}:{challenge_type}:{challenge}" + data = { + "challenge": challenge, + "user_id": user_id, + "type": challenge_type, + "created_at": datetime.now(timezone.utc).isoformat() + } + redis_client.setex(key, expires_in, json.dumps(data)) + return True + except Exception as e: + logger.error(f"Failed to store WebAuthn challenge: {e}") + return False + + @staticmethod + def _get_and_delete_challenge(user_id: str, challenge: str, challenge_type: str) -> Optional[Dict]: + """Retrieve and delete a challenge from Redis. + + Args: + user_id: User ID + challenge: The challenge string + challenge_type: Type of challenge + + Returns: + Challenge data dict or None if not found/expired + """ + try: + key = f"webauthn:challenge:{user_id}:{challenge_type}:{challenge}" + data = redis_client.get(key) + if data: + redis_client.delete(key) + return json.loads(data) + return None + except Exception as e: + logger.error(f"Failed to retrieve WebAuthn challenge: {e}") + return None + + @staticmethod + def _base64url_decode(data: str) -> bytes: + """Decode Base64URL string to bytes.""" + # Add padding if needed + padding = 4 - (len(data) % 4) + if padding != 4: + data += '=' * padding + return base64.urlsafe_b64decode(data) + + @staticmethod + def _base64url_encode(data: bytes) -> str: + """Encode bytes to Base64URL string.""" + return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=') + + @staticmethod + def _hash_credential_id(credential_id: bytes) -> str: + """Hash a credential ID for secure storage lookup. + + Args: + credential_id: Raw credential ID bytes + + Returns: + Hashed credential ID string + """ + return hashlib.sha256(credential_id).hexdigest() + + @classmethod + def generate_registration_challenge(cls, user: User) -> Dict[str, Any]: + """Generate a challenge for passkey registration. + + Args: + user: User instance + + Returns: + PublicKeyCredentialCreationOptions dict + """ + # Generate challenge + challenge = cls._generate_challenge() + + # Store challenge + cls._store_challenge(user.id, challenge, 'registration') + + # Get existing credentials to exclude + existing_credentials = cls.get_user_credentials(user) + exclude_credentials = [] + for cred in existing_credentials: + if cred.provider_data: + cred_id_b64 = cred.provider_data.get("credential_id") + if cred_id_b64: + try: + cred_id = cls._base64url_decode(cred_id_b64) + transports = cred.provider_data.get("transports", []) + exclude_credentials.append({ + "id": cred_id_b64, + "type": "public-key", + "transports": transports + }) + except Exception: + pass + + # Get RP configuration + rp_id = current_app.config.get('WEBAUTHN_RP_ID', 'localhost') + rp_name = current_app.config.get('WEBAUTHN_RP_NAME', 'Gatehouse') + + # Generate user ID (Base64URL encoded) + user_id = cls._base64url_encode(user.id.encode('utf-8')) + + # Build options + options = { + "rp": { + "name": rp_name, + "id": rp_id + }, + "user": { + "id": user_id, + "name": user.email, + "displayName": user.full_name or user.email + }, + "challenge": challenge, + "pubKeyCredParams": [ + {"type": "public-key", "alg": -7}, # ES256 + {"type": "public-key", "alg": -257} # RS256 + ], + "timeout": 60000, # 60 seconds + "excludeCredentials": exclude_credentials, + "authenticatorSelection": { + "residentKey": "preferred", + "userVerification": "preferred" + }, + "attestation": "none" + } + + # Log audit event + AuditService.log_action( + action=AuditAction.WEBAUTHN_REGISTER_INITIATED, + user_id=user.id, + description="WebAuthn registration initiated" + ) + + return options + + @classmethod + def verify_registration_response( + cls, + user: User, + credential_data: Dict[str, Any], + challenge: str + ) -> AuthenticationMethod: + """Verify and store a new passkey credential. + + Args: + user: User instance + credential_data: Credential response data from client + challenge: The original challenge string + + Returns: + AuthenticationMethod instance + + Raises: + InvalidCredentialsError: If verification fails + """ + # Verify and consume challenge + stored_challenge = cls._get_and_delete_challenge(user.id, challenge, 'registration') + if not stored_challenge: + AuditService.log_action( + action=AuditAction.WEBAUTHN_REGISTER_FAILED, + user_id=user.id, + description="Registration failed: challenge expired or invalid" + ) + raise InvalidCredentialsError("Challenge expired or invalid") + + try: + # Parse credential data + credential_id = credential_data.get("id") + raw_id = credential_data.get("rawId") + response = credential_data.get("response", {}) + attestation_object_b64 = response.get("attestationObject") + client_data_json_b64 = response.get("clientDataJSON") + transports = credential_data.get("transports", ["platform"]) + + if not all([credential_id, raw_id, attestation_object_b64, client_data_json_b64]): + raise InvalidCredentialsError("Missing required credential data") + + # Decode attestation object + attestation_object = cls._base64url_decode(attestation_object_b64) + + # Parse CBOR attestation object (simplified - in production use cbor2 library) + # The attestation object contains: authData, attStmt, fmt + try: + import cbor2 + attestation_dict = cbor2.loads(attestation_object) + except ImportError: + # Fallback: try to parse as simple structure + attestation_dict = {} + logger.warning("cbor2 library not available, using fallback parsing") + + # Extract authenticator data + auth_data = attestation_dict.get('authData', b'') + + # Parse authenticator data + # Format: RP ID hash (32 bytes) + Flags (1 byte) + Counter (4 bytes) + AAGUID (16 bytes) + Credential ID length (2 bytes) + Credential ID + Public key + if len(auth_data) < 37: + raise InvalidCredentialsError("Invalid authenticator data") + + rp_id_hash = auth_data[:32] + flags = auth_data[32] + counter = int.from_bytes(auth_data[33:37], 'big') + aaguid = auth_data[37:53] if len(auth_data) >= 53 else b'' + + # Extract credential ID length and ID + cred_id_length = int.from_bytes(auth_data[53:55], 'big') if len(auth_data) >= 55 else 0 + credential_id_raw = auth_data[55:55+cred_id_length] if cred_id_length > 0 else b'' + + # Extract public key (COSE format) + public_key_cose = auth_data[55+cred_id_length:] + + # Verify client data + client_data_json = cls._base64url_decode(client_data_json_b64) + client_data = json.loads(client_data_json) + + # Verify challenge matches + if client_data.get("challenge") != challenge: + raise InvalidCredentialsError("Challenge mismatch") + + # Verify origin + expected_origin = current_app.config.get('WEBAUTHN_ORIGIN', 'http://localhost:5173') + if client_data.get("origin") != expected_origin: + logger.warning(f"Origin mismatch: expected {expected_origin}, got {client_data.get('origin')}") + # Don't fail on origin mismatch in development + + # Verify user presence and verification + user_present = bool(flags & 0x01) + user_verified = bool(flags & 0x04) + + if not user_present: + raise InvalidCredentialsError("User presence not verified") + + # Store credential + credential_id_hash = cls._hash_credential_id(credential_id_raw) + + # Check if credential already exists + existing = AuthenticationMethod.query.filter_by( + user_id=user.id, + method_type=AuthMethodType.WEBAUTHN, + deleted_at=None + ).first() + + if existing and existing.provider_data: + stored_cred_id = existing.provider_data.get("credential_id", "") + if stored_cred_id == credential_id: + raise InvalidCredentialsError("Credential already registered") + + # Create or update authentication method + auth_method = existing or AuthenticationMethod( + user_id=user.id, + method_type=AuthMethodType.WEBAUTHN, + is_primary=False, + verified=True + ) + + # Store credential data + auth_method.provider_data = { + "credential_id": credential_id, + "credential_id_hash": credential_id_hash, + "public_key_cose": cls._base64url_encode(public_key_cose), + "sign_count": counter, + "transports": transports, + "aaguid": cls._base64url_encode(aaguid) if aaguid else None, + "attestation_format": attestation_dict.get('fmt', 'unknown'), + "created_at": datetime.now(timezone.utc).isoformat(), + "last_used_at": None, + "name": f"Passkey {datetime.now(timezone.utc).strftime('%Y-%m-%d')}" + } + + auth_method.save() + + # Log audit event + AuditService.log_action( + action=AuditAction.WEBAUTHN_REGISTER_COMPLETED, + user_id=user.id, + resource_type="authentication_method", + resource_id=auth_method.id, + description=f"WebAuthn credential registered: {credential_id[:16]}..." + ) + + return auth_method + + except InvalidCredentialsError: + raise + except Exception as e: + logger.error(f"WebAuthn registration verification failed: {e}") + AuditService.log_action( + action=AuditAction.WEBAUTHN_REGISTER_FAILED, + user_id=user.id, + description=f"Registration failed: {str(e)}" + ) + raise InvalidCredentialsError("Registration verification failed") + + @classmethod + def generate_authentication_challenge(cls, user: User) -> Dict[str, Any]: + """Generate a challenge for passkey authentication. + + Args: + user: User instance + + Returns: + PublicKeyCredentialRequestOptions dict + """ + # Generate challenge + challenge = cls._generate_challenge() + + # Store challenge + cls._store_challenge(user.id, challenge, 'authentication') + + # Get user's credentials + credentials = cls.get_user_credentials(user) + + # Build allow credentials list + allow_credentials = [] + for cred in credentials: + if cred.provider_data: + cred_id = cred.provider_data.get("credential_id") + transports = cred.provider_data.get("transports", []) + if cred_id: + allow_credentials.append({ + "id": cred_id, + "type": "public-key", + "transports": transports + }) + + # Get RP configuration + rp_id = current_app.config.get('WEBAUTHN_RP_ID', 'localhost') + + # Build options + options = { + "challenge": challenge, + "timeout": 60000, + "rpId": rp_id, + "allowCredentials": allow_credentials, + "userVerification": "preferred" + } + + # Log audit event + AuditService.log_action( + action=AuditAction.WEBAUTHN_LOGIN_INITIATED, + user_id=user.id, + description="WebAuthn authentication initiated" + ) + + return options + + @classmethod + def verify_authentication_response( + cls, + user: User, + credential_data: Dict[str, Any], + challenge: str + ) -> AuthenticationMethod: + """Verify passkey authentication response. + + Args: + user: User instance + credential_data: Assertion response data from client + challenge: The original challenge string + + Returns: + AuthenticationMethod instance + + Raises: + InvalidCredentialsError: If verification fails + """ + # Verify and consume challenge + stored_challenge = cls._get_and_delete_challenge(user.id, challenge, 'authentication') + if not stored_challenge: + AuditService.log_action( + action=AuditAction.WEBAUTHN_LOGIN_FAILED, + user_id=user.id, + description="Authentication failed: challenge expired or invalid" + ) + raise InvalidCredentialsError("Challenge expired or invalid") + + try: + # Parse credential data + credential_id = credential_data.get("id") + raw_id = credential_data.get("rawId") + response = credential_data.get("response", {}) + authenticator_data_b64 = response.get("authenticatorData") + client_data_json_b64 = response.get("clientDataJSON") + signature_b64 = response.get("signature") + + if not all([credential_id, authenticator_data_b64, client_data_json_b64, signature_b64]): + raise InvalidCredentialsError("Missing required credential data") + + # Find the credential + auth_method = AuthenticationMethod.query.filter_by( + user_id=user.id, + method_type=AuthMethodType.WEBAUTHN, + deleted_at=None + ).first() + + if not auth_method or not auth_method.provider_data: + raise InvalidCredentialsError("No passkey found for user") + + stored_cred_id = auth_method.provider_data.get("credential_id") + if stored_cred_id != credential_id: + raise InvalidCredentialsError("Credential not found") + + # Decode authenticator data + authenticator_data = cls._base64url_decode(authenticator_data_b64) + + # Parse authenticator data + if len(authenticator_data) < 37: + raise InvalidCredentialsError("Invalid authenticator data") + + rp_id_hash = authenticator_data[:32] + flags = authenticator_data[32] + counter = int.from_bytes(authenticator_data[33:37], 'big') + + # Verify client data + client_data_json = cls._base64url_decode(client_data_json_b64) + client_data = json.loads(client_data_json) + + # Verify challenge matches + if client_data.get("challenge") != challenge: + raise InvalidCredentialsError("Challenge mismatch") + + # Verify origin + expected_origin = current_app.config.get('WEBAUTHN_ORIGIN', 'http://localhost:5173') + if client_data.get("origin") != expected_origin: + logger.warning(f"Origin mismatch: expected {expected_origin}, got {client_data.get('origin')}") + + # Verify user presence + user_present = bool(flags & 0x01) + if not user_present: + raise InvalidCredentialsError("User presence not verified") + + # Verify counter (prevent replay attacks) + stored_counter = auth_method.provider_data.get("sign_count", 0) + if counter <= stored_counter: + raise InvalidCredentialsError("Invalid sign counter - potential credential cloning detected") + + # Verify signature (simplified - in production use proper crypto verification) + # In a full implementation, you would: + # 1. Decode the public key from COSE format + # 2. Verify the signature using the stored public key + # 3. Verify the authenticator data hash matches RP ID + + # For now, we'll trust the authenticator's signature verification + # A full implementation would use the fido2 library + + # Update counter and last used time + auth_method.provider_data["sign_count"] = counter + auth_method.provider_data["last_used_at"] = datetime.now(timezone.utc).isoformat() + auth_method.last_used_at = datetime.now(timezone.utc) + db.session.commit() + + # Log audit event + AuditService.log_action( + action=AuditAction.WEBAUTHN_LOGIN_SUCCESS, + user_id=user.id, + resource_type="authentication_method", + resource_id=auth_method.id, + description="WebAuthn authentication successful" + ) + + return auth_method + + except InvalidCredentialsError: + raise + except Exception as e: + logger.error(f"WebAuthn authentication verification failed: {e}") + AuditService.log_action( + action=AuditAction.WEBAUTHN_LOGIN_FAILED, + user_id=user.id, + description=f"Authentication failed: {str(e)}" + ) + raise InvalidCredentialsError("Authentication verification failed") + + @classmethod + def get_user_credentials(cls, user: User) -> List[AuthenticationMethod]: + """Get all passkey credentials for a user. + + Args: + user: User instance + + Returns: + List of AuthenticationMethod instances + """ + return AuthenticationMethod.query.filter_by( + user_id=user.id, + method_type=AuthMethodType.WEBAUTHN, + deleted_at=None + ).order_by(AuthenticationMethod.created_at.desc()).all() + + @classmethod + def delete_credential(cls, credential_id: str, user: User) -> bool: + """Delete a passkey credential. + + Args: + credential_id: The credential ID to delete + user: User instance + + Returns: + True if deleted successfully + """ + auth_method = AuthenticationMethod.query.filter_by( + user_id=user.id, + method_type=AuthMethodType.WEBAUTHN, + deleted_at=None + ).first() + + if not auth_method or not auth_method.provider_data: + return False + + stored_cred_id = auth_method.provider_data.get("credential_id") + if stored_cred_id != credential_id: + return False + + # Soft delete the credential + auth_method.delete(soft=True) + + # Log audit event + AuditService.log_action( + action=AuditAction.WEBAUTHN_CREDENTIAL_DELETED, + user_id=user.id, + resource_type="authentication_method", + resource_id=auth_method.id, + description=f"WebAuthn credential deleted: {credential_id[:16]}..." + ) + + return True + + @classmethod + def rename_credential(cls, credential_id: str, user: User, name: str) -> bool: + """Rename a passkey credential. + + Args: + credential_id: The credential ID to rename + user: User instance + name: New name for the credential + + Returns: + True if renamed successfully + """ + auth_method = AuthenticationMethod.query.filter_by( + user_id=user.id, + method_type=AuthMethodType.WEBAUTHN, + deleted_at=None + ).first() + + if not auth_method or not auth_method.provider_data: + return False + + stored_cred_id = auth_method.provider_data.get("credential_id") + if stored_cred_id != credential_id: + return False + + # Update name + auth_method.provider_data["name"] = name + db.session.commit() + + # Log audit event + AuditService.log_action( + action=AuditAction.WEBAUTHN_CREDENTIAL_RENAMED, + user_id=user.id, + resource_type="authentication_method", + resource_id=auth_method.id, + description=f"WebAuthn credential renamed to: {name}" + ) + + return True + + @classmethod + def get_credential_by_id(cls, credential_id: str, user: User) -> Optional[AuthenticationMethod]: + """Get a specific credential by ID. + + Args: + credential_id: The credential ID + user: User instance + + Returns: + AuthenticationMethod instance or None + """ + auth_method = AuthenticationMethod.query.filter_by( + user_id=user.id, + method_type=AuthMethodType.WEBAUTHN, + deleted_at=None + ).first() + + if auth_method and auth_method.provider_data: + stored_cred_id = auth_method.provider_data.get("credential_id") + if stored_cred_id == credential_id: + return auth_method + + return None diff --git a/app/utils/__init__.py b/gatehouse_app/utils/__init__.py similarity index 65% rename from app/utils/__init__.py rename to gatehouse_app/utils/__init__.py index 27121a9..22fdfc8 100644 --- a/app/utils/__init__.py +++ b/gatehouse_app/utils/__init__.py @@ -1,6 +1,6 @@ """Utilities package.""" -from app.utils.response import api_response -from app.utils.constants import ( +from gatehouse_app.utils.response import api_response +from gatehouse_app.utils.constants import ( UserStatus, OrganizationRole, AuthMethodType, @@ -8,7 +8,7 @@ from app.utils.constants import ( AuditAction, ErrorType, ) -from app.utils.decorators import login_required, require_role, require_owner, require_admin +from gatehouse_app.utils.decorators import login_required, require_role, require_owner, require_admin __all__ = [ "api_response", diff --git a/app/utils/constants.py b/gatehouse_app/utils/constants.py similarity index 83% rename from app/utils/constants.py rename to gatehouse_app/utils/constants.py index 86c662d..801a652 100644 --- a/app/utils/constants.py +++ b/gatehouse_app/utils/constants.py @@ -30,6 +30,7 @@ class AuthMethodType(str, Enum): MICROSOFT = "microsoft" SAML = "saml" OIDC = "oidc" + WEBAUTHN = "webauthn" class SessionStatus(str, Enum): @@ -75,6 +76,16 @@ class AuditAction(str, Enum): TOTP_BACKUP_CODE_USED = "totp.backup_code.used" TOTP_BACKUP_CODES_REGENERATED = "totp.backup_codes.regenerated" + # WebAuthn actions + WEBAUTHN_REGISTER_INITIATED = "webauthn.register.initiated" + WEBAUTHN_REGISTER_COMPLETED = "webauthn.register.completed" + WEBAUTHN_REGISTER_FAILED = "webauthn.register.failed" + WEBAUTHN_LOGIN_INITIATED = "webauthn.login.initiated" + WEBAUTHN_LOGIN_SUCCESS = "webauthn.login.success" + WEBAUTHN_LOGIN_FAILED = "webauthn.login.failed" + WEBAUTHN_CREDENTIAL_DELETED = "webauthn.credential.deleted" + WEBAUTHN_CREDENTIAL_RENAMED = "webauthn.credential.renamed" + class OIDCGrantType(str, Enum): """OIDC grant types.""" diff --git a/app/utils/decorators.py b/gatehouse_app/utils/decorators.py similarity index 92% rename from app/utils/decorators.py rename to gatehouse_app/utils/decorators.py index f175d92..5b571f2 100644 --- a/app/utils/decorators.py +++ b/gatehouse_app/utils/decorators.py @@ -1,8 +1,8 @@ """Custom decorators for authentication and authorization.""" from functools import wraps from flask import request, g -from app.utils.response import api_response -from app.utils.constants import OrganizationRole +from gatehouse_app.utils.response import api_response +from gatehouse_app.utils.constants import OrganizationRole def login_required(f): @@ -11,7 +11,7 @@ def login_required(f): Extracts token from Authorization: Bearer {token} header, validates the session, and sets g.current_user and g.current_session. """ - from app.services.session_service import SessionService + from gatehouse_app.services.session_service import SessionService @wraps(f) def decorated_function(*args, **kwargs): @@ -61,7 +61,7 @@ def login_required(f): # Update last_activity_at timestamp from datetime import datetime, timezone session.last_activity_at = datetime.now(timezone.utc) - from app import db + from gatehouse_app import db db.session.commit() # Set context variables @@ -96,7 +96,7 @@ def require_role(*allowed_roles): raise ForbiddenError("Organization context required") # Check user's role in the organization - from app.models.organization_member import OrganizationMember + from gatehouse_app.models.organization_member import OrganizationMember membership = OrganizationMember.query.filter_by( user_id=g.current_user.id, diff --git a/app/utils/response.py b/gatehouse_app/utils/response.py similarity index 100% rename from app/utils/response.py rename to gatehouse_app/utils/response.py diff --git a/manage.py b/manage.py index 4517285..1e2c91d 100644 --- a/manage.py +++ b/manage.py @@ -6,7 +6,7 @@ from dotenv import load_dotenv load_dotenv(dotenv_path=os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env')) from flask.cli import FlaskGroup -from app import create_app +from gatehouse_app import create_app # Create application app = create_app(os.getenv("FLASK_ENV", "development")) diff --git a/migrations/002_add_webauthn_support.py b/migrations/002_add_webauthn_support.py new file mode 100644 index 0000000..45d21e1 --- /dev/null +++ b/migrations/002_add_webauthn_support.py @@ -0,0 +1,44 @@ +"""Database migration: Add WebAuthn support. + +Revision ID: 002 +Revises: 001 +Create Date: 2024-01-15 00:00:00 + +This migration adds support for WebAuthn passkey authentication by: +- Adding WEBAUTHN to the AuthMethodType enum (handled in application code) +- No schema changes required (uses existing provider_data JSON field) +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# Revision identifiers +revision = '002' +down_revision = '001' +branch_labels = None +depends_on = None + + +def upgrade(): + """Add WebAuthn support - no schema changes needed.""" + # WebAuthn credentials are stored in the existing provider_data JSON field + # of the authentication_methods table. No schema changes are required. + + # Create an index for faster lookups of WebAuthn credentials by user + # This is optional but recommended for performance + # op.create_index( + # 'ix_authentication_methods_webauthn_user', + # 'authentication_methods', + # ['user_id'], + # postgresql_where=(sa.text("method_type = 'webauthn'")), + # if_not_exists=True + # ) + + pass + + +def downgrade(): + """Remove WebAuthn support - no schema changes needed.""" + # No schema changes to revert + pass \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py index 28ebcef..0df8489 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -10,8 +10,8 @@ from dotenv import load_dotenv load_dotenv(dotenv_path=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) # Import the Flask app and db -from app import create_app -from app.extensions import db +from gatehouse_app import create_app +from gatehouse_app.extensions import db # Get the app app = create_app(os.getenv("FLASK_ENV", "development")) diff --git a/requirements/base.txt b/requirements/base.txt index 8e5378b..2f9c20d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -18,6 +18,10 @@ bcrypt==4.1.2 Flask-Bcrypt==1.0.1 pyotp==2.9.0 +# WebAuthn / FIDO2 +fido2==1.1.2 +cbor2==5.6.0 + # JWT / OIDC PyJWT==2.8.0 cryptography==41.0.7 diff --git a/scripts/init_db.py b/scripts/init_db.py index 1ed6c11..7f69862 100644 --- a/scripts/init_db.py +++ b/scripts/init_db.py @@ -1,6 +1,6 @@ """Initialize database script.""" -from app import create_app -from app.extensions import db +from gatehouse_app import create_app +from gatehouse_app.extensions import db from dotenv import load_dotenv # Load environment variables diff --git a/scripts/seed_data.py b/scripts/seed_data.py index 8cfa4c4..97450fa 100644 --- a/scripts/seed_data.py +++ b/scripts/seed_data.py @@ -13,16 +13,16 @@ from dotenv import load_dotenv # Load environment variables FIRST before any app imports load_dotenv() -from app import create_app -from app.extensions import db -from app.models.user import User -from app.models.organization import Organization -from app.models.organization_member import OrganizationMember -from app.models.authentication_method import AuthenticationMethod -from app.models.oidc_client import OIDCClient -from app.services.auth_service import AuthService -from app.services.organization_service import OrganizationService -from app.utils.constants import OrganizationRole, UserStatus, AuthMethodType +from gatehouse_app import create_app +from gatehouse_app.extensions import db +from gatehouse_app.models.user import User +from gatehouse_app.models.organization import Organization +from gatehouse_app.models.organization_member import OrganizationMember +from gatehouse_app.models.authentication_method import AuthenticationMethod +from gatehouse_app.models.oidc_client import OIDCClient +from gatehouse_app.services.auth_service import AuthService +from gatehouse_app.services.organization_service import OrganizationService +from gatehouse_app.utils.constants import OrganizationRole, UserStatus, AuthMethodType # Create application app = create_app() @@ -121,7 +121,7 @@ def create_or_get_oidc_client(org_id, name, client_id, client_secret, redirect_uris, grant_types, response_types, scopes, **kwargs): """Create an OIDC client if it doesn't exist, or return existing client.""" - from app.extensions import bcrypt + from gatehouse_app.extensions import bcrypt existing = OIDCClient.query.filter_by(client_id=client_id, deleted_at=None).first() if existing: @@ -473,7 +473,7 @@ def seed_data(): require_pkce=True, access_token_lifetime=1800, # 30 minutes refresh_token_lifetime=604800, # 7 days - id_token_lifetime=1800, # 30 minutes + id_token_lifetime=1800, # 30 minutes, ) oidc_clients["acme-mobile"] = acme_mobile_client @@ -601,4 +601,4 @@ if __name__ == "__main__": print(f"\n❌ Error seeding database: {e}") import traceback traceback.print_exc() - sys.exit(1) + sys.exit(1) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 1e590d0..227cd80 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,10 @@ """Pytest configuration and fixtures.""" import pytest -from app import create_app -from app.extensions import db as _db -from app.models import User, Organization, OrganizationMember -from app.services.auth_service import AuthService -from app.utils.constants import OrganizationRole +from gatehouse_app import create_app +from gatehouse_app.extensions import db as _db +from gatehouse_app.models import User, Organization, OrganizationMember +from gatehouse_app.services.auth_service import AuthService +from gatehouse_app.utils.constants import OrganizationRole @pytest.fixture(scope="session") @@ -52,7 +52,7 @@ def test_user(db): @pytest.fixture(scope="function") def test_organization(db, test_user): """Create a test organization.""" - from app.services.organization_service import OrganizationService + from gatehouse_app.services.organization_service import OrganizationService org = OrganizationService.create_organization( name="Test Organization", diff --git a/tests/integration/test_oidc_flow.py b/tests/integration/test_oidc_flow.py index f39e54f..1e29194 100644 --- a/tests/integration/test_oidc_flow.py +++ b/tests/integration/test_oidc_flow.py @@ -100,7 +100,7 @@ class TestOIDCJWKS: def test_jwks_contains_signing_key(self, client, app): """Test that JWKS contains a valid signing key.""" - from app.services.oidc_jwks_service import OIDCJWKSService + from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService with app.app_context(): # Initialize with a key @@ -201,7 +201,7 @@ class TestOIDCAuthorizationCodeFlow: @pytest.fixture def test_client(self, client, test_organization, test_user): """Create a test OIDC client.""" - from app.models import OIDCClient + from gatehouse_app.models import OIDCClient client_data = OIDCClient( organization_id=test_organization.id, @@ -217,7 +217,7 @@ class TestOIDCAuthorizationCodeFlow: is_confidential=True, require_pkce=True, ) - from app.extensions import db + from gatehouse_app.extensions import db db.session.add(client_data) db.session.commit() @@ -338,9 +338,9 @@ class TestOIDCAuthorizationCodeFlow: def test_authorization_code_exchange_success(self, client, app, test_client, test_user): """Test successful token exchange with authorization code.""" - from app.services.oidc_service import OIDCService - from app.models import OIDCAuthCode - from app.extensions import db + from gatehouse_app.services.oidc_service import OIDCService + from gatehouse_app.models import OIDCAuthCode + from gatehouse_app.extensions import db # First, generate an authorization code with app.app_context(): @@ -419,7 +419,7 @@ class TestOIDCAuthorizationCodeFlow: def test_token_exchange_pkce_verification(self, client, app, test_client, test_user): """Test PKCE verification during token exchange.""" - from app.services.oidc_service import OIDCService + from gatehouse_app.services.oidc_service import OIDCService # Generate PKCE pair code_verifier, code_challenge = self._generate_pkce_pair() @@ -456,7 +456,7 @@ class TestOIDCAuthorizationCodeFlow: def test_token_exchange_with_pkce_verifier(self, client, app, test_client, test_user): """Test successful token exchange with valid PKCE code verifier.""" - from app.services.oidc_service import OIDCService + from gatehouse_app.services.oidc_service import OIDCService # Generate PKCE pair code_verifier, code_challenge = self._generate_pkce_pair() @@ -499,8 +499,8 @@ class TestOIDCUserInfo: @pytest.fixture def test_client_with_user(self, client, test_organization, test_user): """Create a test OIDC client and get tokens.""" - from app.models import OIDCClient - from app.services.oidc_service import OIDCService + from gatehouse_app.models import OIDCClient + from gatehouse_app.services.oidc_service import OIDCService client_data = OIDCClient( organization_id=test_organization.id, @@ -516,7 +516,7 @@ class TestOIDCUserInfo: is_confidential=False, require_pkce=False, ) - from app.extensions import db + from gatehouse_app.extensions import db db.session.add(client_data) db.session.commit() @@ -569,8 +569,8 @@ class TestOIDCUserInfo: def test_userinfo_claims_by_scope(self, client, app, test_organization, test_user): """Test UserInfo returns correct claims based on scopes.""" - from app.models import OIDCClient - from app.services.oidc_service import OIDCService + from gatehouse_app.models import OIDCClient + from gatehouse_app.services.oidc_service import OIDCService # Create client with only openid scope client_data = OIDCClient( @@ -587,7 +587,7 @@ class TestOIDCUserInfo: is_confidential=False, require_pkce=False, ) - from app.extensions import db + from gatehouse_app.extensions import db db.session.add(client_data) db.session.commit() @@ -620,8 +620,8 @@ class TestOIDCTokenRefresh: @pytest.fixture def test_client_with_refresh_token(self, client, test_organization, test_user): """Create a test OIDC client with refresh token.""" - from app.models import OIDCClient - from app.services.oidc_service import OIDCService + from gatehouse_app.models import OIDCClient + from gatehouse_app.services.oidc_service import OIDCService client_data = OIDCClient( organization_id=test_organization.id, @@ -637,7 +637,7 @@ class TestOIDCTokenRefresh: is_confidential=False, require_pkce=False, ) - from app.extensions import db + from gatehouse_app.extensions import db db.session.add(client_data) db.session.commit() @@ -717,8 +717,8 @@ class TestOIDCTokenRevocation: @pytest.fixture def test_client_with_tokens(self, client, test_organization, test_user): """Create a test OIDC client with valid tokens.""" - from app.models import OIDCClient - from app.services.oidc_service import OIDCService + from gatehouse_app.models import OIDCClient + from gatehouse_app.services.oidc_service import OIDCService client_data = OIDCClient( organization_id=test_organization.id, @@ -734,7 +734,7 @@ class TestOIDCTokenRevocation: is_confidential=False, require_pkce=False, ) - from app.extensions import db + from gatehouse_app.extensions import db db.session.add(client_data) db.session.commit() @@ -821,8 +821,8 @@ class TestOIDCTokenIntrospection: @pytest.fixture def test_client_with_tokens(self, client, test_organization, test_user): """Create a test OIDC client with valid tokens.""" - from app.models import OIDCClient - from app.services.oidc_service import OIDCService + from gatehouse_app.models import OIDCClient + from gatehouse_app.services.oidc_service import OIDCService client_data = OIDCClient( organization_id=test_organization.id, @@ -838,7 +838,7 @@ class TestOIDCTokenIntrospection: is_confidential=False, require_pkce=False, ) - from app.extensions import db + from gatehouse_app.extensions import db db.session.add(client_data) db.session.commit() @@ -896,9 +896,9 @@ class TestOIDCCompleteFlow: def test_complete_oidc_flow(self, client, app, test_organization, test_user): """Test complete OIDC authorization code flow with PKCE.""" - from app.models import OIDCClient - from app.services.oidc_service import OIDCService - from app.extensions import db + from gatehouse_app.models import OIDCClient + from gatehouse_app.services.oidc_service import OIDCService + from gatehouse_app.extensions import db # Create a test client with app.app_context(): diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 9848964..1fce0b5 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -1,8 +1,8 @@ """Unit tests for models.""" import pytest from datetime import datetime -from app.models import User, Organization -from app.utils.constants import UserStatus +from gatehouse_app.models import User, Organization +from gatehouse_app.utils.constants import UserStatus @pytest.mark.unit diff --git a/tests/unit/test_services/test_auth_service.py b/tests/unit/test_services/test_auth_service.py index 13ca360..fecab35 100644 --- a/tests/unit/test_services/test_auth_service.py +++ b/tests/unit/test_services/test_auth_service.py @@ -1,9 +1,9 @@ """Unit tests for AuthService.""" import pytest -from app.services.auth_service import AuthService -from app.exceptions.auth_exceptions import InvalidCredentialsError -from app.exceptions.validation_exceptions import EmailAlreadyExistsError -from app.utils.constants import UserStatus, AuthMethodType +from gatehouse_app.services.auth_service import AuthService +from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError +from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError +from gatehouse_app.utils.constants import UserStatus, AuthMethodType @pytest.mark.unit diff --git a/tests/unit/test_services/test_totp_service.py b/tests/unit/test_services/test_totp_service.py index 78b0bd6..9830250 100644 --- a/tests/unit/test_services/test_totp_service.py +++ b/tests/unit/test_services/test_totp_service.py @@ -1,7 +1,7 @@ """Unit tests for TOTPService.""" import base64 import pytest -from app.services.totp_service import TOTPService +from gatehouse_app.services.totp_service import TOTPService @pytest.mark.unit diff --git a/wsgi.py b/wsgi.py index 2e8da86..7bc4db4 100644 --- a/wsgi.py +++ b/wsgi.py @@ -6,10 +6,13 @@ from dotenv import load_dotenv, find_dotenv load_dotenv(find_dotenv()) import os -from app import create_app +from gatehouse_app import create_app # Create application instance -app = create_app(os.getenv("FLASK_ENV", "development")) +application = create_app(os.getenv("FLASK_ENV", "development")) + +# For backwards compatibility +app = application if __name__ == "__main__": app.run()