Add login lockout with ntfy alerts and update docs

- Lock accounts for 15 minutes after 5 consecutive failed login attempts
- Send urgent ntfy notification when an account is locked
- Send high-priority ntfy notification on login attempt against a locked account
- Auto-reset lockout on expiry; reset counter on successful login
- Add v2.4 migration for failed_login_attempts and locked_until columns
- Add ALLOWED_ORIGINS and SECURE_COOKIES to .env.example
- Update README: lockout row in security table, new ntfy events

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 23:11:30 -07:00
parent 7cd2dfb710
commit 2d3ad3a06c
5 changed files with 89 additions and 10 deletions

View File

@@ -19,6 +19,16 @@ ADMIN_PASSWORD=change_me
# openssl rand -hex 32 # openssl rand -hex 32
JWT_SECRET=change_me JWT_SECRET=change_me
# ── CORS ─────────────────────────────────────────────────────────────────────
# Comma-separated list of external origins that may call the API.
# Leave empty if the API is only accessed via the bundled nginx frontend (same-origin).
# Example: ALLOWED_ORIGINS=https://myapp.example.com,https://admin.example.com
ALLOWED_ORIGINS=
# ── Cookies ───────────────────────────────────────────────────────────────────
# Set to false only when testing locally over plain HTTP (no HTTPS/NRP)
SECURE_COOKIES=true
# ── Ntfy push notifications (optional) ─────────────────────────────────────── # ── Ntfy push notifications (optional) ───────────────────────────────────────
# Sends alerts for: new registrations, admin logins, user disable/delete, impersonation. # Sends alerts for: new registrations, admin logins, user disable/delete, impersonation.
# Use https://ntfy.sh/your-secret-topic or a self-hosted ntfy URL. # Use https://ntfy.sh/your-secret-topic or a self-hosted ntfy URL.

View File

@@ -104,6 +104,8 @@ Yolkbook can send alerts via [ntfy](https://ntfy.sh) for security-relevant event
|-------|----------| |-------|----------|
| New user registered | default | | New user registered | default |
| Admin login | high | | Admin login | high |
| Account locked after failed attempts | urgent |
| Login attempt on locked account | high |
| User disabled | high | | User disabled | high |
| User deleted | urgent | | User deleted | urgent |
| Admin impersonation started | high | | Admin impersonation started | high |
@@ -125,7 +127,8 @@ The gear icon (⚙) in the top-right nav opens the Settings panel:
| CSRF protection | SameSite=Strict cookie prevents cross-site request forgery without explicit tokens | | CSRF protection | SameSite=Strict cookie prevents cross-site request forgery without explicit tokens |
| Password hashing | bcrypt | | Password hashing | bcrypt |
| CORS | Locked to same origin by default; configurable via `ALLOWED_ORIGINS` | | CORS | Locked to same origin by default; configurable via `ALLOWED_ORIGINS` |
| Rate limiting | Login: 5 req/min · Register: 3 req/min · Admin endpoints: 10 req/min | | Rate limiting | Login: 5 req/min · Register: 3 req/min · Admin endpoints: 10 req/min (nginx) |
| Login lockout | Account locked for 15 minutes after 5 consecutive failed attempts |
| Security headers | X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Content-Security-Policy | | Security headers | X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Content-Security-Policy |
| Subresource Integrity | Chart.js CDN script pinned with SHA-384 hash | | Subresource Integrity | Chart.js CDN script pinned with SHA-384 hash |
| Input validation | Server-side via Pydantic; all user-rendered content HTML-escaped | | Input validation | Server-side via Pydantic; all user-rendered content HTML-escaped |

View File

@@ -100,6 +100,17 @@ def _run_migrations():
except Exception: except Exception:
db.rollback() # constraint already exists — safe to ignore db.rollback() # constraint already exists — safe to ignore
# v2.4 — login lockout columns
for sql in [
"ALTER TABLE users ADD COLUMN failed_login_attempts INT NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN locked_until DATETIME NULL",
]:
try:
db.execute(text(sql))
db.commit()
except Exception:
db.rollback() # column already exists — safe to ignore
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):

View File

@@ -13,6 +13,8 @@ class User(Base):
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_disabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_disabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default='UTC') timezone: Mapped[str] = mapped_column(String(64), nullable=False, default='UTC')
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

View File

@@ -1,3 +1,5 @@
import math
from datetime import datetime, timezone, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
@@ -15,6 +17,9 @@ from auth import (
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
_LOGIN_MAX_ATTEMPTS = 5
_LOGIN_LOCKOUT_MINUTES = 15
def _issue(response: Response, user: User, admin_id=None) -> AuthResponse: def _issue(response: Response, user: User, admin_id=None) -> AuthResponse:
token = create_access_token(user.id, user.username, user.is_admin, user.timezone, admin_id=admin_id) token = create_access_token(user.id, user.username, user.is_admin, user.timezone, admin_id=admin_id)
@@ -24,20 +29,68 @@ 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, request: Request, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): def login(body: LoginRequest, response: Response, request: Request, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown")
ua = request.headers.get("User-Agent", "unknown")
user = db.scalars(select(User).where(User.username == body.username)).first() user = db.scalars(select(User).where(User.username == body.username)).first()
# Check lockout before verifying password (don't reveal whether account exists)
if user and user.locked_until:
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user.locked_until > now:
remaining = math.ceil((user.locked_until - now).total_seconds() / 60)
background_tasks.add_task(
notify,
title="Yolkbook Login Attempt on Locked Account",
message=f"User: {user.username}\nLocked for {remaining} more minute(s)\nIP: {ip}\nUA: {ua}",
priority="high",
tags=["lock"],
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Account is temporarily locked. Try again in {remaining} minute(s).",
)
else:
# Lockout expired — reset counters
user.failed_login_attempts = 0
user.locked_until = None
db.commit()
if not user or not verify_password(body.password, user.hashed_password): if not user or not verify_password(body.password, user.hashed_password):
if user:
user.failed_login_attempts += 1
if user.failed_login_attempts >= _LOGIN_MAX_ATTEMPTS:
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=_LOGIN_LOCKOUT_MINUTES)
db.commit()
background_tasks.add_task(
notify,
title="Yolkbook Account Locked",
message=f"User: {user.username}\nLocked after {user.failed_login_attempts} failed attempts\nIP: {ip}\nUA: {ua}",
priority="urgent",
tags=["warning"],
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Account locked after too many failed attempts. Try again in {_LOGIN_LOCKOUT_MINUTES} minutes.",
)
db.commit()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password", detail="Invalid username or password",
) )
if user.is_disabled: if user.is_disabled:
raise HTTPException( raise HTTPException(
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.",
) )
# Successful login — reset failure counters
user.failed_login_attempts = 0
user.locked_until = None
db.commit()
if user.is_admin: 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( background_tasks.add_task(
notify, notify,
title="Yolkbook Admin Login", title="Yolkbook Admin Login",