move app to gatehouse-app

This commit is contained in:
2026-01-15 03:40:29 +10:30
parent 5e4cffcf73
commit 2c0aaf484b
69 changed files with 1569 additions and 294 deletions
-6
View File
@@ -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"]
-30
View File
@@ -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",
]
-25
View File
@@ -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",
]
+1 -1
View File
@@ -1454,7 +1454,7 @@ for key in jwks["keys"]:
```bash ```bash
# Test database connection # Test database connection
export DATABASE_URL="postgresql://user:pass@localhost:5432/authy2" 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 #### Migration Issues
+22 -20
View File
@@ -8,11 +8,12 @@ _root_logger.debug("[TEST] Debug logging is working!")
from flask import Flask from flask import Flask
from config import get_config from config import get_config
from app.extensions import db, migrate, bcrypt, ma, limiter, session from gatehouse_app.extensions import db, migrate, bcrypt, ma, limiter
from app.middleware import RequestIDMiddleware, SecurityHeadersMiddleware, setup_cors from gatehouse_app.extensions import session as flask_session
from app.exceptions.base import BaseAPIException from gatehouse_app.middleware import RequestIDMiddleware, SecurityHeadersMiddleware, setup_cors
from app.utils.response import api_response from gatehouse_app.exceptions.base import BaseAPIException
from app.services.oidc_jwks_service import OIDCJWKSService from gatehouse_app.utils.response import api_response
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
import redis import redis
# Configure SQLAlchemy logging BEFORE any database operations # Configure SQLAlchemy logging BEFORE any database operations
@@ -35,31 +36,31 @@ def create_app(config_name=None):
Returns: Returns:
Flask application instance Flask application instance
""" """
app = Flask(__name__) flask_app = Flask(__name__)
# Load configuration # Load configuration
config = get_config(config_name) config = get_config(config_name)
app.config.from_object(config) flask_app.config.from_object(config)
# Initialize extensions # Initialize extensions
initialize_extensions(app) initialize_extensions(flask_app)
# Setup middleware # Setup middleware
setup_middleware(app) setup_middleware(flask_app)
# Register blueprints # Register blueprints
register_blueprints(app) register_blueprints(flask_app)
# Register error handlers # Register error handlers
register_error_handlers(app) register_error_handlers(flask_app)
# Setup logging # Setup logging
setup_logging(app) setup_logging(flask_app)
# Initialize OIDC JWKS service with a signing key # 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): def initialize_extensions(app):
@@ -86,13 +87,14 @@ def initialize_extensions(app):
try: try:
redis_url = app.config.get("REDIS_URL") redis_url = app.config.get("REDIS_URL")
if redis_url: if redis_url:
redis_client = redis.from_url(redis_url) import gatehouse_app.extensions
app.config["SESSION_REDIS"] = redis_client gatehouse_app.extensions.redis_client = redis.from_url(redis_url)
app.config["SESSION_REDIS"] = gatehouse_app.extensions.redis_client
except Exception as e: except Exception as e:
app.logger.warning(f"Redis connection failed: {e}") logging.warning(f"Redis connection failed: {e}")
# Flask-Session # Flask-Session
session.init_app(app) flask_session.init_app(app)
def setup_middleware(app): def setup_middleware(app):
@@ -104,8 +106,8 @@ def setup_middleware(app):
def register_blueprints(app): def register_blueprints(app):
"""Register application blueprints.""" """Register application blueprints."""
from app.api import register_api_blueprints from gatehouse_app.api import register_api_blueprints
from app.api.oidc import oidc_bp from gatehouse_app.api.oidc import oidc_bp
register_api_blueprints(app) register_api_blueprints(app)
@@ -1,6 +1,6 @@
"""API package.""" """API package."""
from flask import Blueprint from flask import Blueprint
from app.utils.response import api_response from gatehouse_app.utils.response import api_response
# Create main API blueprint # Create main API blueprint
api_bp = Blueprint("api", __name__) api_bp = Blueprint("api", __name__)
@@ -17,7 +17,7 @@ def health_check():
def register_api_blueprints(app): def register_api_blueprints(app):
"""Register all API blueprints.""" """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 # Register versioned API blueprints
app.register_blueprint(api_bp, url_prefix="/api") app.register_blueprint(api_bp, url_prefix="/api")
@@ -11,16 +11,16 @@ from flask import Blueprint, request, redirect, jsonify, session, g, current_app
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from app.utils.response import api_response from gatehouse_app.utils.response import api_response
from app.services.oidc_service import ( from gatehouse_app.services.oidc_service import (
OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError
) )
from app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
from app.extensions import db from gatehouse_app.extensions import db
from app.extensions import bcrypt as flask_bcrypt from gatehouse_app.extensions import bcrypt as flask_bcrypt
from app.models import User, OIDCClient from gatehouse_app.models import User, OIDCClient
from app.models.organization import Organization from gatehouse_app.models.organization import Organization
from app.exceptions.auth_exceptions import InvalidCredentialsError from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
# Create OIDC blueprint registered at root level # Create OIDC blueprint registered at root level
@@ -5,4 +5,4 @@ from flask import Blueprint
api_v1_bp = Blueprint("api_v1", __name__) api_v1_bp = Blueprint("api_v1", __name__)
# Import route modules to register them # Import route modules to register them
from app.api.v1 import auth, users, organizations from gatehouse_app.api.v1 import auth, users, organizations
@@ -1,9 +1,10 @@
"""Authentication endpoints.""" """Authentication endpoints."""
from flask import request, session, g import json
from flask import request, session, g, jsonify
from marshmallow import ValidationError from marshmallow import ValidationError
from app.api.v1 import api_v1_bp from gatehouse_app.api.v1 import api_v1_bp
from app.utils.response import api_response from gatehouse_app.utils.response import api_response
from app.schemas.auth_schema import ( from gatehouse_app.schemas.auth_schema import (
RegisterSchema, RegisterSchema,
LoginSchema, LoginSchema,
TOTPVerifyEnrollmentSchema, TOTPVerifyEnrollmentSchema,
@@ -11,12 +12,20 @@ from app.schemas.auth_schema import (
TOTPDisableSchema, TOTPDisableSchema,
TOTPRegenerateBackupCodesSchema, TOTPRegenerateBackupCodesSchema,
) )
from app.services.auth_service import AuthService from gatehouse_app.schemas.webauthn_schema import (
from app.services.user_service import UserService WebAuthnRegistrationBeginSchema,
from app.utils.decorators import login_required WebAuthnRegistrationCompleteSchema,
from app.utils.constants import AuditAction WebAuthnLoginBeginSchema,
from app.exceptions.auth_exceptions import InvalidCredentialsError WebAuthnLoginCompleteSchema,
from app.exceptions.validation_exceptions import ConflictError 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"]) @api_v1_bp.route("/auth/register", methods=["POST"])
@@ -188,7 +197,7 @@ def get_user_sessions():
200: List of active sessions 200: List of active sessions
401: Not authenticated 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) sessions = SessionService.get_user_sessions(g.current_user.id, active_only=True)
@@ -215,7 +224,7 @@ def revoke_session(session_id):
401: Not authenticated 401: Not authenticated
404: Session not found 404: Session not found
""" """
from app.models.session import Session from gatehouse_app.models.session import Session
# Ensure session belongs to current user # Ensure session belongs to current user
user_session = Session.query.filter_by( user_session = Session.query.filter_by(
@@ -347,7 +356,7 @@ def verify_totp():
) )
# Get user from database # Get user from database
from app.models.user import User from gatehouse_app.models.user import User
user = User.query.get(user_id) user = User.query.get(user_id)
if not user: if not user:
return api_response( return api_response(
@@ -527,3 +536,402 @@ def regenerate_totp_backup_codes():
status=e.status_code, status=e.status_code,
error_type=e.error_type, error_type=e.error_type,
) )
# =============================================================================
# WebAuthn Passkey Endpoints
# =============================================================================
@api_v1_bp.route("/auth/webauthn/register/begin", methods=["POST"])
@login_required
def begin_webauthn_registration():
"""
Begin WebAuthn passkey registration.
Returns:
200: PublicKeyCredentialCreationOptions (raw JSON, no wrapper)
401: Not authenticated
"""
user = g.current_user
# Generate registration challenge
options = WebAuthnService.generate_registration_challenge(user)
# Return unwrapped JSON for WebAuthn
return jsonify(options), 200
@api_v1_bp.route("/auth/webauthn/register/complete", methods=["POST"])
@login_required
def complete_webauthn_registration():
"""
Complete WebAuthn passkey registration.
Request body:
id: Credential ID
rawId: Base64URL-encoded credential ID
type: "public-key"
response: Attestation response data
transports: List of transport types
Returns:
200: Registration successful
400: Validation error
401: Not authenticated
409: Credential already exists
"""
try:
# Validate request data
schema = WebAuthnRegistrationCompleteSchema()
data = schema.load(request.json)
# Extract challenge from client data
client_data = data.get("response", {}).get("clientDataJSON", "")
import base64
client_data_json = base64.urlsafe_b64decode(client_data + "==")
client_data_dict = json.loads(client_data_json)
challenge = client_data_dict.get("challenge")
if not challenge:
return api_response(
success=False,
message="Invalid challenge in client data",
status=400,
error_type="VALIDATION_ERROR",
)
# Verify registration response
auth_method = WebAuthnService.verify_registration_response(
g.current_user,
data,
challenge
)
return api_response(
data={
"credential": auth_method.to_webauthn_dict(),
},
message="Passkey registered successfully",
status=201,
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
except InvalidCredentialsError as e:
return api_response(
success=False,
message=e.message,
status=e.status_code,
error_type=e.error_type,
)
@api_v1_bp.route("/auth/webauthn/login/begin", methods=["POST"])
def begin_webauthn_login():
"""
Begin WebAuthn passkey login.
Request body:
email: User email address
Returns:
200: PublicKeyCredentialRequestOptions (raw JSON, no wrapper)
400: Validation error
404: User not found
"""
try:
# Validate request data
schema = WebAuthnLoginBeginSchema()
data = schema.load(request.json)
# Find user by email
from gatehouse_app.models.user import User
user = User.query.filter_by(
email=data["email"].lower(),
deleted_at=None
).first()
if not user:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
# Check if user has any WebAuthn credentials
if not user.has_webauthn_enabled():
return api_response(
success=False,
message="No passkeys found for this account",
status=404,
error_type="NOT_FOUND",
)
# Generate authentication challenge
options = WebAuthnService.generate_authentication_challenge(user)
# Store user_id in session for verification
session["webauthn_pending_user_id"] = user.id
# Return unwrapped JSON for WebAuthn
return jsonify(options), 200
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/auth/webauthn/login/complete", methods=["POST"])
def complete_webauthn_login():
"""
Complete WebAuthn passkey login.
Request body:
id: Credential ID
rawId: Base64URL-encoded credential ID
type: "public-key"
response: Assertion response data
Returns:
200: Login successful with session token
400: Validation error
401: Authentication failed
"""
try:
# Get user from session
user_id = session.get("webauthn_pending_user_id")
if not user_id:
return api_response(
success=False,
message="No pending WebAuthn verification. Please initiate login first.",
status=401,
error_type="AUTHENTICATION_ERROR",
)
# Validate request data
schema = WebAuthnLoginCompleteSchema()
data = schema.load(request.json)
# Get user from database
from gatehouse_app.models.user import User
user = User.query.get(user_id)
if not user:
return api_response(
success=False,
message="User not found",
status=401,
error_type="AUTHENTICATION_ERROR",
)
# Extract challenge from client data
client_data = data.get("response", {}).get("clientDataJSON", "")
import base64
client_data_json = base64.urlsafe_b64decode(client_data + "==")
client_data_dict = json.loads(client_data_json)
challenge = client_data_dict.get("challenge")
if not challenge:
return api_response(
success=False,
message="Invalid challenge in client data",
status=400,
error_type="VALIDATION_ERROR",
)
# Verify authentication response
WebAuthnService.verify_authentication_response(
user,
data,
challenge
)
# Create session
user_session = AuthService.create_session(user)
# Clear pending session
session.pop("webauthn_pending_user_id", None)
return api_response(
data={
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
},
message="Login successful",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
except InvalidCredentialsError as e:
return api_response(
success=False,
message=e.message,
status=e.status_code,
error_type=e.error_type,
)
@api_v1_bp.route("/auth/webauthn/credentials", methods=["GET"])
@login_required
def list_webauthn_credentials():
"""
List all WebAuthn passkey credentials for the current user.
Returns:
200: List of credentials
401: Not authenticated
"""
user = g.current_user
credentials = WebAuthnService.get_user_credentials(user)
return api_response(
data={
"credentials": [cred.to_webauthn_dict() for cred in credentials],
"count": len(credentials),
},
message="Credentials retrieved successfully",
)
@api_v1_bp.route("/auth/webauthn/credentials/<credential_id>", methods=["DELETE"])
@login_required
def delete_webauthn_credential(credential_id):
"""
Delete a WebAuthn passkey credential.
Args:
credential_id: ID of the credential to delete
Returns:
200: Credential deleted successfully
401: Not authenticated
404: Credential not found
"""
user = g.current_user
# Check if this is the last credential
credential_count = user.get_webauthn_credential_count()
if credential_count <= 1:
return api_response(
success=False,
message="Cannot delete the last passkey. Add another passkey first.",
status=400,
error_type="BAD_REQUEST",
)
# Delete the credential
success = WebAuthnService.delete_credential(credential_id, user)
if not success:
return api_response(
success=False,
message="Credential not found",
status=404,
error_type="NOT_FOUND",
)
return api_response(
message="Passkey deleted successfully",
)
@api_v1_bp.route("/auth/webauthn/credentials/<credential_id>", methods=["PATCH"])
@login_required
def rename_webauthn_credential(credential_id):
"""
Rename a WebAuthn passkey credential.
Args:
credential_id: ID of the credential to rename
Request body:
name: New name for the credential
Returns:
200: Credential renamed successfully
400: Validation error
401: Not authenticated
404: Credential not found
"""
try:
# Validate request data
schema = WebAuthnCredentialRenameSchema()
data = schema.load(request.json)
# Rename the credential
success = WebAuthnService.rename_credential(
credential_id,
g.current_user,
data["name"]
)
if not success:
return api_response(
success=False,
message="Credential not found",
status=404,
error_type="NOT_FOUND",
)
# Get updated credential
credential = WebAuthnService.get_credential_by_id(credential_id, g.current_user)
return api_response(
data={
"credential": credential.to_webauthn_dict() if credential else None,
},
message="Passkey renamed successfully",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/auth/webauthn/status", methods=["GET"])
@login_required
def get_webauthn_status():
"""
Get WebAuthn status for the current user.
Returns:
200: WebAuthn status with webauthn_enabled and credential_count
401: Not authenticated
"""
user = g.current_user
return api_response(
data={
"webauthn_enabled": user.has_webauthn_enabled(),
"credential_count": user.get_webauthn_credential_count(),
},
message="WebAuthn status retrieved successfully",
)
@@ -1,18 +1,18 @@
"""Organization endpoints.""" """Organization endpoints."""
from flask import g, request from flask import g, request
from marshmallow import ValidationError from marshmallow import ValidationError
from app.api.v1 import api_v1_bp from gatehouse_app.api.v1 import api_v1_bp
from app.utils.response import api_response from gatehouse_app.utils.response import api_response
from app.utils.decorators import login_required, require_admin, require_owner from gatehouse_app.utils.decorators import login_required, require_admin, require_owner
from app.schemas.organization_schema import ( from gatehouse_app.schemas.organization_schema import (
OrganizationCreateSchema, OrganizationCreateSchema,
OrganizationUpdateSchema, OrganizationUpdateSchema,
InviteMemberSchema, InviteMemberSchema,
UpdateMemberRoleSchema, UpdateMemberRoleSchema,
) )
from app.services.organization_service import OrganizationService from gatehouse_app.services.organization_service import OrganizationService
from app.services.user_service import UserService from gatehouse_app.services.user_service import UserService
from app.utils.constants import OrganizationRole from gatehouse_app.utils.constants import OrganizationRole
@api_v1_bp.route("/organizations", methods=["POST"]) @api_v1_bp.route("/organizations", methods=["POST"])
@@ -1,12 +1,12 @@
"""User endpoints.""" """User endpoints."""
from flask import g, request from flask import g, request
from marshmallow import ValidationError from marshmallow import ValidationError
from app.api.v1 import api_v1_bp from gatehouse_app.api.v1 import api_v1_bp
from app.utils.response import api_response from gatehouse_app.utils.response import api_response
from app.utils.decorators import login_required from gatehouse_app.utils.decorators import login_required
from app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema from gatehouse_app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
from app.services.user_service import UserService from gatehouse_app.services.user_service import UserService
from app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
@api_v1_bp.route("/users/me", methods=["GET"]) @api_v1_bp.route("/users/me", methods=["GET"])
@@ -1,6 +1,6 @@
"""Exceptions package.""" """Exceptions package."""
from app.exceptions.base import BaseAPIException from gatehouse_app.exceptions.base import BaseAPIException
from app.exceptions.auth_exceptions import ( from gatehouse_app.exceptions.auth_exceptions import (
UnauthorizedError, UnauthorizedError,
ForbiddenError, ForbiddenError,
InvalidCredentialsError, InvalidCredentialsError,
@@ -9,7 +9,7 @@ from app.exceptions.auth_exceptions import (
SessionExpiredError, SessionExpiredError,
InvalidTokenError, InvalidTokenError,
) )
from app.exceptions.validation_exceptions import ( from gatehouse_app.exceptions.validation_exceptions import (
ValidationError, ValidationError,
NotFoundError, NotFoundError,
ConflictError, ConflictError,
@@ -1,5 +1,5 @@
"""Authentication and authorization exceptions.""" """Authentication and authorization exceptions."""
from app.exceptions.base import BaseAPIException from gatehouse_app.exceptions.base import BaseAPIException
class UnauthorizedError(BaseAPIException): class UnauthorizedError(BaseAPIException):
@@ -1,5 +1,5 @@
"""Validation and resource exceptions.""" """Validation and resource exceptions."""
from app.exceptions.base import BaseAPIException from gatehouse_app.exceptions.base import BaseAPIException
class ValidationError(BaseAPIException): class ValidationError(BaseAPIException):
@@ -7,6 +7,7 @@ from flask_marshmallow import Marshmallow
from flask_limiter import Limiter from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
from flask_session import Session from flask_session import Session
import redis
# Initialize extensions # Initialize extensions
db = SQLAlchemy() db = SQLAlchemy()
@@ -20,3 +21,6 @@ limiter = Limiter(
storage_uri="memory://", # Will be overridden by config storage_uri="memory://", # Will be overridden by config
) )
session = Session() session = Session()
# Redis client - will be initialized with app
redis_client = None
+6
View File
@@ -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"]
+30
View File
@@ -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",
]
@@ -1,7 +1,7 @@
"""Audit log model.""" """Audit log model."""
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
from app.utils.constants import AuditAction from gatehouse_app.utils.constants import AuditAction
class AuditLog(BaseModel): class AuditLog(BaseModel):
@@ -1,7 +1,7 @@
"""Authentication method model.""" """Authentication method model."""
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
from app.utils.constants import AuthMethodType from gatehouse_app.utils.constants import AuthMethodType
class AuthenticationMethod(BaseModel): class AuthenticationMethod(BaseModel):
@@ -60,6 +60,10 @@ class AuthenticationMethod(BaseModel):
"""Check if this is a TOTP authentication method.""" """Check if this is a TOTP authentication method."""
return self.method_type == AuthMethodType.TOTP 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): def to_dict(self, exclude=None):
"""Convert to dictionary, excluding sensitive fields.""" """Convert to dictionary, excluding sensitive fields."""
exclude = exclude or [] exclude = exclude or []
@@ -68,3 +72,22 @@ class AuthenticationMethod(BaseModel):
exclude.append("totp_secret") exclude.append("totp_secret")
exclude.append("totp_backup_codes") exclude.append("totp_backup_codes")
return super().to_dict(exclude=exclude) 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),
}
@@ -1,7 +1,7 @@
"""Base model with common fields and functionality.""" """Base model with common fields and functionality."""
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from app.extensions import db from gatehouse_app.extensions import db
class BaseModel(db.Model): class BaseModel(db.Model):
@@ -1,7 +1,7 @@
"""OIDC Audit Log model for comprehensive OIDC event tracking.""" """OIDC Audit Log model for comprehensive OIDC event tracking."""
from datetime import datetime from datetime import datetime
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
class OIDCAuditLog(BaseModel): class OIDCAuditLog(BaseModel):
@@ -219,13 +219,13 @@ class OIDCAuditLog(BaseModel):
# Add relationship back to User model # 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( User.oidc_audit_logs = db.relationship(
"OIDCAuditLog", back_populates="user", cascade="all, delete-orphan" "OIDCAuditLog", back_populates="user", cascade="all, delete-orphan"
) )
# Add relationship back to OIDCClient model # 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( OIDCClient.audit_logs = db.relationship(
"OIDCAuditLog", back_populates="client", cascade="all, delete-orphan" "OIDCAuditLog", back_populates="client", cascade="all, delete-orphan"
) )
@@ -1,7 +1,7 @@
"""OIDC Authorization Code model for auth code flow.""" """OIDC Authorization Code model for auth code flow."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
class OIDCAuthCode(BaseModel): class OIDCAuthCode(BaseModel):
@@ -113,13 +113,13 @@ class OIDCAuthCode(BaseModel):
# Add relationship back to User model # 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( User.oidc_auth_codes = db.relationship(
"OIDCAuthCode", back_populates="user", cascade="all, delete-orphan" "OIDCAuthCode", back_populates="user", cascade="all, delete-orphan"
) )
# Add relationship back to OIDCClient model # 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( OIDCClient.authorization_codes = db.relationship(
"OIDCAuthCode", back_populates="client", cascade="all, delete-orphan" "OIDCAuthCode", back_populates="client", cascade="all, delete-orphan"
) )
@@ -1,7 +1,7 @@
"""OIDC Client model.""" """OIDC Client model."""
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
from app.utils.constants import OIDCGrantType, OIDCResponseType from gatehouse_app.utils.constants import OIDCGrantType, OIDCResponseType
class OIDCClient(BaseModel): class OIDCClient(BaseModel):
@@ -1,7 +1,7 @@
"""OIDC JWKS Key model for persisting signing keys.""" """OIDC JWKS Key model for persisting signing keys."""
from datetime import datetime, timezone from datetime import datetime, timezone
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
class OidcJwksKey(BaseModel): class OidcJwksKey(BaseModel):
@@ -1,7 +1,7 @@
"""OIDC Refresh Token model for token rotation.""" """OIDC Refresh Token model for token rotation."""
from datetime import datetime, timezone from datetime import datetime, timezone
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
class OIDCRefreshToken(BaseModel): class OIDCRefreshToken(BaseModel):
@@ -145,19 +145,19 @@ class OIDCRefreshToken(BaseModel):
# Add relationship back to User model # 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( User.oidc_refresh_tokens = db.relationship(
"OIDCRefreshToken", back_populates="user", cascade="all, delete-orphan" "OIDCRefreshToken", back_populates="user", cascade="all, delete-orphan"
) )
# Add relationship back to OIDCClient model # 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( OIDCClient.refresh_tokens = db.relationship(
"OIDCRefreshToken", back_populates="client", cascade="all, delete-orphan" "OIDCRefreshToken", back_populates="client", cascade="all, delete-orphan"
) )
# Add relationship back to Session model # 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( Session.oidc_refresh_token = db.relationship(
"OIDCRefreshToken", back_populates="access_token", uselist=False "OIDCRefreshToken", back_populates="access_token", uselist=False
) )
@@ -1,7 +1,7 @@
"""OIDC Session model for OIDC session tracking.""" """OIDC Session model for OIDC session tracking."""
from datetime import datetime, timezone from datetime import datetime, timezone
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
class OIDCSession(BaseModel): class OIDCSession(BaseModel):
@@ -150,13 +150,13 @@ class OIDCSession(BaseModel):
# Add relationship back to User model # Add relationship back to User model
from app.models.user import User from gatehouse_app.models.user import User
User.oidc_sessions = db.relationship( User.oidc_sessions = db.relationship(
"OIDCSession", back_populates="user", cascade="all, delete-orphan" "OIDCSession", back_populates="user", cascade="all, delete-orphan"
) )
# Add relationship back to OIDCClient model # 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( OIDCClient.oidc_sessions = db.relationship(
"OIDCSession", back_populates="client", cascade="all, delete-orphan" "OIDCSession", back_populates="client", cascade="all, delete-orphan"
) )
@@ -1,8 +1,8 @@
"""OIDC Token Metadata model for token revocation tracking.""" """OIDC Token Metadata model for token revocation tracking."""
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
class OIDCTokenMetadata(BaseModel): class OIDCTokenMetadata(BaseModel):
@@ -184,13 +184,13 @@ class OIDCTokenMetadata(BaseModel):
# Add relationship back to User model # 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( User.oidc_token_metadata = db.relationship(
"OIDCTokenMetadata", back_populates="user", cascade="all, delete-orphan" "OIDCTokenMetadata", back_populates="user", cascade="all, delete-orphan"
) )
# Add relationship back to OIDCClient model # 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( OIDCClient.token_metadata = db.relationship(
"OIDCTokenMetadata", back_populates="client", cascade="all, delete-orphan" "OIDCTokenMetadata", back_populates="client", cascade="all, delete-orphan"
) )
@@ -1,6 +1,6 @@
"""Organization model.""" """Organization model."""
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
class Organization(BaseModel): class Organization(BaseModel):
@@ -35,7 +35,7 @@ class Organization(BaseModel):
def get_owner(self): def get_owner(self):
"""Get the owner of the organization.""" """Get the owner of the organization."""
from app.utils.constants import OrganizationRole from gatehouse_app.utils.constants import OrganizationRole
for member in self.members: for member in self.members:
if member.role == OrganizationRole.OWNER and member.deleted_at is None: if member.role == OrganizationRole.OWNER and member.deleted_at is None:
@@ -44,7 +44,7 @@ class Organization(BaseModel):
def is_member(self, user_id): def is_member(self, user_id):
"""Check if a user is a member of the organization.""" """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 ( return (
OrganizationMember.query.filter_by( OrganizationMember.query.filter_by(
@@ -1,7 +1,7 @@
"""Organization member model.""" """Organization member model."""
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
from app.utils.constants import OrganizationRole from gatehouse_app.utils.constants import OrganizationRole
class OrganizationMember(BaseModel): class OrganizationMember(BaseModel):
@@ -1,8 +1,8 @@
"""Session model.""" """Session model."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
from app.utils.constants import SessionStatus from gatehouse_app.utils.constants import SessionStatus
class Session(BaseModel): class Session(BaseModel):
@@ -1,7 +1,7 @@
"""User model.""" """User model."""
from app.extensions import db from gatehouse_app.extensions import db
from app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
from app.utils.constants import UserStatus from gatehouse_app.utils.constants import UserStatus
class User(BaseModel): class User(BaseModel):
@@ -46,8 +46,8 @@ class User(BaseModel):
def has_password_auth(self): def has_password_auth(self):
"""Check if user has password authentication enabled.""" """Check if user has password authentication enabled."""
from app.models.authentication_method import AuthenticationMethod from gatehouse_app.models.authentication_method import AuthenticationMethod
from app.utils.constants import AuthMethodType from gatehouse_app.utils.constants import AuthMethodType
return ( return (
AuthenticationMethod.query.filter_by( AuthenticationMethod.query.filter_by(
@@ -66,8 +66,8 @@ class User(BaseModel):
Returns: Returns:
True if user has a verified TOTP authentication method, False otherwise. True if user has a verified TOTP authentication method, False otherwise.
""" """
from app.models.authentication_method import AuthenticationMethod from gatehouse_app.models.authentication_method import AuthenticationMethod
from app.utils.constants import AuthMethodType from gatehouse_app.utils.constants import AuthMethodType
return ( return (
AuthenticationMethod.query.filter_by( AuthenticationMethod.query.filter_by(
@@ -89,9 +89,53 @@ class User(BaseModel):
Returns the most recently created TOTP method to handle cases where Returns the most recently created TOTP method to handle cases where
multiple enrollment attempts may exist. multiple enrollment attempts may exist.
""" """
from app.models.authentication_method import AuthenticationMethod from gatehouse_app.models.authentication_method import AuthenticationMethod
from app.utils.constants import AuthMethodType from gatehouse_app.utils.constants import AuthMethodType
return AuthenticationMethod.query.filter_by( return AuthenticationMethod.query.filter_by(
user_id=self.id, method_type=AuthMethodType.TOTP, deleted_at=None user_id=self.id, method_type=AuthMethodType.TOTP, deleted_at=None
).order_by(AuthenticationMethod.created_at.desc()).first() ).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()
@@ -1,13 +1,13 @@
"""Schemas package.""" """Schemas package."""
from app.schemas.user_schema import UserSchema, UserUpdateSchema, ChangePasswordSchema from gatehouse_app.schemas.user_schema import UserSchema, UserUpdateSchema, ChangePasswordSchema
from app.schemas.auth_schema import ( from gatehouse_app.schemas.auth_schema import (
RegisterSchema, RegisterSchema,
LoginSchema, LoginSchema,
RefreshTokenSchema, RefreshTokenSchema,
ForgotPasswordSchema, ForgotPasswordSchema,
ResetPasswordSchema, ResetPasswordSchema,
) )
from app.schemas.organization_schema import ( from gatehouse_app.schemas.organization_schema import (
OrganizationSchema, OrganizationSchema,
OrganizationCreateSchema, OrganizationCreateSchema,
OrganizationUpdateSchema, OrganizationUpdateSchema,
@@ -1,6 +1,6 @@
"""User schemas for validation and serialization.""" """User schemas for validation and serialization."""
from marshmallow import Schema, fields, validate, validates, ValidationError from marshmallow import Schema, fields, validate, validates, ValidationError
from app.utils.constants import UserStatus from gatehouse_app.utils.constants import UserStatus
class UserSchema(Schema): class UserSchema(Schema):
+85
View File
@@ -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)
)
+25
View File
@@ -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",
]
@@ -1,7 +1,7 @@
"""Audit service.""" """Audit service."""
from flask import request, g from flask import request, g
from app.models.audit_log import AuditLog from gatehouse_app.models.audit_log import AuditLog
from app.utils.constants import AuditAction from gatehouse_app.utils.constants import AuditAction
class AuditService: class AuditService:
@@ -3,15 +3,15 @@ import logging
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from flask import request, g, current_app from flask import request, g, current_app
from app.extensions import db, bcrypt from gatehouse_app.extensions import db, bcrypt
from app.models.user import User from gatehouse_app.models.user import User
from app.models.authentication_method import AuthenticationMethod from gatehouse_app.models.authentication_method import AuthenticationMethod
from app.models.session import Session from gatehouse_app.models.session import Session
from app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
from app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
from app.exceptions.validation_exceptions import EmailAlreadyExistsError from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
from app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
from app.services.totp_service import TOTPService from gatehouse_app.services.totp_service import TOTPService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -254,7 +254,7 @@ class AuthService:
Raises: Raises:
ConflictError: If user already has TOTP enabled 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 # Check if user already has TOTP enabled
if user.has_totp_enabled(): if user.has_totp_enabled():
@@ -4,8 +4,8 @@ from typing import Dict, List, Optional
from flask import g from flask import g
from app.models import OIDCAuditLog, OIDCClient, User from gatehouse_app.models import OIDCAuditLog, OIDCClient, User
from app.exceptions.validation_exceptions import NotFoundError from gatehouse_app.exceptions.validation_exceptions import NotFoundError
class OIDCAuditService: class OIDCAuditService:
@@ -7,8 +7,8 @@ from typing import Dict, List, Optional, Tuple
from flask import current_app from flask import current_app
from app.extensions import db from gatehouse_app.extensions import db
from app.models.oidc_jwks_key import OidcJwksKey from gatehouse_app.models.oidc_jwks_key import OidcJwksKey
class JWKSKey: class JWKSKey:
@@ -9,20 +9,20 @@ from flask import current_app, g
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from app.extensions import db from gatehouse_app.extensions import db
from app.models import ( from gatehouse_app.models import (
User, OIDCClient, OIDCAuthCode, OIDCRefreshToken, User, OIDCClient, OIDCAuthCode, OIDCRefreshToken,
OIDCSession, OIDCTokenMetadata OIDCSession, OIDCTokenMetadata
) )
from app.models.organization_member import OrganizationMember from gatehouse_app.models.organization_member import OrganizationMember
from app.exceptions.validation_exceptions import ( from gatehouse_app.exceptions.validation_exceptions import (
ValidationError, NotFoundError, BadRequestError ValidationError, NotFoundError, BadRequestError
) )
from app.exceptions.auth_exceptions import UnauthorizedError, InvalidTokenError from gatehouse_app.exceptions.auth_exceptions import UnauthorizedError, InvalidTokenError
from app.services.oidc_token_service import OIDCTokenService from gatehouse_app.services.oidc_token_service import OIDCTokenService
from app.services.oidc_session_service import OIDCSessionService from gatehouse_app.services.oidc_session_service import OIDCSessionService
from app.services.oidc_audit_service import OIDCAuditService from gatehouse_app.services.oidc_audit_service import OIDCAuditService
from app.services.oidc_jwks_service import OIDCJWKSService from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
class OIDCError(Exception): class OIDCError(Exception):
@@ -6,9 +6,9 @@ from typing import Dict, Optional, Tuple
from datetime import timezone from datetime import timezone
from flask import current_app, g from flask import current_app, g
from app.extensions import db from gatehouse_app.extensions import db
from app.models import OIDCSession, OIDCClient, User from gatehouse_app.models import OIDCSession, OIDCClient, User
from app.exceptions.validation_exceptions import NotFoundError, ValidationError from gatehouse_app.exceptions.validation_exceptions import NotFoundError, ValidationError
class OIDCSessionService: class OIDCSessionService:
@@ -10,9 +10,9 @@ from typing import Dict, Optional, Any
import jwt import jwt
from flask import current_app, g from flask import current_app, g
from app.models import User, OIDCClient from gatehouse_app.models import User, OIDCClient
from app.models.organization_member import OrganizationMember from gatehouse_app.models.organization_member import OrganizationMember
from app.services.oidc_jwks_service import OIDCJWKSService from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -2,12 +2,12 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import current_app from flask import current_app
from app.extensions import db from gatehouse_app.extensions import db
from app.models.organization import Organization from gatehouse_app.models.organization import Organization
from app.models.organization_member import OrganizationMember from gatehouse_app.models.organization_member import OrganizationMember
from app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError from gatehouse_app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError
from app.utils.constants import OrganizationRole, AuditAction from gatehouse_app.utils.constants import OrganizationRole, AuditAction
from app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1,7 +1,7 @@
"""Session service.""" """Session service."""
from datetime import datetime, timezone from datetime import datetime, timezone
from app.models.session import Session from gatehouse_app.models.session import Session
from app.utils.constants import SessionStatus from gatehouse_app.utils.constants import SessionStatus
class SessionService: class SessionService:
@@ -17,8 +17,8 @@ class SessionService:
Returns: Returns:
Session object if found and active, None otherwise Session object if found and active, None otherwise
""" """
from app.models.session import Session from gatehouse_app.models.session import Session
from app.utils.constants import SessionStatus from gatehouse_app.utils.constants import SessionStatus
return Session.query.filter_by( return Session.query.filter_by(
token=token, token=token,
status=SessionStatus.ACTIVE, status=SessionStatus.ACTIVE,
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
from typing import Tuple from typing import Tuple
import pyotp import pyotp
from app.extensions import bcrypt from gatehouse_app.extensions import bcrypt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1,11 +1,11 @@
"""User service.""" """User service."""
import logging import logging
from flask import current_app from flask import current_app
from app.extensions import db from gatehouse_app.extensions import db
from app.models.user import User from gatehouse_app.models.user import User
from app.exceptions.validation_exceptions import UserNotFoundError from gatehouse_app.exceptions.validation_exceptions import UserNotFoundError
from app.utils.constants import AuditAction from gatehouse_app.utils.constants import AuditAction
from app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+647
View File
@@ -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
@@ -1,6 +1,6 @@
"""Utilities package.""" """Utilities package."""
from app.utils.response import api_response from gatehouse_app.utils.response import api_response
from app.utils.constants import ( from gatehouse_app.utils.constants import (
UserStatus, UserStatus,
OrganizationRole, OrganizationRole,
AuthMethodType, AuthMethodType,
@@ -8,7 +8,7 @@ from app.utils.constants import (
AuditAction, AuditAction,
ErrorType, 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__ = [ __all__ = [
"api_response", "api_response",
@@ -30,6 +30,7 @@ class AuthMethodType(str, Enum):
MICROSOFT = "microsoft" MICROSOFT = "microsoft"
SAML = "saml" SAML = "saml"
OIDC = "oidc" OIDC = "oidc"
WEBAUTHN = "webauthn"
class SessionStatus(str, Enum): class SessionStatus(str, Enum):
@@ -75,6 +76,16 @@ class AuditAction(str, Enum):
TOTP_BACKUP_CODE_USED = "totp.backup_code.used" TOTP_BACKUP_CODE_USED = "totp.backup_code.used"
TOTP_BACKUP_CODES_REGENERATED = "totp.backup_codes.regenerated" 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): class OIDCGrantType(str, Enum):
"""OIDC grant types.""" """OIDC grant types."""
@@ -1,8 +1,8 @@
"""Custom decorators for authentication and authorization.""" """Custom decorators for authentication and authorization."""
from functools import wraps from functools import wraps
from flask import request, g from flask import request, g
from app.utils.response import api_response from gatehouse_app.utils.response import api_response
from app.utils.constants import OrganizationRole from gatehouse_app.utils.constants import OrganizationRole
def login_required(f): def login_required(f):
@@ -11,7 +11,7 @@ def login_required(f):
Extracts token from Authorization: Bearer {token} header, Extracts token from Authorization: Bearer {token} header,
validates the session, and sets g.current_user and g.current_session. 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) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
@@ -61,7 +61,7 @@ def login_required(f):
# Update last_activity_at timestamp # Update last_activity_at timestamp
from datetime import datetime, timezone from datetime import datetime, timezone
session.last_activity_at = datetime.now(timezone.utc) session.last_activity_at = datetime.now(timezone.utc)
from app import db from gatehouse_app import db
db.session.commit() db.session.commit()
# Set context variables # Set context variables
@@ -96,7 +96,7 @@ def require_role(*allowed_roles):
raise ForbiddenError("Organization context required") raise ForbiddenError("Organization context required")
# Check user's role in the organization # 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( membership = OrganizationMember.query.filter_by(
user_id=g.current_user.id, user_id=g.current_user.id,
+1 -1
View File
@@ -6,7 +6,7 @@ from dotenv import load_dotenv
load_dotenv(dotenv_path=os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env')) load_dotenv(dotenv_path=os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env'))
from flask.cli import FlaskGroup from flask.cli import FlaskGroup
from app import create_app from gatehouse_app import create_app
# Create application # Create application
app = create_app(os.getenv("FLASK_ENV", "development")) app = create_app(os.getenv("FLASK_ENV", "development"))
+44
View File
@@ -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
+2 -2
View File
@@ -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')) load_dotenv(dotenv_path=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
# Import the Flask app and db # Import the Flask app and db
from app import create_app from gatehouse_app import create_app
from app.extensions import db from gatehouse_app.extensions import db
# Get the app # Get the app
app = create_app(os.getenv("FLASK_ENV", "development")) app = create_app(os.getenv("FLASK_ENV", "development"))
+4
View File
@@ -18,6 +18,10 @@ bcrypt==4.1.2
Flask-Bcrypt==1.0.1 Flask-Bcrypt==1.0.1
pyotp==2.9.0 pyotp==2.9.0
# WebAuthn / FIDO2
fido2==1.1.2
cbor2==5.6.0
# JWT / OIDC # JWT / OIDC
PyJWT==2.8.0 PyJWT==2.8.0
cryptography==41.0.7 cryptography==41.0.7
+2 -2
View File
@@ -1,6 +1,6 @@
"""Initialize database script.""" """Initialize database script."""
from app import create_app from gatehouse_app import create_app
from app.extensions import db from gatehouse_app.extensions import db
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables # Load environment variables
+13 -13
View File
@@ -13,16 +13,16 @@ from dotenv import load_dotenv
# Load environment variables FIRST before any app imports # Load environment variables FIRST before any app imports
load_dotenv() load_dotenv()
from app import create_app from gatehouse_app import create_app
from app.extensions import db from gatehouse_app.extensions import db
from app.models.user import User from gatehouse_app.models.user import User
from app.models.organization import Organization from gatehouse_app.models.organization import Organization
from app.models.organization_member import OrganizationMember from gatehouse_app.models.organization_member import OrganizationMember
from app.models.authentication_method import AuthenticationMethod from gatehouse_app.models.authentication_method import AuthenticationMethod
from app.models.oidc_client import OIDCClient from gatehouse_app.models.oidc_client import OIDCClient
from app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
from app.services.organization_service import OrganizationService from gatehouse_app.services.organization_service import OrganizationService
from app.utils.constants import OrganizationRole, UserStatus, AuthMethodType from gatehouse_app.utils.constants import OrganizationRole, UserStatus, AuthMethodType
# Create application # Create application
app = create_app() 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, redirect_uris, grant_types, response_types, scopes,
**kwargs): **kwargs):
"""Create an OIDC client if it doesn't exist, or return existing client.""" """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() existing = OIDCClient.query.filter_by(client_id=client_id, deleted_at=None).first()
if existing: if existing:
@@ -473,7 +473,7 @@ def seed_data():
require_pkce=True, require_pkce=True,
access_token_lifetime=1800, # 30 minutes access_token_lifetime=1800, # 30 minutes
refresh_token_lifetime=604800, # 7 days 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 oidc_clients["acme-mobile"] = acme_mobile_client
@@ -601,4 +601,4 @@ if __name__ == "__main__":
print(f"\n❌ Error seeding database: {e}") print(f"\n❌ Error seeding database: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
sys.exit(1) sys.exit(1)
+6 -6
View File
@@ -1,10 +1,10 @@
"""Pytest configuration and fixtures.""" """Pytest configuration and fixtures."""
import pytest import pytest
from app import create_app from gatehouse_app import create_app
from app.extensions import db as _db from gatehouse_app.extensions import db as _db
from app.models import User, Organization, OrganizationMember from gatehouse_app.models import User, Organization, OrganizationMember
from app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
from app.utils.constants import OrganizationRole from gatehouse_app.utils.constants import OrganizationRole
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -52,7 +52,7 @@ def test_user(db):
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def test_organization(db, test_user): def test_organization(db, test_user):
"""Create a test organization.""" """Create a test organization."""
from app.services.organization_service import OrganizationService from gatehouse_app.services.organization_service import OrganizationService
org = OrganizationService.create_organization( org = OrganizationService.create_organization(
name="Test Organization", name="Test Organization",
+26 -26
View File
@@ -100,7 +100,7 @@ class TestOIDCJWKS:
def test_jwks_contains_signing_key(self, client, app): def test_jwks_contains_signing_key(self, client, app):
"""Test that JWKS contains a valid signing key.""" """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(): with app.app_context():
# Initialize with a key # Initialize with a key
@@ -201,7 +201,7 @@ class TestOIDCAuthorizationCodeFlow:
@pytest.fixture @pytest.fixture
def test_client(self, client, test_organization, test_user): def test_client(self, client, test_organization, test_user):
"""Create a test OIDC client.""" """Create a test OIDC client."""
from app.models import OIDCClient from gatehouse_app.models import OIDCClient
client_data = OIDCClient( client_data = OIDCClient(
organization_id=test_organization.id, organization_id=test_organization.id,
@@ -217,7 +217,7 @@ class TestOIDCAuthorizationCodeFlow:
is_confidential=True, is_confidential=True,
require_pkce=True, require_pkce=True,
) )
from app.extensions import db from gatehouse_app.extensions import db
db.session.add(client_data) db.session.add(client_data)
db.session.commit() db.session.commit()
@@ -338,9 +338,9 @@ class TestOIDCAuthorizationCodeFlow:
def test_authorization_code_exchange_success(self, client, app, test_client, test_user): def test_authorization_code_exchange_success(self, client, app, test_client, test_user):
"""Test successful token exchange with authorization code.""" """Test successful token exchange with authorization code."""
from app.services.oidc_service import OIDCService from gatehouse_app.services.oidc_service import OIDCService
from app.models import OIDCAuthCode from gatehouse_app.models import OIDCAuthCode
from app.extensions import db from gatehouse_app.extensions import db
# First, generate an authorization code # First, generate an authorization code
with app.app_context(): with app.app_context():
@@ -419,7 +419,7 @@ class TestOIDCAuthorizationCodeFlow:
def test_token_exchange_pkce_verification(self, client, app, test_client, test_user): def test_token_exchange_pkce_verification(self, client, app, test_client, test_user):
"""Test PKCE verification during token exchange.""" """Test PKCE verification during token exchange."""
from app.services.oidc_service import OIDCService from gatehouse_app.services.oidc_service import OIDCService
# Generate PKCE pair # Generate PKCE pair
code_verifier, code_challenge = self._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): def test_token_exchange_with_pkce_verifier(self, client, app, test_client, test_user):
"""Test successful token exchange with valid PKCE code verifier.""" """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 # Generate PKCE pair
code_verifier, code_challenge = self._generate_pkce_pair() code_verifier, code_challenge = self._generate_pkce_pair()
@@ -499,8 +499,8 @@ class TestOIDCUserInfo:
@pytest.fixture @pytest.fixture
def test_client_with_user(self, client, test_organization, test_user): def test_client_with_user(self, client, test_organization, test_user):
"""Create a test OIDC client and get tokens.""" """Create a test OIDC client and get tokens."""
from app.models import OIDCClient from gatehouse_app.models import OIDCClient
from app.services.oidc_service import OIDCService from gatehouse_app.services.oidc_service import OIDCService
client_data = OIDCClient( client_data = OIDCClient(
organization_id=test_organization.id, organization_id=test_organization.id,
@@ -516,7 +516,7 @@ class TestOIDCUserInfo:
is_confidential=False, is_confidential=False,
require_pkce=False, require_pkce=False,
) )
from app.extensions import db from gatehouse_app.extensions import db
db.session.add(client_data) db.session.add(client_data)
db.session.commit() db.session.commit()
@@ -569,8 +569,8 @@ class TestOIDCUserInfo:
def test_userinfo_claims_by_scope(self, client, app, test_organization, test_user): def test_userinfo_claims_by_scope(self, client, app, test_organization, test_user):
"""Test UserInfo returns correct claims based on scopes.""" """Test UserInfo returns correct claims based on scopes."""
from app.models import OIDCClient from gatehouse_app.models import OIDCClient
from app.services.oidc_service import OIDCService from gatehouse_app.services.oidc_service import OIDCService
# Create client with only openid scope # Create client with only openid scope
client_data = OIDCClient( client_data = OIDCClient(
@@ -587,7 +587,7 @@ class TestOIDCUserInfo:
is_confidential=False, is_confidential=False,
require_pkce=False, require_pkce=False,
) )
from app.extensions import db from gatehouse_app.extensions import db
db.session.add(client_data) db.session.add(client_data)
db.session.commit() db.session.commit()
@@ -620,8 +620,8 @@ class TestOIDCTokenRefresh:
@pytest.fixture @pytest.fixture
def test_client_with_refresh_token(self, client, test_organization, test_user): def test_client_with_refresh_token(self, client, test_organization, test_user):
"""Create a test OIDC client with refresh token.""" """Create a test OIDC client with refresh token."""
from app.models import OIDCClient from gatehouse_app.models import OIDCClient
from app.services.oidc_service import OIDCService from gatehouse_app.services.oidc_service import OIDCService
client_data = OIDCClient( client_data = OIDCClient(
organization_id=test_organization.id, organization_id=test_organization.id,
@@ -637,7 +637,7 @@ class TestOIDCTokenRefresh:
is_confidential=False, is_confidential=False,
require_pkce=False, require_pkce=False,
) )
from app.extensions import db from gatehouse_app.extensions import db
db.session.add(client_data) db.session.add(client_data)
db.session.commit() db.session.commit()
@@ -717,8 +717,8 @@ class TestOIDCTokenRevocation:
@pytest.fixture @pytest.fixture
def test_client_with_tokens(self, client, test_organization, test_user): def test_client_with_tokens(self, client, test_organization, test_user):
"""Create a test OIDC client with valid tokens.""" """Create a test OIDC client with valid tokens."""
from app.models import OIDCClient from gatehouse_app.models import OIDCClient
from app.services.oidc_service import OIDCService from gatehouse_app.services.oidc_service import OIDCService
client_data = OIDCClient( client_data = OIDCClient(
organization_id=test_organization.id, organization_id=test_organization.id,
@@ -734,7 +734,7 @@ class TestOIDCTokenRevocation:
is_confidential=False, is_confidential=False,
require_pkce=False, require_pkce=False,
) )
from app.extensions import db from gatehouse_app.extensions import db
db.session.add(client_data) db.session.add(client_data)
db.session.commit() db.session.commit()
@@ -821,8 +821,8 @@ class TestOIDCTokenIntrospection:
@pytest.fixture @pytest.fixture
def test_client_with_tokens(self, client, test_organization, test_user): def test_client_with_tokens(self, client, test_organization, test_user):
"""Create a test OIDC client with valid tokens.""" """Create a test OIDC client with valid tokens."""
from app.models import OIDCClient from gatehouse_app.models import OIDCClient
from app.services.oidc_service import OIDCService from gatehouse_app.services.oidc_service import OIDCService
client_data = OIDCClient( client_data = OIDCClient(
organization_id=test_organization.id, organization_id=test_organization.id,
@@ -838,7 +838,7 @@ class TestOIDCTokenIntrospection:
is_confidential=False, is_confidential=False,
require_pkce=False, require_pkce=False,
) )
from app.extensions import db from gatehouse_app.extensions import db
db.session.add(client_data) db.session.add(client_data)
db.session.commit() db.session.commit()
@@ -896,9 +896,9 @@ class TestOIDCCompleteFlow:
def test_complete_oidc_flow(self, client, app, test_organization, test_user): def test_complete_oidc_flow(self, client, app, test_organization, test_user):
"""Test complete OIDC authorization code flow with PKCE.""" """Test complete OIDC authorization code flow with PKCE."""
from app.models import OIDCClient from gatehouse_app.models import OIDCClient
from app.services.oidc_service import OIDCService from gatehouse_app.services.oidc_service import OIDCService
from app.extensions import db from gatehouse_app.extensions import db
# Create a test client # Create a test client
with app.app_context(): with app.app_context():
+2 -2
View File
@@ -1,8 +1,8 @@
"""Unit tests for models.""" """Unit tests for models."""
import pytest import pytest
from datetime import datetime from datetime import datetime
from app.models import User, Organization from gatehouse_app.models import User, Organization
from app.utils.constants import UserStatus from gatehouse_app.utils.constants import UserStatus
@pytest.mark.unit @pytest.mark.unit
@@ -1,9 +1,9 @@
"""Unit tests for AuthService.""" """Unit tests for AuthService."""
import pytest import pytest
from app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
from app.exceptions.auth_exceptions import InvalidCredentialsError from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from app.exceptions.validation_exceptions import EmailAlreadyExistsError from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
from app.utils.constants import UserStatus, AuthMethodType from gatehouse_app.utils.constants import UserStatus, AuthMethodType
@pytest.mark.unit @pytest.mark.unit
@@ -1,7 +1,7 @@
"""Unit tests for TOTPService.""" """Unit tests for TOTPService."""
import base64 import base64
import pytest import pytest
from app.services.totp_service import TOTPService from gatehouse_app.services.totp_service import TOTPService
@pytest.mark.unit @pytest.mark.unit
+5 -2
View File
@@ -6,10 +6,13 @@ from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv()) load_dotenv(find_dotenv())
import os import os
from app import create_app from gatehouse_app import create_app
# Create application instance # 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__": if __name__ == "__main__":
app.run() app.run()