Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,18 @@ THALIA_COVER_SEARCH_ENABLED=false # For research only. Do not use in production

# TLS / reverse proxy settings
# Forwarded headers are trusted only from matching IPs. "*" trusts all proxies.
FORWARDED_ALLOW_IPS=*
FORWARDED_ALLOW_IPS=*

# Password reset email settings (SMTP)
# For local development, use Mailpit: mail_server=mailpit mail_port=1025 mail_username="" mail_password="" mail_starttls=False mail_ssl_tls=False
# In docker-compose: mail_server=mailpit mail_port=1025
MAIL_SERVER=
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM=
MAIL_FROM_NAME=LibrisLog
MAIL_STARTTLS=True
MAIL_SSL_TLS=False
PUBLIC_APP_URL=http://localhost:5173
PASSWORD_RESET_TOKEN_MAX_AGE=3600
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""add_credentials_version_to_user

Revision ID: 784de5d2bf69
Revises: 1a2b3c4d5e6f
Create Date: 2026-06-22 10:01:24.732359

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "784de5d2bf69"
down_revision: Union[str, Sequence[str], None] = "1a2b3c4d5e6f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column("user", sa.Column("credentials_version", sa.Integer(), nullable=False, server_default="0"))


def downgrade() -> None:
op.drop_column("user", "credentials_version")
43 changes: 42 additions & 1 deletion backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from passlib.context import CryptContext
from sqlmodel import Session, select

from itsdangerous import URLSafeTimedSerializer

from app.config import settings
from app.database import get_session
from app.models import ApiKey, User, UserRole
Expand Down Expand Up @@ -140,6 +142,37 @@ def get_embed_token_prefix(token: str) -> str:
return token[:12]


# --- Password reset token utilities ---

_password_reset_serializer = URLSafeTimedSerializer(
secret_key=settings.api_key_encryption_key,
salt="password-reset",
)


def generate_password_reset_token(email: str, credentials_version: int = 0) -> str:
"""Generate a signed, time-limited token for resetting a user's password."""
return _password_reset_serializer.dumps({
"email": email,
"credentials_version": credentials_version,
})


def verify_password_reset_token(token: str, max_age: int = 3600) -> dict | None:
"""Verify a password reset token and return the payload.

Returns a dict with 'email' and 'credentials_version' if the token is valid
and not expired, otherwise None.
"""
try:
result = _password_reset_serializer.loads(token, max_age=max_age)
if isinstance(result, dict) and "email" in result and "credentials_version" in result:
return result
return None
except Exception:
return None


api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)


Expand Down Expand Up @@ -191,6 +224,13 @@ def require_user(
if user_id is not None:
user = session.get(User, user_id)
if user is not None:
session_cv = request.session.get("credentials_version", 0)
if user.credentials_version != session_cv:
request.session.clear()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired, please log in again",
)
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
csrf_token = request.session.get("csrf_token")
if not csrf_token or not x_csrf_token or x_csrf_token != csrf_token:
Expand All @@ -210,10 +250,11 @@ def require_admin(user: User = Depends(require_user)) -> User:
return user


def start_browser_session(request: Request, user_id: int) -> None:
def start_browser_session(request: Request, user_id: int, credentials_version: int = 0) -> None:
"""Start an authenticated browser session by storing user_id and a CSRF token."""
request.session["user_id"] = user_id
request.session["csrf_token"] = secrets.token_urlsafe(32)
request.session["credentials_version"] = credentials_version


def clear_browser_session(request: Request) -> None:
Expand Down
10 changes: 10 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ class Settings(BaseSettings):
hardcover_app_api_token: str = ""
thalia_cover_search_enabled: bool = False
embed_enabled: bool = True
mail_server: str = ""
mail_port: int = 587
mail_username: str = ""
mail_password: str = ""
mail_from: str = ""
mail_from_name: str = "LibrisLog"
mail_starttls: bool = True
mail_ssl_tls: bool = False
password_reset_token_max_age: int = 3600
public_app_url: str = "http://localhost:5173"
forwarded_allow_ips: str = "*"

@field_validator("api_key_encryption_key")
Expand Down
46 changes: 46 additions & 0 deletions backend/app/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Email sending utilities using fastapi-mail."""

import logging

from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType

from app.config import settings
from app.i18n import translate

logger = logging.getLogger(__name__)


def _connection_config() -> ConnectionConfig:
return ConnectionConfig(
MAIL_USERNAME=settings.mail_username,
MAIL_PASSWORD=settings.mail_password,
MAIL_FROM=settings.mail_from,
MAIL_PORT=settings.mail_port,
MAIL_SERVER=settings.mail_server,
MAIL_FROM_NAME=settings.mail_from_name,
MAIL_STARTTLS=settings.mail_starttls,
MAIL_SSL_TLS=settings.mail_ssl_tls,
USE_CREDENTIALS=bool(settings.mail_username) and bool(settings.mail_password),
VALIDATE_CERTS=True,
)


async def send_password_reset_email(recipient: str, reset_url: str, locale: str = "en") -> None:
conf = _connection_config()
duration_minutes = settings.password_reset_token_max_age // 60
message = MessageSchema(
subject=translate("email.passwordResetSubject", locale=locale),
recipients=[recipient],
body=translate(
"email.passwordResetBody",
locale=locale,
duration_minutes=str(duration_minutes),
reset_url=reset_url,
),
subtype=MessageType.html,
)
fm = FastMail(conf)
try:
await fm.send_message(message)
except Exception:
logger.exception("Failed to send password reset email to %s", recipient)
32 changes: 32 additions & 0 deletions backend/app/i18n/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Simple backend i18n using locale JSON files."""

import json
from functools import lru_cache
from pathlib import Path


@lru_cache(maxsize=32)
def _load_translations(locale: str) -> dict:
i18n_dir = Path(__file__).resolve().parent
path = i18n_dir / f"{locale}.json"
if not path.exists():
path = i18n_dir / "en.json"
with open(path, encoding="utf-8") as f:
return json.load(f)


def translate(key: str, locale: str = "en", **kwargs: str) -> str:
"""Resolve a dot-separated key in the locale JSON and interpolate {placeholders}."""
translations = _load_translations(locale)
parts = key.split(".")
value: object = translations
for part in parts:
if isinstance(value, dict):
value = value.get(part, "")
else:
value = ""
if not isinstance(value, str):
value = ""
if kwargs:
value = value.format(**kwargs)
return value
4 changes: 4 additions & 0 deletions backend/app/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
"pages": "Seiten",
"avg_pages": "Seiten/Buch"
}
},
"email": {
"passwordResetSubject": "Passwort zurücksetzen – LibrisLog",
"passwordResetBody": "<html>\n<body>\n<p>Du hast das Zurücksetzen deines Passworts für dein LibrisLog-Konto beantragt.</p>\n<p>Klicke auf den Link unten, um dein Passwort zurückzusetzen. Dieser Link ist {duration_minutes} Minuten gültig.</p>\n<p><a href=\"{reset_url}\">{reset_url}</a></p>\n<p>Falls du dies nicht angefordert hast, ignoriere bitte diese E-Mail.</p>\n</body>\n</html>"
}
}
4 changes: 4 additions & 0 deletions backend/app/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
"pages": "Pages",
"avg_pages": "Avg/Book"
}
},
"email": {
"passwordResetSubject": "Password Reset – LibrisLog",
"passwordResetBody": "<html>\n<body>\n<p>You have requested a password reset for your LibrisLog account.</p>\n<p>Click the link below to reset your password. This link is valid for {duration_minutes} minutes.</p>\n<p><a href=\"{reset_url}\">{reset_url}</a></p>\n<p>If you did not request this, please ignore this email.</p>\n</body>\n</html>"
}
}
4 changes: 4 additions & 0 deletions backend/app/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
"pages": "Páginas",
"avg_pages": "Páginas/Libro"
}
},
"email": {
"passwordResetSubject": "Restablecer contraseña – LibrisLog",
"passwordResetBody": "<html>\n<body>\n<p>Has solicitado un restablecimiento de contraseña para tu cuenta de LibrisLog.</p>\n<p>Haz clic en el enlace de abajo para restablecer tu contraseña. Este enlace es válido por {duration_minutes} minutos.</p>\n<p><a href=\"{reset_url}\">{reset_url}</a></p>\n<p>Si no solicitaste esto, ignora este correo electrónico.</p>\n</body>\n</html>"
}
}
4 changes: 4 additions & 0 deletions backend/app/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
"pages": "Pages",
"avg_pages": "Pages/Livre"
}
},
"email": {
"passwordResetSubject": "Réinitialisation du mot de passe – LibrisLog",
"passwordResetBody": "<html>\n<body>\n<p>Vous avez demandé une réinitialisation de mot de passe pour votre compte LibrisLog.</p>\n<p>Cliquez sur le lien ci-dessous pour réinitialiser votre mot de passe. Ce lien est valable {duration_minutes} minutes.</p>\n<p><a href=\"{reset_url}\">{reset_url}</a></p>\n<p>Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.</p>\n</body>\n</html>"
}
}
4 changes: 4 additions & 0 deletions backend/app/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
"pages": "页数",
"avg_pages": "每本页数"
}
},
"email": {
"passwordResetSubject": "密码重置 – LibrisLog",
"passwordResetBody": "<html>\n<body>\n<p>您已请求重置 LibrisLog 帐户的密码。</p>\n<p>点击下面的链接重置您的密码。此链接有效期为 {duration_minutes} 分钟。</p>\n<p><a href=\"{reset_url}\">{reset_url}</a></p>\n<p>如果您没有请求此操作,请忽略此邮件。</p>\n</body>\n</html>"
}
}
6 changes: 6 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ async def lifespan(app: FastAPI):
Path(settings.import_temp_dir).mkdir(parents=True, exist_ok=True)
cleanup_temp_files()

if not settings.mail_server.strip() or not settings.mail_from.strip():
logger.warning(
"MAIL_SERVER or MAIL_FROM is not configured — password reset emails will not be sent. "
"See .env.example for mail setup instructions."
)

maintenance_task = asyncio.create_task(_periodic_maintenance())
yield
maintenance_task.cancel()
Expand Down
4 changes: 4 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ class User(SQLModel, table=True):
email: str = Field(index=True, unique=True)
role: UserRole = Field(default=UserRole.user, index=True)
hashed_password: str
credentials_version: int = Field(
default=0,
sa_column=Column(sa.Integer, default=0, nullable=False)
)
created_at: datetime = Field(
default_factory=utcnow,
sa_column=Column(UtcDateTime, default=utcnow)
Expand Down
66 changes: 61 additions & 5 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
"""Authentication endpoints — login, logout, setup, session, and CSRF."""
"""Authentication endpoints — login, logout, setup, session, CSRF, and password reset."""

from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from sqlmodel import Session, select

from app.auth import (
clear_browser_session,
ensure_password_complexity,
generate_password_reset_token,
get_password_hash,
require_user,
start_browser_session,
verify_password_reset_token,
verify_password,
)
from app.config import settings
from app.database import get_session
from app.email import send_password_reset_email
from app.models import User, UserRole, UserSettings
from app.schemas import SetupRequest, UserLogin, UserRead
from app.schemas import (
ForgotPasswordRequest,
ResetPasswordRequest,
SetupRequest,
UserLogin,
UserRead,
)
from app.time_utils import utcnow

router = APIRouter(prefix="/api/auth", tags=["auth"])

Expand Down Expand Up @@ -56,7 +67,7 @@ def setup(
session.add(UserSettings(user_id=user.id, language="en"))
session.commit()

start_browser_session(http_request, user.id)
start_browser_session(http_request, user.id, user.credentials_version)
return {"user": UserRead.model_validate(user)}


Expand All @@ -71,7 +82,7 @@ def login(
if not user or not verify_password(credentials.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password")

start_browser_session(http_request, user.id)
start_browser_session(http_request, user.id, user.credentials_version)
return {"user": UserRead.model_validate(user)}


Expand All @@ -95,3 +106,48 @@ def csrf_token(request: Request) -> dict:
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return {"csrf_token": token}


@router.post("/forgot-password", status_code=status.HTTP_200_OK)
async def forgot_password(
body: ForgotPasswordRequest,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session),
) -> dict:
"""Send a password reset email to the user.

Always returns 200 to prevent user enumeration.
"""
user = session.exec(select(User).where(User.email == body.email)).first()
if user and settings.mail_server:
token = generate_password_reset_token(body.email, user.credentials_version)
reset_url = f"{settings.public_app_url}/reset-password?token={token}"
background_tasks.add_task(send_password_reset_email, body.email, reset_url, body.locale)
return {"message": "If the email is registered, a reset link has been sent"}


@router.post("/reset-password", status_code=status.HTTP_200_OK)
def reset_password(
body: ResetPasswordRequest,
session: Session = Depends(get_session),
) -> dict:
"""Reset a user's password using a valid reset token."""
payload = verify_password_reset_token(body.token, max_age=settings.password_reset_token_max_age)
if not payload:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token",
)
user = session.exec(select(User).where(User.email == payload["email"])).first()
if not user or user.credentials_version != payload["credentials_version"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token",
)
ensure_password_complexity(body.password)
user.hashed_password = get_password_hash(body.password)
user.credentials_version += 1
user.updated_at = utcnow()
session.add(user)
session.commit()
return {"message": "Password has been reset successfully"}
Loading
Loading