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

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
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
httpx==0.28.1

View File

@@ -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))

View File

@@ -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)