feat(backend): FastAPI + libSQL + auth register/login/me + tests (TDD)
- FastAPI app with lifespan, CORS, health endpoint - SQLAlchemy 2.0 async with aiosqlite, Base/UUIDMixin/TenantMixin/TimestampMixin - Tenant and User models with multi-tenant isolation - Auth: register (creates tenant+user), login, /me endpoint - JWT HS256 tokens, bcrypt password hashing - Alembic async setup with initial migration - 6 passing tests (register, login, wrong password, me, no token, health) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
58
backend/app/auth/router.py
Normal file
58
backend/app/auth/router.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import schemas, service
|
||||
from app.db.session import get_db
|
||||
from app.deps import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=schemas.TokenResponse)
|
||||
async def register(
|
||||
data: schemas.RegisterRequest, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
user, tenant = await service.register(
|
||||
db, data.email, data.password, data.tenant_name, data.telefon
|
||||
)
|
||||
return schemas.TokenResponse(
|
||||
access_token=service.create_token(user.id, tenant.id, tenant.plan),
|
||||
tenant_id=tenant.id,
|
||||
plan=tenant.plan,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=schemas.TokenResponse)
|
||||
async def login(data: schemas.LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
user, tenant = await service.authenticate(db, data.email, data.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Credentiale invalide")
|
||||
return schemas.TokenResponse(
|
||||
access_token=service.create_token(user.id, tenant.id, tenant.plan),
|
||||
tenant_id=tenant.id,
|
||||
plan=tenant.plan,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=schemas.UserResponse)
|
||||
async def me(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from sqlalchemy import select
|
||||
from app.db.models.user import User
|
||||
from app.db.models.tenant import Tenant
|
||||
|
||||
r = await db.execute(select(User).where(User.id == current_user["sub"]))
|
||||
user = r.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id))
|
||||
tenant = r.scalar_one()
|
||||
return schemas.UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
tenant_id=user.tenant_id,
|
||||
plan=tenant.plan,
|
||||
rol=user.rol,
|
||||
)
|
||||
28
backend/app/auth/schemas.py
Normal file
28
backend/app/auth/schemas.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_name: str
|
||||
telefon: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
tenant_id: str
|
||||
plan: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
tenant_id: str
|
||||
plan: str
|
||||
rol: str
|
||||
62
backend/app/auth/service.py
Normal file
62
backend/app/auth/service.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from jose import jwt
|
||||
import bcrypt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.db.base import uuid7
|
||||
from app.db.models.tenant import Tenant
|
||||
from app.db.models.user import User
|
||||
|
||||
|
||||
def hash_password(p: str) -> str:
|
||||
return bcrypt.hashpw(p.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_token(user_id: str, tenant_id: str, plan: str) -> str:
|
||||
exp = datetime.now(UTC) + timedelta(days=settings.ACCESS_TOKEN_EXPIRE_DAYS)
|
||||
return jwt.encode(
|
||||
{"sub": user_id, "tenant_id": tenant_id, "plan": plan, "exp": exp},
|
||||
settings.SECRET_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
|
||||
async def register(
|
||||
db: AsyncSession, email: str, password: str, tenant_name: str, telefon: str
|
||||
):
|
||||
trial_exp = (datetime.now(UTC) + timedelta(days=settings.TRIAL_DAYS)).isoformat()
|
||||
tenant = Tenant(
|
||||
id=uuid7(),
|
||||
nume=tenant_name,
|
||||
telefon=telefon,
|
||||
plan="trial",
|
||||
trial_expires_at=trial_exp,
|
||||
)
|
||||
db.add(tenant)
|
||||
user = User(
|
||||
id=uuid7(),
|
||||
tenant_id=tenant.id,
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
nume=email.split("@")[0],
|
||||
rol="owner",
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return user, tenant
|
||||
|
||||
|
||||
async def authenticate(db: AsyncSession, email: str, password: str):
|
||||
r = await db.execute(select(User).where(User.email == email))
|
||||
user = r.scalar_one_or_none()
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
return None, None
|
||||
r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id))
|
||||
return user, r.scalar_one_or_none()
|
||||
15
backend/app/config.py
Normal file
15
backend/app/config.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
|
||||
SECRET_KEY: str = "dev-secret-change-me"
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./data/roaauto.db"
|
||||
ACCESS_TOKEN_EXPIRE_DAYS: int = 30
|
||||
TRIAL_DAYS: int = 30
|
||||
SMSAPI_TOKEN: str = ""
|
||||
CORS_ORIGINS: str = "http://localhost:5173"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
32
backend/app/db/base.py
Normal file
32
backend/app/db/base.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from datetime import datetime, UTC
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def uuid7() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid7)
|
||||
|
||||
|
||||
class TenantMixin:
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
Text, default=lambda: datetime.now(UTC).isoformat()
|
||||
)
|
||||
updated_at: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
default=lambda: datetime.now(UTC).isoformat(),
|
||||
onupdate=lambda: datetime.now(UTC).isoformat(),
|
||||
)
|
||||
4
backend/app/db/models/__init__.py
Normal file
4
backend/app/db/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.db.models.tenant import Tenant
|
||||
from app.db.models.user import User
|
||||
|
||||
__all__ = ["Tenant", "User"]
|
||||
18
backend/app/db/models/tenant.py
Normal file
18
backend/app/db/models/tenant.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, UUIDMixin, TimestampMixin
|
||||
|
||||
|
||||
class Tenant(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "tenants"
|
||||
nume: Mapped[str] = mapped_column(String(200))
|
||||
cui: Mapped[str | None] = mapped_column(String(20))
|
||||
reg_com: Mapped[str | None] = mapped_column(String(30))
|
||||
adresa: Mapped[str | None] = mapped_column(Text)
|
||||
telefon: Mapped[str | None] = mapped_column(String(20))
|
||||
email: Mapped[str | None] = mapped_column(String(200))
|
||||
iban: Mapped[str | None] = mapped_column(String(34))
|
||||
banca: Mapped[str | None] = mapped_column(String(100))
|
||||
plan: Mapped[str] = mapped_column(String(20), default="trial")
|
||||
trial_expires_at: Mapped[str | None] = mapped_column(Text)
|
||||
13
backend/app/db/models/user.py
Normal file
13
backend/app/db/models/user.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
||||
|
||||
|
||||
class User(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
email: Mapped[str] = mapped_column(String(200), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(200))
|
||||
nume: Mapped[str] = mapped_column(String(200))
|
||||
rol: Mapped[str] = mapped_column(String(20), default="owner")
|
||||
activ: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
11
backend/app/db/session.py
Normal file
11
backend/app/db/session.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
22
backend/app/deps.py
Normal file
22
backend/app/deps.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
creds: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
) -> dict:
|
||||
if creds is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
try:
|
||||
return jwt.decode(creds.credentials, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
async def get_tenant_id(user: dict = Depends(get_current_user)) -> str:
|
||||
return user["tenant_id"]
|
||||
35
backend/app/main.py
Normal file
35
backend/app/main.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.auth.router import router as auth_router
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
|
||||
# Import models so Base.metadata knows about them
|
||||
import app.db.models # noqa: F401
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS.split(","),
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=True,
|
||||
)
|
||||
app.include_router(auth_router, prefix="/api/auth")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user