add auth
This commit is contained in:
29
backend/alembic/versions/0002_add_users_table.py
Normal file
29
backend/alembic/versions/0002_add_users_table.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""add users table
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-06-28
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("username", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("password_hash", sa.String(), nullable=False),
|
||||
sa.Column("is_admin", sa.Boolean(), default=False),
|
||||
sa.Column("created_at", sa.DateTime(), default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("users")
|
||||
81
backend/app/auth.py
Normal file
81
backend/app/auth.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
|
||||
security = HTTPBearer()
|
||||
_optional_security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.access_token_expire_days)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
try:
|
||||
return jwt.decode(token, settings.secret_key, algorithms=["HS256"])
|
||||
except JWTError:
|
||||
raise HTTPException(401, "Invalid or expired token")
|
||||
|
||||
|
||||
async def _resolve_user(token_str: str, db: AsyncSession) -> User:
|
||||
payload = decode_access_token(token_str)
|
||||
user_id_str: str = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise HTTPException(401, "Invalid token payload")
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(401, "Invalid token payload")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(401, "User not found")
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
return await _resolve_user(credentials.credentials, db)
|
||||
|
||||
|
||||
async def get_current_user_from_query_or_header(
|
||||
token: str | None = Query(default=None),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_optional_security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
if credentials:
|
||||
token_str = credentials.credentials
|
||||
elif token:
|
||||
token_str = token
|
||||
else:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return await _resolve_user(token_str, db)
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin privileges required")
|
||||
return current_user
|
||||
@@ -1,15 +1,34 @@
|
||||
import secrets
|
||||
import logging
|
||||
from pydantic_settings import BaseSettings
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "sqlite+aiosqlite:////data/medialore.db"
|
||||
media_root: str = "/media"
|
||||
thumbnail_dir: str = "/data/thumbnails"
|
||||
|
||||
secret_key: str = ""
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = ""
|
||||
access_token_expire_days: int = 30
|
||||
|
||||
model_config = {"env_file": ".env"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
if not settings.secret_key:
|
||||
settings.secret_key = secrets.token_urlsafe(32)
|
||||
log.warning(
|
||||
"SECRET_KEY not set — generated random key: %s. "
|
||||
"All sessions will be invalidated on restart. "
|
||||
"Set SECRET_KEY in .env for persistent sessions.",
|
||||
settings.secret_key,
|
||||
)
|
||||
|
||||
THUMBNAIL_DIR = Path(settings.thumbnail_dir)
|
||||
THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
# uvicorn's dictConfig only configures uvicorn.* loggers; the root logger
|
||||
# ends up with no handler, so app.* records are silently discarded.
|
||||
@@ -17,18 +18,45 @@ _app_logger.propagate = False
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from app.database import engine, Base
|
||||
from app.routers import libraries, media, tags, search
|
||||
from app.config import settings
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.routers import libraries, media, tags, search, auth
|
||||
from app.services import watcher as watcher_service
|
||||
from app.auth import hash_password
|
||||
import app.models # noqa: F401 — registers models with Base.metadata
|
||||
|
||||
|
||||
async def _ensure_admin_user():
|
||||
from app.models import User
|
||||
|
||||
if not settings.admin_password:
|
||||
log.warning("ADMIN_PASSWORD not set — no admin user will be created.")
|
||||
return
|
||||
|
||||
async with SessionLocal() as db:
|
||||
result = await db.execute(select(User).where(User.username == settings.admin_username))
|
||||
if result.scalars().first():
|
||||
log.info("Admin user '%s' already exists.", settings.admin_username)
|
||||
return
|
||||
|
||||
user = User(
|
||||
username=settings.admin_username,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
log.info("Created admin user '%s'.", settings.admin_username)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
log.info("Creating database tables...")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
await _ensure_admin_user()
|
||||
|
||||
log.info("Starting library watchers...")
|
||||
await watcher_service.start_all()
|
||||
|
||||
@@ -54,6 +82,7 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(libraries.router, prefix="/api")
|
||||
app.include_router(media.router, prefix="/api")
|
||||
app.include_router(tags.router, prefix="/api")
|
||||
|
||||
@@ -54,3 +54,13 @@ class Tag(Base):
|
||||
category: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
items: Mapped[list["MediaItem"]] = relationship("MediaItem", secondary=media_item_tags, back_populates="tags")
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String, nullable=False)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
76
backend/app/routers/auth.py
Normal file
76
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import LoginRequest, TokenResponse, UserOut, UserCreate
|
||||
from app.auth import (
|
||||
verify_password,
|
||||
hash_password,
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
get_current_admin_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.username == body.username))
|
||||
user = result.scalars().first()
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(401, "Invalid username or password")
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
result = await db.execute(select(User).order_by(User.username))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserOut, status_code=201)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
existing = await db.execute(select(User).where(User.username == body.username))
|
||||
if existing.scalars().first():
|
||||
raise HTTPException(409, "Username already exists")
|
||||
user = User(
|
||||
username=body.username,
|
||||
password_hash=hash_password(body.password),
|
||||
is_admin=False,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=204)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "Cannot delete your own account")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
@@ -5,15 +5,19 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Library, MediaItem
|
||||
from app.models import Library, MediaItem, User
|
||||
from app.schemas import LibraryCreate, LibraryOut, MediaItemOut, BrowseResult, BrowseEntry
|
||||
from app.services import scanner, watcher as watcher_service
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/libraries", tags=["libraries"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[LibraryOut])
|
||||
async def list_libraries(db: AsyncSession = Depends(get_db)):
|
||||
async def list_libraries(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Library))
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -23,6 +27,7 @@ async def create_library(
|
||||
body: LibraryCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
path = Path(body.path)
|
||||
if not path.is_dir():
|
||||
@@ -44,7 +49,10 @@ async def create_library(
|
||||
|
||||
|
||||
@router.get("/{library_id}/scan-status")
|
||||
async def get_scan_status(library_id: int):
|
||||
async def get_scan_status(
|
||||
library_id: int,
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
return {"scanning": scanner.is_scanning(library_id)}
|
||||
|
||||
|
||||
@@ -53,6 +61,7 @@ async def rescan_library(
|
||||
library_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Library).where(Library.id == library_id))
|
||||
lib = result.scalars().first()
|
||||
@@ -69,6 +78,7 @@ async def doom_scroll(
|
||||
library_id: int,
|
||||
path: str = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Library).where(Library.id == library_id))
|
||||
if not result.scalars().first():
|
||||
@@ -87,7 +97,11 @@ async def doom_scroll(
|
||||
|
||||
|
||||
@router.delete("/{library_id}", status_code=204)
|
||||
async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_library(
|
||||
library_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Library).where(Library.id == library_id))
|
||||
lib = result.scalars().first()
|
||||
if not lib:
|
||||
@@ -98,7 +112,12 @@ async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{library_id}/browse", response_model=BrowseResult)
|
||||
async def browse_library(library_id: int, path: str = "", db: AsyncSession = Depends(get_db)):
|
||||
async def browse_library(
|
||||
library_id: int,
|
||||
path: str = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Library).where(Library.id == library_id))
|
||||
lib = result.scalars().first()
|
||||
if not lib:
|
||||
|
||||
@@ -5,9 +5,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Library, MediaItem, Tag
|
||||
from app.models import Library, MediaItem, Tag, User
|
||||
from app.schemas import MediaItemOut, TagIdList
|
||||
from app.services.thumbnails import get_or_create_thumbnail
|
||||
from app.auth import get_current_user, get_current_user_from_query_or_header
|
||||
|
||||
router = APIRouter(prefix="/media", tags=["media"])
|
||||
|
||||
@@ -33,7 +34,11 @@ def _resolve_safe(lib: Library, item: MediaItem) -> Path:
|
||||
|
||||
|
||||
@router.get("/{media_id}", response_model=MediaItemOut)
|
||||
async def get_media_item(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_media_item(
|
||||
media_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(MediaItem).where(MediaItem.id == media_id)
|
||||
)
|
||||
@@ -46,7 +51,11 @@ async def get_media_item(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{media_id}/file")
|
||||
async def serve_file(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def serve_file(
|
||||
media_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user_from_query_or_header),
|
||||
):
|
||||
item, lib = await _get_item_and_lib(media_id, db)
|
||||
if item.missing:
|
||||
raise HTTPException(404, "File is missing from disk")
|
||||
@@ -57,7 +66,11 @@ async def serve_file(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{media_id}/thumbnail")
|
||||
async def serve_thumbnail(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def serve_thumbnail(
|
||||
media_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user_from_query_or_header),
|
||||
):
|
||||
item, lib = await _get_item_and_lib(media_id, db)
|
||||
abs_path = _resolve_safe(lib, item)
|
||||
thumb = get_or_create_thumbnail(media_id, str(abs_path), item.media_type)
|
||||
@@ -67,7 +80,12 @@ async def serve_thumbnail(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/{media_id}/tags", response_model=MediaItemOut)
|
||||
async def set_tags(media_id: int, body: TagIdList, db: AsyncSession = Depends(get_db)):
|
||||
async def set_tags(
|
||||
media_id: int,
|
||||
body: TagIdList,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(MediaItem).where(MediaItem.id == media_id))
|
||||
item = result.scalars().first()
|
||||
if not item:
|
||||
|
||||
@@ -4,8 +4,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import MediaItem, media_item_tags
|
||||
from app.models import MediaItem, media_item_tags, User
|
||||
from app.schemas import MediaItemOut
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
@@ -16,6 +17,7 @@ async def search(
|
||||
tags: str = Query(default=""),
|
||||
library_id: int | None = Query(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
stmt = (
|
||||
select(MediaItem)
|
||||
|
||||
@@ -3,14 +3,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Tag
|
||||
from app.models import Tag, User
|
||||
from app.schemas import TagCreate, TagOut, TagsByCategory
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TagsByCategory])
|
||||
async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||
async def list_tags(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Tag).order_by(Tag.category, Tag.name))
|
||||
tags = result.scalars().all()
|
||||
|
||||
@@ -22,7 +26,11 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("", response_model=TagOut, status_code=201)
|
||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_tag(
|
||||
body: TagCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
existing = await db.execute(
|
||||
select(Tag).where(Tag.name == body.name, Tag.category == body.category)
|
||||
)
|
||||
@@ -36,7 +44,11 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
async def delete_tag(tag_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_tag(
|
||||
tag_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||
tag = result.scalars().first()
|
||||
if not tag:
|
||||
|
||||
@@ -86,3 +86,28 @@ class SearchResult(BaseModel):
|
||||
|
||||
class TagIdList(BaseModel):
|
||||
tag_ids: list[int]
|
||||
|
||||
|
||||
# --- Auth ---
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@@ -12,6 +12,8 @@ dependencies = [
|
||||
"watchdog>=4.0",
|
||||
"Pillow>=10.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"python-jose[cryptography]>=3.3",
|
||||
"bcrypt>=4.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
Reference in New Issue
Block a user