Add Ntfy push notifications for super admin events

Sends alerts to a configurable Ntfy topic on: new user registration,
account lockout after 5 failed login attempts, and login attempts on an
already-locked account. Fire-and-forget — never raises if Ntfy is down.

Configure via NTFY_URL and NTFY_TOKEN in .env.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 01:04:12 -07:00
parent 663b506868
commit 090ebc109e
7 changed files with 64 additions and 0 deletions

View File

@@ -20,3 +20,8 @@ ADMIN_PASSWORD=change_me_strong_password_here
# Set to true only for local development (exposes /api/docs, /api/redoc) # Set to true only for local development (exposes /api/docs, /api/redoc)
DOCS_ENABLED=false DOCS_ENABLED=false
# Ntfy push notifications for super admin alerts (optional)
# Use https://ntfy.sh/your-secret-topic or self-hosted URL
NTFY_URL=
NTFY_TOKEN=

View File

@@ -25,6 +25,7 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning
- **Password Change** — Users can change their own account password from Admin → Settings → Reset Password. The form requires the current password before accepting a new one. - **Password Change** — Users can change their own account password from Admin → Settings → Reset Password. The form requires the current password before accepting a new one.
- **Multi-Child Support** — Manage multiple students under one parent account, each with their own color, schedule, and history. - **Multi-Child Support** — Manage multiple students under one parent account, each with their own color, schedule, and history.
- **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required). Disabled accounts receive a clear error message explaining the account is disabled rather than a generic "invalid credentials" response. After **5 consecutive failed login attempts**, an account is locked for **15 minutes** — the error message includes the remaining wait time. Locks clear automatically after the cooldown, or immediately when a super admin resets the account's password. - **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required). Disabled accounts receive a clear error message explaining the account is disabled rather than a generic "invalid credentials" response. After **5 consecutive failed login attempts**, an account is locked for **15 minutes** — the error message includes the remaining wait time. Locks clear automatically after the cooldown, or immediately when a super admin resets the account's password.
- **Ntfy Push Notifications** — Optional push notifications delivered to the super admin via [Ntfy](https://ntfy.sh). Alerts are sent for: new user registration, account lockout after 5 failed login attempts, and login attempts on an already-locked account. Works with ntfy.sh (public or self-hosted). Configure via `NTFY_URL` and optionally `NTFY_TOKEN` in `.env`.
- **Super Admin Panel** — A separate admin interface (at `/super-admin`) for site-wide management. Log in with a dedicated admin username and password (set in `.env`). Provides full control over all registered parent accounts: - **Super Admin Panel** — A separate admin interface (at `/super-admin`) for site-wide management. Log in with a dedicated admin username and password (set in `.env`). Provides full control over all registered parent accounts:
- **Impersonate** — Enter any user's session to view and manage their data. An impersonation banner is shown at the top of every page with a one-click "Exit to Admin Panel" button. - **Impersonate** — Enter any user's session to view and manage their data. An impersonation banner is shown at the top of every page with a one-click "Exit to Admin Panel" button.
- **Reset Password** — Set a new password for any user without needing the current password. Also clears any active login lockout on the account. - **Reset Password** — Set a new password for any user without needing the current password. Also clears any active login lockout on the account.
@@ -168,6 +169,11 @@ ADMIN_PASSWORD=change_me_admin_password
# Set to true only for local development (exposes /api/docs, /api/redoc) # Set to true only for local development (exposes /api/docs, /api/redoc)
DOCS_ENABLED=false DOCS_ENABLED=false
# Ntfy push notifications for super admin alerts (optional)
# Use https://ntfy.sh/your-secret-topic or a self-hosted Ntfy server
NTFY_URL=
NTFY_TOKEN=
``` ```
### 3. Build and start ### 3. Build and start
@@ -346,6 +352,8 @@ The TV dashboard connects to `ws://host/ws/{tv_token}` (using the child's 6-digi
| `ADMIN_USERNAME` | No | Super admin login username (default: `admin`) | | `ADMIN_USERNAME` | No | Super admin login username (default: `admin`) |
| `ADMIN_PASSWORD` | No | Super admin login password (default: `change_me_admin_password`) | | `ADMIN_PASSWORD` | No | Super admin login password (default: `change_me_admin_password`) |
| `DOCS_ENABLED` | No | Set to `true` to enable `/api/docs` and `/api/redoc` (default: `false`). Recommended only for local development. | | `DOCS_ENABLED` | No | Set to `true` to enable `/api/docs` and `/api/redoc` (default: `false`). Recommended only for local development. |
| `NTFY_URL` | No | Full Ntfy topic URL (e.g. `https://ntfy.sh/your-secret-topic`). Leave blank to disable notifications. |
| `NTFY_TOKEN` | No | Bearer token for a protected Ntfy topic. Leave blank for public topics. |
> **Note:** `ADMIN_USERNAME` and `ADMIN_PASSWORD` must be set in `.env` **and** listed in the `backend` service's `environment` block in `docker-compose.yml`. Changing them in `.env` alone is not sufficient — the backend container reads them as environment variables, not from the file directly. > **Note:** `ADMIN_USERNAME` and `ADMIN_PASSWORD` must be set in `.env` **and** listed in the `backend` service's `environment` block in `docker-compose.yml`. Changing them in `.env` alone is not sufficient — the backend container reads them as environment variables, not from the file directly.

View File

@@ -12,6 +12,8 @@ class Settings(BaseSettings):
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str # no default — must be explicitly set in .env admin_password: str # no default — must be explicitly set in .env
docs_enabled: bool = False docs_enabled: bool = False
ntfy_url: str = "" # e.g. https://ntfy.sh/your-secret-topic
ntfy_token: str = "" # optional Bearer token if topic is protected
@property @property
def cors_origins_list(self) -> list[str]: def cors_origins_list(self) -> list[str]:

View File

@@ -18,6 +18,7 @@ from app.models.user import User
from app.models.subject import Subject from app.models.subject import Subject
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.schemas.user import UserOut from app.schemas.user import UserOut
from app.utils.ntfy import notify
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -57,6 +58,14 @@ async def register(body: RegisterRequest, response: Response, db: AsyncSession =
access = create_access_token({"sub": str(user.id)}) access = create_access_token({"sub": str(user.id)})
refresh = create_refresh_token({"sub": str(user.id)}) refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS) response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS)
await notify(
title="New User Registered",
message=f"{body.full_name} ({body.email})",
priority="default",
tags=["bust_in_silhouette"],
)
return TokenResponse(access_token=access) return TokenResponse(access_token=access)
@@ -76,6 +85,12 @@ async def login(body: LoginRequest, response: Response, db: AsyncSession = Depen
if user.locked_until and user.locked_until > now: if user.locked_until and user.locked_until > now:
remaining = int((user.locked_until - now).total_seconds() / 60) + 1 remaining = int((user.locked_until - now).total_seconds() / 60) + 1
logger.warning("Locked account login attempt for email=%s", body.email) logger.warning("Locked account login attempt for email=%s", body.email)
await notify(
title="Login Attempt on Locked Account",
message=f"{body.email} — locked for {remaining} more minute(s)",
priority="high",
tags=["lock"],
)
raise HTTPException(status_code=429, detail=f"Account locked. Try again in {remaining} minute(s).") raise HTTPException(status_code=429, detail=f"Account locked. Try again in {remaining} minute(s).")
if not user.is_active: if not user.is_active:
@@ -87,6 +102,12 @@ async def login(body: LoginRequest, response: Response, db: AsyncSession = Depen
if user.failed_login_attempts >= _LOGIN_MAX_ATTEMPTS: if user.failed_login_attempts >= _LOGIN_MAX_ATTEMPTS:
user.locked_until = now + timedelta(minutes=_LOGIN_LOCKOUT_MINUTES) user.locked_until = now + timedelta(minutes=_LOGIN_LOCKOUT_MINUTES)
logger.warning("Account locked for email=%s after %d failed attempts", body.email, user.failed_login_attempts) logger.warning("Account locked for email=%s after %d failed attempts", body.email, user.failed_login_attempts)
await notify(
title="Account Locked",
message=f"{body.email} locked after {user.failed_login_attempts} failed attempts",
priority="urgent",
tags=["warning"],
)
else: else:
logger.warning("Failed login attempt %d/%d for email=%s", user.failed_login_attempts, _LOGIN_MAX_ATTEMPTS, body.email) logger.warning("Failed login attempt %d/%d for email=%s", user.failed_login_attempts, _LOGIN_MAX_ATTEMPTS, body.email)
await db.commit() await db.commit()

25
backend/app/utils/ntfy.py Normal file
View File

@@ -0,0 +1,25 @@
import logging
import httpx
from app.config import get_settings
logger = logging.getLogger("homeschool.ntfy")
async def notify(title: str, message: str, priority: str = "default", tags: list[str] | None = None) -> None:
"""Fire-and-forget notification to Ntfy. Silently logs errors — never raises."""
settings = get_settings()
if not settings.ntfy_url:
return
headers = {"Title": title, "Priority": priority}
if tags:
headers["Tags"] = ",".join(tags)
if settings.ntfy_token:
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.post(settings.ntfy_url, content=message, headers=headers)
resp.raise_for_status()
except Exception as exc:
logger.warning("Ntfy notification failed: %s", exc)

View File

@@ -10,3 +10,4 @@ pydantic-settings==2.5.2
alembic==1.13.3 alembic==1.13.3
python-multipart==0.0.22 python-multipart==0.0.22
email-validator==2.2.0 email-validator==2.2.0
httpx==0.27.2

View File

@@ -33,6 +33,8 @@ services:
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8057} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8057}
ADMIN_USERNAME: ${ADMIN_USERNAME} ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
NTFY_URL: ${NTFY_URL:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy