Update admin theme/layout and refresh README details.
Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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.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:
|
||||
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)
|
||||
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")
|
||||
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")
|
||||
|
||||
user = repository.get_user_by_id(token_row.user_id)
|
||||
if not user:
|
||||
raise PermissionError("INVALID_REFRESH")
|
||||
if user.status in {"pending", "blocked"}:
|
||||
raise PermissionError("INVALID_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 revoke_user_refresh_family(user_id: str) -> None:
|
||||
auth_repository.revoke_user_families(user_id)
|
||||
Reference in New Issue
Block a user