İlk commit: Stepstead backend (FastAPI)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
ea049b8ccd
45 changed files with 6751 additions and 0 deletions
180
routers/auth.py
Normal file
180
routers/auth.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from deps import get_current_user, get_user_perms
|
||||
from email_utils import password_reset_template, send_email, verify_email_template
|
||||
from models import PasswordResetToken, User, get_db
|
||||
from security import create_access_token, hash_password, verify_password
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
display_name: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in_minutes: int = settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
display_name: str | None
|
||||
email_verified: bool
|
||||
is_admin: bool
|
||||
role: str | None = None
|
||||
permissions: list[str] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RequestResetIn(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ConfirmResetIn(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register(data: RegisterIn, db: AsyncSession = Depends(get_db)):
|
||||
exists = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(409, "Bu e-posta adresi zaten kayıtlı")
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
user = User(
|
||||
email=str(data.email).lower(),
|
||||
password_hash=hash_password(data.password),
|
||||
display_name=data.display_name,
|
||||
email_verified=False,
|
||||
email_verify_token=token,
|
||||
email_verify_sent_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
verify_url = f"{settings.APP_URL}/verify-email?token={token}"
|
||||
body, html = verify_email_template(verify_url)
|
||||
try:
|
||||
await send_email(user.email, "Stepstead — E-posta Doğrulama", body, html)
|
||||
except Exception as e:
|
||||
print(f"[email] verify mail failed: {e}")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenOut)
|
||||
async def login(data: LoginIn, db: AsyncSession = Depends(get_db)):
|
||||
user = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
|
||||
if not user or not verify_password(data.password, user.password_hash):
|
||||
raise HTTPException(401, "Geçersiz e-posta veya şifre")
|
||||
if not user.is_active:
|
||||
raise HTTPException(403, "Hesap askıya alınmış")
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return TokenOut(access_token=create_access_token(user.id, {"email": user.email}))
|
||||
|
||||
|
||||
@router.get("/verify-email")
|
||||
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
|
||||
user = (await db.execute(select(User).where(User.email_verify_token == token))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(400, "Geçersiz veya kullanılmış token")
|
||||
|
||||
sent_at = user.email_verify_sent_at
|
||||
if sent_at and (datetime.now(timezone.utc) - sent_at) > timedelta(hours=24):
|
||||
raise HTTPException(400, "Token süresi dolmuş")
|
||||
|
||||
user.email_verified = True
|
||||
user.email_verify_token = None
|
||||
await db.commit()
|
||||
return {"status": "ok", "message": "E-posta doğrulandı"}
|
||||
|
||||
|
||||
@router.post("/resend-verification")
|
||||
async def resend_verification(data: RequestResetIn, db: AsyncSession = Depends(get_db)):
|
||||
user = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
|
||||
if not user:
|
||||
return {"status": "ok"}
|
||||
if user.email_verified:
|
||||
return {"status": "already_verified"}
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.email_verify_token = token
|
||||
user.email_verify_sent_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
body, html = verify_email_template(f"{settings.APP_URL}/verify-email?token={token}")
|
||||
await send_email(user.email, "Stepstead — E-posta Doğrulama", body, html)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/request-password-reset")
|
||||
async def request_password_reset(data: RequestResetIn, db: AsyncSession = Depends(get_db)):
|
||||
user = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
|
||||
if not user:
|
||||
return {"status": "ok"}
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
db.add(PasswordResetToken(
|
||||
user_id=user.id,
|
||||
token=token,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
))
|
||||
await db.commit()
|
||||
body, html = password_reset_template(f"{settings.APP_URL}/reset-password?token={token}")
|
||||
try:
|
||||
await send_email(user.email, "Stepstead — Şifre Sıfırlama", body, html)
|
||||
except Exception as e:
|
||||
print(f"[email] reset mail failed: {e}")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(data: ConfirmResetIn, db: AsyncSession = Depends(get_db)):
|
||||
rt = (await db.execute(select(PasswordResetToken).where(PasswordResetToken.token == data.token))).scalar_one_or_none()
|
||||
if not rt or rt.used_at is not None:
|
||||
raise HTTPException(400, "Geçersiz veya kullanılmış token")
|
||||
if rt.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(400, "Token süresi dolmuş")
|
||||
|
||||
user = (await db.execute(select(User).where(User.id == rt.user_id))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(400, "Kullanıcı bulunamadı")
|
||||
|
||||
user.password_hash = hash_password(data.new_password)
|
||||
rt.used_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {"status": "ok", "message": "Şifre değiştirildi"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
out = UserOut.model_validate(user, from_attributes=True)
|
||||
perms = await get_user_perms(user, db)
|
||||
out.permissions = sorted(perms)
|
||||
if user.role_id:
|
||||
from models import Role
|
||||
out.role = (await db.execute(select(Role.name).where(Role.id == user.role_id))).scalar_one_or_none()
|
||||
elif "*" in perms:
|
||||
out.role = "admin"
|
||||
return out
|
||||
Loading…
Add table
Add a link
Reference in a new issue