Files
flipventory_v2/app/routers/auth.py
Martin Hohenberg 4e0d0d3962 feat: initial FastAPI stub with auth, user management, and inventory CRUD
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>
2026-06-16 10:40:53 +02:00

114 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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