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