Add ntfy push notifications for security-relevant events

Sends alerts on admin login, new registrations, user disable/delete, and
impersonation. NTFY_URL and NTFY_TOKEN are optional — leave blank to disable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 23:01:13 -07:00
parent 4172b63dc2
commit 7cd2dfb710
7 changed files with 108 additions and 4 deletions

View File

@@ -18,3 +18,10 @@ ADMIN_PASSWORD=change_me
# Generate a strong random value before deploying: # Generate a strong random value before deploying:
# openssl rand -hex 32 # openssl rand -hex 32
JWT_SECRET=change_me JWT_SECRET=change_me
# ── Ntfy push notifications (optional) ───────────────────────────────────────
# Sends alerts for: new registrations, admin logins, user disable/delete, impersonation.
# Use https://ntfy.sh/your-secret-topic or a self-hosted ntfy URL.
# Leave blank to disable notifications entirely.
NTFY_URL=
NTFY_TOKEN=

View File

@@ -62,6 +62,8 @@ A self-hosted, multi-user web app for backyard chicken keepers to track egg prod
|----------|---------|-------------| |----------|---------|-------------|
| `SECURE_COOKIES` | `true` | Set to `false` for local HTTP testing only; leave `true` when behind HTTPS | | `SECURE_COOKIES` | `true` | Set to `false` for local HTTP testing only; leave `true` when behind HTTPS |
| `ALLOWED_ORIGINS` | *(empty)* | Comma-separated list of external origins allowed to call the API. Leave empty if accessed only through the bundled nginx frontend. | | `ALLOWED_ORIGINS` | *(empty)* | Comma-separated list of external origins allowed to call the API. Leave empty if accessed only through the bundled nginx frontend. |
| `NTFY_URL` | *(empty)* | ntfy topic URL for push notifications (e.g. `https://ntfy.sh/your-secret-topic`). Leave empty to disable. |
| `NTFY_TOKEN` | *(empty)* | Bearer token for authenticated ntfy topics. Leave empty if your topic is public or not needed. |
3. Start the stack: 3. Start the stack:
```bash ```bash
@@ -94,6 +96,20 @@ Accessible at `/admin` for admin accounts. Features:
When impersonating a user, an amber banner appears in the nav with a **Return to Admin** button. The original admin session is preserved server-side — no admin credentials are stored in the browser during impersonation. When impersonating a user, an amber banner appears in the nav with a **Return to Admin** button. The original admin session is preserved server-side — no admin credentials are stored in the browser during impersonation.
## Push Notifications
Yolkbook can send alerts via [ntfy](https://ntfy.sh) for security-relevant events. Set `NTFY_URL` in `.env` to enable.
| Event | Priority |
|-------|----------|
| New user registered | default |
| Admin login | high |
| User disabled | high |
| User deleted | urgent |
| Admin impersonation started | high |
Use `https://ntfy.sh/your-secret-topic` for the hosted service or a self-hosted ntfy URL. Set `NTFY_TOKEN` if your topic requires authentication. Notifications are fire-and-forget — a failed delivery is logged but never interrupts a request.
## User Settings ## User Settings
The gear icon (⚙) in the top-right nav opens the Settings panel: The gear icon (⚙) in the top-right nav opens the Settings panel:

27
backend/ntfy.py Normal file
View File

@@ -0,0 +1,27 @@
import logging
import os
import httpx
logger = logging.getLogger("yolkbook")
def notify(title: str, message: str, priority: str = "default", tags: list[str] | None = None) -> None:
"""Send a fire-and-forget notification to ntfy. Silently logs errors — never raises."""
url = os.environ.get("NTFY_URL", "")
if not url:
return
headers = {"Title": title, "Priority": priority}
if tags:
headers["Tags"] = ",".join(tags)
token = os.environ.get("NTFY_TOKEN", "")
if token:
headers["Authorization"] = f"Bearer {token}"
try:
with httpx.Client(timeout=5) as client:
resp = client.post(url, content=message, headers=headers)
resp.raise_for_status()
except Exception as exc:
logger.warning("Ntfy notification failed: %s", exc)

View File

@@ -7,3 +7,4 @@ pydantic==2.9.2
python-jose[cryptography]==3.4.0 python-jose[cryptography]==3.4.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.0.1 bcrypt==4.0.1
httpx==0.28.1

View File

@@ -1,11 +1,12 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import User from models import User
from ntfy import notify
from schemas import UserCreate, UserOut, ResetPasswordRequest, AuthResponse from schemas import UserCreate, UserOut, ResetPasswordRequest, AuthResponse
from auth import hash_password, create_access_token, get_current_admin, get_token_payload, set_auth_cookie, token_to_user_payload from auth import hash_password, create_access_token, get_current_admin, get_token_payload, set_auth_cookie, token_to_user_payload
@@ -60,6 +61,8 @@ def reset_password(
@router.post("/users/{user_id}/disable") @router.post("/users/{user_id}/disable")
def disable_user( def disable_user(
user_id: int, user_id: int,
request: Request,
background_tasks: BackgroundTasks,
current_admin: User = Depends(get_current_admin), current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -71,6 +74,14 @@ def disable_user(
user.is_disabled = True user.is_disabled = True
db.commit() db.commit()
logger.warning("Admin '%s' disabled user '%s' (id=%d).", current_admin.username, user.username, user.id) logger.warning("Admin '%s' disabled user '%s' (id=%d).", current_admin.username, user.username, user.id)
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown")
background_tasks.add_task(
notify,
title="Yolkbook User Disabled",
message=f"User: {user.username}\nBy admin: {current_admin.username}\nIP: {ip}",
priority="high",
tags=["warning"],
)
return {"detail": f"User {user.username} disabled"} return {"detail": f"User {user.username} disabled"}
@@ -91,6 +102,8 @@ def enable_user(
@router.delete("/users/{user_id}", status_code=204) @router.delete("/users/{user_id}", status_code=204)
def delete_user( def delete_user(
user_id: int, user_id: int,
request: Request,
background_tasks: BackgroundTasks,
current_admin: User = Depends(get_current_admin), current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -100,6 +113,14 @@ def delete_user(
if user.id == current_admin.id: if user.id == current_admin.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account") raise HTTPException(status_code=400, detail="Cannot delete your own account")
logger.warning("Admin '%s' deleted user '%s' (id=%d).", current_admin.username, user.username, user.id) logger.warning("Admin '%s' deleted user '%s' (id=%d).", current_admin.username, user.username, user.id)
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown")
background_tasks.add_task(
notify,
title="Yolkbook User Deleted",
message=f"User: {user.username}\nBy admin: {current_admin.username}\nIP: {ip}",
priority="urgent",
tags=["warning", "wastebasket"],
)
db.delete(user) db.delete(user)
db.commit() db.commit()
@@ -108,6 +129,8 @@ def delete_user(
def impersonate_user( def impersonate_user(
user_id: int, user_id: int,
response: Response, response: Response,
request: Request,
background_tasks: BackgroundTasks,
current_admin: User = Depends(get_current_admin), current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -117,6 +140,14 @@ def impersonate_user(
token = create_access_token(user.id, user.username, user.is_admin, user.timezone, admin_id=current_admin.id) token = create_access_token(user.id, user.username, user.is_admin, user.timezone, admin_id=current_admin.id)
set_auth_cookie(response, token) set_auth_cookie(response, token)
logger.warning("Admin '%s' (id=%d) is impersonating user '%s' (id=%d).", current_admin.username, current_admin.id, user.username, user.id) logger.warning("Admin '%s' (id=%d) is impersonating user '%s' (id=%d).", current_admin.username, current_admin.id, user.username, user.id)
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown")
background_tasks.add_task(
notify,
title="Yolkbook Admin Impersonation",
message=f"Admin: {current_admin.username} → User: {user.username}\nIP: {ip}",
priority="high",
tags=["eyes"],
)
return AuthResponse(user=token_to_user_payload(token)) return AuthResponse(user=token_to_user_payload(token))

View File

@@ -1,11 +1,12 @@
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import User from models import User
from ntfy import notify
from schemas import LoginRequest, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate, AuthResponse from schemas import LoginRequest, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate, AuthResponse
from auth import ( from auth import (
verify_password, hash_password, create_access_token, get_current_user, verify_password, hash_password, create_access_token, get_current_user,
@@ -22,7 +23,7 @@ def _issue(response: Response, user: User, admin_id=None) -> AuthResponse:
@router.post("/login", response_model=AuthResponse) @router.post("/login", response_model=AuthResponse)
def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)): def login(body: LoginRequest, response: Response, request: Request, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
user = db.scalars(select(User).where(User.username == body.username)).first() user = db.scalars(select(User).where(User.username == body.username)).first()
if not user or not verify_password(body.password, user.hashed_password): if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException( raise HTTPException(
@@ -34,11 +35,21 @@ def login(body: LoginRequest, response: Response, db: Session = Depends(get_db))
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled. Contact your administrator.", detail="Account is disabled. Contact your administrator.",
) )
if user.is_admin:
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown")
ua = request.headers.get("User-Agent", "unknown")
background_tasks.add_task(
notify,
title="Yolkbook Admin Login",
message=f"User: {user.username}\nIP: {ip}\nUA: {ua}",
priority="high",
tags=["key"],
)
return _issue(response, user) return _issue(response, user)
@router.post("/register", response_model=AuthResponse, status_code=201) @router.post("/register", response_model=AuthResponse, status_code=201)
def register(body: UserCreate, response: Response, db: Session = Depends(get_db)): def register(body: UserCreate, response: Response, request: Request, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
existing = db.scalars(select(User).where(User.username == body.username)).first() existing = db.scalars(select(User).where(User.username == body.username)).first()
if existing: if existing:
raise HTTPException(status_code=409, detail="Username already taken") raise HTTPException(status_code=409, detail="Username already taken")
@@ -51,6 +62,15 @@ def register(body: UserCreate, response: Response, db: Session = Depends(get_db)
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown")
ua = request.headers.get("User-Agent", "unknown")
background_tasks.add_task(
notify,
title="Yolkbook New User Registered",
message=f"Username: {user.username}\nIP: {ip}\nUA: {ua}",
priority="default",
tags=["bust_in_silhouette"],
)
return _issue(response, user) return _issue(response, user)

View File

@@ -54,6 +54,8 @@ services:
ADMIN_USERNAME: ${ADMIN_USERNAME} ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
NTFY_URL: ${NTFY_URL:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 30s interval: 30s