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

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