first commit
This commit is contained in:
0
app/auth/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
34
app/auth/models.py
Normal file
34
app/auth/models.py
Normal 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
46
app/auth/repository.py
Normal 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
81
app/auth/router.py
Normal 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
53
app/auth/schemas.py
Normal 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
195
app/auth/service.py
Normal 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()
|
||||
Reference in New Issue
Block a user