241 lines
8.2 KiB
Python
241 lines
8.2 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
from uuid import uuid4
|
|
|
|
from app.core.config import settings
|
|
from app.core.datetime_utils import ensure_utc, utc_now
|
|
from app.core.email import send_template_email
|
|
from app.core.jwt_denylist import bump_auth_epoch, revoke_access_token
|
|
from app.core.security import (
|
|
create_access_token,
|
|
generate_opaque_token,
|
|
generate_refresh_token,
|
|
hash_opaque_token,
|
|
hash_password,
|
|
hash_refresh_token,
|
|
verify_password,
|
|
)
|
|
from app.modules.auth import repository as auth_repository
|
|
from app.modules.users import repository
|
|
from app.modules.users.models import User
|
|
|
|
def _token_expires_at():
|
|
return utc_now() + timedelta(hours=settings.auth_token_ttl_hours)
|
|
|
|
|
|
def _send_verification_email(user: User, token: str) -> None:
|
|
verify_url = f"{settings.frontend_url}/verify?token={token}"
|
|
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
|
|
token_line = f"TOKEN:{token}\n" if show_token else ""
|
|
body = "Confirm your Compton account.\n\n" + f"Open: {verify_url}\n" + token_line
|
|
send_template_email(
|
|
to=user.email,
|
|
template="verify_email",
|
|
subject="Confirm your Compton account",
|
|
body=body,
|
|
)
|
|
|
|
|
|
def _send_password_reset_email(user: User, token: str) -> None:
|
|
reset_url = f"{settings.frontend_url}/reset-password?token={token}"
|
|
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
|
|
token_line = f"TOKEN:{token}\n" if show_token else ""
|
|
body = "Reset your Compton password.\n\n" + f"Open: {reset_url}\n" + token_line
|
|
send_template_email(
|
|
to=user.email,
|
|
template="reset_password",
|
|
subject="Reset your Compton password",
|
|
body=body,
|
|
)
|
|
|
|
|
|
def _issue_verification_token(user_id: str) -> str:
|
|
token = generate_opaque_token()
|
|
auth_repository.create_email_verification_token(
|
|
user_id=user_id,
|
|
token_hash=hash_opaque_token(token),
|
|
expires_at=_token_expires_at(),
|
|
)
|
|
return token
|
|
|
|
|
|
def _issue_password_reset_token(user_id: str) -> str:
|
|
token = generate_opaque_token()
|
|
auth_repository.create_password_reset_token(
|
|
user_id=user_id,
|
|
token_hash=hash_opaque_token(token),
|
|
expires_at=_token_expires_at(),
|
|
)
|
|
return token
|
|
|
|
|
|
def register(email: str, password: str) -> User:
|
|
existing = repository.get_user_by_email(email)
|
|
if existing:
|
|
if existing.status == "pending":
|
|
token = _issue_verification_token(existing.id)
|
|
_send_verification_email(existing, token)
|
|
return existing
|
|
user = repository.create_user(email=email, password_hash=hash_password(password), status="pending")
|
|
token = _issue_verification_token(user.id)
|
|
_send_verification_email(user, token)
|
|
return user
|
|
|
|
|
|
def verify_email_token(token: str) -> User:
|
|
token_hash = hash_opaque_token(token)
|
|
token_row = auth_repository.get_email_verification_token(token_hash)
|
|
if not token_row or token_row.used_at is not None:
|
|
raise ValueError("INVALID_TOKEN")
|
|
if ensure_utc(token_row.expires_at) < utc_now():
|
|
raise ValueError("TOKEN_EXPIRED")
|
|
|
|
user = repository.get_user_by_id(token_row.user_id)
|
|
if not user:
|
|
raise ValueError("INVALID_TOKEN")
|
|
|
|
user.status = "active"
|
|
user.email_verified_at = utc_now()
|
|
repository.update_user(user)
|
|
auth_repository.mark_email_verification_token_used(token_hash)
|
|
return user
|
|
|
|
|
|
def resend_verification(email: str) -> None:
|
|
user = repository.get_user_by_email(email)
|
|
if not user or user.status != "pending":
|
|
return
|
|
token = _issue_verification_token(user.id)
|
|
_send_verification_email(user, token)
|
|
|
|
|
|
def forgot_password(email: str) -> None:
|
|
user = repository.get_user_by_email(email)
|
|
if not user or user.status == "blocked":
|
|
return
|
|
token = _issue_password_reset_token(user.id)
|
|
_send_password_reset_email(user, token)
|
|
|
|
|
|
def reset_password(token: str, new_password: str) -> None:
|
|
token_hash = hash_opaque_token(token)
|
|
token_row = auth_repository.get_password_reset_token(token_hash)
|
|
if not token_row or token_row.used_at is not None:
|
|
raise ValueError("INVALID_TOKEN")
|
|
if ensure_utc(token_row.expires_at) < utc_now():
|
|
raise ValueError("TOKEN_EXPIRED")
|
|
|
|
user = repository.get_user_by_id(token_row.user_id)
|
|
if not user:
|
|
raise ValueError("INVALID_TOKEN")
|
|
|
|
user.password_hash = hash_password(new_password)
|
|
repository.update_user(user)
|
|
auth_repository.mark_password_reset_token_used(token_hash)
|
|
bump_auth_epoch(user.id)
|
|
revoke_user_refresh_family(user.id)
|
|
|
|
|
|
def _is_locked(user: User) -> bool:
|
|
locked_until = ensure_utc(user.locked_until)
|
|
if locked_until and locked_until > utc_now():
|
|
return True
|
|
if locked_until and locked_until <= utc_now():
|
|
user.failed_login_attempts = 0
|
|
user.locked_until = None
|
|
repository.update_user(user)
|
|
return False
|
|
|
|
|
|
def _record_failed_login(user: User) -> None:
|
|
locked_until = ensure_utc(user.locked_until)
|
|
if locked_until and locked_until <= utc_now():
|
|
user.failed_login_attempts = 0
|
|
user.locked_until = None
|
|
user.failed_login_attempts += 1
|
|
if user.failed_login_attempts >= settings.auth_lockout_attempts:
|
|
user.locked_until = utc_now() + timedelta(minutes=settings.auth_lockout_minutes)
|
|
repository.update_user(user)
|
|
|
|
|
|
def _reset_login_attempts(user: User) -> None:
|
|
user.failed_login_attempts = 0
|
|
user.locked_until = None
|
|
repository.update_user(user)
|
|
|
|
|
|
def login(email: str, password: str) -> tuple[str, str, User]:
|
|
user = repository.get_user_by_email(email)
|
|
if user and _is_locked(user):
|
|
raise PermissionError("ACCOUNT_TEMPORARILY_LOCKED")
|
|
if not user or not verify_password(password, user.password_hash):
|
|
if user:
|
|
_record_failed_login(user)
|
|
raise ValueError("INVALID_CREDENTIALS")
|
|
if user.status == "pending":
|
|
raise PermissionError("EMAIL_NOT_VERIFIED")
|
|
if user.status == "blocked":
|
|
raise PermissionError("ACCOUNT_BLOCKED")
|
|
_reset_login_attempts(user)
|
|
access_token = create_access_token(user.id, user.role, user.is_superuser)
|
|
refresh_token = issue_refresh_token(user.id)
|
|
return access_token, refresh_token, user
|
|
|
|
|
|
def issue_refresh_token(user_id: str, family_id: str | None = None) -> str:
|
|
token = generate_refresh_token()
|
|
token_hash = hash_refresh_token(token)
|
|
family = family_id or str(uuid4())
|
|
auth_repository.create_refresh_token(
|
|
user_id=user_id,
|
|
token_hash=token_hash,
|
|
family_id=family,
|
|
expires_at=utc_now() + timedelta(days=settings.jwt_refresh_ttl_days),
|
|
)
|
|
return token
|
|
|
|
|
|
def refresh(refresh_token: str) -> tuple[str, str, User]:
|
|
token_hash = hash_refresh_token(refresh_token)
|
|
token_row = auth_repository.get_refresh_token(token_hash)
|
|
if not token_row:
|
|
raise PermissionError("INVALID_REFRESH")
|
|
|
|
user = repository.get_user_by_id(token_row.user_id)
|
|
if not user:
|
|
raise PermissionError("INVALID_REFRESH")
|
|
if user.status == "pending":
|
|
raise PermissionError("EMAIL_NOT_VERIFIED")
|
|
if user.status == "blocked":
|
|
raise PermissionError("ACCOUNT_BLOCKED")
|
|
|
|
if token_row.revoked_at is not None:
|
|
auth_repository.revoke_family_tokens(token_row.family_id)
|
|
raise PermissionError("INVALID_REFRESH")
|
|
if ensure_utc(token_row.expires_at) < utc_now():
|
|
raise PermissionError("EXPIRED_REFRESH")
|
|
|
|
auth_repository.revoke_refresh_token(token_hash)
|
|
new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id)
|
|
access = create_access_token(user.id, user.role, user.is_superuser)
|
|
return access, new_refresh, user
|
|
|
|
|
|
def revoke_refresh_token(refresh_token: str) -> None:
|
|
token_hash = hash_refresh_token(refresh_token)
|
|
token_row = auth_repository.revoke_refresh_token(token_hash)
|
|
if token_row:
|
|
auth_repository.revoke_family_tokens(token_row.family_id)
|
|
|
|
|
|
def logout(refresh_token: str | None, access_token: str | None) -> None:
|
|
if access_token:
|
|
revoke_access_token(access_token)
|
|
if refresh_token:
|
|
revoke_refresh_token(refresh_token)
|
|
|
|
|
|
def revoke_user_refresh_family(user_id: str) -> None:
|
|
auth_repository.revoke_user_families(user_id)
|