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>
This commit is contained in:
113
app/routers/auth.py
Normal file
113
app/routers/auth.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user