Sets up the complete application skeleton for Flipventory v2, an inventory management tool for small/medium resellers using chaotic stockpiling. Architecture ------------ - app/config.py pydantic-settings; reads .env; holds admin_email guard - app/database.py SQLAlchemy 2.x engine, SessionLocal, Base, get_db dep - app/models/user.py User (email, bcrypt hash, is_admin bool, is_active) - app/models/item.py StorageBin + Item with FOUND/LISTED/SOLD/ARCHIVED enum - app/schemas/ Pydantic request/response shapes (separate from models) - app/auth/security.py bcrypt password hashing + HS256 JWT creation/validation - app/routers/auth.py POST /auth/register, POST /auth/login, GET /auth/me - app/routers/users.py Admin-only CRUD for user accounts - app/routers/items.py /bins and /items CRUD with role-based access control Key design decisions -------------------- - martin@hohenberg.jp is ALWAYS admin: enforced on every login, cannot be demoted, cannot be deactivated. Prevents accidental lockout. - All other registered e-mails are customers (is_admin=False). - Customers can manage their own bins and items; admins see everything. - Item hard-delete is admin-only; customers archive instead (audit safety). - SQLite default for zero-infra local dev; any SQLAlchemy URL works in prod. - Tables are created on startup via create_tables(); swap for Alembic later. Addresses v1 Gitea issues -------------------------- - Issue #1 (storage bins): StorageBin model + /bins CRUD endpoints - Issue #2 (item CRUD): Item model + /items CRUD endpoints - Issue #3 (audit logging): created_by_id / updated_by_id on every item row Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
114 lines
4.0 KiB
Python
114 lines
4.0 KiB
Python
"""
|
||
Authentication endpoints.
|
||
|
||
POST /auth/register – create a new account (always customer, unless the
|
||
registered e-mail matches settings.admin_email)
|
||
POST /auth/login – exchange credentials for a JWT Bearer token
|
||
GET /auth/me – return the currently logged-in user's profile
|
||
|
||
Login flow
|
||
-----------
|
||
1. Client POSTs e-mail + password as form data (OAuth2 compatible).
|
||
2. We look up the user, verify the password, and issue a JWT.
|
||
3. The admin e-mail guard: if this is the hardcoded admin address, we force
|
||
is_admin=True in the DB on every login. This prevents accidental lockout.
|
||
4. Client stores the token and sends it as ``Authorization: Bearer <token>``
|
||
on subsequent requests.
|
||
"""
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from fastapi.security import OAuth2PasswordRequestForm
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.auth.security import (
|
||
create_access_token,
|
||
get_current_user,
|
||
hash_password,
|
||
verify_password,
|
||
)
|
||
from app.config import settings
|
||
from app.database import get_db
|
||
from app.models.user import User
|
||
from app.schemas.user import TokenResponse, UserCreate, UserRead
|
||
|
||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||
|
||
|
||
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||
def register(body: UserCreate, db: Session = Depends(get_db)) -> User:
|
||
"""
|
||
Register a new user account.
|
||
|
||
- E-mail must be unique.
|
||
- If the e-mail matches the configured admin address the account is
|
||
immediately given admin rights (regardless of what the caller sends).
|
||
- All other accounts are customers (is_admin=False).
|
||
"""
|
||
# Reject duplicate e-mails early with a clear error.
|
||
existing = db.query(User).filter(User.email == body.email).first()
|
||
if existing is not None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail="An account with this e-mail already exists",
|
||
)
|
||
|
||
# The hardcoded admin e-mail is always an admin.
|
||
is_admin = body.email.lower() == settings.admin_email.lower()
|
||
|
||
user = User(
|
||
email=body.email.lower(),
|
||
display_name=body.display_name,
|
||
hashed_password=hash_password(body.password),
|
||
is_admin=is_admin,
|
||
)
|
||
db.add(user)
|
||
db.commit()
|
||
db.refresh(user)
|
||
return user
|
||
|
||
|
||
@router.post("/login", response_model=TokenResponse)
|
||
def login(
|
||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""
|
||
Log in and receive a JWT Bearer token.
|
||
|
||
Uses the standard OAuth2 ``application/x-www-form-urlencoded`` format so
|
||
that Swagger UI's "Authorize" button works without extra setup.
|
||
|
||
Fields: ``username`` (= e-mail), ``password``
|
||
"""
|
||
# username field holds the e-mail (OAuth2 standard naming)
|
||
user = db.query(User).filter(User.email == form_data.username.lower()).first()
|
||
|
||
if user is None or not verify_password(form_data.password, user.hashed_password):
|
||
# Return the same generic message for both "user not found" and
|
||
# "wrong password" to avoid leaking which e-mail addresses exist.
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Incorrect e-mail or password",
|
||
headers={"WWW-Authenticate": "Bearer"},
|
||
)
|
||
|
||
if not user.is_active:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="Account is disabled – contact the administrator",
|
||
)
|
||
|
||
# Enforce the "admin e-mail is always admin" rule on every login.
|
||
if user.email == settings.admin_email.lower() and not user.is_admin:
|
||
user.is_admin = True
|
||
db.commit()
|
||
db.refresh(user)
|
||
|
||
token = create_access_token(subject=user.email)
|
||
return {"access_token": token, "token_type": "bearer", "user": user}
|
||
|
||
|
||
@router.get("/me", response_model=UserRead)
|
||
def get_me(current_user: User = Depends(get_current_user)) -> User:
|
||
"""Return the profile of the currently authenticated user."""
|
||
return current_user
|