P0 security batch: admin-only user mgmt, unified role model, login rate limiting, webhook secret, security headers, pagination caps
- Remove POST /api/auth/register (404); no sign-up UI; users are admin-managed - Add admin-only POST/PATCH/DELETE /api/auth/users (forced canonical roles, self-lockout + reference guards) - Unify role model in app/core/roles.py; reject unknown roles at creation and at login/JWT validation; startup normalizes unambiguous legacy aliases - Login rate limiting ~5 fails/15 min per IP+email -> 429 (in-process, tunable) - WhatsApp webhook requires X-Webhook-Secret; fail-closed when env unset; GET handshake uses constant-time verify token (403 on mismatch) - GET /api/whatsapp/mock-log now requires auth - Security headers middleware: X-Frame-Options DENY, nosniff, CSP on HTML, HSTS behind TLS - Pagination: limit alias for page_size, hard cap enforced, both -> 422
This commit is contained in:
@@ -36,6 +36,13 @@ class Settings(BaseSettings):
|
||||
WHATSAPP_ACCESS_TOKEN: str = ""
|
||||
WHATSAPP_VERIFY_TOKEN: str = ""
|
||||
META_GRAPH_BASE: str = "https://graph.facebook.com/v18.0"
|
||||
# Shared secret for inbound webhook POSTs (header ``X-Webhook-Secret``).
|
||||
# Fail-closed: when unset/empty the webhook rejects every message.
|
||||
WHATSAPP_WEBHOOK_SECRET: str = ""
|
||||
|
||||
# ── Login rate limiting ──────────────────────────────────────────
|
||||
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: int = 5
|
||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = 15 * 60
|
||||
|
||||
# ── Paths ────────────────────────────────────────────────────────
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Dependency-light login rate limiter.
|
||||
|
||||
Brute-force protection for ``POST /api/auth/login``: a sliding window of
|
||||
failed attempts keyed by ``ip|email``. Defaults to ~5 failures / 15 minutes
|
||||
(env-tunable via ``LOGIN_RATE_LIMIT_MAX_ATTEMPTS`` /
|
||||
``LOGIN_RATE_LIMIT_WINDOW_SECONDS``).
|
||||
|
||||
In-process storage is intentional: the app currently runs a single uvicorn
|
||||
worker, and keeping the limiter dependency-light avoids pulling slowapi in
|
||||
for one endpoint. ``_now`` is a module-level hook so tests can fast-forward
|
||||
the clock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
"""Wall-clock epoch seconds; overridable in tests via monkeypatch."""
|
||||
return time.time()
|
||||
|
||||
|
||||
class LoginRateLimiter:
|
||||
"""Sliding-window failure limiter keyed by ``ip|email``."""
|
||||
|
||||
def __init__(self, max_attempts: int = 5, window_seconds: int = 15 * 60) -> None:
|
||||
self.max_attempts = max(max_attempts, 1)
|
||||
self.window_seconds = max(window_seconds, 1)
|
||||
self._failures: defaultdict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def key(self, ip: str, email: str) -> str:
|
||||
return f"{ip}|{email.strip().lower()}"
|
||||
|
||||
def _prune(self, key: str, now: float | None = None) -> None:
|
||||
now = now if now is not None else _now()
|
||||
window_start = now - self.window_seconds
|
||||
bucket = self._failures.get(key)
|
||||
if bucket is None:
|
||||
return
|
||||
while bucket and bucket[0] <= window_start:
|
||||
bucket.popleft()
|
||||
if not bucket:
|
||||
self._failures.pop(key, None)
|
||||
|
||||
def failure_count(self, key: str) -> int:
|
||||
self._prune(key)
|
||||
return len(self._failures.get(key, ()))
|
||||
|
||||
def is_blocked(self, key: str) -> bool:
|
||||
return self.failure_count(key) >= self.max_attempts
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
self._failures[key].append(_now())
|
||||
self._prune(key)
|
||||
|
||||
def clear(self, key: str) -> None:
|
||||
self._failures.pop(key, None)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._failures.clear()
|
||||
|
||||
def check_or_raise(self, key: str) -> None:
|
||||
"""Raise HTTP 429 when the key has exhausted its attempts."""
|
||||
if self.is_blocked(key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many failed login attempts. Try again later.",
|
||||
)
|
||||
|
||||
|
||||
# Shared instance — module import is safe because the app fails closed at
|
||||
# boot (app/core/config.py) before any request can reach the login route.
|
||||
login_rate_limiter = LoginRateLimiter(
|
||||
max_attempts=settings.LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
window_seconds=settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Unified role model — single source of truth for user roles.
|
||||
|
||||
HARDENING (P0 batch): every role string used by seeds, RBAC checks, admin
|
||||
user management, login, and JWT validation derives from this module so the
|
||||
system can never silently drift between role vocabularies.
|
||||
|
||||
Canonical roles are the human-readable taxonomy the whole product already
|
||||
uses (PRD §4, ``app/services/seed.py``, the frontend nav in base.html):
|
||||
|
||||
Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher,
|
||||
Tech, CEO, Director
|
||||
|
||||
Legacy databases created under the pre-P0 open-registration builds can carry
|
||||
lowercase/nickname role strings (``technician``, ``cs``, ``fm``, ``ceo``,
|
||||
``admin``, ``superadmin`` …). ``ROLE_ALIASES`` maps the *unambiguous*
|
||||
nicknames onto a canonical role so startup normalization (see
|
||||
``app/services/seed.py::normalize_legacy_user_roles``) can converge the data.
|
||||
|
||||
``admin`` / ``superadmin`` are deliberately NOT aliased: they are
|
||||
identity-ambiguous (they cannot be attributed to Jerome or Wahab) and were
|
||||
mintable by anyone during the open-registration window, so they are treated
|
||||
as unknown and fail closed — the operator must remediate those rows manually
|
||||
(role cleanup on the live DB is owned by the deployer).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ── Canonical taxonomy ────────────────────────────────────────────────
|
||||
# Order is cosmetic; membership is what matters.
|
||||
CANONICAL_ROLES: tuple[str, ...] = (
|
||||
"Admin/Jerome",
|
||||
"Admin/Wahab",
|
||||
"CS Rep",
|
||||
"CS Manager",
|
||||
"FM Dispatcher",
|
||||
"Tech",
|
||||
"CEO",
|
||||
"Director",
|
||||
)
|
||||
|
||||
# Roles that pass admin gates (ticket DELETE, user management, …).
|
||||
ADMIN_ROLES: tuple[str, ...] = ("Admin/Jerome", "Admin/Wahab")
|
||||
|
||||
# Roles shown to the frontend nav/assignment helpers as "technician" pool.
|
||||
TECHNICIAN_ROLE = "Tech"
|
||||
|
||||
# ── Legacy alias → canonical mapping (case-insensitive) ───────────────
|
||||
# Keys are lowercased. Unambiguous nicknames from legacy/early seeds and the
|
||||
# brief's role model ("technician/cs/fm/ceo") converge onto canonical roles.
|
||||
ROLE_ALIASES: dict[str, str] = {
|
||||
"technician": TECHNICIAN_ROLE,
|
||||
"tech": TECHNICIAN_ROLE,
|
||||
"cs": "CS Rep",
|
||||
"cs rep": "CS Rep",
|
||||
"cs representative": "CS Rep",
|
||||
"cs manager": "CS Manager",
|
||||
"fm": "FM Dispatcher",
|
||||
"fm dispatcher": "FM Dispatcher",
|
||||
"ceo": "CEO",
|
||||
"director": "Director",
|
||||
}
|
||||
|
||||
# Aliases that are explicitly NOT auto-mapped (identity-ambiguous and/or
|
||||
# mintable by the old open register). They stay unknown → denied at login
|
||||
# and JWT validation until an operator remediates the row.
|
||||
_BLOCKED_LEGACY_ROLES = frozenset({"admin", "superadmin", "administrator"})
|
||||
|
||||
|
||||
def normalize_role(role: str | None) -> str | None:
|
||||
"""Return the canonical role for *role*, or ``None`` when unrecognised.
|
||||
|
||||
``Admin/Jerome`` → ``Admin/Jerome``; ``technician`` → ``Tech``;
|
||||
``superadmin`` → ``None`` (unknown; caller must fail closed).
|
||||
"""
|
||||
if not role:
|
||||
return None
|
||||
stripped = role.strip()
|
||||
if stripped in CANONICAL_ROLES:
|
||||
return stripped
|
||||
return ROLE_ALIASES.get(stripped.lower())
|
||||
|
||||
|
||||
def is_known_role(role: str | None) -> bool:
|
||||
"""True when *role* is canonical or maps to a canonical role."""
|
||||
return normalize_role(role) is not None
|
||||
|
||||
|
||||
def is_admin_role(role: str | None) -> bool:
|
||||
"""True when *role* is one of the canonical administrator roles."""
|
||||
return normalize_role(role) in ADMIN_ROLES
|
||||
|
||||
|
||||
def is_blocked_legacy_role(role: str | None) -> bool:
|
||||
"""True for legacy ``admin``/``superadmin`` rows that need remediation.
|
||||
|
||||
Such rows are not canonical, are not auto-mapped, and must not pass any
|
||||
authorization gate; an operator should reassign or remove them.
|
||||
"""
|
||||
return bool(role) and role.strip().lower() in _BLOCKED_LEGACY_ROLES
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.roles import is_known_role
|
||||
from app.models.user import User
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
@@ -90,6 +91,14 @@ async def get_current_user(
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
|
||||
# Unified role model: reject rows whose stored role is not canonical or a
|
||||
# known legacy alias. Legacy junk roles (e.g. lowercase ``admin``) must
|
||||
# fail closed here so they can never ride an access token into the app.
|
||||
if not is_known_role(user.role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Account role is not recognised; contact an administrator",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
+71
-1
@@ -14,7 +14,12 @@ from sqlalchemy import text
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, async_session_factory, engine
|
||||
from app.routers import auth, health, pages, tickets, whatsapp
|
||||
from app.services.seed import seed_categories, seed_units, seed_users
|
||||
from app.services.seed import (
|
||||
normalize_legacy_user_roles,
|
||||
seed_categories,
|
||||
seed_units,
|
||||
seed_users,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,6 +72,9 @@ async def lifespan(app: FastAPI):
|
||||
await ensure_legacy_schema(conn)
|
||||
async with async_session_factory() as session:
|
||||
await seed_users(session)
|
||||
# P0 role-model unification: converge legacy nickname roles (e.g.
|
||||
# ``technician``/``cs``/``fm``) onto the canonical taxonomy at startup.
|
||||
await normalize_legacy_user_roles(session)
|
||||
await session.commit()
|
||||
await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json"))
|
||||
await session.commit()
|
||||
@@ -83,6 +91,68 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# ── Security headers (P0 batch) ──────────────────────────────────────
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Set hardening headers on every HTTP response.
|
||||
|
||||
* ``X-Frame-Options: DENY`` and ``X-Content-Type-Options: nosniff`` on
|
||||
all responses;
|
||||
* CSP on HTML pages (login + app pages; the Alpine.js/Tailwind CDNs need
|
||||
the CDN hosts + inline script/style for this demo);
|
||||
* ``Strict-Transport-Security`` only when TLS terminates (https scheme
|
||||
or ``X-Forwarded-Proto: https`` from the reverse proxy).
|
||||
"""
|
||||
|
||||
HSTS = "max-age=31536000; includeSubDomains"
|
||||
CSP = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdn.tailwindcss.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdn.tailwindcss.com; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"object-src 'none'"
|
||||
)
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
is_tls = scope.get("scheme") == "https"
|
||||
for name, value in scope.get("headers") or []:
|
||||
if name.lower() == b"x-forwarded-proto":
|
||||
first = value.decode("latin-1").split(",", 1)[0].strip().lower()
|
||||
if first == "https":
|
||||
is_tls = True
|
||||
|
||||
async def send_wrapper(message):
|
||||
if message["type"] == "http.response.start":
|
||||
headers = list(message.get("headers") or [])
|
||||
content_type = next(
|
||||
(v for k, v in headers if k.lower() == b"content-type"), b""
|
||||
)
|
||||
if content_type.startswith(b"text/html"):
|
||||
headers.append((b"content-security-policy", self.CSP.encode()))
|
||||
headers.append((b"x-frame-options", b"DENY"))
|
||||
headers.append((b"x-content-type-options", b"nosniff"))
|
||||
if is_tls:
|
||||
headers.append((b"strict-transport-security", self.HSTS.encode()))
|
||||
message["headers"] = headers
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# ── CORS (HARDENING.md P0.2 — explicit origin allow-list, never "*") ──
|
||||
_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||
if "*" in _origins or not _origins:
|
||||
|
||||
+75
-28
@@ -1,20 +1,27 @@
|
||||
"""Authentication router — register, login, refresh, me."""
|
||||
"""Authentication router — login, refresh, me, and admin user management.
|
||||
|
||||
Self-registration was removed (P0 hardening): users are created/managed by
|
||||
admins only via ``POST/PATCH/DELETE /api/auth/users``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.ratelimit import login_rate_limiter
|
||||
from app.core.roles import ADMIN_ROLES
|
||||
from app.core.security import get_current_user, require_roles
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
AdminCreateUserRequest,
|
||||
AdminUpdateUserRequest,
|
||||
LoginRequest,
|
||||
RefreshRequest,
|
||||
RegisterRequest,
|
||||
TokenResponse,
|
||||
UserOut,
|
||||
)
|
||||
@@ -22,36 +29,29 @@ from app.services import auth as auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> list[User]:
|
||||
"""List users (id, name, role) for assignment pickers.
|
||||
|
||||
Previously the frontend hard-coded technician ids/names in detail.html;
|
||||
this endpoint makes the assign dropdown data-driven so a seed change never
|
||||
silently breaks technician assignment.
|
||||
"""
|
||||
result = await db.execute(select(User).order_by(User.full_name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=201)
|
||||
async def register(
|
||||
body: RegisterRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> User:
|
||||
return await auth_service.register(db, body)
|
||||
_require_admin = require_roles(*ADMIN_ROLES)
|
||||
|
||||
|
||||
# ── Public authN ─────────────────────────────────────────────────────
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
request: Request,
|
||||
body: LoginRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> TokenResponse:
|
||||
access, refresh, _user = await auth_service.login(db, body.email, body.password)
|
||||
"""Log in. Brute-force limited to ~5 failures / 15 min per IP+email (429)."""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
key = login_rate_limiter.key(client_ip, body.email)
|
||||
login_rate_limiter.check_or_raise(key)
|
||||
|
||||
try:
|
||||
access, refresh, _user = await auth_service.login(db, body.email, body.password)
|
||||
except HTTPException as exc:
|
||||
# Count only real auth failures toward the limit; success resets it.
|
||||
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
|
||||
login_rate_limiter.record_failure(key)
|
||||
raise
|
||||
login_rate_limiter.clear(key) # successful login resets the failure window
|
||||
return TokenResponse(access_token=access, refresh_token=refresh)
|
||||
|
||||
|
||||
@@ -71,7 +71,54 @@ async def me(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
|
||||
@router.get("/admin-only", response_model=UserOut)
|
||||
async def admin_only(
|
||||
current_user: Annotated[User, Depends(require_roles("Admin/Jerome", "Admin/Wahab"))],
|
||||
current_user: Annotated[User, Depends(_require_admin)],
|
||||
) -> User:
|
||||
"""Example RBAC-protected endpoint — only Admins can access."""
|
||||
"""Example RBAC-protected endpoint — only canonical Admins can access."""
|
||||
return current_user
|
||||
|
||||
|
||||
# ── User directory ───────────────────────────────────────────────────
|
||||
@router.get("/users", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> list[User]:
|
||||
"""List users (id, name, role) for authenticated assignment pickers."""
|
||||
result = await db.execute(select(User).order_by(User.full_name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ── Admin user management ────────────────────────────────────────────
|
||||
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: AdminCreateUserRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(_require_admin)],
|
||||
) -> User:
|
||||
"""Admin-only: create a user with a forced canonical role.
|
||||
|
||||
The client cannot self-register or pick an arbitrary role — unknown roles
|
||||
(e.g. ``admin``, ``superadmin``) are rejected with 422.
|
||||
"""
|
||||
return await auth_service.create_user(db, body)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserOut)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
body: AdminUpdateUserRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(_require_admin)],
|
||||
) -> User:
|
||||
"""Admin-only: change a user's role and/or deactivate the account."""
|
||||
return await auth_service.update_user(db, current_user, user_id, body)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(_require_admin)],
|
||||
) -> None:
|
||||
"""Admin-only: delete a user account (guarded; see auth_service)."""
|
||||
await auth_service.delete_user(db, current_user, user_id)
|
||||
|
||||
+26
-5
@@ -13,6 +13,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.roles import ADMIN_ROLES
|
||||
from app.core.security import get_current_user, require_roles
|
||||
from app.models.category import Category
|
||||
from app.models.ticket import Ticket, TicketPhoto
|
||||
@@ -37,6 +38,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
||||
|
||||
_require_admin = require_roles(*ADMIN_ROLES)
|
||||
|
||||
# Pagination contract: sane defaults and a hard page-size cap so list
|
||||
# responses never balloon into truncation territory (P0 batch).
|
||||
DEFAULT_PAGE_SIZE = 50
|
||||
MAX_PAGE_SIZE = 200
|
||||
|
||||
# Ensure uploads directory exists
|
||||
UPLOADS_DIR = settings.BASE_DIR / "uploads"
|
||||
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -197,7 +205,10 @@ async def create_ticket(
|
||||
async def list_tickets(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
page_size: int | None = Query(None, ge=1, le=MAX_PAGE_SIZE),
|
||||
limit: int | None = Query(
|
||||
None, ge=1, le=MAX_PAGE_SIZE, description="Alias for page_size (also capped)"
|
||||
),
|
||||
status: str | None = Query(None),
|
||||
priority: str | None = Query(None),
|
||||
property: str | None = Query(None),
|
||||
@@ -208,7 +219,17 @@ async def list_tickets(
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
) -> TicketListResponse:
|
||||
"""List tickets with optional filtering and pagination."""
|
||||
"""List tickets with optional filtering and pagination.
|
||||
|
||||
Both ``page_size`` and its alias ``limit`` are capped at MAX_PAGE_SIZE;
|
||||
supplying both is an error. Neither defaults to DEFAULT_PAGE_SIZE.
|
||||
"""
|
||||
if page_size is not None and limit is not None and page_size != limit:
|
||||
raise HTTPException(
|
||||
status_code=422, # noqa: PLR2004 — param ``status`` shadows fastapi.status here
|
||||
detail="Provide either 'page_size' or 'limit', not both",
|
||||
)
|
||||
effective_page_size = page_size if page_size is not None else (limit or DEFAULT_PAGE_SIZE)
|
||||
tickets, total = await ticket_service.list_tickets(
|
||||
db,
|
||||
status_filter=status,
|
||||
@@ -221,10 +242,10 @@ async def list_tickets(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
page_size=effective_page_size,
|
||||
)
|
||||
items = [TicketBrief.model_validate(t) for t in tickets]
|
||||
return TicketListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
return TicketListResponse(items=items, total=total, page=page, page_size=effective_page_size)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/transitions")
|
||||
@@ -270,7 +291,7 @@ async def update_ticket(
|
||||
async def delete_ticket(
|
||||
ticket_id: int,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_roles("Admin/Jerome", "Admin/Wahab"))],
|
||||
current_user: Annotated[User, Depends(_require_admin)],
|
||||
) -> None:
|
||||
"""Delete a ticket and its children (timeline, photos, escalations).
|
||||
|
||||
|
||||
+75
-20
@@ -1,18 +1,31 @@
|
||||
"""WhatsApp webhook handler — Meta Graph API integration."""
|
||||
"""WhatsApp webhook handler — Meta Graph API integration.
|
||||
|
||||
P0 hardening:
|
||||
* ``POST /api/whatsapp/webhook`` requires the ``X-Webhook-Secret`` header to
|
||||
match ``WHATSAPP_WEBHOOK_SECRET``. Fail-closed: when the env var is unset
|
||||
every message is rejected (same posture as the SECRET_KEY guard).
|
||||
* ``GET /api/whatsapp/webhook`` (Meta handshake) validates ``hub.verify_token``
|
||||
with a constant-time compare and returns 403 on mismatch.
|
||||
* ``GET /api/whatsapp/mock-log`` now requires authentication (was a public
|
||||
debug endpoint that could 500 and leak stack traces without auth).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.whatsapp_log import WhatsAppLog
|
||||
from app.schemas.whatsapp import (
|
||||
MetaWebhookRequest,
|
||||
@@ -62,24 +75,55 @@ async def send_whatsapp_reply(
|
||||
return WhatsAppReplyResponse(success=False, message=str(exc))
|
||||
|
||||
|
||||
# ── Webhook auth (fail-closed) ──────────────────────────────────────
|
||||
async def require_webhook_secret(
|
||||
x_webhook_secret: Annotated[str | None, Header(alias="X-Webhook-Secret")] = None,
|
||||
) -> None:
|
||||
"""Reject webhook messages unless X-Webhook-Secret matches the env secret.
|
||||
|
||||
Reads ``settings.WHATSAPP_WEBHOOK_SECRET`` at request time so the value
|
||||
can be injected per-deployment. Empty/unset env ⇒ reject everything.
|
||||
"""
|
||||
expected = settings.WHATSAPP_WEBHOOK_SECRET
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Webhook disabled: WHATSAPP_WEBHOOK_SECRET is not configured",
|
||||
)
|
||||
if not x_webhook_secret or not secrets.compare_digest(x_webhook_secret, expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid webhook secret",
|
||||
)
|
||||
|
||||
|
||||
# ── Webhook endpoint ────────────────────────────────────────────────
|
||||
@router.post("/webhook")
|
||||
async def whatsapp_webhook(
|
||||
@router.get("/webhook")
|
||||
async def whatsapp_webhook_verify(
|
||||
mode: str | None = Query(None, alias="hub.mode"),
|
||||
verify_token: str | None = Query(None, alias="hub.verify_token"),
|
||||
challenge: str | None = Query(None, alias="hub.challenge"),
|
||||
) -> WebhookVerificationResponse | dict:
|
||||
"""Meta webhook handshake (GET): echo the challenge when the token matches."""
|
||||
if mode != "subscribe" or not challenge:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing hub.mode / hub.challenge",
|
||||
)
|
||||
expected = settings.WHATSAPP_VERIFY_TOKEN
|
||||
if not expected or not secrets.compare_digest(verify_token or "", expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Verify token mismatch",
|
||||
)
|
||||
return WebhookVerificationResponse(challenge=challenge)
|
||||
|
||||
|
||||
async def _process_entries(
|
||||
body: MetaWebhookRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
|
||||
mode: str | None = Query(None),
|
||||
hub_challenge: str | None = Query(None),
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""Handle incoming WhatsApp webhook from Meta."""
|
||||
|
||||
# ── Verification GET request (Meta sends this on webhook setup) ──
|
||||
if mode and hub_challenge:
|
||||
if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
|
||||
return {"error": "Verify token mismatch"}
|
||||
return WebhookVerificationResponse(challenge=hub_challenge).model_dump()
|
||||
|
||||
# ── Process inbound messages ────────────────────────────────────
|
||||
"""Create tickets + logs for inbound messages. Shared by the POST handler."""
|
||||
if not body.entry:
|
||||
return {"status": "no entry"}
|
||||
|
||||
@@ -153,13 +197,24 @@ async def whatsapp_webhook(
|
||||
return {"status": "no messages"}
|
||||
|
||||
|
||||
# ── Legacy debug endpoint ──────────────────────────────────────────
|
||||
@router.post("/webhook")
|
||||
async def whatsapp_webhook(
|
||||
body: MetaWebhookRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_auth: None = Depends(require_webhook_secret),
|
||||
) -> dict:
|
||||
"""Process an inbound WhatsApp message (Meta POST). Requires webhook secret."""
|
||||
return await _process_entries(body, db)
|
||||
|
||||
|
||||
# ── Debug endpoint (auth required) ──────────────────────────────────
|
||||
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
|
||||
async def mock_whatsapp_log(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
limit: int = 50,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
) -> list[MockWhatsAppLogEntry]:
|
||||
"""Return recent WhatsApp webhook submissions for debugging."""
|
||||
"""Return recent WhatsApp webhook submissions (authenticated only)."""
|
||||
result = await db.execute(
|
||||
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
|
||||
)
|
||||
|
||||
+66
-10
@@ -2,18 +2,27 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from app.core.roles import CANONICAL_ROLES
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
full_name: str
|
||||
phone: str | None = None
|
||||
# HARDENING.md P0.3: role is NOT client-controllable. Self-registration
|
||||
# always creates the least-privilege role; privileged roles are assigned
|
||||
# by an admin directly in the DB (or a future admin-gated endpoint).
|
||||
role: str = "CS Rep" # kept for backward compat; ignored by the service
|
||||
def _validate_canonical_role(value: str | None) -> str | None:
|
||||
"""Reject any role that is not part of the unified canonical taxonomy.
|
||||
|
||||
The role vocabulary is closed: admin user-management must only ever mint
|
||||
canonical roles (see app/core/roles.py). Legacy/unknown strings
|
||||
(``admin``, ``superadmin``, ``technician``, …) are rejected here so junk
|
||||
roles can never be (re)created through the API.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
role = value.strip()
|
||||
if role not in CANONICAL_ROLES:
|
||||
raise ValueError(
|
||||
f"Unknown role '{value}'. Allowed roles: {', '.join(CANONICAL_ROLES)}"
|
||||
)
|
||||
return role
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -40,3 +49,50 @@ class UserOut(BaseModel):
|
||||
active: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Admin user management (self-registration is removed) ──────────────
|
||||
class AdminCreateUserRequest(BaseModel):
|
||||
"""Admin-created user. The role is mandatory and must be canonical.
|
||||
|
||||
``role`` is deliberately NOT optional and has no default — an admin must
|
||||
state the intended role explicitly; the server never infers one.
|
||||
"""
|
||||
|
||||
email: str = Field(min_length=1)
|
||||
password: str = Field(min_length=8, description="Minimum 8 characters")
|
||||
full_name: str = Field(min_length=1)
|
||||
phone: str | None = None
|
||||
role: str
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def _role_canonical(cls, value: str) -> str:
|
||||
return _validate_canonical_role(value) # type: ignore[return-value]
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def _lower_email(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
class AdminUpdateUserRequest(BaseModel):
|
||||
"""Admin edits to an existing user: role change and/or deactivation.
|
||||
|
||||
At least one field must be present. ``active=False`` deactivates the
|
||||
account (login and token refresh then fail closed).
|
||||
"""
|
||||
|
||||
role: str | None = None
|
||||
active: bool | None = None
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def _role_canonical(cls, value: str | None) -> str | None:
|
||||
return _validate_canonical_role(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _at_least_one_field(self) -> "AdminUpdateUserRequest":
|
||||
if self.role is None and self.active is None:
|
||||
raise ValueError("Provide at least one of 'role' or 'active'")
|
||||
return self
|
||||
|
||||
+139
-38
@@ -1,11 +1,16 @@
|
||||
"""Authentication service — register, login, refresh."""
|
||||
"""Authentication service — login, refresh, and admin user management.
|
||||
|
||||
Self-registration was removed (HARDENING/P0 batch): users are created and
|
||||
managed exclusively by admins through the admin user-management endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.roles import is_known_role, normalize_role
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
@@ -13,47 +18,31 @@ from app.core.security import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.ticket import Escalation, Ticket, TicketTimeline
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import RegisterRequest
|
||||
from app.schemas.auth import AdminCreateUserRequest, AdminUpdateUserRequest
|
||||
|
||||
# HARDENING.md P0.3 — least-privilege default for self-registered users.
|
||||
_SELF_REGISTER_ROLE = "CS Rep"
|
||||
|
||||
|
||||
async def register(db: AsyncSession, body: RegisterRequest) -> User:
|
||||
"""Create a new user. Raises 409 if email already exists.
|
||||
|
||||
HARDENING.md P0.3: unauthenticated self-registration must never mint a
|
||||
privileged role. The client-supplied ``role`` field is IGNORED — new
|
||||
self-registered users always land on the least-privilege role.
|
||||
Admins assign elevated roles directly (DB seed / admin endpoint).
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
|
||||
user = User(
|
||||
email=body.email,
|
||||
password_hash=hash_password(body.password),
|
||||
full_name=body.full_name,
|
||||
phone=body.phone,
|
||||
role=_SELF_REGISTER_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
_UNAUTHORIZED = status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
# ── AuthN ────────────────────────────────────────────────────────────
|
||||
async def login(db: AsyncSession, email: str, password: str) -> tuple[str, str, User]:
|
||||
"""Authenticate and return (access_token, refresh_token, user)."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
"""Authenticate and return (access_token, refresh_token, user).
|
||||
|
||||
Fails closed (401) for bad credentials, inactive accounts, and any user
|
||||
whose stored role is not part of the unified role model.
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.email == email.strip().lower()))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
|
||||
raise HTTPException(status_code=_UNAUTHORIZED, detail="Invalid email or password")
|
||||
if not user.active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
|
||||
|
||||
raise HTTPException(status_code=_UNAUTHORIZED, detail="Account is inactive")
|
||||
if not is_known_role(user.role):
|
||||
raise HTTPException(
|
||||
status_code=_UNAUTHORIZED,
|
||||
detail="Account role is not recognised; contact an administrator",
|
||||
)
|
||||
access_token = create_access_token({"sub": str(user.id)})
|
||||
refresh_token = create_refresh_token({"sub": str(user.id)})
|
||||
return access_token, refresh_token, user
|
||||
@@ -64,18 +53,130 @@ async def refresh_access_token(db: AsyncSession, token: str) -> tuple[str, str]:
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||
raise HTTPException(status_code=_UNAUTHORIZED, detail="Invalid token type")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
|
||||
raise HTTPException(status_code=_UNAUTHORIZED, detail="Invalid refresh token")
|
||||
|
||||
user_id: int = int(payload["sub"])
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
|
||||
raise HTTPException(status_code=_UNAUTHORIZED, detail="User not found or inactive")
|
||||
if not is_known_role(user.role):
|
||||
raise HTTPException(
|
||||
status_code=_UNAUTHORIZED,
|
||||
detail="Account role is not recognised; contact an administrator",
|
||||
)
|
||||
|
||||
new_access = create_access_token({"sub": str(user.id)})
|
||||
new_refresh = create_refresh_token({"sub": str(user.id)})
|
||||
return new_access, new_refresh
|
||||
|
||||
|
||||
# ── Admin user management ────────────────────────────────────────────
|
||||
async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
async def create_user(db: AsyncSession, body: AdminCreateUserRequest) -> User:
|
||||
"""Admin-created user with an explicit, canonical role. 409 on duplicate email."""
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
|
||||
# Defense in depth: schema already guarantees a canonical role.
|
||||
role = normalize_role(body.role)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown role")
|
||||
|
||||
user = User(
|
||||
email=body.email,
|
||||
password_hash=hash_password(body.password),
|
||||
full_name=body.full_name.strip(),
|
||||
phone=body.phone,
|
||||
role=role,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def update_user(
|
||||
db: AsyncSession,
|
||||
actor: User,
|
||||
user_id: int,
|
||||
body: AdminUpdateUserRequest,
|
||||
) -> User:
|
||||
"""Admin role-change / deactivation for an existing user.
|
||||
|
||||
Guards:
|
||||
* an admin cannot modify their own account through the API (self-lockout);
|
||||
* role changes are limited to the canonical taxonomy.
|
||||
|
||||
The ≥1-active-admin invariant holds structurally: only admins can demote
|
||||
admins, and no admin can demote/deactivate themselves, so at least one
|
||||
canonical admin always remains.
|
||||
"""
|
||||
user = await _get_user_or_404(db, user_id)
|
||||
|
||||
if actor.id == user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Admins cannot change their own role or active state through the API",
|
||||
)
|
||||
|
||||
new_role = normalize_role(body.role) if body.role is not None else None
|
||||
new_active = body.active
|
||||
|
||||
if new_role is not None:
|
||||
user.role = new_role
|
||||
if new_active is not None:
|
||||
user.active = new_active
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, actor: User, user_id: int) -> None:
|
||||
"""Admin deletes a user account (hard delete).
|
||||
|
||||
Guards:
|
||||
* an admin cannot delete their own account (self-guard also keeps the
|
||||
≥1-active-admin invariant: admins can never remove themselves);
|
||||
* users referenced by tickets / timeline / escalations are kept (409) so
|
||||
historical data never dangles — reassign or deactivate instead.
|
||||
"""
|
||||
user = await _get_user_or_404(db, user_id)
|
||||
|
||||
if actor.id == user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Admins cannot delete their own account through the API",
|
||||
)
|
||||
|
||||
referenced = False
|
||||
for clause in (
|
||||
select(func.count(Ticket.id)).where(Ticket.assigned_to == user_id),
|
||||
select(func.count(TicketTimeline.id)).where(TicketTimeline.user_id == user_id),
|
||||
select(func.count(Escalation.id)).where(Escalation.escalated_to == user_id),
|
||||
):
|
||||
count = (await db.execute(clause)).scalar() or 0
|
||||
if count:
|
||||
referenced = True
|
||||
break
|
||||
if referenced:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User has related tickets, timeline entries, or escalations; "
|
||||
"reassign or deactivate instead of deleting",
|
||||
)
|
||||
|
||||
await db.delete(user)
|
||||
await db.flush()
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.core.roles import is_blocked_legacy_role, normalize_role
|
||||
from app.models.unit import Unit
|
||||
from app.models.user import User
|
||||
from app.models.category import Category
|
||||
@@ -41,6 +42,37 @@ SEED_USERS_DATA = [
|
||||
]
|
||||
|
||||
|
||||
async def normalize_legacy_user_roles(db: AsyncSession) -> int:
|
||||
"""Converge legacy role strings onto the unified canonical taxonomy.
|
||||
|
||||
Databases built before the P0 role-model batch can hold nickname roles
|
||||
(``technician``, ``cs``, ``fm``, ``ceo`` …) minted by the old open
|
||||
self-registration. Unambiguous aliases are rewritten to their canonical
|
||||
role so RBAC keeps working. Ambiguous/unknown roles (e.g. lowercase
|
||||
``admin``/``superadmin``) are NOT auto-mapped — they fail closed at login
|
||||
and JWT validation until an operator remediates the row.
|
||||
|
||||
Returns the number of rows rewritten. Idempotent.
|
||||
"""
|
||||
result = await db.execute(select(User))
|
||||
changed = 0
|
||||
for user in result.scalars().all():
|
||||
canonical = normalize_role(user.role)
|
||||
if canonical and canonical != user.role:
|
||||
logger.info("Normalizing legacy role %r → %r for %s", user.role, canonical, user.email)
|
||||
user.role = canonical
|
||||
changed += 1
|
||||
elif canonical is None and not is_blocked_legacy_role(user.role):
|
||||
logger.warning(
|
||||
"User %s has unrecognized role %r; login will be denied until fixed",
|
||||
user.email,
|
||||
user.role,
|
||||
)
|
||||
if changed:
|
||||
await db.flush()
|
||||
return changed
|
||||
|
||||
|
||||
async def seed_users(db: AsyncSession, default_password: str = "denya123") -> list[User]:
|
||||
"""Insert seed users if they don't already exist."""
|
||||
hashed = hash_password(default_password)
|
||||
|
||||
Reference in New Issue
Block a user