From 7cd2dfb710c7c752d3711e4af8ae06d7f3b1cf05 Mon Sep 17 00:00:00 2001 From: derekc Date: Mon, 23 Mar 2026 23:01:13 -0700 Subject: [PATCH] Add ntfy push notifications for security-relevant events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 7 +++++++ README.md | 16 ++++++++++++++++ backend/ntfy.py | 27 +++++++++++++++++++++++++++ backend/requirements.txt | 1 + backend/routers/admin.py | 33 ++++++++++++++++++++++++++++++++- backend/routers/auth_router.py | 26 +++++++++++++++++++++++--- docker-compose.yml | 2 ++ 7 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 backend/ntfy.py diff --git a/.env.example b/.env.example index 9a305b5..ee7b0d8 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,10 @@ ADMIN_PASSWORD=change_me # Generate a strong random value before deploying: # openssl rand -hex 32 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= diff --git a/README.md b/README.md index abcf00d..0a7c503 100644 --- a/README.md +++ b/README.md @@ -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 | | `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: ```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. +## 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 The gear icon (⚙) in the top-right nav opens the Settings panel: diff --git a/backend/ntfy.py b/backend/ntfy.py new file mode 100644 index 0000000..4a22294 --- /dev/null +++ b/backend/ntfy.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt index 88982ab..728fea6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ pydantic==2.9.2 python-jose[cryptography]==3.4.0 passlib[bcrypt]==1.7.4 bcrypt==4.0.1 +httpx==0.28.1 diff --git a/backend/routers/admin.py b/backend/routers/admin.py index beb459d..f0a744a 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -1,11 +1,12 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response from sqlalchemy import select from sqlalchemy.orm import Session from database import get_db from models import User +from ntfy import notify 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 @@ -60,6 +61,8 @@ def reset_password( @router.post("/users/{user_id}/disable") def disable_user( user_id: int, + request: Request, + background_tasks: BackgroundTasks, current_admin: User = Depends(get_current_admin), db: Session = Depends(get_db), ): @@ -71,6 +74,14 @@ def disable_user( user.is_disabled = True db.commit() 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"} @@ -91,6 +102,8 @@ def enable_user( @router.delete("/users/{user_id}", status_code=204) def delete_user( user_id: int, + request: Request, + background_tasks: BackgroundTasks, current_admin: User = Depends(get_current_admin), db: Session = Depends(get_db), ): @@ -100,6 +113,14 @@ def delete_user( if user.id == current_admin.id: 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) + 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.commit() @@ -108,6 +129,8 @@ def delete_user( def impersonate_user( user_id: int, response: Response, + request: Request, + background_tasks: BackgroundTasks, current_admin: User = Depends(get_current_admin), 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) 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) + 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)) diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py index 99c4897..c39cec7 100644 --- a/backend/routers/auth_router.py +++ b/backend/routers/auth_router.py @@ -1,11 +1,12 @@ 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.orm import Session from database import get_db from models import User +from ntfy import notify from schemas import LoginRequest, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate, AuthResponse from auth import ( 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) -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() if not user or not verify_password(body.password, user.hashed_password): raise HTTPException( @@ -34,11 +35,21 @@ def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)) status_code=status.HTTP_403_FORBIDDEN, 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) @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() if existing: 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.commit() 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) diff --git a/docker-compose.yml b/docker-compose.yml index fb452dd..a4c875d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,8 @@ services: ADMIN_USERNAME: ${ADMIN_USERNAME} ADMIN_PASSWORD: ${ADMIN_PASSWORD} JWT_SECRET: ${JWT_SECRET} + NTFY_URL: ${NTFY_URL:-} + NTFY_TOKEN: ${NTFY_TOKEN:-} healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] interval: 30s