cfd79190ee
Adds TOTP (Time-based One-Time Password) two-factor authentication support including: - New TOTP service with secret generation, QR code provisioning, and code verification - New auth endpoints for enrollment, verification, status, and backup code management - New TOTP authentication method type and user methods for TOTP management - Backup codes generation and verification for account recovery - Updated OIDC endpoints with timezone-aware datetime handling and RFC-compliant responses - Added "roles" scope support for OIDC userinfo and ID tokens - New pyotp dependency for TOTP operations - Comprehensive unit tests for TOTP service
550 lines
18 KiB
Python
550 lines
18 KiB
Python
"""Authentication service."""
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
from flask import request, g, current_app
|
|
from app.extensions import db, bcrypt
|
|
from app.models.user import User
|
|
from app.models.authentication_method import AuthenticationMethod
|
|
from app.models.session import Session
|
|
from app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
|
|
from app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
|
|
from app.exceptions.validation_exceptions import EmailAlreadyExistsError
|
|
from app.services.audit_service import AuditService
|
|
from app.services.totp_service import TOTPService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AuthService:
|
|
"""Service for authentication operations."""
|
|
|
|
@staticmethod
|
|
def register_user(email, password, full_name=None):
|
|
"""
|
|
Register a new user with email/password.
|
|
|
|
Args:
|
|
email: User email address
|
|
password: Plain text password
|
|
full_name: Optional full name
|
|
|
|
Returns:
|
|
User instance
|
|
|
|
Raises:
|
|
EmailAlreadyExistsError: If email is already registered
|
|
"""
|
|
# Check if email already exists
|
|
existing_user = User.query.filter_by(email=email.lower()).first()
|
|
if existing_user and existing_user.deleted_at is None:
|
|
raise EmailAlreadyExistsError()
|
|
|
|
# Create user
|
|
user = User(
|
|
email=email.lower(),
|
|
full_name=full_name,
|
|
status=UserStatus.ACTIVE,
|
|
)
|
|
user.save()
|
|
|
|
# Create password authentication method
|
|
password_hash = bcrypt.generate_password_hash(password).decode("utf-8")
|
|
auth_method = AuthenticationMethod(
|
|
user_id=user.id,
|
|
method_type=AuthMethodType.PASSWORD,
|
|
password_hash=password_hash,
|
|
is_primary=True,
|
|
verified=True,
|
|
)
|
|
auth_method.save()
|
|
|
|
# Log the registration
|
|
AuditService.log_action(
|
|
action=AuditAction.USER_REGISTER,
|
|
user_id=user.id,
|
|
resource_type="user",
|
|
resource_id=user.id,
|
|
description=f"User registered with email: {email}",
|
|
)
|
|
|
|
return user
|
|
|
|
@staticmethod
|
|
def authenticate(email, password):
|
|
"""
|
|
Authenticate user with email/password.
|
|
|
|
Args:
|
|
email: User email
|
|
password: Plain text password
|
|
|
|
Returns:
|
|
User instance if authentication succeeds
|
|
|
|
Raises:
|
|
InvalidCredentialsError: If credentials are invalid
|
|
AccountSuspendedError: If account is suspended
|
|
AccountInactiveError: If account is inactive
|
|
"""
|
|
# 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
|
|
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
|
|
user.last_login_at = datetime.utcnow()
|
|
user.last_login_ip = request.remote_addr
|
|
auth_method.last_used_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
return user
|
|
|
|
@staticmethod
|
|
def create_session(user, duration_seconds=86400):
|
|
"""
|
|
Create a new session for the user.
|
|
|
|
Args:
|
|
user: User instance
|
|
duration_seconds: Session duration in seconds
|
|
|
|
Returns:
|
|
Session instance
|
|
"""
|
|
# Generate session token
|
|
token = secrets.token_urlsafe(32)
|
|
|
|
# Create session
|
|
session = Session(
|
|
user_id=user.id,
|
|
token=token,
|
|
status=SessionStatus.ACTIVE,
|
|
ip_address=request.remote_addr,
|
|
user_agent=request.headers.get("User-Agent"),
|
|
expires_at=datetime.utcnow() + timedelta(seconds=duration_seconds),
|
|
last_activity_at=datetime.utcnow(),
|
|
)
|
|
session.save()
|
|
|
|
# Log session creation
|
|
AuditService.log_action(
|
|
action=AuditAction.SESSION_CREATE,
|
|
user_id=user.id,
|
|
resource_type="session",
|
|
resource_id=session.id,
|
|
description="User session created",
|
|
)
|
|
|
|
return session
|
|
|
|
@staticmethod
|
|
def change_password(user, current_password, new_password):
|
|
"""
|
|
Change user password.
|
|
|
|
Args:
|
|
user: User instance
|
|
current_password: Current password
|
|
new_password: New password
|
|
|
|
Raises:
|
|
InvalidCredentialsError: If current password is incorrect
|
|
"""
|
|
# Find password auth method
|
|
auth_method = AuthenticationMethod.query.filter_by(
|
|
user_id=user.id,
|
|
method_type=AuthMethodType.PASSWORD,
|
|
deleted_at=None,
|
|
).first()
|
|
|
|
if not auth_method or not auth_method.password_hash:
|
|
raise InvalidCredentialsError("No password authentication method found")
|
|
|
|
# Verify current password
|
|
if not bcrypt.check_password_hash(auth_method.password_hash, current_password):
|
|
raise InvalidCredentialsError("Current password is incorrect")
|
|
|
|
# Update password
|
|
auth_method.password_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
|
|
db.session.commit()
|
|
|
|
# Log password change
|
|
AuditService.log_action(
|
|
action=AuditAction.PASSWORD_CHANGE,
|
|
user_id=user.id,
|
|
description="User changed password",
|
|
)
|
|
|
|
@staticmethod
|
|
def revoke_session(session_id, reason=None):
|
|
"""
|
|
Revoke a session.
|
|
|
|
Args:
|
|
session_id: Session ID to revoke
|
|
reason: Optional revocation reason
|
|
"""
|
|
session = Session.query.get(session_id)
|
|
if session:
|
|
session.revoke(reason=reason)
|
|
|
|
# Log session revocation
|
|
AuditService.log_action(
|
|
action=AuditAction.SESSION_REVOKE,
|
|
user_id=session.user_id,
|
|
resource_type="session",
|
|
resource_id=session.id,
|
|
description=f"Session revoked: {reason or 'User logout'}",
|
|
)
|
|
|
|
@staticmethod
|
|
def enroll_totp(user: User) -> dict:
|
|
"""
|
|
Initiate TOTP enrollment for a user.
|
|
|
|
Args:
|
|
user: User instance
|
|
|
|
Returns:
|
|
Dictionary containing:
|
|
- secret: TOTP secret (base32 encoded)
|
|
- provisioning_uri: otpauth:// URI for QR code
|
|
- qr_code: Base64 encoded QR code as data URI
|
|
- backup_codes: List of plain text backup codes
|
|
|
|
Raises:
|
|
ConflictError: If user already has TOTP enabled
|
|
"""
|
|
from app.exceptions.validation_exceptions import ConflictError
|
|
|
|
# Check if user already has TOTP enabled
|
|
if user.has_totp_enabled():
|
|
raise ConflictError("TOTP is already enabled for this account")
|
|
|
|
# Generate TOTP secret
|
|
secret = TOTPService.generate_secret()
|
|
|
|
# Generate provisioning URI
|
|
provisioning_uri = TOTPService.generate_provisioning_uri(
|
|
user_email=user.email,
|
|
secret=secret,
|
|
issuer="Gatehouse",
|
|
)
|
|
|
|
# Generate QR code data URI
|
|
qr_code = TOTPService.generate_qr_code_data_uri(provisioning_uri)
|
|
|
|
# Generate backup codes
|
|
backup_codes, hashed_backup_codes = TOTPService.generate_backup_codes()
|
|
|
|
# Create unverified TOTP authentication method
|
|
auth_method = AuthenticationMethod(
|
|
user_id=user.id,
|
|
method_type=AuthMethodType.TOTP,
|
|
verified=False,
|
|
is_primary=False,
|
|
)
|
|
auth_method.save()
|
|
|
|
# Store TOTP data in provider_data (since totp_secret field is commented out)
|
|
auth_method.provider_data = {
|
|
"secret": secret,
|
|
"backup_codes": hashed_backup_codes,
|
|
}
|
|
db.session.commit()
|
|
|
|
# Log TOTP enrollment initiation
|
|
AuditService.log_action(
|
|
action=AuditAction.TOTP_ENROLL_INITIATED,
|
|
user_id=user.id,
|
|
resource_type="authentication_method",
|
|
resource_id=auth_method.id,
|
|
description="TOTP enrollment initiated",
|
|
)
|
|
|
|
return {
|
|
"secret": secret,
|
|
"provisioning_uri": provisioning_uri,
|
|
"qr_code": qr_code,
|
|
"backup_codes": backup_codes,
|
|
}
|
|
|
|
@staticmethod
|
|
def verify_totp_enrollment(user: User, code: str) -> bool:
|
|
"""
|
|
Complete TOTP enrollment by verifying the first TOTP code.
|
|
|
|
Args:
|
|
user: User instance
|
|
code: 6-digit TOTP code from authenticator app
|
|
|
|
Returns:
|
|
True if verification successful
|
|
|
|
Raises:
|
|
InvalidCredentialsError: If code is invalid or TOTP method not found
|
|
"""
|
|
# Get user's TOTP authentication method
|
|
auth_method = user.get_totp_method()
|
|
if not auth_method:
|
|
raise InvalidCredentialsError("TOTP enrollment not found")
|
|
|
|
# Get secret from provider_data
|
|
secret = auth_method.provider_data.get("secret") if auth_method.provider_data else None
|
|
if not secret:
|
|
raise InvalidCredentialsError("TOTP secret not found")
|
|
|
|
# Verify the code
|
|
if not TOTPService.verify_code(secret, code):
|
|
raise InvalidCredentialsError("Invalid TOTP code")
|
|
|
|
# Mark TOTP as verified
|
|
auth_method.verified = True
|
|
auth_method.totp_verified_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
# Log TOTP enrollment completion
|
|
AuditService.log_action(
|
|
action=AuditAction.TOTP_ENROLL_COMPLETED,
|
|
user_id=user.id,
|
|
resource_type="authentication_method",
|
|
resource_id=auth_method.id,
|
|
description="TOTP enrollment completed",
|
|
)
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def disable_totp(user: User, password: str) -> bool:
|
|
"""
|
|
Disable TOTP for a user.
|
|
|
|
Args:
|
|
user: User instance
|
|
password: User's current password for verification
|
|
|
|
Returns:
|
|
True if TOTP disabled successfully
|
|
|
|
Raises:
|
|
InvalidCredentialsError: If password is invalid or TOTP method not found
|
|
"""
|
|
# Verify user's password
|
|
auth_method = AuthenticationMethod.query.filter_by(
|
|
user_id=user.id,
|
|
method_type=AuthMethodType.PASSWORD,
|
|
deleted_at=None,
|
|
).first()
|
|
|
|
if not auth_method or not auth_method.password_hash:
|
|
raise InvalidCredentialsError("No password authentication method found")
|
|
|
|
if not bcrypt.check_password_hash(auth_method.password_hash, password):
|
|
raise InvalidCredentialsError("Invalid password")
|
|
|
|
# Get user's TOTP authentication method
|
|
totp_method = user.get_totp_method()
|
|
if not totp_method:
|
|
raise InvalidCredentialsError("TOTP is not enabled for this account")
|
|
|
|
# Soft-delete the TOTP authentication method
|
|
totp_method.delete(soft=True)
|
|
|
|
# Log TOTP disabled
|
|
AuditService.log_action(
|
|
action=AuditAction.TOTP_DISABLED,
|
|
user_id=user.id,
|
|
resource_type="authentication_method",
|
|
resource_id=totp_method.id,
|
|
description="TOTP disabled",
|
|
)
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def authenticate_with_totp(user: User, code: str, is_backup_code: bool = False) -> bool:
|
|
"""
|
|
Verify TOTP code during login.
|
|
|
|
Args:
|
|
user: User instance
|
|
code: 6-digit TOTP code or backup code
|
|
is_backup_code: True if code is a backup code, False if TOTP code
|
|
|
|
Returns:
|
|
True if code is valid
|
|
|
|
Raises:
|
|
InvalidCredentialsError: If code is invalid or TOTP method not found
|
|
"""
|
|
# Get user's TOTP authentication method
|
|
auth_method = user.get_totp_method()
|
|
if not auth_method:
|
|
raise InvalidCredentialsError("TOTP is not enabled for this account")
|
|
|
|
if is_backup_code:
|
|
# Verify backup code
|
|
backup_codes = (
|
|
auth_method.provider_data.get("backup_codes")
|
|
if auth_method.provider_data
|
|
else []
|
|
)
|
|
is_valid, remaining_codes = TOTPService.verify_backup_code(backup_codes, code)
|
|
|
|
if is_valid:
|
|
# Update remaining backup codes
|
|
auth_method.provider_data = {
|
|
"secret": auth_method.provider_data.get("secret"),
|
|
"backup_codes": remaining_codes,
|
|
}
|
|
auth_method.last_used_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
# Log backup code usage
|
|
AuditService.log_action(
|
|
action=AuditAction.TOTP_BACKUP_CODE_USED,
|
|
user_id=user.id,
|
|
resource_type="authentication_method",
|
|
resource_id=auth_method.id,
|
|
description="Backup code used for authentication",
|
|
)
|
|
else:
|
|
# Log failed verification
|
|
AuditService.log_action(
|
|
action=AuditAction.TOTP_VERIFY_FAILED,
|
|
user_id=user.id,
|
|
resource_type="authentication_method",
|
|
resource_id=auth_method.id,
|
|
description="Invalid backup code provided",
|
|
)
|
|
raise InvalidCredentialsError("Invalid backup code")
|
|
else:
|
|
# Verify TOTP code
|
|
secret = (
|
|
auth_method.provider_data.get("secret")
|
|
if auth_method.provider_data
|
|
else None
|
|
)
|
|
if not secret:
|
|
raise InvalidCredentialsError("TOTP secret not found")
|
|
|
|
is_valid = TOTPService.verify_code(secret, code)
|
|
|
|
if is_valid:
|
|
auth_method.last_used_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
# Log successful verification
|
|
AuditService.log_action(
|
|
action=AuditAction.TOTP_VERIFY_SUCCESS,
|
|
user_id=user.id,
|
|
resource_type="authentication_method",
|
|
resource_id=auth_method.id,
|
|
description="TOTP code verified successfully",
|
|
)
|
|
else:
|
|
# Log failed verification
|
|
AuditService.log_action(
|
|
action=AuditAction.TOTP_VERIFY_FAILED,
|
|
user_id=user.id,
|
|
resource_type="authentication_method",
|
|
resource_id=auth_method.id,
|
|
description="Invalid TOTP code provided",
|
|
)
|
|
raise InvalidCredentialsError("Invalid TOTP code")
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def regenerate_totp_backup_codes(user: User, password: str) -> list[str]:
|
|
"""
|
|
Generate new backup codes for TOTP.
|
|
|
|
Args:
|
|
user: User instance
|
|
password: User's current password for verification
|
|
|
|
Returns:
|
|
List of new plain text backup codes
|
|
|
|
Raises:
|
|
InvalidCredentialsError: If password is invalid or TOTP method not found
|
|
"""
|
|
# Verify user's password
|
|
auth_method = AuthenticationMethod.query.filter_by(
|
|
user_id=user.id,
|
|
method_type=AuthMethodType.PASSWORD,
|
|
deleted_at=None,
|
|
).first()
|
|
|
|
if not auth_method or not auth_method.password_hash:
|
|
raise InvalidCredentialsError("No password authentication method found")
|
|
|
|
if not bcrypt.check_password_hash(auth_method.password_hash, password):
|
|
raise InvalidCredentialsError("Invalid password")
|
|
|
|
# Get user's TOTP authentication method
|
|
totp_method = user.get_totp_method()
|
|
if not totp_method:
|
|
raise InvalidCredentialsError("TOTP is not enabled for this account")
|
|
|
|
# Generate new backup codes
|
|
backup_codes, hashed_backup_codes = TOTPService.generate_backup_codes()
|
|
|
|
# Update the authentication method with new backup codes
|
|
totp_method.provider_data = {
|
|
"secret": totp_method.provider_data.get("secret"),
|
|
"backup_codes": hashed_backup_codes,
|
|
}
|
|
db.session.commit()
|
|
|
|
# Log backup codes regeneration
|
|
AuditService.log_action(
|
|
action=AuditAction.TOTP_BACKUP_CODES_REGENERATED,
|
|
user_id=user.id,
|
|
resource_type="authentication_method",
|
|
resource_id=totp_method.id,
|
|
description="TOTP backup codes regenerated",
|
|
)
|
|
|
|
return backup_codes
|