feat(oidc): add debug logging and migrate client secret hashing to Flask-Bcrypt
- Add comprehensive debug logging across OIDC endpoints and services for development troubleshooting - Implement backward-compatible password hash checking with automatic migration from raw bcrypt to Flask-Bcrypt format - Refactor logging configuration to ensure proper propagation across all app modules - Configure root logger and disable Werkzeug duplication for cleaner log output - Initialize OIDC JWKS service on application startup - Update seed script to use Flask-Bcrypt for client secret hashing - Fix audit service to use correct event_metadata parameter BREAKING CHANGE: Client secrets created with old raw bcrypt format will be automatically migrated to Flask-Bcrypt format on first successful authentication
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
"""Authentication service."""
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from flask import request, g
|
||||
from flask import request, g, current_app
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models.user import User
|
||||
from app.models.authentication_method import AuthenticationMethod
|
||||
@@ -9,7 +11,8 @@ from app.utils.constants import AuthMethodType, SessionStatus, UserStatus, Audit
|
||||
from app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
|
||||
from app.exceptions.validation_exceptions import EmailAlreadyExistsError
|
||||
from app.services.audit_service import AuditService
|
||||
import secrets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
@@ -85,27 +88,45 @@ class AuthService:
|
||||
"""
|
||||
# Find user
|
||||
user = User.query.filter_by(email=email.lower(), deleted_at=None).first()
|
||||
|
||||
# Development-only debug logging for user existence check
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[Auth] User lookup: email={email}, exists={user is not None}")
|
||||
|
||||
if not user:
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
|
||||
# Check account status
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[Auth] Account status: user_id={user.id}, status={user.status}")
|
||||
|
||||
if user.status == UserStatus.SUSPENDED:
|
||||
raise AccountSuspendedError()
|
||||
if user.status == UserStatus.INACTIVE:
|
||||
raise AccountInactiveError()
|
||||
|
||||
|
||||
# Find password auth method
|
||||
auth_method = AuthenticationMethod.query.filter_by(
|
||||
user_id=user.id,
|
||||
method_type=AuthMethodType.PASSWORD,
|
||||
deleted_at=None,
|
||||
).first()
|
||||
|
||||
|
||||
# Development-only debug logging for auth method lookup
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[Auth] Auth method lookup: user_id={user.id}, has_password_auth={auth_method is not None and auth_method.password_hash is not None}")
|
||||
|
||||
if not auth_method or not auth_method.password_hash:
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
|
||||
# Verify password
|
||||
if not bcrypt.check_password_hash(auth_method.password_hash, password):
|
||||
password_valid = bcrypt.check_password_hash(auth_method.password_hash, password)
|
||||
|
||||
# Development-only debug logging for password validation (without logging actual password)
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[Auth] Password validation: user_id={user.id}, valid={password_valid}")
|
||||
|
||||
if not password_valid:
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
# Update last login
|
||||
|
||||
@@ -84,7 +84,7 @@ class OIDCAuditService:
|
||||
ip_address=context["ip_address"],
|
||||
user_agent=context["user_agent"],
|
||||
request_id=context["request_id"],
|
||||
metadata=metadata,
|
||||
event_metadata=metadata,
|
||||
)
|
||||
|
||||
return log
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""OIDC Service - Main OIDC service layer."""
|
||||
import logging
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
@@ -6,6 +7,8 @@ from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from flask import current_app, g
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
User, OIDCClient, OIDCAuthCode, OIDCRefreshToken,
|
||||
@@ -120,13 +123,24 @@ class OIDCService:
|
||||
"""
|
||||
# Validate client exists and is active
|
||||
client = OIDCClient.query.filter_by(client_id=client_id).first()
|
||||
|
||||
# Development-only debug logging for client validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Generate auth code - Client validation: client_id={client_id}, exists={client is not None}")
|
||||
|
||||
if not client:
|
||||
raise NotFoundError("Client not found")
|
||||
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Generate auth code - Client active validation: client_id={client_id}, is_active={client.is_active}")
|
||||
|
||||
if not client.is_active:
|
||||
raise ValidationError("Client is not active")
|
||||
|
||||
# Validate redirect URI
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Generate auth code - Redirect URI validation: client_id={client_id}, redirect_uri={redirect_uri}")
|
||||
|
||||
if not client.is_redirect_uri_allowed(redirect_uri):
|
||||
raise ValidationError("Invalid redirect_uri")
|
||||
|
||||
@@ -141,6 +155,10 @@ class OIDCService:
|
||||
code = cls._generate_code()
|
||||
code_hash = cls._hash_value(code)
|
||||
|
||||
# Development-only debug logging for PKCE in code creation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Generate auth code - PKCE: code_challenge={code_challenge is not None}, code_challenge_method={code_challenge_method}")
|
||||
|
||||
# Create auth code record
|
||||
auth_code = OIDCAuthCode.create_code(
|
||||
client_id=client.id,
|
||||
@@ -195,7 +213,13 @@ class OIDCService:
|
||||
"""
|
||||
# Get client
|
||||
client = OIDCClient.query.filter_by(client_id=client_id).first()
|
||||
|
||||
# Development-only debug logging for client validation in code validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Validate auth code - Client validation: client_id={client_id}, exists={client is not None}")
|
||||
|
||||
if not client:
|
||||
logger.error(f"[OIDC] Validate auth code - Client not found: client_id={client_id}")
|
||||
raise InvalidGrantError("Invalid client")
|
||||
|
||||
# Hash the provided code and find matching auth code
|
||||
@@ -206,7 +230,11 @@ class OIDCService:
|
||||
deleted_at=None
|
||||
).first()
|
||||
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Validate auth code - Code lookup: code_hash={code_hash[:20]}..., found={auth_code is not None}")
|
||||
|
||||
if not auth_code:
|
||||
logger.error(f"[OIDC] Validate auth code - Code not found or deleted: code_hash={code_hash[:20]}...")
|
||||
OIDCAuditService.log_authorization_event(
|
||||
client_id=client_id,
|
||||
success=False,
|
||||
@@ -217,6 +245,7 @@ class OIDCService:
|
||||
|
||||
# Check if already used
|
||||
if auth_code.is_used:
|
||||
logger.error(f"[OIDC] Validate auth code - Code already used: code_hash={code_hash[:20]}..., user_id={auth_code.user_id}")
|
||||
OIDCAuditService.log_authorization_event(
|
||||
client_id=client_id,
|
||||
user_id=auth_code.user_id,
|
||||
@@ -228,6 +257,7 @@ class OIDCService:
|
||||
|
||||
# Check expiration
|
||||
if auth_code.is_expired():
|
||||
logger.error(f"[OIDC] Validate auth code - Code expired: code_hash={code_hash[:20]}..., expires_at={auth_code.expires_at}")
|
||||
OIDCAuditService.log_authorization_event(
|
||||
client_id=client_id,
|
||||
user_id=auth_code.user_id,
|
||||
@@ -239,16 +269,22 @@ class OIDCService:
|
||||
|
||||
# Validate redirect URI
|
||||
if auth_code.redirect_uri != redirect_uri:
|
||||
logger.error(f"[OIDC] Validate auth code - Redirect URI mismatch: expected={auth_code.redirect_uri}, got={redirect_uri}")
|
||||
raise InvalidGrantError("Invalid redirect_uri")
|
||||
|
||||
# Validate PKCE if required
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Validate auth code - PKCE: require_pkce={client.require_pkce}, has_verifier={bool(auth_code.code_verifier)}, provided_verifier={bool(code_verifier)}")
|
||||
|
||||
if client.require_pkce and auth_code.code_verifier:
|
||||
if not code_verifier:
|
||||
logger.error(f"[OIDC] Validate auth code - PKCE required but no code_verifier provided")
|
||||
raise ValidationError("code_verifier is required")
|
||||
|
||||
# Verify code verifier
|
||||
expected_challenge = cls._compute_code_challenge(code_verifier, "S256")
|
||||
if expected_challenge != auth_code.code_verifier:
|
||||
logger.error(f"[OIDC] Validate auth code - Invalid code_verifier: expected={expected_challenge[:20]}..., got={auth_code.code_verifier[:20]}...")
|
||||
OIDCAuditService.log_authorization_event(
|
||||
client_id=client_id,
|
||||
user_id=auth_code.user_id,
|
||||
@@ -263,7 +299,13 @@ class OIDCService:
|
||||
|
||||
# Get user
|
||||
user = User.query.get(auth_code.user_id)
|
||||
|
||||
# Development-only debug logging for user validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Validate auth code - User validation: user_id={auth_code.user_id}, exists={user is not None}")
|
||||
|
||||
if not user:
|
||||
logger.error(f"[OIDC] Validate auth code - User not found: user_id={auth_code.user_id}")
|
||||
raise InvalidGrantError("User not found")
|
||||
|
||||
claims = {
|
||||
@@ -326,6 +368,11 @@ class OIDCService:
|
||||
|
||||
# Get client
|
||||
client = OIDCClient.query.filter_by(client_id=client_id).first()
|
||||
|
||||
# Development-only debug logging for token generation client validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Generate tokens - Client validation: client_id={client_id}, exists={client is not None}")
|
||||
|
||||
if not client:
|
||||
raise InvalidClientError()
|
||||
|
||||
@@ -466,6 +513,11 @@ class OIDCService:
|
||||
|
||||
# Get client
|
||||
client = OIDCClient.query.filter_by(client_id=client_id).first()
|
||||
|
||||
# Development-only debug logging for refresh token client validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Refresh token - Client validation: client_id={client_id}, exists={client is not None}")
|
||||
|
||||
if not client:
|
||||
raise InvalidClientError()
|
||||
|
||||
@@ -476,6 +528,10 @@ class OIDCService:
|
||||
deleted_at=None
|
||||
).first()
|
||||
|
||||
# Development-only debug logging for refresh token validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Refresh token - Token validation: user_id={refresh_token_obj.user_id if refresh_token_obj else None}, found={refresh_token_obj is not None}")
|
||||
|
||||
if not refresh_token_obj:
|
||||
OIDCAuditService.log_token_event(
|
||||
client_id=client_id,
|
||||
@@ -497,6 +553,9 @@ class OIDCService:
|
||||
raise InvalidGrantError("Refresh token expired or revoked")
|
||||
|
||||
# Validate client matches
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[OIDC] Refresh token - Client match validation: expected={client.id}, actual={refresh_token_obj.client_id}, match={refresh_token_obj.client_id == client.id}")
|
||||
|
||||
if refresh_token_obj.client_id != client.id:
|
||||
raise InvalidGrantError("Client mismatch")
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Organization service."""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
from app.models.organization import Organization
|
||||
from app.models.organization_member import OrganizationMember
|
||||
@@ -7,6 +9,8 @@ from app.exceptions.validation_exceptions import OrganizationNotFoundError, Conf
|
||||
from app.utils.constants import OrganizationRole, AuditAction
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrganizationService:
|
||||
"""Service for organization operations."""
|
||||
@@ -80,6 +84,11 @@ class OrganizationService:
|
||||
OrganizationNotFoundError: If organization not found
|
||||
"""
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
|
||||
# Development-only debug logging for organization validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[Org] Get organization by ID: org_id={org_id}, exists={org is not None}")
|
||||
|
||||
if not org:
|
||||
raise OrganizationNotFoundError()
|
||||
return org
|
||||
@@ -95,7 +104,13 @@ class OrganizationService:
|
||||
Returns:
|
||||
Organization instance or None
|
||||
"""
|
||||
return Organization.query.filter_by(slug=slug, deleted_at=None).first()
|
||||
org = Organization.query.filter_by(slug=slug, deleted_at=None).first()
|
||||
|
||||
# Development-only debug logging for organization validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[Org] Get organization by slug: slug={slug}, exists={org is not None}")
|
||||
|
||||
return org
|
||||
|
||||
@staticmethod
|
||||
def update_organization(org, user_id, **kwargs):
|
||||
@@ -180,6 +195,10 @@ class OrganizationService:
|
||||
deleted_at=None,
|
||||
).first()
|
||||
|
||||
# Development-only debug logging for membership validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[Org] Member check: org_id={org.id}, user_id={user_id}, already_member={existing is not None}")
|
||||
|
||||
if existing:
|
||||
raise ConflictError("User is already a member of this organization")
|
||||
|
||||
@@ -223,6 +242,10 @@ class OrganizationService:
|
||||
deleted_at=None,
|
||||
).first()
|
||||
|
||||
# Development-only debug logging for membership removal validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[Org] Member removal: org_id={org.id}, user_id={user_id}, found={member is not None}")
|
||||
|
||||
if member:
|
||||
member.delete(soft=True)
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""User service."""
|
||||
import logging
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
from app.models.user import User
|
||||
from app.exceptions.validation_exceptions import UserNotFoundError
|
||||
from app.utils.constants import AuditAction
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Service for user operations."""
|
||||
@@ -24,6 +28,11 @@ class UserService:
|
||||
UserNotFoundError: If user not found
|
||||
"""
|
||||
user = User.query.filter_by(id=user_id, deleted_at=None).first()
|
||||
|
||||
# Development-only debug logging for user validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[User] Get user by ID: user_id={user_id}, exists={user is not None}")
|
||||
|
||||
if not user:
|
||||
raise UserNotFoundError()
|
||||
return user
|
||||
@@ -39,7 +48,13 @@ class UserService:
|
||||
Returns:
|
||||
User instance or None
|
||||
"""
|
||||
return User.query.filter_by(email=email.lower(), deleted_at=None).first()
|
||||
user = User.query.filter_by(email=email.lower(), deleted_at=None).first()
|
||||
|
||||
# Development-only debug logging for user validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[User] Get user by email: email={email}, exists={user is not None}")
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def update_user(user, **kwargs):
|
||||
|
||||
Reference in New Issue
Block a user