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
|
```bash
|
||||||
# Test database connection
|
# Test database connection
|
||||||
export DATABASE_URL="postgresql://user:pass@localhost:5432/authy2"
|
export DATABASE_URL="postgresql://user:pass@localhost:5432/authy2"
|
||||||
python -c "from app import create_app; app = create_app(); app.test_request_context().push()"
|
python -c "create_app create_app; app = create_app(); app.test_request_context().push()"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Migration Issues
|
#### Migration Issues
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ _root_logger.debug("[TEST] Debug logging is working!")
|
|||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from config import get_config
|
from config import get_config
|
||||||
from app.extensions import db, migrate, bcrypt, ma, limiter, session
|
from gatehouse_app.extensions import db, migrate, bcrypt, ma, limiter
|
||||||
from app.middleware import RequestIDMiddleware, SecurityHeadersMiddleware, setup_cors
|
from gatehouse_app.extensions import session as flask_session
|
||||||
from app.exceptions.base import BaseAPIException
|
from gatehouse_app.middleware import RequestIDMiddleware, SecurityHeadersMiddleware, setup_cors
|
||||||
from app.utils.response import api_response
|
from gatehouse_app.exceptions.base import BaseAPIException
|
||||||
from app.services.oidc_jwks_service import OIDCJWKSService
|
from gatehouse_app.utils.response import api_response
|
||||||
|
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
# Configure SQLAlchemy logging BEFORE any database operations
|
# Configure SQLAlchemy logging BEFORE any database operations
|
||||||
@@ -35,31 +36,31 @@ def create_app(config_name=None):
|
|||||||
Returns:
|
Returns:
|
||||||
Flask application instance
|
Flask application instance
|
||||||
"""
|
"""
|
||||||
app = Flask(__name__)
|
flask_app = Flask(__name__)
|
||||||
|
|
||||||
# Load configuration
|
# Load configuration
|
||||||
config = get_config(config_name)
|
config = get_config(config_name)
|
||||||
app.config.from_object(config)
|
flask_app.config.from_object(config)
|
||||||
|
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
initialize_extensions(app)
|
initialize_extensions(flask_app)
|
||||||
|
|
||||||
# Setup middleware
|
# Setup middleware
|
||||||
setup_middleware(app)
|
setup_middleware(flask_app)
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
register_blueprints(app)
|
register_blueprints(flask_app)
|
||||||
|
|
||||||
# Register error handlers
|
# Register error handlers
|
||||||
register_error_handlers(app)
|
register_error_handlers(flask_app)
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
setup_logging(app)
|
setup_logging(flask_app)
|
||||||
|
|
||||||
# Initialize OIDC JWKS service with a signing key
|
# Initialize OIDC JWKS service with a signing key
|
||||||
initialize_oidc_jwks(app)
|
initialize_oidc_jwks(flask_app)
|
||||||
|
|
||||||
return app
|
return flask_app
|
||||||
|
|
||||||
|
|
||||||
def initialize_extensions(app):
|
def initialize_extensions(app):
|
||||||
@@ -86,13 +87,14 @@ def initialize_extensions(app):
|
|||||||
try:
|
try:
|
||||||
redis_url = app.config.get("REDIS_URL")
|
redis_url = app.config.get("REDIS_URL")
|
||||||
if redis_url:
|
if redis_url:
|
||||||
redis_client = redis.from_url(redis_url)
|
import gatehouse_app.extensions
|
||||||
app.config["SESSION_REDIS"] = redis_client
|
gatehouse_app.extensions.redis_client = redis.from_url(redis_url)
|
||||||
|
app.config["SESSION_REDIS"] = gatehouse_app.extensions.redis_client
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.warning(f"Redis connection failed: {e}")
|
logging.warning(f"Redis connection failed: {e}")
|
||||||
|
|
||||||
# Flask-Session
|
# Flask-Session
|
||||||
session.init_app(app)
|
flask_session.init_app(app)
|
||||||
|
|
||||||
|
|
||||||
def setup_middleware(app):
|
def setup_middleware(app):
|
||||||
@@ -104,8 +106,8 @@ def setup_middleware(app):
|
|||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register application blueprints."""
|
"""Register application blueprints."""
|
||||||
from app.api import register_api_blueprints
|
from gatehouse_app.api import register_api_blueprints
|
||||||
from app.api.oidc import oidc_bp
|
from gatehouse_app.api.oidc import oidc_bp
|
||||||
|
|
||||||
register_api_blueprints(app)
|
register_api_blueprints(app)
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""API package."""
|
"""API package."""
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from app.utils.response import api_response
|
from gatehouse_app.utils.response import api_response
|
||||||
|
|
||||||
# Create main API blueprint
|
# Create main API blueprint
|
||||||
api_bp = Blueprint("api", __name__)
|
api_bp = Blueprint("api", __name__)
|
||||||
@@ -17,7 +17,7 @@ def health_check():
|
|||||||
|
|
||||||
def register_api_blueprints(app):
|
def register_api_blueprints(app):
|
||||||
"""Register all API blueprints."""
|
"""Register all API blueprints."""
|
||||||
from app.api.v1 import api_v1_bp
|
from gatehouse_app.api.v1 import api_v1_bp
|
||||||
|
|
||||||
# Register versioned API blueprints
|
# Register versioned API blueprints
|
||||||
app.register_blueprint(api_bp, url_prefix="/api")
|
app.register_blueprint(api_bp, url_prefix="/api")
|
||||||
@@ -11,16 +11,16 @@ from flask import Blueprint, request, redirect, jsonify, session, g, current_app
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from app.utils.response import api_response
|
from gatehouse_app.utils.response import api_response
|
||||||
from app.services.oidc_service import (
|
from gatehouse_app.services.oidc_service import (
|
||||||
OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError
|
OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError
|
||||||
)
|
)
|
||||||
from app.services.auth_service import AuthService
|
from gatehouse_app.services.auth_service import AuthService
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.extensions import bcrypt as flask_bcrypt
|
from gatehouse_app.extensions import bcrypt as flask_bcrypt
|
||||||
from app.models import User, OIDCClient
|
from gatehouse_app.models import User, OIDCClient
|
||||||
from app.models.organization import Organization
|
from gatehouse_app.models.organization import Organization
|
||||||
from app.exceptions.auth_exceptions import InvalidCredentialsError
|
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||||
|
|
||||||
|
|
||||||
# Create OIDC blueprint registered at root level
|
# Create OIDC blueprint registered at root level
|
||||||
@@ -5,4 +5,4 @@ from flask import Blueprint
|
|||||||
api_v1_bp = Blueprint("api_v1", __name__)
|
api_v1_bp = Blueprint("api_v1", __name__)
|
||||||
|
|
||||||
# Import route modules to register them
|
# Import route modules to register them
|
||||||
from app.api.v1 import auth, users, organizations
|
from gatehouse_app.api.v1 import auth, users, organizations
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Authentication endpoints."""
|
"""Authentication endpoints."""
|
||||||
from flask import request, session, g
|
import json
|
||||||
|
from flask import request, session, g, jsonify
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from app.api.v1 import api_v1_bp
|
from gatehouse_app.api.v1 import api_v1_bp
|
||||||
from app.utils.response import api_response
|
from gatehouse_app.utils.response import api_response
|
||||||
from app.schemas.auth_schema import (
|
from gatehouse_app.schemas.auth_schema import (
|
||||||
RegisterSchema,
|
RegisterSchema,
|
||||||
LoginSchema,
|
LoginSchema,
|
||||||
TOTPVerifyEnrollmentSchema,
|
TOTPVerifyEnrollmentSchema,
|
||||||
@@ -11,12 +12,20 @@ from app.schemas.auth_schema import (
|
|||||||
TOTPDisableSchema,
|
TOTPDisableSchema,
|
||||||
TOTPRegenerateBackupCodesSchema,
|
TOTPRegenerateBackupCodesSchema,
|
||||||
)
|
)
|
||||||
from app.services.auth_service import AuthService
|
from gatehouse_app.schemas.webauthn_schema import (
|
||||||
from app.services.user_service import UserService
|
WebAuthnRegistrationBeginSchema,
|
||||||
from app.utils.decorators import login_required
|
WebAuthnRegistrationCompleteSchema,
|
||||||
from app.utils.constants import AuditAction
|
WebAuthnLoginBeginSchema,
|
||||||
from app.exceptions.auth_exceptions import InvalidCredentialsError
|
WebAuthnLoginCompleteSchema,
|
||||||
from app.exceptions.validation_exceptions import ConflictError
|
WebAuthnCredentialRenameSchema,
|
||||||
|
)
|
||||||
|
from gatehouse_app.services.auth_service import AuthService
|
||||||
|
from gatehouse_app.services.webauthn_service import WebAuthnService
|
||||||
|
from gatehouse_app.services.user_service import UserService
|
||||||
|
from gatehouse_app.utils.decorators import login_required
|
||||||
|
from gatehouse_app.utils.constants import AuditAction
|
||||||
|
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||||
|
from gatehouse_app.exceptions.validation_exceptions import ConflictError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
@api_v1_bp.route("/auth/register", methods=["POST"])
|
@api_v1_bp.route("/auth/register", methods=["POST"])
|
||||||
@@ -188,7 +197,7 @@ def get_user_sessions():
|
|||||||
200: List of active sessions
|
200: List of active sessions
|
||||||
401: Not authenticated
|
401: Not authenticated
|
||||||
"""
|
"""
|
||||||
from app.services.session_service import SessionService
|
from gatehouse_app.services.session_service import SessionService
|
||||||
|
|
||||||
sessions = SessionService.get_user_sessions(g.current_user.id, active_only=True)
|
sessions = SessionService.get_user_sessions(g.current_user.id, active_only=True)
|
||||||
|
|
||||||
@@ -215,7 +224,7 @@ def revoke_session(session_id):
|
|||||||
401: Not authenticated
|
401: Not authenticated
|
||||||
404: Session not found
|
404: Session not found
|
||||||
"""
|
"""
|
||||||
from app.models.session import Session
|
from gatehouse_app.models.session import Session
|
||||||
|
|
||||||
# Ensure session belongs to current user
|
# Ensure session belongs to current user
|
||||||
user_session = Session.query.filter_by(
|
user_session = Session.query.filter_by(
|
||||||
@@ -347,7 +356,7 @@ def verify_totp():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get user from database
|
# Get user from database
|
||||||
from app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
user = User.query.get(user_id)
|
user = User.query.get(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return api_response(
|
return api_response(
|
||||||
@@ -527,3 +536,402 @@ def regenerate_totp_backup_codes():
|
|||||||
status=e.status_code,
|
status=e.status_code,
|
||||||
error_type=e.error_type,
|
error_type=e.error_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WebAuthn Passkey Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/auth/webauthn/register/begin", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def begin_webauthn_registration():
|
||||||
|
"""
|
||||||
|
Begin WebAuthn passkey registration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: PublicKeyCredentialCreationOptions (raw JSON, no wrapper)
|
||||||
|
401: Not authenticated
|
||||||
|
"""
|
||||||
|
user = g.current_user
|
||||||
|
|
||||||
|
# Generate registration challenge
|
||||||
|
options = WebAuthnService.generate_registration_challenge(user)
|
||||||
|
|
||||||
|
# Return unwrapped JSON for WebAuthn
|
||||||
|
return jsonify(options), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/auth/webauthn/register/complete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def complete_webauthn_registration():
|
||||||
|
"""
|
||||||
|
Complete WebAuthn passkey registration.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
id: Credential ID
|
||||||
|
rawId: Base64URL-encoded credential ID
|
||||||
|
type: "public-key"
|
||||||
|
response: Attestation response data
|
||||||
|
transports: List of transport types
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Registration successful
|
||||||
|
400: Validation error
|
||||||
|
401: Not authenticated
|
||||||
|
409: Credential already exists
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate request data
|
||||||
|
schema = WebAuthnRegistrationCompleteSchema()
|
||||||
|
data = schema.load(request.json)
|
||||||
|
|
||||||
|
# Extract challenge from client data
|
||||||
|
client_data = data.get("response", {}).get("clientDataJSON", "")
|
||||||
|
import base64
|
||||||
|
client_data_json = base64.urlsafe_b64decode(client_data + "==")
|
||||||
|
client_data_dict = json.loads(client_data_json)
|
||||||
|
challenge = client_data_dict.get("challenge")
|
||||||
|
|
||||||
|
if not challenge:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Invalid challenge in client data",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify registration response
|
||||||
|
auth_method = WebAuthnService.verify_registration_response(
|
||||||
|
g.current_user,
|
||||||
|
data,
|
||||||
|
challenge
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"credential": auth_method.to_webauthn_dict(),
|
||||||
|
},
|
||||||
|
message="Passkey registered successfully",
|
||||||
|
status=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
except InvalidCredentialsError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=e.message,
|
||||||
|
status=e.status_code,
|
||||||
|
error_type=e.error_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/auth/webauthn/login/begin", methods=["POST"])
|
||||||
|
def begin_webauthn_login():
|
||||||
|
"""
|
||||||
|
Begin WebAuthn passkey login.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
email: User email address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: PublicKeyCredentialRequestOptions (raw JSON, no wrapper)
|
||||||
|
400: Validation error
|
||||||
|
404: User not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate request data
|
||||||
|
schema = WebAuthnLoginBeginSchema()
|
||||||
|
data = schema.load(request.json)
|
||||||
|
|
||||||
|
# Find user by email
|
||||||
|
from gatehouse_app.models.user import User
|
||||||
|
user = User.query.filter_by(
|
||||||
|
email=data["email"].lower(),
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="User not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user has any WebAuthn credentials
|
||||||
|
if not user.has_webauthn_enabled():
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="No passkeys found for this account",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate authentication challenge
|
||||||
|
options = WebAuthnService.generate_authentication_challenge(user)
|
||||||
|
|
||||||
|
# Store user_id in session for verification
|
||||||
|
session["webauthn_pending_user_id"] = user.id
|
||||||
|
|
||||||
|
# Return unwrapped JSON for WebAuthn
|
||||||
|
return jsonify(options), 200
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/auth/webauthn/login/complete", methods=["POST"])
|
||||||
|
def complete_webauthn_login():
|
||||||
|
"""
|
||||||
|
Complete WebAuthn passkey login.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
id: Credential ID
|
||||||
|
rawId: Base64URL-encoded credential ID
|
||||||
|
type: "public-key"
|
||||||
|
response: Assertion response data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Login successful with session token
|
||||||
|
400: Validation error
|
||||||
|
401: Authentication failed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get user from session
|
||||||
|
user_id = session.get("webauthn_pending_user_id")
|
||||||
|
if not user_id:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="No pending WebAuthn verification. Please initiate login first.",
|
||||||
|
status=401,
|
||||||
|
error_type="AUTHENTICATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate request data
|
||||||
|
schema = WebAuthnLoginCompleteSchema()
|
||||||
|
data = schema.load(request.json)
|
||||||
|
|
||||||
|
# Get user from database
|
||||||
|
from gatehouse_app.models.user import User
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if not user:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="User not found",
|
||||||
|
status=401,
|
||||||
|
error_type="AUTHENTICATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract challenge from client data
|
||||||
|
client_data = data.get("response", {}).get("clientDataJSON", "")
|
||||||
|
import base64
|
||||||
|
client_data_json = base64.urlsafe_b64decode(client_data + "==")
|
||||||
|
client_data_dict = json.loads(client_data_json)
|
||||||
|
challenge = client_data_dict.get("challenge")
|
||||||
|
|
||||||
|
if not challenge:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Invalid challenge in client data",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify authentication response
|
||||||
|
WebAuthnService.verify_authentication_response(
|
||||||
|
user,
|
||||||
|
data,
|
||||||
|
challenge
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
user_session = AuthService.create_session(user)
|
||||||
|
|
||||||
|
# Clear pending session
|
||||||
|
session.pop("webauthn_pending_user_id", None)
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"user": user.to_dict(),
|
||||||
|
"token": user_session.token,
|
||||||
|
"expires_at": user_session.expires_at.isoformat() + "Z"
|
||||||
|
if user_session.expires_at.isoformat()[-1] != "Z"
|
||||||
|
else user_session.expires_at.isoformat(),
|
||||||
|
},
|
||||||
|
message="Login successful",
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
except InvalidCredentialsError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=e.message,
|
||||||
|
status=e.status_code,
|
||||||
|
error_type=e.error_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/auth/webauthn/credentials", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def list_webauthn_credentials():
|
||||||
|
"""
|
||||||
|
List all WebAuthn passkey credentials for the current user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: List of credentials
|
||||||
|
401: Not authenticated
|
||||||
|
"""
|
||||||
|
user = g.current_user
|
||||||
|
credentials = WebAuthnService.get_user_credentials(user)
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"credentials": [cred.to_webauthn_dict() for cred in credentials],
|
||||||
|
"count": len(credentials),
|
||||||
|
},
|
||||||
|
message="Credentials retrieved successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/auth/webauthn/credentials/<credential_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_webauthn_credential(credential_id):
|
||||||
|
"""
|
||||||
|
Delete a WebAuthn passkey credential.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credential_id: ID of the credential to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Credential deleted successfully
|
||||||
|
401: Not authenticated
|
||||||
|
404: Credential not found
|
||||||
|
"""
|
||||||
|
user = g.current_user
|
||||||
|
|
||||||
|
# Check if this is the last credential
|
||||||
|
credential_count = user.get_webauthn_credential_count()
|
||||||
|
if credential_count <= 1:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Cannot delete the last passkey. Add another passkey first.",
|
||||||
|
status=400,
|
||||||
|
error_type="BAD_REQUEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete the credential
|
||||||
|
success = WebAuthnService.delete_credential(credential_id, user)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Credential not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
message="Passkey deleted successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/auth/webauthn/credentials/<credential_id>", methods=["PATCH"])
|
||||||
|
@login_required
|
||||||
|
def rename_webauthn_credential(credential_id):
|
||||||
|
"""
|
||||||
|
Rename a WebAuthn passkey credential.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credential_id: ID of the credential to rename
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
name: New name for the credential
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Credential renamed successfully
|
||||||
|
400: Validation error
|
||||||
|
401: Not authenticated
|
||||||
|
404: Credential not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate request data
|
||||||
|
schema = WebAuthnCredentialRenameSchema()
|
||||||
|
data = schema.load(request.json)
|
||||||
|
|
||||||
|
# Rename the credential
|
||||||
|
success = WebAuthnService.rename_credential(
|
||||||
|
credential_id,
|
||||||
|
g.current_user,
|
||||||
|
data["name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Credential not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get updated credential
|
||||||
|
credential = WebAuthnService.get_credential_by_id(credential_id, g.current_user)
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"credential": credential.to_webauthn_dict() if credential else None,
|
||||||
|
},
|
||||||
|
message="Passkey renamed successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/auth/webauthn/status", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_webauthn_status():
|
||||||
|
"""
|
||||||
|
Get WebAuthn status for the current user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: WebAuthn status with webauthn_enabled and credential_count
|
||||||
|
401: Not authenticated
|
||||||
|
"""
|
||||||
|
user = g.current_user
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"webauthn_enabled": user.has_webauthn_enabled(),
|
||||||
|
"credential_count": user.get_webauthn_credential_count(),
|
||||||
|
},
|
||||||
|
message="WebAuthn status retrieved successfully",
|
||||||
|
)
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
"""Organization endpoints."""
|
"""Organization endpoints."""
|
||||||
from flask import g, request
|
from flask import g, request
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from app.api.v1 import api_v1_bp
|
from gatehouse_app.api.v1 import api_v1_bp
|
||||||
from app.utils.response import api_response
|
from gatehouse_app.utils.response import api_response
|
||||||
from app.utils.decorators import login_required, require_admin, require_owner
|
from gatehouse_app.utils.decorators import login_required, require_admin, require_owner
|
||||||
from app.schemas.organization_schema import (
|
from gatehouse_app.schemas.organization_schema import (
|
||||||
OrganizationCreateSchema,
|
OrganizationCreateSchema,
|
||||||
OrganizationUpdateSchema,
|
OrganizationUpdateSchema,
|
||||||
InviteMemberSchema,
|
InviteMemberSchema,
|
||||||
UpdateMemberRoleSchema,
|
UpdateMemberRoleSchema,
|
||||||
)
|
)
|
||||||
from app.services.organization_service import OrganizationService
|
from gatehouse_app.services.organization_service import OrganizationService
|
||||||
from app.services.user_service import UserService
|
from gatehouse_app.services.user_service import UserService
|
||||||
from app.utils.constants import OrganizationRole
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
|
||||||
|
|
||||||
@api_v1_bp.route("/organizations", methods=["POST"])
|
@api_v1_bp.route("/organizations", methods=["POST"])
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"""User endpoints."""
|
"""User endpoints."""
|
||||||
from flask import g, request
|
from flask import g, request
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from app.api.v1 import api_v1_bp
|
from gatehouse_app.api.v1 import api_v1_bp
|
||||||
from app.utils.response import api_response
|
from gatehouse_app.utils.response import api_response
|
||||||
from app.utils.decorators import login_required
|
from gatehouse_app.utils.decorators import login_required
|
||||||
from app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
|
from gatehouse_app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
|
||||||
from app.services.user_service import UserService
|
from gatehouse_app.services.user_service import UserService
|
||||||
from app.services.auth_service import AuthService
|
from gatehouse_app.services.auth_service import AuthService
|
||||||
|
|
||||||
|
|
||||||
@api_v1_bp.route("/users/me", methods=["GET"])
|
@api_v1_bp.route("/users/me", methods=["GET"])
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Exceptions package."""
|
"""Exceptions package."""
|
||||||
from app.exceptions.base import BaseAPIException
|
from gatehouse_app.exceptions.base import BaseAPIException
|
||||||
from app.exceptions.auth_exceptions import (
|
from gatehouse_app.exceptions.auth_exceptions import (
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
@@ -9,7 +9,7 @@ from app.exceptions.auth_exceptions import (
|
|||||||
SessionExpiredError,
|
SessionExpiredError,
|
||||||
InvalidTokenError,
|
InvalidTokenError,
|
||||||
)
|
)
|
||||||
from app.exceptions.validation_exceptions import (
|
from gatehouse_app.exceptions.validation_exceptions import (
|
||||||
ValidationError,
|
ValidationError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
ConflictError,
|
ConflictError,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Authentication and authorization exceptions."""
|
"""Authentication and authorization exceptions."""
|
||||||
from app.exceptions.base import BaseAPIException
|
from gatehouse_app.exceptions.base import BaseAPIException
|
||||||
|
|
||||||
|
|
||||||
class UnauthorizedError(BaseAPIException):
|
class UnauthorizedError(BaseAPIException):
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
"""Validation and resource exceptions."""
|
"""Validation and resource exceptions."""
|
||||||
from app.exceptions.base import BaseAPIException
|
from gatehouse_app.exceptions.base import BaseAPIException
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(BaseAPIException):
|
class ValidationError(BaseAPIException):
|
||||||
@@ -7,6 +7,7 @@ from flask_marshmallow import Marshmallow
|
|||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
from flask_session import Session
|
from flask_session import Session
|
||||||
|
import redis
|
||||||
|
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
@@ -20,3 +21,6 @@ limiter = Limiter(
|
|||||||
storage_uri="memory://", # Will be overridden by config
|
storage_uri="memory://", # Will be overridden by config
|
||||||
)
|
)
|
||||||
session = Session()
|
session = Session()
|
||||||
|
|
||||||
|
# Redis client - will be initialized with app
|
||||||
|
redis_client = None
|
||||||
@@ -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."""
|
"""Audit log model."""
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
from app.utils.constants import AuditAction
|
from gatehouse_app.utils.constants import AuditAction
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(BaseModel):
|
class AuditLog(BaseModel):
|
||||||
+26
-3
@@ -1,7 +1,7 @@
|
|||||||
"""Authentication method model."""
|
"""Authentication method model."""
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
from app.utils.constants import AuthMethodType
|
from gatehouse_app.utils.constants import AuthMethodType
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationMethod(BaseModel):
|
class AuthenticationMethod(BaseModel):
|
||||||
@@ -60,6 +60,10 @@ class AuthenticationMethod(BaseModel):
|
|||||||
"""Check if this is a TOTP authentication method."""
|
"""Check if this is a TOTP authentication method."""
|
||||||
return self.method_type == AuthMethodType.TOTP
|
return self.method_type == AuthMethodType.TOTP
|
||||||
|
|
||||||
|
def is_webauthn(self):
|
||||||
|
"""Check if this is a WebAuthn authentication method."""
|
||||||
|
return self.method_type == AuthMethodType.WEBAUTHN
|
||||||
|
|
||||||
def to_dict(self, exclude=None):
|
def to_dict(self, exclude=None):
|
||||||
"""Convert to dictionary, excluding sensitive fields."""
|
"""Convert to dictionary, excluding sensitive fields."""
|
||||||
exclude = exclude or []
|
exclude = exclude or []
|
||||||
@@ -68,3 +72,22 @@ class AuthenticationMethod(BaseModel):
|
|||||||
exclude.append("totp_secret")
|
exclude.append("totp_secret")
|
||||||
exclude.append("totp_backup_codes")
|
exclude.append("totp_backup_codes")
|
||||||
return super().to_dict(exclude=exclude)
|
return super().to_dict(exclude=exclude)
|
||||||
|
|
||||||
|
def to_webauthn_dict(self):
|
||||||
|
"""Convert WebAuthn credential to public dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with safe-to-expose credential information.
|
||||||
|
"""
|
||||||
|
if not self.is_webauthn() or not self.provider_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = self.provider_data
|
||||||
|
return {
|
||||||
|
"id": data.get("credential_id"),
|
||||||
|
"name": data.get("name"),
|
||||||
|
"transports": data.get("transports", []),
|
||||||
|
"created_at": data.get("created_at"),
|
||||||
|
"last_used_at": data.get("last_used_at"),
|
||||||
|
"sign_count": data.get("sign_count", 0),
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Base model with common fields and functionality."""
|
"""Base model with common fields and functionality."""
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(db.Model):
|
class BaseModel(db.Model):
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""OIDC Audit Log model for comprehensive OIDC event tracking."""
|
"""OIDC Audit Log model for comprehensive OIDC event tracking."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuditLog(BaseModel):
|
class OIDCAuditLog(BaseModel):
|
||||||
@@ -219,13 +219,13 @@ class OIDCAuditLog(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Add relationship back to User model
|
# Add relationship back to User model
|
||||||
from app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
User.oidc_audit_logs = db.relationship(
|
User.oidc_audit_logs = db.relationship(
|
||||||
"OIDCAuditLog", back_populates="user", cascade="all, delete-orphan"
|
"OIDCAuditLog", back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add relationship back to OIDCClient model
|
# Add relationship back to OIDCClient model
|
||||||
from app.models.oidc_client import OIDCClient
|
from gatehouse_app.models.oidc_client import OIDCClient
|
||||||
OIDCClient.audit_logs = db.relationship(
|
OIDCClient.audit_logs = db.relationship(
|
||||||
"OIDCAuditLog", back_populates="client", cascade="all, delete-orphan"
|
"OIDCAuditLog", back_populates="client", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
"""OIDC Authorization Code model for auth code flow."""
|
"""OIDC Authorization Code model for auth code flow."""
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthCode(BaseModel):
|
class OIDCAuthCode(BaseModel):
|
||||||
@@ -113,13 +113,13 @@ class OIDCAuthCode(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Add relationship back to User model
|
# Add relationship back to User model
|
||||||
from app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
User.oidc_auth_codes = db.relationship(
|
User.oidc_auth_codes = db.relationship(
|
||||||
"OIDCAuthCode", back_populates="user", cascade="all, delete-orphan"
|
"OIDCAuthCode", back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add relationship back to OIDCClient model
|
# Add relationship back to OIDCClient model
|
||||||
from app.models.oidc_client import OIDCClient
|
from gatehouse_app.models.oidc_client import OIDCClient
|
||||||
OIDCClient.authorization_codes = db.relationship(
|
OIDCClient.authorization_codes = db.relationship(
|
||||||
"OIDCAuthCode", back_populates="client", cascade="all, delete-orphan"
|
"OIDCAuthCode", back_populates="client", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""OIDC Client model."""
|
"""OIDC Client model."""
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
from app.utils.constants import OIDCGrantType, OIDCResponseType
|
from gatehouse_app.utils.constants import OIDCGrantType, OIDCResponseType
|
||||||
|
|
||||||
|
|
||||||
class OIDCClient(BaseModel):
|
class OIDCClient(BaseModel):
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""OIDC JWKS Key model for persisting signing keys."""
|
"""OIDC JWKS Key model for persisting signing keys."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class OidcJwksKey(BaseModel):
|
class OidcJwksKey(BaseModel):
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""OIDC Refresh Token model for token rotation."""
|
"""OIDC Refresh Token model for token rotation."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class OIDCRefreshToken(BaseModel):
|
class OIDCRefreshToken(BaseModel):
|
||||||
@@ -145,19 +145,19 @@ class OIDCRefreshToken(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Add relationship back to User model
|
# Add relationship back to User model
|
||||||
from app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
User.oidc_refresh_tokens = db.relationship(
|
User.oidc_refresh_tokens = db.relationship(
|
||||||
"OIDCRefreshToken", back_populates="user", cascade="all, delete-orphan"
|
"OIDCRefreshToken", back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add relationship back to OIDCClient model
|
# Add relationship back to OIDCClient model
|
||||||
from app.models.oidc_client import OIDCClient
|
from gatehouse_app.models.oidc_client import OIDCClient
|
||||||
OIDCClient.refresh_tokens = db.relationship(
|
OIDCClient.refresh_tokens = db.relationship(
|
||||||
"OIDCRefreshToken", back_populates="client", cascade="all, delete-orphan"
|
"OIDCRefreshToken", back_populates="client", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add relationship back to Session model
|
# Add relationship back to Session model
|
||||||
from app.models.session import Session
|
from gatehouse_app.models.session import Session
|
||||||
Session.oidc_refresh_token = db.relationship(
|
Session.oidc_refresh_token = db.relationship(
|
||||||
"OIDCRefreshToken", back_populates="access_token", uselist=False
|
"OIDCRefreshToken", back_populates="access_token", uselist=False
|
||||||
)
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""OIDC Session model for OIDC session tracking."""
|
"""OIDC Session model for OIDC session tracking."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class OIDCSession(BaseModel):
|
class OIDCSession(BaseModel):
|
||||||
@@ -150,13 +150,13 @@ class OIDCSession(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Add relationship back to User model
|
# Add relationship back to User model
|
||||||
from app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
User.oidc_sessions = db.relationship(
|
User.oidc_sessions = db.relationship(
|
||||||
"OIDCSession", back_populates="user", cascade="all, delete-orphan"
|
"OIDCSession", back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add relationship back to OIDCClient model
|
# Add relationship back to OIDCClient model
|
||||||
from app.models.oidc_client import OIDCClient
|
from gatehouse_app.models.oidc_client import OIDCClient
|
||||||
OIDCClient.oidc_sessions = db.relationship(
|
OIDCClient.oidc_sessions = db.relationship(
|
||||||
"OIDCSession", back_populates="client", cascade="all, delete-orphan"
|
"OIDCSession", back_populates="client", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""OIDC Token Metadata model for token revocation tracking."""
|
"""OIDC Token Metadata model for token revocation tracking."""
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class OIDCTokenMetadata(BaseModel):
|
class OIDCTokenMetadata(BaseModel):
|
||||||
@@ -184,13 +184,13 @@ class OIDCTokenMetadata(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Add relationship back to User model
|
# Add relationship back to User model
|
||||||
from app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
User.oidc_token_metadata = db.relationship(
|
User.oidc_token_metadata = db.relationship(
|
||||||
"OIDCTokenMetadata", back_populates="user", cascade="all, delete-orphan"
|
"OIDCTokenMetadata", back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add relationship back to OIDCClient model
|
# Add relationship back to OIDCClient model
|
||||||
from app.models.oidc_client import OIDCClient
|
from gatehouse_app.models.oidc_client import OIDCClient
|
||||||
OIDCClient.token_metadata = db.relationship(
|
OIDCClient.token_metadata = db.relationship(
|
||||||
"OIDCTokenMetadata", back_populates="client", cascade="all, delete-orphan"
|
"OIDCTokenMetadata", back_populates="client", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Organization model."""
|
"""Organization model."""
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class Organization(BaseModel):
|
class Organization(BaseModel):
|
||||||
@@ -35,7 +35,7 @@ class Organization(BaseModel):
|
|||||||
|
|
||||||
def get_owner(self):
|
def get_owner(self):
|
||||||
"""Get the owner of the organization."""
|
"""Get the owner of the organization."""
|
||||||
from app.utils.constants import OrganizationRole
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
|
||||||
for member in self.members:
|
for member in self.members:
|
||||||
if member.role == OrganizationRole.OWNER and member.deleted_at is None:
|
if member.role == OrganizationRole.OWNER and member.deleted_at is None:
|
||||||
@@ -44,7 +44,7 @@ class Organization(BaseModel):
|
|||||||
|
|
||||||
def is_member(self, user_id):
|
def is_member(self, user_id):
|
||||||
"""Check if a user is a member of the organization."""
|
"""Check if a user is a member of the organization."""
|
||||||
from app.models.organization_member import OrganizationMember
|
from gatehouse_app.models.organization_member import OrganizationMember
|
||||||
|
|
||||||
return (
|
return (
|
||||||
OrganizationMember.query.filter_by(
|
OrganizationMember.query.filter_by(
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Organization member model."""
|
"""Organization member model."""
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
from app.utils.constants import OrganizationRole
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
|
||||||
|
|
||||||
class OrganizationMember(BaseModel):
|
class OrganizationMember(BaseModel):
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Session model."""
|
"""Session model."""
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
from app.utils.constants import SessionStatus
|
from gatehouse_app.utils.constants import SessionStatus
|
||||||
|
|
||||||
|
|
||||||
class Session(BaseModel):
|
class Session(BaseModel):
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""User model."""
|
"""User model."""
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
from app.utils.constants import UserStatus
|
from gatehouse_app.utils.constants import UserStatus
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
@@ -46,8 +46,8 @@ class User(BaseModel):
|
|||||||
|
|
||||||
def has_password_auth(self):
|
def has_password_auth(self):
|
||||||
"""Check if user has password authentication enabled."""
|
"""Check if user has password authentication enabled."""
|
||||||
from app.models.authentication_method import AuthenticationMethod
|
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||||
from app.utils.constants import AuthMethodType
|
from gatehouse_app.utils.constants import AuthMethodType
|
||||||
|
|
||||||
return (
|
return (
|
||||||
AuthenticationMethod.query.filter_by(
|
AuthenticationMethod.query.filter_by(
|
||||||
@@ -66,8 +66,8 @@ class User(BaseModel):
|
|||||||
Returns:
|
Returns:
|
||||||
True if user has a verified TOTP authentication method, False otherwise.
|
True if user has a verified TOTP authentication method, False otherwise.
|
||||||
"""
|
"""
|
||||||
from app.models.authentication_method import AuthenticationMethod
|
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||||
from app.utils.constants import AuthMethodType
|
from gatehouse_app.utils.constants import AuthMethodType
|
||||||
|
|
||||||
return (
|
return (
|
||||||
AuthenticationMethod.query.filter_by(
|
AuthenticationMethod.query.filter_by(
|
||||||
@@ -89,9 +89,53 @@ class User(BaseModel):
|
|||||||
Returns the most recently created TOTP method to handle cases where
|
Returns the most recently created TOTP method to handle cases where
|
||||||
multiple enrollment attempts may exist.
|
multiple enrollment attempts may exist.
|
||||||
"""
|
"""
|
||||||
from app.models.authentication_method import AuthenticationMethod
|
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||||
from app.utils.constants import AuthMethodType
|
from gatehouse_app.utils.constants import AuthMethodType
|
||||||
|
|
||||||
return AuthenticationMethod.query.filter_by(
|
return AuthenticationMethod.query.filter_by(
|
||||||
user_id=self.id, method_type=AuthMethodType.TOTP, deleted_at=None
|
user_id=self.id, method_type=AuthMethodType.TOTP, deleted_at=None
|
||||||
).order_by(AuthenticationMethod.created_at.desc()).first()
|
).order_by(AuthenticationMethod.created_at.desc()).first()
|
||||||
|
|
||||||
|
def has_webauthn_enabled(self) -> bool:
|
||||||
|
"""Check if user has any WebAuthn passkey credentials.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has at least one WebAuthn credential, False otherwise.
|
||||||
|
"""
|
||||||
|
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||||
|
from gatehouse_app.utils.constants import AuthMethodType
|
||||||
|
|
||||||
|
return (
|
||||||
|
AuthenticationMethod.query.filter_by(
|
||||||
|
user_id=self.id,
|
||||||
|
method_type=AuthMethodType.WEBAUTHN,
|
||||||
|
deleted_at=None,
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_webauthn_credentials(self):
|
||||||
|
"""Get all WebAuthn credentials for the user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of AuthenticationMethod instances for WebAuthn, ordered by creation date.
|
||||||
|
"""
|
||||||
|
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||||
|
from gatehouse_app.utils.constants import AuthMethodType
|
||||||
|
|
||||||
|
return AuthenticationMethod.query.filter_by(
|
||||||
|
user_id=self.id, method_type=AuthMethodType.WEBAUTHN, deleted_at=None
|
||||||
|
).order_by(AuthenticationMethod.created_at.desc()).all()
|
||||||
|
|
||||||
|
def get_webauthn_credential_count(self) -> int:
|
||||||
|
"""Get the count of WebAuthn credentials for the user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of WebAuthn credentials.
|
||||||
|
"""
|
||||||
|
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||||
|
from gatehouse_app.utils.constants import AuthMethodType
|
||||||
|
|
||||||
|
return AuthenticationMethod.query.filter_by(
|
||||||
|
user_id=self.id, method_type=AuthMethodType.WEBAUTHN, deleted_at=None
|
||||||
|
).count()
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Schemas package."""
|
"""Schemas package."""
|
||||||
from app.schemas.user_schema import UserSchema, UserUpdateSchema, ChangePasswordSchema
|
from gatehouse_app.schemas.user_schema import UserSchema, UserUpdateSchema, ChangePasswordSchema
|
||||||
from app.schemas.auth_schema import (
|
from gatehouse_app.schemas.auth_schema import (
|
||||||
RegisterSchema,
|
RegisterSchema,
|
||||||
LoginSchema,
|
LoginSchema,
|
||||||
RefreshTokenSchema,
|
RefreshTokenSchema,
|
||||||
ForgotPasswordSchema,
|
ForgotPasswordSchema,
|
||||||
ResetPasswordSchema,
|
ResetPasswordSchema,
|
||||||
)
|
)
|
||||||
from app.schemas.organization_schema import (
|
from gatehouse_app.schemas.organization_schema import (
|
||||||
OrganizationSchema,
|
OrganizationSchema,
|
||||||
OrganizationCreateSchema,
|
OrganizationCreateSchema,
|
||||||
OrganizationUpdateSchema,
|
OrganizationUpdateSchema,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""User schemas for validation and serialization."""
|
"""User schemas for validation and serialization."""
|
||||||
from marshmallow import Schema, fields, validate, validates, ValidationError
|
from marshmallow import Schema, fields, validate, validates, ValidationError
|
||||||
from app.utils.constants import UserStatus
|
from gatehouse_app.utils.constants import UserStatus
|
||||||
|
|
||||||
|
|
||||||
class UserSchema(Schema):
|
class UserSchema(Schema):
|
||||||
@@ -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."""
|
"""Audit service."""
|
||||||
from flask import request, g
|
from flask import request, g
|
||||||
from app.models.audit_log import AuditLog
|
from gatehouse_app.models.audit_log import AuditLog
|
||||||
from app.utils.constants import AuditAction
|
from gatehouse_app.utils.constants import AuditAction
|
||||||
|
|
||||||
|
|
||||||
class AuditService:
|
class AuditService:
|
||||||
@@ -3,15 +3,15 @@ import logging
|
|||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from flask import request, g, current_app
|
from flask import request, g, current_app
|
||||||
from app.extensions import db, bcrypt
|
from gatehouse_app.extensions import db, bcrypt
|
||||||
from app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
from app.models.authentication_method import AuthenticationMethod
|
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||||
from app.models.session import Session
|
from gatehouse_app.models.session import Session
|
||||||
from app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
|
from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
|
||||||
from app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
|
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
|
||||||
from app.exceptions.validation_exceptions import EmailAlreadyExistsError
|
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
|
||||||
from app.services.audit_service import AuditService
|
from gatehouse_app.services.audit_service import AuditService
|
||||||
from app.services.totp_service import TOTPService
|
from gatehouse_app.services.totp_service import TOTPService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@ class AuthService:
|
|||||||
Raises:
|
Raises:
|
||||||
ConflictError: If user already has TOTP enabled
|
ConflictError: If user already has TOTP enabled
|
||||||
"""
|
"""
|
||||||
from app.exceptions.validation_exceptions import ConflictError
|
from gatehouse_app.exceptions.validation_exceptions import ConflictError
|
||||||
|
|
||||||
# Check if user already has TOTP enabled
|
# Check if user already has TOTP enabled
|
||||||
if user.has_totp_enabled():
|
if user.has_totp_enabled():
|
||||||
@@ -4,8 +4,8 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from flask import g
|
from flask import g
|
||||||
|
|
||||||
from app.models import OIDCAuditLog, OIDCClient, User
|
from gatehouse_app.models import OIDCAuditLog, OIDCClient, User
|
||||||
from app.exceptions.validation_exceptions import NotFoundError
|
from gatehouse_app.exceptions.validation_exceptions import NotFoundError
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuditService:
|
class OIDCAuditService:
|
||||||
@@ -7,8 +7,8 @@ from typing import Dict, List, Optional, Tuple
|
|||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.oidc_jwks_key import OidcJwksKey
|
from gatehouse_app.models.oidc_jwks_key import OidcJwksKey
|
||||||
|
|
||||||
|
|
||||||
class JWKSKey:
|
class JWKSKey:
|
||||||
@@ -9,20 +9,20 @@ from flask import current_app, g
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models import (
|
from gatehouse_app.models import (
|
||||||
User, OIDCClient, OIDCAuthCode, OIDCRefreshToken,
|
User, OIDCClient, OIDCAuthCode, OIDCRefreshToken,
|
||||||
OIDCSession, OIDCTokenMetadata
|
OIDCSession, OIDCTokenMetadata
|
||||||
)
|
)
|
||||||
from app.models.organization_member import OrganizationMember
|
from gatehouse_app.models.organization_member import OrganizationMember
|
||||||
from app.exceptions.validation_exceptions import (
|
from gatehouse_app.exceptions.validation_exceptions import (
|
||||||
ValidationError, NotFoundError, BadRequestError
|
ValidationError, NotFoundError, BadRequestError
|
||||||
)
|
)
|
||||||
from app.exceptions.auth_exceptions import UnauthorizedError, InvalidTokenError
|
from gatehouse_app.exceptions.auth_exceptions import UnauthorizedError, InvalidTokenError
|
||||||
from app.services.oidc_token_service import OIDCTokenService
|
from gatehouse_app.services.oidc_token_service import OIDCTokenService
|
||||||
from app.services.oidc_session_service import OIDCSessionService
|
from gatehouse_app.services.oidc_session_service import OIDCSessionService
|
||||||
from app.services.oidc_audit_service import OIDCAuditService
|
from gatehouse_app.services.oidc_audit_service import OIDCAuditService
|
||||||
from app.services.oidc_jwks_service import OIDCJWKSService
|
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
|
||||||
|
|
||||||
|
|
||||||
class OIDCError(Exception):
|
class OIDCError(Exception):
|
||||||
+3
-3
@@ -6,9 +6,9 @@ from typing import Dict, Optional, Tuple
|
|||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from flask import current_app, g
|
from flask import current_app, g
|
||||||
|
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models import OIDCSession, OIDCClient, User
|
from gatehouse_app.models import OIDCSession, OIDCClient, User
|
||||||
from app.exceptions.validation_exceptions import NotFoundError, ValidationError
|
from gatehouse_app.exceptions.validation_exceptions import NotFoundError, ValidationError
|
||||||
|
|
||||||
|
|
||||||
class OIDCSessionService:
|
class OIDCSessionService:
|
||||||
@@ -10,9 +10,9 @@ from typing import Dict, Optional, Any
|
|||||||
import jwt
|
import jwt
|
||||||
from flask import current_app, g
|
from flask import current_app, g
|
||||||
|
|
||||||
from app.models import User, OIDCClient
|
from gatehouse_app.models import User, OIDCClient
|
||||||
from app.models.organization_member import OrganizationMember
|
from gatehouse_app.models.organization_member import OrganizationMember
|
||||||
from app.services.oidc_jwks_service import OIDCJWKSService
|
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
+6
-6
@@ -2,12 +2,12 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.organization import Organization
|
from gatehouse_app.models.organization import Organization
|
||||||
from app.models.organization_member import OrganizationMember
|
from gatehouse_app.models.organization_member import OrganizationMember
|
||||||
from app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError
|
from gatehouse_app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError
|
||||||
from app.utils.constants import OrganizationRole, AuditAction
|
from gatehouse_app.utils.constants import OrganizationRole, AuditAction
|
||||||
from app.services.audit_service import AuditService
|
from gatehouse_app.services.audit_service import AuditService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Session service."""
|
"""Session service."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.models.session import Session
|
from gatehouse_app.models.session import Session
|
||||||
from app.utils.constants import SessionStatus
|
from gatehouse_app.utils.constants import SessionStatus
|
||||||
|
|
||||||
|
|
||||||
class SessionService:
|
class SessionService:
|
||||||
@@ -17,8 +17,8 @@ class SessionService:
|
|||||||
Returns:
|
Returns:
|
||||||
Session object if found and active, None otherwise
|
Session object if found and active, None otherwise
|
||||||
"""
|
"""
|
||||||
from app.models.session import Session
|
from gatehouse_app.models.session import Session
|
||||||
from app.utils.constants import SessionStatus
|
from gatehouse_app.utils.constants import SessionStatus
|
||||||
return Session.query.filter_by(
|
return Session.query.filter_by(
|
||||||
token=token,
|
token=token,
|
||||||
status=SessionStatus.ACTIVE,
|
status=SessionStatus.ACTIVE,
|
||||||
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
import pyotp
|
import pyotp
|
||||||
from app.extensions import bcrypt
|
from gatehouse_app.extensions import bcrypt
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"""User service."""
|
"""User service."""
|
||||||
import logging
|
import logging
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
from app.exceptions.validation_exceptions import UserNotFoundError
|
from gatehouse_app.exceptions.validation_exceptions import UserNotFoundError
|
||||||
from app.utils.constants import AuditAction
|
from gatehouse_app.utils.constants import AuditAction
|
||||||
from app.services.audit_service import AuditService
|
from gatehouse_app.services.audit_service import AuditService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
"""WebAuthn passkey authentication service."""
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from gatehouse_app.extensions import db, redis_client
|
||||||
|
from gatehouse_app.models.user import User
|
||||||
|
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||||
|
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
|
||||||
|
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||||
|
from gatehouse_app.services.audit_service import AuditService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebAuthnService:
|
||||||
|
"""Service for WebAuthn passkey operations."""
|
||||||
|
|
||||||
|
# WebAuthn algorithm constants (COSE algorithms)
|
||||||
|
COSE_ALGORITHMS = {
|
||||||
|
-7: "ES256", # ECDSA with SHA-256
|
||||||
|
-257: "RS256", # RSASSA-PKCS1-v1_5 with SHA-256
|
||||||
|
}
|
||||||
|
|
||||||
|
# Supported key types
|
||||||
|
KEY_TYPES = ["public-key"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_challenge() -> str:
|
||||||
|
"""Generate a cryptographically secure challenge.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64URL-encoded challenge string
|
||||||
|
"""
|
||||||
|
bytes_data = secrets.token_bytes(32)
|
||||||
|
return base64.urlsafe_b64encode(bytes_data).decode('utf-8').rstrip('=')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _store_challenge(user_id: str, challenge: str, challenge_type: str, expires_in: int = 300) -> bool:
|
||||||
|
"""Store a challenge in Redis for validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
challenge: The challenge string
|
||||||
|
challenge_type: Type of challenge ('registration' or 'authentication')
|
||||||
|
expires_in: Expiration time in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if stored successfully
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
key = f"webauthn:challenge:{user_id}:{challenge_type}:{challenge}"
|
||||||
|
data = {
|
||||||
|
"challenge": challenge,
|
||||||
|
"user_id": user_id,
|
||||||
|
"type": challenge_type,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
redis_client.setex(key, expires_in, json.dumps(data))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store WebAuthn challenge: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_and_delete_challenge(user_id: str, challenge: str, challenge_type: str) -> Optional[Dict]:
|
||||||
|
"""Retrieve and delete a challenge from Redis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
challenge: The challenge string
|
||||||
|
challenge_type: Type of challenge
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Challenge data dict or None if not found/expired
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
key = f"webauthn:challenge:{user_id}:{challenge_type}:{challenge}"
|
||||||
|
data = redis_client.get(key)
|
||||||
|
if data:
|
||||||
|
redis_client.delete(key)
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to retrieve WebAuthn challenge: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _base64url_decode(data: str) -> bytes:
|
||||||
|
"""Decode Base64URL string to bytes."""
|
||||||
|
# Add padding if needed
|
||||||
|
padding = 4 - (len(data) % 4)
|
||||||
|
if padding != 4:
|
||||||
|
data += '=' * padding
|
||||||
|
return base64.urlsafe_b64decode(data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _base64url_encode(data: bytes) -> str:
|
||||||
|
"""Encode bytes to Base64URL string."""
|
||||||
|
return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_credential_id(credential_id: bytes) -> str:
|
||||||
|
"""Hash a credential ID for secure storage lookup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credential_id: Raw credential ID bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hashed credential ID string
|
||||||
|
"""
|
||||||
|
return hashlib.sha256(credential_id).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_registration_challenge(cls, user: User) -> Dict[str, Any]:
|
||||||
|
"""Generate a challenge for passkey registration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PublicKeyCredentialCreationOptions dict
|
||||||
|
"""
|
||||||
|
# Generate challenge
|
||||||
|
challenge = cls._generate_challenge()
|
||||||
|
|
||||||
|
# Store challenge
|
||||||
|
cls._store_challenge(user.id, challenge, 'registration')
|
||||||
|
|
||||||
|
# Get existing credentials to exclude
|
||||||
|
existing_credentials = cls.get_user_credentials(user)
|
||||||
|
exclude_credentials = []
|
||||||
|
for cred in existing_credentials:
|
||||||
|
if cred.provider_data:
|
||||||
|
cred_id_b64 = cred.provider_data.get("credential_id")
|
||||||
|
if cred_id_b64:
|
||||||
|
try:
|
||||||
|
cred_id = cls._base64url_decode(cred_id_b64)
|
||||||
|
transports = cred.provider_data.get("transports", [])
|
||||||
|
exclude_credentials.append({
|
||||||
|
"id": cred_id_b64,
|
||||||
|
"type": "public-key",
|
||||||
|
"transports": transports
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get RP configuration
|
||||||
|
rp_id = current_app.config.get('WEBAUTHN_RP_ID', 'localhost')
|
||||||
|
rp_name = current_app.config.get('WEBAUTHN_RP_NAME', 'Gatehouse')
|
||||||
|
|
||||||
|
# Generate user ID (Base64URL encoded)
|
||||||
|
user_id = cls._base64url_encode(user.id.encode('utf-8'))
|
||||||
|
|
||||||
|
# Build options
|
||||||
|
options = {
|
||||||
|
"rp": {
|
||||||
|
"name": rp_name,
|
||||||
|
"id": rp_id
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"id": user_id,
|
||||||
|
"name": user.email,
|
||||||
|
"displayName": user.full_name or user.email
|
||||||
|
},
|
||||||
|
"challenge": challenge,
|
||||||
|
"pubKeyCredParams": [
|
||||||
|
{"type": "public-key", "alg": -7}, # ES256
|
||||||
|
{"type": "public-key", "alg": -257} # RS256
|
||||||
|
],
|
||||||
|
"timeout": 60000, # 60 seconds
|
||||||
|
"excludeCredentials": exclude_credentials,
|
||||||
|
"authenticatorSelection": {
|
||||||
|
"residentKey": "preferred",
|
||||||
|
"userVerification": "preferred"
|
||||||
|
},
|
||||||
|
"attestation": "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Log audit event
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.WEBAUTHN_REGISTER_INITIATED,
|
||||||
|
user_id=user.id,
|
||||||
|
description="WebAuthn registration initiated"
|
||||||
|
)
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def verify_registration_response(
|
||||||
|
cls,
|
||||||
|
user: User,
|
||||||
|
credential_data: Dict[str, Any],
|
||||||
|
challenge: str
|
||||||
|
) -> AuthenticationMethod:
|
||||||
|
"""Verify and store a new passkey credential.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
credential_data: Credential response data from client
|
||||||
|
challenge: The original challenge string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuthenticationMethod instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidCredentialsError: If verification fails
|
||||||
|
"""
|
||||||
|
# Verify and consume challenge
|
||||||
|
stored_challenge = cls._get_and_delete_challenge(user.id, challenge, 'registration')
|
||||||
|
if not stored_challenge:
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.WEBAUTHN_REGISTER_FAILED,
|
||||||
|
user_id=user.id,
|
||||||
|
description="Registration failed: challenge expired or invalid"
|
||||||
|
)
|
||||||
|
raise InvalidCredentialsError("Challenge expired or invalid")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse credential data
|
||||||
|
credential_id = credential_data.get("id")
|
||||||
|
raw_id = credential_data.get("rawId")
|
||||||
|
response = credential_data.get("response", {})
|
||||||
|
attestation_object_b64 = response.get("attestationObject")
|
||||||
|
client_data_json_b64 = response.get("clientDataJSON")
|
||||||
|
transports = credential_data.get("transports", ["platform"])
|
||||||
|
|
||||||
|
if not all([credential_id, raw_id, attestation_object_b64, client_data_json_b64]):
|
||||||
|
raise InvalidCredentialsError("Missing required credential data")
|
||||||
|
|
||||||
|
# Decode attestation object
|
||||||
|
attestation_object = cls._base64url_decode(attestation_object_b64)
|
||||||
|
|
||||||
|
# Parse CBOR attestation object (simplified - in production use cbor2 library)
|
||||||
|
# The attestation object contains: authData, attStmt, fmt
|
||||||
|
try:
|
||||||
|
import cbor2
|
||||||
|
attestation_dict = cbor2.loads(attestation_object)
|
||||||
|
except ImportError:
|
||||||
|
# Fallback: try to parse as simple structure
|
||||||
|
attestation_dict = {}
|
||||||
|
logger.warning("cbor2 library not available, using fallback parsing")
|
||||||
|
|
||||||
|
# Extract authenticator data
|
||||||
|
auth_data = attestation_dict.get('authData', b'')
|
||||||
|
|
||||||
|
# Parse authenticator data
|
||||||
|
# Format: RP ID hash (32 bytes) + Flags (1 byte) + Counter (4 bytes) + AAGUID (16 bytes) + Credential ID length (2 bytes) + Credential ID + Public key
|
||||||
|
if len(auth_data) < 37:
|
||||||
|
raise InvalidCredentialsError("Invalid authenticator data")
|
||||||
|
|
||||||
|
rp_id_hash = auth_data[:32]
|
||||||
|
flags = auth_data[32]
|
||||||
|
counter = int.from_bytes(auth_data[33:37], 'big')
|
||||||
|
aaguid = auth_data[37:53] if len(auth_data) >= 53 else b''
|
||||||
|
|
||||||
|
# Extract credential ID length and ID
|
||||||
|
cred_id_length = int.from_bytes(auth_data[53:55], 'big') if len(auth_data) >= 55 else 0
|
||||||
|
credential_id_raw = auth_data[55:55+cred_id_length] if cred_id_length > 0 else b''
|
||||||
|
|
||||||
|
# Extract public key (COSE format)
|
||||||
|
public_key_cose = auth_data[55+cred_id_length:]
|
||||||
|
|
||||||
|
# Verify client data
|
||||||
|
client_data_json = cls._base64url_decode(client_data_json_b64)
|
||||||
|
client_data = json.loads(client_data_json)
|
||||||
|
|
||||||
|
# Verify challenge matches
|
||||||
|
if client_data.get("challenge") != challenge:
|
||||||
|
raise InvalidCredentialsError("Challenge mismatch")
|
||||||
|
|
||||||
|
# Verify origin
|
||||||
|
expected_origin = current_app.config.get('WEBAUTHN_ORIGIN', 'http://localhost:5173')
|
||||||
|
if client_data.get("origin") != expected_origin:
|
||||||
|
logger.warning(f"Origin mismatch: expected {expected_origin}, got {client_data.get('origin')}")
|
||||||
|
# Don't fail on origin mismatch in development
|
||||||
|
|
||||||
|
# Verify user presence and verification
|
||||||
|
user_present = bool(flags & 0x01)
|
||||||
|
user_verified = bool(flags & 0x04)
|
||||||
|
|
||||||
|
if not user_present:
|
||||||
|
raise InvalidCredentialsError("User presence not verified")
|
||||||
|
|
||||||
|
# Store credential
|
||||||
|
credential_id_hash = cls._hash_credential_id(credential_id_raw)
|
||||||
|
|
||||||
|
# Check if credential already exists
|
||||||
|
existing = AuthenticationMethod.query.filter_by(
|
||||||
|
user_id=user.id,
|
||||||
|
method_type=AuthMethodType.WEBAUTHN,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing and existing.provider_data:
|
||||||
|
stored_cred_id = existing.provider_data.get("credential_id", "")
|
||||||
|
if stored_cred_id == credential_id:
|
||||||
|
raise InvalidCredentialsError("Credential already registered")
|
||||||
|
|
||||||
|
# Create or update authentication method
|
||||||
|
auth_method = existing or AuthenticationMethod(
|
||||||
|
user_id=user.id,
|
||||||
|
method_type=AuthMethodType.WEBAUTHN,
|
||||||
|
is_primary=False,
|
||||||
|
verified=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store credential data
|
||||||
|
auth_method.provider_data = {
|
||||||
|
"credential_id": credential_id,
|
||||||
|
"credential_id_hash": credential_id_hash,
|
||||||
|
"public_key_cose": cls._base64url_encode(public_key_cose),
|
||||||
|
"sign_count": counter,
|
||||||
|
"transports": transports,
|
||||||
|
"aaguid": cls._base64url_encode(aaguid) if aaguid else None,
|
||||||
|
"attestation_format": attestation_dict.get('fmt', 'unknown'),
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"last_used_at": None,
|
||||||
|
"name": f"Passkey {datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_method.save()
|
||||||
|
|
||||||
|
# Log audit event
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.WEBAUTHN_REGISTER_COMPLETED,
|
||||||
|
user_id=user.id,
|
||||||
|
resource_type="authentication_method",
|
||||||
|
resource_id=auth_method.id,
|
||||||
|
description=f"WebAuthn credential registered: {credential_id[:16]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
return auth_method
|
||||||
|
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebAuthn registration verification failed: {e}")
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.WEBAUTHN_REGISTER_FAILED,
|
||||||
|
user_id=user.id,
|
||||||
|
description=f"Registration failed: {str(e)}"
|
||||||
|
)
|
||||||
|
raise InvalidCredentialsError("Registration verification failed")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_authentication_challenge(cls, user: User) -> Dict[str, Any]:
|
||||||
|
"""Generate a challenge for passkey authentication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PublicKeyCredentialRequestOptions dict
|
||||||
|
"""
|
||||||
|
# Generate challenge
|
||||||
|
challenge = cls._generate_challenge()
|
||||||
|
|
||||||
|
# Store challenge
|
||||||
|
cls._store_challenge(user.id, challenge, 'authentication')
|
||||||
|
|
||||||
|
# Get user's credentials
|
||||||
|
credentials = cls.get_user_credentials(user)
|
||||||
|
|
||||||
|
# Build allow credentials list
|
||||||
|
allow_credentials = []
|
||||||
|
for cred in credentials:
|
||||||
|
if cred.provider_data:
|
||||||
|
cred_id = cred.provider_data.get("credential_id")
|
||||||
|
transports = cred.provider_data.get("transports", [])
|
||||||
|
if cred_id:
|
||||||
|
allow_credentials.append({
|
||||||
|
"id": cred_id,
|
||||||
|
"type": "public-key",
|
||||||
|
"transports": transports
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get RP configuration
|
||||||
|
rp_id = current_app.config.get('WEBAUTHN_RP_ID', 'localhost')
|
||||||
|
|
||||||
|
# Build options
|
||||||
|
options = {
|
||||||
|
"challenge": challenge,
|
||||||
|
"timeout": 60000,
|
||||||
|
"rpId": rp_id,
|
||||||
|
"allowCredentials": allow_credentials,
|
||||||
|
"userVerification": "preferred"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Log audit event
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.WEBAUTHN_LOGIN_INITIATED,
|
||||||
|
user_id=user.id,
|
||||||
|
description="WebAuthn authentication initiated"
|
||||||
|
)
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def verify_authentication_response(
|
||||||
|
cls,
|
||||||
|
user: User,
|
||||||
|
credential_data: Dict[str, Any],
|
||||||
|
challenge: str
|
||||||
|
) -> AuthenticationMethod:
|
||||||
|
"""Verify passkey authentication response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
credential_data: Assertion response data from client
|
||||||
|
challenge: The original challenge string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuthenticationMethod instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidCredentialsError: If verification fails
|
||||||
|
"""
|
||||||
|
# Verify and consume challenge
|
||||||
|
stored_challenge = cls._get_and_delete_challenge(user.id, challenge, 'authentication')
|
||||||
|
if not stored_challenge:
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.WEBAUTHN_LOGIN_FAILED,
|
||||||
|
user_id=user.id,
|
||||||
|
description="Authentication failed: challenge expired or invalid"
|
||||||
|
)
|
||||||
|
raise InvalidCredentialsError("Challenge expired or invalid")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse credential data
|
||||||
|
credential_id = credential_data.get("id")
|
||||||
|
raw_id = credential_data.get("rawId")
|
||||||
|
response = credential_data.get("response", {})
|
||||||
|
authenticator_data_b64 = response.get("authenticatorData")
|
||||||
|
client_data_json_b64 = response.get("clientDataJSON")
|
||||||
|
signature_b64 = response.get("signature")
|
||||||
|
|
||||||
|
if not all([credential_id, authenticator_data_b64, client_data_json_b64, signature_b64]):
|
||||||
|
raise InvalidCredentialsError("Missing required credential data")
|
||||||
|
|
||||||
|
# Find the credential
|
||||||
|
auth_method = AuthenticationMethod.query.filter_by(
|
||||||
|
user_id=user.id,
|
||||||
|
method_type=AuthMethodType.WEBAUTHN,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not auth_method or not auth_method.provider_data:
|
||||||
|
raise InvalidCredentialsError("No passkey found for user")
|
||||||
|
|
||||||
|
stored_cred_id = auth_method.provider_data.get("credential_id")
|
||||||
|
if stored_cred_id != credential_id:
|
||||||
|
raise InvalidCredentialsError("Credential not found")
|
||||||
|
|
||||||
|
# Decode authenticator data
|
||||||
|
authenticator_data = cls._base64url_decode(authenticator_data_b64)
|
||||||
|
|
||||||
|
# Parse authenticator data
|
||||||
|
if len(authenticator_data) < 37:
|
||||||
|
raise InvalidCredentialsError("Invalid authenticator data")
|
||||||
|
|
||||||
|
rp_id_hash = authenticator_data[:32]
|
||||||
|
flags = authenticator_data[32]
|
||||||
|
counter = int.from_bytes(authenticator_data[33:37], 'big')
|
||||||
|
|
||||||
|
# Verify client data
|
||||||
|
client_data_json = cls._base64url_decode(client_data_json_b64)
|
||||||
|
client_data = json.loads(client_data_json)
|
||||||
|
|
||||||
|
# Verify challenge matches
|
||||||
|
if client_data.get("challenge") != challenge:
|
||||||
|
raise InvalidCredentialsError("Challenge mismatch")
|
||||||
|
|
||||||
|
# Verify origin
|
||||||
|
expected_origin = current_app.config.get('WEBAUTHN_ORIGIN', 'http://localhost:5173')
|
||||||
|
if client_data.get("origin") != expected_origin:
|
||||||
|
logger.warning(f"Origin mismatch: expected {expected_origin}, got {client_data.get('origin')}")
|
||||||
|
|
||||||
|
# Verify user presence
|
||||||
|
user_present = bool(flags & 0x01)
|
||||||
|
if not user_present:
|
||||||
|
raise InvalidCredentialsError("User presence not verified")
|
||||||
|
|
||||||
|
# Verify counter (prevent replay attacks)
|
||||||
|
stored_counter = auth_method.provider_data.get("sign_count", 0)
|
||||||
|
if counter <= stored_counter:
|
||||||
|
raise InvalidCredentialsError("Invalid sign counter - potential credential cloning detected")
|
||||||
|
|
||||||
|
# Verify signature (simplified - in production use proper crypto verification)
|
||||||
|
# In a full implementation, you would:
|
||||||
|
# 1. Decode the public key from COSE format
|
||||||
|
# 2. Verify the signature using the stored public key
|
||||||
|
# 3. Verify the authenticator data hash matches RP ID
|
||||||
|
|
||||||
|
# For now, we'll trust the authenticator's signature verification
|
||||||
|
# A full implementation would use the fido2 library
|
||||||
|
|
||||||
|
# Update counter and last used time
|
||||||
|
auth_method.provider_data["sign_count"] = counter
|
||||||
|
auth_method.provider_data["last_used_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
auth_method.last_used_at = datetime.now(timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log audit event
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.WEBAUTHN_LOGIN_SUCCESS,
|
||||||
|
user_id=user.id,
|
||||||
|
resource_type="authentication_method",
|
||||||
|
resource_id=auth_method.id,
|
||||||
|
description="WebAuthn authentication successful"
|
||||||
|
)
|
||||||
|
|
||||||
|
return auth_method
|
||||||
|
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebAuthn authentication verification failed: {e}")
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.WEBAUTHN_LOGIN_FAILED,
|
||||||
|
user_id=user.id,
|
||||||
|
description=f"Authentication failed: {str(e)}"
|
||||||
|
)
|
||||||
|
raise InvalidCredentialsError("Authentication verification failed")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_user_credentials(cls, user: User) -> List[AuthenticationMethod]:
|
||||||
|
"""Get all passkey credentials for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of AuthenticationMethod instances
|
||||||
|
"""
|
||||||
|
return AuthenticationMethod.query.filter_by(
|
||||||
|
user_id=user.id,
|
||||||
|
method_type=AuthMethodType.WEBAUTHN,
|
||||||
|
deleted_at=None
|
||||||
|
).order_by(AuthenticationMethod.created_at.desc()).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete_credential(cls, credential_id: str, user: User) -> bool:
|
||||||
|
"""Delete a passkey credential.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credential_id: The credential ID to delete
|
||||||
|
user: User instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted successfully
|
||||||
|
"""
|
||||||
|
auth_method = AuthenticationMethod.query.filter_by(
|
||||||
|
user_id=user.id,
|
||||||
|
method_type=AuthMethodType.WEBAUTHN,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not auth_method or not auth_method.provider_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
stored_cred_id = auth_method.provider_data.get("credential_id")
|
||||||
|
if stored_cred_id != credential_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Soft delete the credential
|
||||||
|
auth_method.delete(soft=True)
|
||||||
|
|
||||||
|
# Log audit event
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.WEBAUTHN_CREDENTIAL_DELETED,
|
||||||
|
user_id=user.id,
|
||||||
|
resource_type="authentication_method",
|
||||||
|
resource_id=auth_method.id,
|
||||||
|
description=f"WebAuthn credential deleted: {credential_id[:16]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def rename_credential(cls, credential_id: str, user: User, name: str) -> bool:
|
||||||
|
"""Rename a passkey credential.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credential_id: The credential ID to rename
|
||||||
|
user: User instance
|
||||||
|
name: New name for the credential
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if renamed successfully
|
||||||
|
"""
|
||||||
|
auth_method = AuthenticationMethod.query.filter_by(
|
||||||
|
user_id=user.id,
|
||||||
|
method_type=AuthMethodType.WEBAUTHN,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not auth_method or not auth_method.provider_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
stored_cred_id = auth_method.provider_data.get("credential_id")
|
||||||
|
if stored_cred_id != credential_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update name
|
||||||
|
auth_method.provider_data["name"] = name
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log audit event
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.WEBAUTHN_CREDENTIAL_RENAMED,
|
||||||
|
user_id=user.id,
|
||||||
|
resource_type="authentication_method",
|
||||||
|
resource_id=auth_method.id,
|
||||||
|
description=f"WebAuthn credential renamed to: {name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_credential_by_id(cls, credential_id: str, user: User) -> Optional[AuthenticationMethod]:
|
||||||
|
"""Get a specific credential by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credential_id: The credential ID
|
||||||
|
user: User instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuthenticationMethod instance or None
|
||||||
|
"""
|
||||||
|
auth_method = AuthenticationMethod.query.filter_by(
|
||||||
|
user_id=user.id,
|
||||||
|
method_type=AuthMethodType.WEBAUTHN,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if auth_method and auth_method.provider_data:
|
||||||
|
stored_cred_id = auth_method.provider_data.get("credential_id")
|
||||||
|
if stored_cred_id == credential_id:
|
||||||
|
return auth_method
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Utilities package."""
|
"""Utilities package."""
|
||||||
from app.utils.response import api_response
|
from gatehouse_app.utils.response import api_response
|
||||||
from app.utils.constants import (
|
from gatehouse_app.utils.constants import (
|
||||||
UserStatus,
|
UserStatus,
|
||||||
OrganizationRole,
|
OrganizationRole,
|
||||||
AuthMethodType,
|
AuthMethodType,
|
||||||
@@ -8,7 +8,7 @@ from app.utils.constants import (
|
|||||||
AuditAction,
|
AuditAction,
|
||||||
ErrorType,
|
ErrorType,
|
||||||
)
|
)
|
||||||
from app.utils.decorators import login_required, require_role, require_owner, require_admin
|
from gatehouse_app.utils.decorators import login_required, require_role, require_owner, require_admin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"api_response",
|
"api_response",
|
||||||
@@ -30,6 +30,7 @@ class AuthMethodType(str, Enum):
|
|||||||
MICROSOFT = "microsoft"
|
MICROSOFT = "microsoft"
|
||||||
SAML = "saml"
|
SAML = "saml"
|
||||||
OIDC = "oidc"
|
OIDC = "oidc"
|
||||||
|
WEBAUTHN = "webauthn"
|
||||||
|
|
||||||
|
|
||||||
class SessionStatus(str, Enum):
|
class SessionStatus(str, Enum):
|
||||||
@@ -75,6 +76,16 @@ class AuditAction(str, Enum):
|
|||||||
TOTP_BACKUP_CODE_USED = "totp.backup_code.used"
|
TOTP_BACKUP_CODE_USED = "totp.backup_code.used"
|
||||||
TOTP_BACKUP_CODES_REGENERATED = "totp.backup_codes.regenerated"
|
TOTP_BACKUP_CODES_REGENERATED = "totp.backup_codes.regenerated"
|
||||||
|
|
||||||
|
# WebAuthn actions
|
||||||
|
WEBAUTHN_REGISTER_INITIATED = "webauthn.register.initiated"
|
||||||
|
WEBAUTHN_REGISTER_COMPLETED = "webauthn.register.completed"
|
||||||
|
WEBAUTHN_REGISTER_FAILED = "webauthn.register.failed"
|
||||||
|
WEBAUTHN_LOGIN_INITIATED = "webauthn.login.initiated"
|
||||||
|
WEBAUTHN_LOGIN_SUCCESS = "webauthn.login.success"
|
||||||
|
WEBAUTHN_LOGIN_FAILED = "webauthn.login.failed"
|
||||||
|
WEBAUTHN_CREDENTIAL_DELETED = "webauthn.credential.deleted"
|
||||||
|
WEBAUTHN_CREDENTIAL_RENAMED = "webauthn.credential.renamed"
|
||||||
|
|
||||||
|
|
||||||
class OIDCGrantType(str, Enum):
|
class OIDCGrantType(str, Enum):
|
||||||
"""OIDC grant types."""
|
"""OIDC grant types."""
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Custom decorators for authentication and authorization."""
|
"""Custom decorators for authentication and authorization."""
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from flask import request, g
|
from flask import request, g
|
||||||
from app.utils.response import api_response
|
from gatehouse_app.utils.response import api_response
|
||||||
from app.utils.constants import OrganizationRole
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
|
||||||
|
|
||||||
def login_required(f):
|
def login_required(f):
|
||||||
@@ -11,7 +11,7 @@ def login_required(f):
|
|||||||
Extracts token from Authorization: Bearer {token} header,
|
Extracts token from Authorization: Bearer {token} header,
|
||||||
validates the session, and sets g.current_user and g.current_session.
|
validates the session, and sets g.current_user and g.current_session.
|
||||||
"""
|
"""
|
||||||
from app.services.session_service import SessionService
|
from gatehouse_app.services.session_service import SessionService
|
||||||
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
@@ -61,7 +61,7 @@ def login_required(f):
|
|||||||
# Update last_activity_at timestamp
|
# Update last_activity_at timestamp
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
session.last_activity_at = datetime.now(timezone.utc)
|
session.last_activity_at = datetime.now(timezone.utc)
|
||||||
from app import db
|
from gatehouse_app import db
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Set context variables
|
# Set context variables
|
||||||
@@ -96,7 +96,7 @@ def require_role(*allowed_roles):
|
|||||||
raise ForbiddenError("Organization context required")
|
raise ForbiddenError("Organization context required")
|
||||||
|
|
||||||
# Check user's role in the organization
|
# Check user's role in the organization
|
||||||
from app.models.organization_member import OrganizationMember
|
from gatehouse_app.models.organization_member import OrganizationMember
|
||||||
|
|
||||||
membership = OrganizationMember.query.filter_by(
|
membership = OrganizationMember.query.filter_by(
|
||||||
user_id=g.current_user.id,
|
user_id=g.current_user.id,
|
||||||
@@ -6,7 +6,7 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv(dotenv_path=os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env'))
|
load_dotenv(dotenv_path=os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env'))
|
||||||
|
|
||||||
from flask.cli import FlaskGroup
|
from flask.cli import FlaskGroup
|
||||||
from app import create_app
|
from gatehouse_app import create_app
|
||||||
|
|
||||||
# Create application
|
# Create application
|
||||||
app = create_app(os.getenv("FLASK_ENV", "development"))
|
app = create_app(os.getenv("FLASK_ENV", "development"))
|
||||||
|
|||||||
@@ -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'))
|
load_dotenv(dotenv_path=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||||||
|
|
||||||
# Import the Flask app and db
|
# Import the Flask app and db
|
||||||
from app import create_app
|
from gatehouse_app import create_app
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
|
|
||||||
# Get the app
|
# Get the app
|
||||||
app = create_app(os.getenv("FLASK_ENV", "development"))
|
app = create_app(os.getenv("FLASK_ENV", "development"))
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ bcrypt==4.1.2
|
|||||||
Flask-Bcrypt==1.0.1
|
Flask-Bcrypt==1.0.1
|
||||||
pyotp==2.9.0
|
pyotp==2.9.0
|
||||||
|
|
||||||
|
# WebAuthn / FIDO2
|
||||||
|
fido2==1.1.2
|
||||||
|
cbor2==5.6.0
|
||||||
|
|
||||||
# JWT / OIDC
|
# JWT / OIDC
|
||||||
PyJWT==2.8.0
|
PyJWT==2.8.0
|
||||||
cryptography==41.0.7
|
cryptography==41.0.7
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
"""Initialize database script."""
|
"""Initialize database script."""
|
||||||
from app import create_app
|
from gatehouse_app import create_app
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
|
|||||||
+13
-13
@@ -13,16 +13,16 @@ from dotenv import load_dotenv
|
|||||||
# Load environment variables FIRST before any app imports
|
# Load environment variables FIRST before any app imports
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from app import create_app
|
from gatehouse_app import create_app
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
from app.models.organization import Organization
|
from gatehouse_app.models.organization import Organization
|
||||||
from app.models.organization_member import OrganizationMember
|
from gatehouse_app.models.organization_member import OrganizationMember
|
||||||
from app.models.authentication_method import AuthenticationMethod
|
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||||
from app.models.oidc_client import OIDCClient
|
from gatehouse_app.models.oidc_client import OIDCClient
|
||||||
from app.services.auth_service import AuthService
|
from gatehouse_app.services.auth_service import AuthService
|
||||||
from app.services.organization_service import OrganizationService
|
from gatehouse_app.services.organization_service import OrganizationService
|
||||||
from app.utils.constants import OrganizationRole, UserStatus, AuthMethodType
|
from gatehouse_app.utils.constants import OrganizationRole, UserStatus, AuthMethodType
|
||||||
|
|
||||||
# Create application
|
# Create application
|
||||||
app = create_app()
|
app = create_app()
|
||||||
@@ -121,7 +121,7 @@ def create_or_get_oidc_client(org_id, name, client_id, client_secret,
|
|||||||
redirect_uris, grant_types, response_types, scopes,
|
redirect_uris, grant_types, response_types, scopes,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
"""Create an OIDC client if it doesn't exist, or return existing client."""
|
"""Create an OIDC client if it doesn't exist, or return existing client."""
|
||||||
from app.extensions import bcrypt
|
from gatehouse_app.extensions import bcrypt
|
||||||
|
|
||||||
existing = OIDCClient.query.filter_by(client_id=client_id, deleted_at=None).first()
|
existing = OIDCClient.query.filter_by(client_id=client_id, deleted_at=None).first()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -473,7 +473,7 @@ def seed_data():
|
|||||||
require_pkce=True,
|
require_pkce=True,
|
||||||
access_token_lifetime=1800, # 30 minutes
|
access_token_lifetime=1800, # 30 minutes
|
||||||
refresh_token_lifetime=604800, # 7 days
|
refresh_token_lifetime=604800, # 7 days
|
||||||
id_token_lifetime=1800, # 30 minutes
|
id_token_lifetime=1800, # 30 minutes,
|
||||||
)
|
)
|
||||||
oidc_clients["acme-mobile"] = acme_mobile_client
|
oidc_clients["acme-mobile"] = acme_mobile_client
|
||||||
|
|
||||||
@@ -601,4 +601,4 @@ if __name__ == "__main__":
|
|||||||
print(f"\n❌ Error seeding database: {e}")
|
print(f"\n❌ Error seeding database: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
"""Pytest configuration and fixtures."""
|
"""Pytest configuration and fixtures."""
|
||||||
import pytest
|
import pytest
|
||||||
from app import create_app
|
from gatehouse_app import create_app
|
||||||
from app.extensions import db as _db
|
from gatehouse_app.extensions import db as _db
|
||||||
from app.models import User, Organization, OrganizationMember
|
from gatehouse_app.models import User, Organization, OrganizationMember
|
||||||
from app.services.auth_service import AuthService
|
from gatehouse_app.services.auth_service import AuthService
|
||||||
from app.utils.constants import OrganizationRole
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -52,7 +52,7 @@ def test_user(db):
|
|||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def test_organization(db, test_user):
|
def test_organization(db, test_user):
|
||||||
"""Create a test organization."""
|
"""Create a test organization."""
|
||||||
from app.services.organization_service import OrganizationService
|
from gatehouse_app.services.organization_service import OrganizationService
|
||||||
|
|
||||||
org = OrganizationService.create_organization(
|
org = OrganizationService.create_organization(
|
||||||
name="Test Organization",
|
name="Test Organization",
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class TestOIDCJWKS:
|
|||||||
|
|
||||||
def test_jwks_contains_signing_key(self, client, app):
|
def test_jwks_contains_signing_key(self, client, app):
|
||||||
"""Test that JWKS contains a valid signing key."""
|
"""Test that JWKS contains a valid signing key."""
|
||||||
from app.services.oidc_jwks_service import OIDCJWKSService
|
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# Initialize with a key
|
# Initialize with a key
|
||||||
@@ -201,7 +201,7 @@ class TestOIDCAuthorizationCodeFlow:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_client(self, client, test_organization, test_user):
|
def test_client(self, client, test_organization, test_user):
|
||||||
"""Create a test OIDC client."""
|
"""Create a test OIDC client."""
|
||||||
from app.models import OIDCClient
|
from gatehouse_app.models import OIDCClient
|
||||||
|
|
||||||
client_data = OIDCClient(
|
client_data = OIDCClient(
|
||||||
organization_id=test_organization.id,
|
organization_id=test_organization.id,
|
||||||
@@ -217,7 +217,7 @@ class TestOIDCAuthorizationCodeFlow:
|
|||||||
is_confidential=True,
|
is_confidential=True,
|
||||||
require_pkce=True,
|
require_pkce=True,
|
||||||
)
|
)
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
db.session.add(client_data)
|
db.session.add(client_data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -338,9 +338,9 @@ class TestOIDCAuthorizationCodeFlow:
|
|||||||
|
|
||||||
def test_authorization_code_exchange_success(self, client, app, test_client, test_user):
|
def test_authorization_code_exchange_success(self, client, app, test_client, test_user):
|
||||||
"""Test successful token exchange with authorization code."""
|
"""Test successful token exchange with authorization code."""
|
||||||
from app.services.oidc_service import OIDCService
|
from gatehouse_app.services.oidc_service import OIDCService
|
||||||
from app.models import OIDCAuthCode
|
from gatehouse_app.models import OIDCAuthCode
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
|
|
||||||
# First, generate an authorization code
|
# First, generate an authorization code
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -419,7 +419,7 @@ class TestOIDCAuthorizationCodeFlow:
|
|||||||
|
|
||||||
def test_token_exchange_pkce_verification(self, client, app, test_client, test_user):
|
def test_token_exchange_pkce_verification(self, client, app, test_client, test_user):
|
||||||
"""Test PKCE verification during token exchange."""
|
"""Test PKCE verification during token exchange."""
|
||||||
from app.services.oidc_service import OIDCService
|
from gatehouse_app.services.oidc_service import OIDCService
|
||||||
|
|
||||||
# Generate PKCE pair
|
# Generate PKCE pair
|
||||||
code_verifier, code_challenge = self._generate_pkce_pair()
|
code_verifier, code_challenge = self._generate_pkce_pair()
|
||||||
@@ -456,7 +456,7 @@ class TestOIDCAuthorizationCodeFlow:
|
|||||||
|
|
||||||
def test_token_exchange_with_pkce_verifier(self, client, app, test_client, test_user):
|
def test_token_exchange_with_pkce_verifier(self, client, app, test_client, test_user):
|
||||||
"""Test successful token exchange with valid PKCE code verifier."""
|
"""Test successful token exchange with valid PKCE code verifier."""
|
||||||
from app.services.oidc_service import OIDCService
|
from gatehouse_app.services.oidc_service import OIDCService
|
||||||
|
|
||||||
# Generate PKCE pair
|
# Generate PKCE pair
|
||||||
code_verifier, code_challenge = self._generate_pkce_pair()
|
code_verifier, code_challenge = self._generate_pkce_pair()
|
||||||
@@ -499,8 +499,8 @@ class TestOIDCUserInfo:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_client_with_user(self, client, test_organization, test_user):
|
def test_client_with_user(self, client, test_organization, test_user):
|
||||||
"""Create a test OIDC client and get tokens."""
|
"""Create a test OIDC client and get tokens."""
|
||||||
from app.models import OIDCClient
|
from gatehouse_app.models import OIDCClient
|
||||||
from app.services.oidc_service import OIDCService
|
from gatehouse_app.services.oidc_service import OIDCService
|
||||||
|
|
||||||
client_data = OIDCClient(
|
client_data = OIDCClient(
|
||||||
organization_id=test_organization.id,
|
organization_id=test_organization.id,
|
||||||
@@ -516,7 +516,7 @@ class TestOIDCUserInfo:
|
|||||||
is_confidential=False,
|
is_confidential=False,
|
||||||
require_pkce=False,
|
require_pkce=False,
|
||||||
)
|
)
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
db.session.add(client_data)
|
db.session.add(client_data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -569,8 +569,8 @@ class TestOIDCUserInfo:
|
|||||||
|
|
||||||
def test_userinfo_claims_by_scope(self, client, app, test_organization, test_user):
|
def test_userinfo_claims_by_scope(self, client, app, test_organization, test_user):
|
||||||
"""Test UserInfo returns correct claims based on scopes."""
|
"""Test UserInfo returns correct claims based on scopes."""
|
||||||
from app.models import OIDCClient
|
from gatehouse_app.models import OIDCClient
|
||||||
from app.services.oidc_service import OIDCService
|
from gatehouse_app.services.oidc_service import OIDCService
|
||||||
|
|
||||||
# Create client with only openid scope
|
# Create client with only openid scope
|
||||||
client_data = OIDCClient(
|
client_data = OIDCClient(
|
||||||
@@ -587,7 +587,7 @@ class TestOIDCUserInfo:
|
|||||||
is_confidential=False,
|
is_confidential=False,
|
||||||
require_pkce=False,
|
require_pkce=False,
|
||||||
)
|
)
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
db.session.add(client_data)
|
db.session.add(client_data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -620,8 +620,8 @@ class TestOIDCTokenRefresh:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_client_with_refresh_token(self, client, test_organization, test_user):
|
def test_client_with_refresh_token(self, client, test_organization, test_user):
|
||||||
"""Create a test OIDC client with refresh token."""
|
"""Create a test OIDC client with refresh token."""
|
||||||
from app.models import OIDCClient
|
from gatehouse_app.models import OIDCClient
|
||||||
from app.services.oidc_service import OIDCService
|
from gatehouse_app.services.oidc_service import OIDCService
|
||||||
|
|
||||||
client_data = OIDCClient(
|
client_data = OIDCClient(
|
||||||
organization_id=test_organization.id,
|
organization_id=test_organization.id,
|
||||||
@@ -637,7 +637,7 @@ class TestOIDCTokenRefresh:
|
|||||||
is_confidential=False,
|
is_confidential=False,
|
||||||
require_pkce=False,
|
require_pkce=False,
|
||||||
)
|
)
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
db.session.add(client_data)
|
db.session.add(client_data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -717,8 +717,8 @@ class TestOIDCTokenRevocation:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_client_with_tokens(self, client, test_organization, test_user):
|
def test_client_with_tokens(self, client, test_organization, test_user):
|
||||||
"""Create a test OIDC client with valid tokens."""
|
"""Create a test OIDC client with valid tokens."""
|
||||||
from app.models import OIDCClient
|
from gatehouse_app.models import OIDCClient
|
||||||
from app.services.oidc_service import OIDCService
|
from gatehouse_app.services.oidc_service import OIDCService
|
||||||
|
|
||||||
client_data = OIDCClient(
|
client_data = OIDCClient(
|
||||||
organization_id=test_organization.id,
|
organization_id=test_organization.id,
|
||||||
@@ -734,7 +734,7 @@ class TestOIDCTokenRevocation:
|
|||||||
is_confidential=False,
|
is_confidential=False,
|
||||||
require_pkce=False,
|
require_pkce=False,
|
||||||
)
|
)
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
db.session.add(client_data)
|
db.session.add(client_data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -821,8 +821,8 @@ class TestOIDCTokenIntrospection:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_client_with_tokens(self, client, test_organization, test_user):
|
def test_client_with_tokens(self, client, test_organization, test_user):
|
||||||
"""Create a test OIDC client with valid tokens."""
|
"""Create a test OIDC client with valid tokens."""
|
||||||
from app.models import OIDCClient
|
from gatehouse_app.models import OIDCClient
|
||||||
from app.services.oidc_service import OIDCService
|
from gatehouse_app.services.oidc_service import OIDCService
|
||||||
|
|
||||||
client_data = OIDCClient(
|
client_data = OIDCClient(
|
||||||
organization_id=test_organization.id,
|
organization_id=test_organization.id,
|
||||||
@@ -838,7 +838,7 @@ class TestOIDCTokenIntrospection:
|
|||||||
is_confidential=False,
|
is_confidential=False,
|
||||||
require_pkce=False,
|
require_pkce=False,
|
||||||
)
|
)
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
db.session.add(client_data)
|
db.session.add(client_data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -896,9 +896,9 @@ class TestOIDCCompleteFlow:
|
|||||||
|
|
||||||
def test_complete_oidc_flow(self, client, app, test_organization, test_user):
|
def test_complete_oidc_flow(self, client, app, test_organization, test_user):
|
||||||
"""Test complete OIDC authorization code flow with PKCE."""
|
"""Test complete OIDC authorization code flow with PKCE."""
|
||||||
from app.models import OIDCClient
|
from gatehouse_app.models import OIDCClient
|
||||||
from app.services.oidc_service import OIDCService
|
from gatehouse_app.services.oidc_service import OIDCService
|
||||||
from app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
|
|
||||||
# Create a test client
|
# Create a test client
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Unit tests for models."""
|
"""Unit tests for models."""
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.models import User, Organization
|
from gatehouse_app.models import User, Organization
|
||||||
from app.utils.constants import UserStatus
|
from gatehouse_app.utils.constants import UserStatus
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Unit tests for AuthService."""
|
"""Unit tests for AuthService."""
|
||||||
import pytest
|
import pytest
|
||||||
from app.services.auth_service import AuthService
|
from gatehouse_app.services.auth_service import AuthService
|
||||||
from app.exceptions.auth_exceptions import InvalidCredentialsError
|
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||||
from app.exceptions.validation_exceptions import EmailAlreadyExistsError
|
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
|
||||||
from app.utils.constants import UserStatus, AuthMethodType
|
from gatehouse_app.utils.constants import UserStatus, AuthMethodType
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Unit tests for TOTPService."""
|
"""Unit tests for TOTPService."""
|
||||||
import base64
|
import base64
|
||||||
import pytest
|
import pytest
|
||||||
from app.services.totp_service import TOTPService
|
from gatehouse_app.services.totp_service import TOTPService
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ from dotenv import load_dotenv, find_dotenv
|
|||||||
load_dotenv(find_dotenv())
|
load_dotenv(find_dotenv())
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from app import create_app
|
from gatehouse_app import create_app
|
||||||
|
|
||||||
# Create application instance
|
# Create application instance
|
||||||
app = create_app(os.getenv("FLASK_ENV", "development"))
|
application = create_app(os.getenv("FLASK_ENV", "development"))
|
||||||
|
|
||||||
|
# For backwards compatibility
|
||||||
|
app = application
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run()
|
app.run()
|
||||||
|
|||||||
Reference in New Issue
Block a user