Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Auth module."""
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
family_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class EmailVerificationToken(Base):
|
||||
__tablename__ = "email_verification_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
|
||||
|
||||
|
||||
def _detach(db, instance):
|
||||
db.refresh(instance)
|
||||
db.expunge(instance)
|
||||
return instance
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
user_id: str,
|
||||
token_hash: str,
|
||||
family_id: str,
|
||||
expires_at: datetime,
|
||||
) -> RefreshToken:
|
||||
with session_scope() as db:
|
||||
token = RefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
family_id=family_id,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(token)
|
||||
db.flush()
|
||||
return _detach(db, token)
|
||||
|
||||
|
||||
def get_refresh_token(token_hash: str) -> RefreshToken | None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||
if not token:
|
||||
return None
|
||||
return _detach(db, token)
|
||||
|
||||
|
||||
def revoke_refresh_token(token_hash: str) -> RefreshToken | None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||
if not token:
|
||||
return None
|
||||
token.revoked_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
return _detach(db, token)
|
||||
|
||||
|
||||
def revoke_user_families(user_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
tokens = db.scalars(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
now = datetime.now(UTC)
|
||||
for token in tokens:
|
||||
token.revoked_at = now
|
||||
|
||||
|
||||
def revoke_family_tokens(family_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
tokens = db.scalars(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.family_id == family_id,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
now = datetime.now(UTC)
|
||||
for token in tokens:
|
||||
token.revoked_at = now
|
||||
|
||||
|
||||
def create_email_verification_token(user_id: str, token_hash: str, expires_at: datetime) -> None:
|
||||
with session_scope() as db:
|
||||
db.execute(
|
||||
delete(EmailVerificationToken).where(
|
||||
EmailVerificationToken.user_id == user_id,
|
||||
EmailVerificationToken.used_at.is_(None),
|
||||
)
|
||||
)
|
||||
db.add(EmailVerificationToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at))
|
||||
|
||||
|
||||
def get_email_verification_token(token_hash: str) -> EmailVerificationToken | None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(
|
||||
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
|
||||
)
|
||||
if not token:
|
||||
return None
|
||||
return _detach(db, token)
|
||||
|
||||
|
||||
def mark_email_verification_token_used(token_hash: str) -> None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(
|
||||
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
|
||||
)
|
||||
if token:
|
||||
token.used_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def create_password_reset_token(user_id: str, token_hash: str, expires_at: datetime) -> None:
|
||||
with session_scope() as db:
|
||||
db.execute(
|
||||
delete(PasswordResetToken).where(
|
||||
PasswordResetToken.user_id == user_id,
|
||||
PasswordResetToken.used_at.is_(None),
|
||||
)
|
||||
)
|
||||
db.add(PasswordResetToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at))
|
||||
|
||||
|
||||
def get_password_reset_token(token_hash: str) -> PasswordResetToken | None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash))
|
||||
if not token:
|
||||
return None
|
||||
return _detach(db, token)
|
||||
|
||||
|
||||
def mark_password_reset_token_used(token_hash: str) -> None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash))
|
||||
if token:
|
||||
token.used_at = datetime.now(UTC)
|
||||
@@ -0,0 +1,217 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.datetime_utils import ensure_utc, utc_now
|
||||
from app.core.redis import check_rate_limit, client_ip
|
||||
from app.modules.auth.schemas import (
|
||||
ForgotPasswordIn,
|
||||
LoginIn,
|
||||
LoginOut,
|
||||
RegisterIn,
|
||||
ResendVerificationIn,
|
||||
ResetPasswordIn,
|
||||
VerifyEmailIn,
|
||||
)
|
||||
from app.modules.auth.service import (
|
||||
forgot_password,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
register,
|
||||
resend_verification,
|
||||
reset_password,
|
||||
verify_email_token,
|
||||
)
|
||||
from app.modules.users import repository
|
||||
|
||||
router = APIRouter()
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _allowed_origins() -> set[str]:
|
||||
origins: set[str] = set()
|
||||
for raw in (settings.frontend_url, settings.public_base_url, *settings.cors_origins):
|
||||
if not raw:
|
||||
continue
|
||||
normalized = raw.rstrip("/")
|
||||
origins.add(normalized)
|
||||
if "://localhost" in normalized:
|
||||
origins.add(normalized.replace("://localhost", "://127.0.0.1"))
|
||||
if "://127.0.0.1" in normalized:
|
||||
origins.add(normalized.replace("://127.0.0.1", "://localhost"))
|
||||
return origins
|
||||
|
||||
|
||||
def _enforce_origin(request: Request, *, require_header: bool = False) -> None:
|
||||
allowed = _allowed_origins()
|
||||
origin = (request.headers.get("Origin") or "").rstrip("/")
|
||||
referer = request.headers.get("Referer") or ""
|
||||
if require_header and not origin and not referer:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||
if origin and origin not in allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||
if not origin and referer:
|
||||
if not any(referer.startswith(base) for base in allowed):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||
|
||||
|
||||
def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path="/api/v1/auth",
|
||||
max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register_route(payload: RegisterIn, request: Request):
|
||||
_enforce_origin(request)
|
||||
check_rate_limit(f"register:{client_ip(request)}", limit=3, window_seconds=3600)
|
||||
user = register(payload.email, payload.password)
|
||||
_ = user
|
||||
return {"message": "If email is valid, verification has been sent."}
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email_route(payload: VerifyEmailIn):
|
||||
try:
|
||||
user = verify_email_token(payload.token)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "TOKEN_EXPIRED":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
|
||||
_ = user
|
||||
return {"status": "active"}
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginOut)
|
||||
async def login_route(payload: LoginIn, request: Request, response: Response):
|
||||
_enforce_origin(request)
|
||||
user = repository.get_user_by_email(payload.email)
|
||||
if user:
|
||||
locked_until = ensure_utc(user.locked_until)
|
||||
if locked_until and locked_until > utc_now():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="ACCOUNT_TEMPORARILY_LOCKED",
|
||||
)
|
||||
check_rate_limit(
|
||||
f"login:{client_ip(request)}:{payload.email.lower()}",
|
||||
limit=5,
|
||||
window_seconds=60,
|
||||
)
|
||||
try:
|
||||
access_token, refresh_token, user = login(payload.email, payload.password)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_CREDENTIALS")
|
||||
except PermissionError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "EMAIL_NOT_VERIFIED":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||
if detail == "ACCOUNT_TEMPORARILY_LOCKED":
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="ACCOUNT_TEMPORARILY_LOCKED")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||
|
||||
_set_refresh_cookie(response, refresh_token)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"expires_in": settings.jwt_access_ttl_min * 60,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=LoginOut)
|
||||
async def refresh_route(request: Request, response: Response):
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="REFRESH_MISSING")
|
||||
_enforce_origin(request, require_header=True)
|
||||
check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60)
|
||||
try:
|
||||
access_token, new_refresh, user = refresh(refresh_token)
|
||||
except PermissionError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "EMAIL_NOT_VERIFIED":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||
if detail == "ACCOUNT_BLOCKED":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH")
|
||||
_set_refresh_cookie(response, new_refresh)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"expires_in": settings.jwt_access_ttl_min * 60,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout_route(
|
||||
request: Request,
|
||||
response: Response,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
):
|
||||
_enforce_origin(request, require_header=True)
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
access_token = credentials.credentials if credentials else None
|
||||
logout(refresh_token, access_token)
|
||||
response.delete_cookie(
|
||||
"refresh_token",
|
||||
path="/api/v1/auth",
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
return {"message": "logged_out"}
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password_route(payload: ForgotPasswordIn, request: Request):
|
||||
_enforce_origin(request)
|
||||
check_rate_limit(
|
||||
f"forgot:{client_ip(request)}:{payload.email.lower()}",
|
||||
limit=3,
|
||||
window_seconds=3600,
|
||||
)
|
||||
forgot_password(payload.email)
|
||||
return {"message": "If email is registered, reset instructions have been sent."}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password_route(payload: ResetPasswordIn):
|
||||
try:
|
||||
reset_password(payload.token, payload.new_password)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "TOKEN_EXPIRED":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
|
||||
return {"message": "password_updated"}
|
||||
|
||||
|
||||
@router.post("/resend-verification")
|
||||
async def resend_verification_route(payload: ResendVerificationIn, request: Request):
|
||||
_enforce_origin(request)
|
||||
check_rate_limit(
|
||||
f"resend:{client_ip(request)}:{payload.email.lower()}",
|
||||
limit=3,
|
||||
window_seconds=3600,
|
||||
)
|
||||
resend_verification(payload.email)
|
||||
return {"message": "If email is registered, verification has been sent."}
|
||||
@@ -0,0 +1,69 @@
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
from app.core.password_policy import validate_password_strength
|
||||
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("email", "password", mode="before")
|
||||
@classmethod
|
||||
def strip_whitespace(cls, value: str) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_policy(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("email", "password", mode="before")
|
||||
@classmethod
|
||||
def strip_whitespace(cls, value: str) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
|
||||
class VerifyEmailIn(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class ForgotPasswordIn(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResendVerificationIn(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordIn(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def password_policy(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
|
||||
|
||||
class AuthUserOut(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
role: str
|
||||
is_superuser: bool
|
||||
status: str
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = 900
|
||||
user: AuthUserOut
|
||||
@@ -0,0 +1,240 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user