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 @@
|
||||
"""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,206 @@
|
||||
from app.core.datetime_utils import ensure_utc, utc_now
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
|
||||
from app.core.config import settings
|
||||
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,
|
||||
refresh,
|
||||
register,
|
||||
resend_verification,
|
||||
reset_password,
|
||||
revoke_refresh_token,
|
||||
verify_email_token,
|
||||
)
|
||||
from app.modules.users import repository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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:
|
||||
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):
|
||||
_enforce_origin(request, require_header=True)
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
if refresh_token:
|
||||
revoke_refresh_token(refresh_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,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