first commit

This commit is contained in:
2026-03-07 21:31:38 +03:00
commit a879ba7b50
68 changed files with 2487 additions and 0 deletions

0
app/auth/__init__.py Normal file
View File

34
app/auth/models.py Normal file
View File

@@ -0,0 +1,34 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base
if TYPE_CHECKING:
from app.users.models import User
class EmailVerificationToken(Base):
__tablename__ = "email_verification_tokens"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
user: Mapped["User"] = relationship(back_populates="email_verification_tokens")
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
user: Mapped["User"] = relationship(back_populates="password_reset_tokens")

46
app/auth/repository.py Normal file
View File

@@ -0,0 +1,46 @@
from datetime import datetime, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.models import EmailVerificationToken, PasswordResetToken
async def create_email_verification_token(db: AsyncSession, user_id: int, token: str, expires_at: datetime) -> None:
db.add(
EmailVerificationToken(
user_id=user_id,
token=token,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
)
)
async def get_email_verification_token(db: AsyncSession, token: str) -> EmailVerificationToken | None:
result = await db.execute(select(EmailVerificationToken).where(EmailVerificationToken.token == token))
return result.scalar_one_or_none()
async def delete_email_verification_tokens_for_user(db: AsyncSession, user_id: int) -> None:
await db.execute(delete(EmailVerificationToken).where(EmailVerificationToken.user_id == user_id))
async def create_password_reset_token(db: AsyncSession, user_id: int, token: str, expires_at: datetime) -> None:
db.add(
PasswordResetToken(
user_id=user_id,
token=token,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
)
)
async def get_password_reset_token(db: AsyncSession, token: str) -> PasswordResetToken | None:
result = await db.execute(select(PasswordResetToken).where(PasswordResetToken.token == token))
return result.scalar_one_or_none()
async def delete_password_reset_tokens_for_user(db: AsyncSession, user_id: int) -> None:
await db.execute(delete(PasswordResetToken).where(PasswordResetToken.user_id == user_id))

81
app/auth/router.py Normal file
View File

@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.schemas import (
AuthUserResponse,
LoginRequest,
MessageResponse,
RegisterRequest,
RequestPasswordResetRequest,
ResendVerificationRequest,
ResetPasswordRequest,
TokenResponse,
VerifyEmailRequest,
)
from app.auth.service import (
get_current_user,
get_email_sender,
login_user,
register_user,
request_password_reset,
resend_verification_email,
reset_password,
verify_email,
)
from app.database.session import get_db
from app.email.service import EmailService
from app.users.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
async def register(
payload: RegisterRequest,
db: AsyncSession = Depends(get_db),
email_service: EmailService = Depends(get_email_sender),
) -> MessageResponse:
await register_user(db, payload, email_service)
return MessageResponse(message="Registration successful. Verification email sent.")
@router.post("/login", response_model=TokenResponse)
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
return await login_user(db, payload)
@router.post("/verify-email", response_model=MessageResponse)
async def verify_email_endpoint(payload: VerifyEmailRequest, db: AsyncSession = Depends(get_db)) -> MessageResponse:
await verify_email(db, payload)
return MessageResponse(message="Email verified successfully.")
@router.post("/resend-verification", response_model=MessageResponse)
async def resend_verification(
payload: ResendVerificationRequest,
db: AsyncSession = Depends(get_db),
email_service: EmailService = Depends(get_email_sender),
) -> MessageResponse:
await resend_verification_email(db, payload, email_service)
return MessageResponse(message="If the account exists, a verification email was sent.")
@router.post("/request-password-reset", response_model=MessageResponse)
async def request_password_reset_endpoint(
payload: RequestPasswordResetRequest,
db: AsyncSession = Depends(get_db),
email_service: EmailService = Depends(get_email_sender),
) -> MessageResponse:
await request_password_reset(db, payload, email_service)
return MessageResponse(message="If the account exists, a reset email was sent.")
@router.post("/reset-password", response_model=MessageResponse)
async def reset_password_endpoint(payload: ResetPasswordRequest, db: AsyncSession = Depends(get_db)) -> MessageResponse:
await reset_password(db, payload)
return MessageResponse(message="Password reset successfully.")
@router.get("/me", response_model=AuthUserResponse)
async def me(current_user: User = Depends(get_current_user)) -> AuthUserResponse:
return current_user

53
app/auth/schemas.py Normal file
View File

@@ -0,0 +1,53 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class RegisterRequest(BaseModel):
email: EmailStr
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8, max_length=128)
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
class VerifyEmailRequest(BaseModel):
token: str = Field(min_length=16, max_length=512)
class ResendVerificationRequest(BaseModel):
email: EmailStr
class RequestPasswordResetRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str = Field(min_length=16, max_length=512)
new_password: str = Field(min_length=8, max_length=128)
class MessageResponse(BaseModel):
message: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class AuthUserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: EmailStr
username: str
avatar_url: str | None = None
email_verified: bool
created_at: datetime
updated_at: datetime

195
app/auth/service.py Normal file
View File

@@ -0,0 +1,195 @@
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import repository as auth_repository
from app.auth.schemas import (
LoginRequest,
RegisterRequest,
RequestPasswordResetRequest,
ResendVerificationRequest,
ResetPasswordRequest,
TokenResponse,
VerifyEmailRequest,
)
from app.config.settings import settings
from app.database.session import get_db
from app.email.service import EmailService, get_email_service
from app.users.models import User
from app.users.repository import create_user, get_user_by_email, get_user_by_id, get_user_by_username
from app.utils.security import (
create_access_token,
create_refresh_token,
decode_token,
generate_random_token,
hash_password,
verify_password,
)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api_v1_prefix}/auth/login")
async def register_user(
db: AsyncSession,
payload: RegisterRequest,
email_service: EmailService,
) -> None:
existing_email = await get_user_by_email(db, payload.email)
if existing_email:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already registered")
existing_username = await get_user_by_username(db, payload.username)
if existing_username:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username is already taken")
user = await create_user(
db,
email=payload.email,
username=payload.username,
password_hash=hash_password(payload.password),
)
verification_token = generate_random_token()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_token_expire_hours)
await auth_repository.delete_email_verification_tokens_for_user(db, user.id)
await auth_repository.create_email_verification_token(db, user.id, verification_token, expires_at)
await db.commit()
await email_service.send_verification_email(payload.email, verification_token)
async def verify_email(db: AsyncSession, payload: VerifyEmailRequest) -> None:
record = await auth_repository.get_email_verification_token(db, payload.token)
if not record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification token")
now = datetime.now(timezone.utc)
expires_at = record.expires_at if record.expires_at.tzinfo else record.expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification token expired")
user = await get_user_by_id(db, record.user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.email_verified = True
await auth_repository.delete_email_verification_tokens_for_user(db, user.id)
await db.commit()
async def resend_verification_email(
db: AsyncSession,
payload: ResendVerificationRequest,
email_service: EmailService,
) -> None:
user = await get_user_by_email(db, payload.email)
if not user or user.email_verified:
return
verification_token = generate_random_token()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_token_expire_hours)
await auth_repository.delete_email_verification_tokens_for_user(db, user.id)
await auth_repository.create_email_verification_token(db, user.id, verification_token, expires_at)
await db.commit()
await email_service.send_verification_email(user.email, verification_token)
async def login_user(db: AsyncSession, payload: LoginRequest) -> TokenResponse:
user = await get_user_by_email(db, payload.email)
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not user.email_verified:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email not verified")
return TokenResponse(
access_token=create_access_token(str(user.id)),
refresh_token=create_refresh_token(str(user.id)),
)
async def request_password_reset(
db: AsyncSession,
payload: RequestPasswordResetRequest,
email_service: EmailService,
) -> None:
user = await get_user_by_email(db, payload.email)
if not user:
return
reset_token = generate_random_token()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_token_expire_hours)
await auth_repository.delete_password_reset_tokens_for_user(db, user.id)
await auth_repository.create_password_reset_token(db, user.id, reset_token, expires_at)
await db.commit()
await email_service.send_password_reset_email(user.email, reset_token)
async def reset_password(db: AsyncSession, payload: ResetPasswordRequest) -> None:
record = await auth_repository.get_password_reset_token(db, payload.token)
if not record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid reset token")
now = datetime.now(timezone.utc)
expires_at = record.expires_at if record.expires_at.tzinfo else record.expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token expired")
user = await get_user_by_id(db, record.user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.password_hash = hash_password(payload.new_password)
await auth_repository.delete_password_reset_tokens_for_user(db, user.id)
await db.commit()
async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)) -> User:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
except ValueError as exc:
raise credentials_error from exc
if payload.get("type") != "access":
raise credentials_error
user_id = payload.get("sub")
if not user_id or not str(user_id).isdigit():
raise credentials_error
user = await get_user_by_id(db, int(user_id))
if not user:
raise credentials_error
return user
async def get_current_user_for_ws(token: str, db: AsyncSession) -> User:
try:
payload = decode_token(token)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
user_id = payload.get("sub")
if not user_id or not str(user_id).isdigit():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
user = await get_user_by_id(db, int(user_id))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def get_email_sender() -> EmailService:
return get_email_service()