Files
flipventory_v2/app/auth/security.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

140 lines
4.5 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 helpers: password hashing and JWT token management.
Password hashing
-----------------
We use passlib's bcrypt scheme. bcrypt is intentionally slow (tunable cost
factor) which makes brute-force attacks against the hash database expensive.
Never store or log plaintext passwords.
JWT tokens
-----------
Tokens are signed with HS256 (HMAC-SHA256). The payload contains:
- ``sub`` the user's e-mail address (the "subject")
- ``exp`` expiry timestamp (unix epoch seconds)
The token is passed by the client in the ``Authorization: Bearer <token>``
header. FastAPI's OAuth2PasswordBearer extracts the raw token string for us.
"""
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
# ---------------------------------------------------------------------------
# Password helpers
# ---------------------------------------------------------------------------
# CryptContext manages the algorithm list. If we ever want to upgrade (e.g.,
# argon2) we can add it to the list and passlib will migrate hashes on login.
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plaintext: str) -> str:
"""Return the bcrypt hash of ``plaintext``."""
return _pwd_context.hash(plaintext)
def verify_password(plaintext: str, hashed: str) -> bool:
"""Return True if ``plaintext`` matches ``hashed``."""
return _pwd_context.verify(plaintext, hashed)
# ---------------------------------------------------------------------------
# JWT helpers
# ---------------------------------------------------------------------------
def create_access_token(subject: str) -> str:
"""
Create a signed JWT token for ``subject`` (the user's e-mail).
The token expires after ``settings.access_token_expire_minutes`` minutes
from the time of creation.
"""
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def _decode_token(token: str) -> str:
"""
Decode and validate a JWT token.
Returns the ``sub`` claim (e-mail) on success.
Raises HTTPException(401) if the token is invalid or expired.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.secret_key, algorithms=[settings.algorithm]
)
email: str | None = payload.get("sub")
if email is None:
raise credentials_exception
return email
except JWTError:
raise credentials_exception
# ---------------------------------------------------------------------------
# FastAPI dependencies
# ---------------------------------------------------------------------------
# tokenUrl must match the path of our login endpoint so Swagger UI's
# "Authorize" button works.
_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def get_current_user(
token: str = Depends(_oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
"""
Dependency: resolve a Bearer token to a User ORM object.
Raises 401 if the token is invalid/expired.
Raises 403 if the account is disabled.
"""
email = _decode_token(token)
user = db.query(User).filter(User.email == email).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled",
)
return user
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""
Dependency: ensure the logged-in user is an admin.
Raises 403 if they are not. Use this on any endpoint that should be
admin-only.
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return current_user