agnes-the-ai-analyst/app/auth/router.py
ZdenekSrotyr 5f6bb7a4b2
fix(security+ops) + release(0.12.1): #82 #85 #87 hardening + cut 0.12.1 (#104)
* fix(security+ops): #82 #85 #87 — auth hardening, API validation, deploy posture

Security and operational hardening across three issue groups:

- M23: docker-compose.override.yml → docker-compose.dev.yml (BREAKING, prod foot-gun)
- C13: Container runs as non-root user 'agnes' (USER directive in Dockerfile)
- M21: Docker resource limits (mem_limit, cpus) on app + scheduler
- M22: Caddyfile security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, -Server)
- M17: /api/health split into minimal (unauth) + /api/health/detailed (auth) (BREAKING)
- M26: release.yml restricts build-and-push to main + workflow_dispatch; paths-ignore for docs

- C2: table_id traversal validation on /api/data/{table_id}/download
- M4: Upload streaming (chunk-read + temp file) instead of full-buffer; /local-md hashed filename

- C5: reset_token removed from POST /api/users/{id}/reset-password response
- C8: Startup WARNING when no user has password_hash (bootstrap window visible)
- M9: Audit log on failed web form login (mirrors /auth/token endpoint)
- M10: Atomic magic-link consume via compare-and-swap (CONSUMED: marker + DuckDB conflict catch)

Also: SSRF protection on /api/admin/configure (#46), memory stats SQL aggregation (#90)

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(review): SSRF 169.254.x.x + IPv6 multicast; M10 marker cleanup safety

Review fixes:
- Add 169.254.0.0/16 (link-local, cloud metadata) to SSRF regex — was
  missing, allowing requests to AWS/GCP/Azure metadata endpoints
- Add ff[0-9a-f]{2}: (IPv6 multicast) to SSRF regex
- M10: wrap Step 3 (CONSUMED marker cleanup) in try-except with
  warning log — prevents unhandled exception if DB write fails after
  successful token consumption
- Add test for 169.254.169.254 SSRF rejection

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(review): SSRF IPv6 bypass, CLI health endpoint, upload FD leak

Address Devin Review findings on PR #104:

1. SSRF IPv6 bypass: Replace hostname regex with DNS resolution +
   ipaddress module checks. The old regex patterns like `fe80:` only
   matched up to the first colon, missing real IPv6 addresses like
   `fe80::1`, `fc00::1`, `ff02::1`. The new approach resolves the
   hostname via getaddrinfo and checks each resulting IP against
   ipaddress.is_private/is_loopback/is_link_local/is_reserved/is_multicast.

2. CLI commands broken: `da setup test-connection`, `da setup verify`,
   `da diagnose`, `da status` all called /api/health expecting the old
   format (status=="healthy", services dict). Now they call
   /api/health/detailed for service-level checks (with graceful fallback
   to the minimal endpoint when auth is not configured).

3. Temp file handle leak: _stream_to_temp returns an open
   NamedTemporaryFile; callers now close it before shutil.move() to
   prevent FD leaks until GC.

Also adds IPv6 SSRF test cases (loopback, link-local, unique-local,
multicast) with mocked DNS resolution for test environment independence.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(review): download regex blocks hyphenated IDs; document health split

Address Devin Review round-3 findings on PR #104:

1. _SAFE_IDENTIFIER regex blocked hyphenated table IDs: The download
   endpoint used the strict SQL-identifier regex which does not allow
   dots or hyphens, but Keboola table IDs like in.c-crm.orders
   contain both. Switched to _SAFE_QUOTED_IDENTIFIER which allows dots
   and hyphens while still blocking path-traversal chars (/, .., \)
   and quote/control characters. Added test for hyphenated/dotted IDs.

2. Documented health endpoint split in DEPLOYMENT.md: Added Health
   checks & external monitoring section explaining both endpoints
   (minimal unauth /api/health vs authenticated /api/health/detailed)
   and how to wire external monitoring tools to the detailed endpoint
   with a PAT.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* release(0.12.1): cut hotfix for snapshot integrity + #82/#85/#87 hardening

* fix(security): apply CAS pattern to password reset confirm (#82/M10 follow-up)

Devin review on the rebased PR flagged the asymmetry: magic-link verify
got the atomic compare-and-swap pattern in the original M10 fix, but
password reset confirm at /auth/password/reset/confirm was still using
read-validate-clear. Two concurrent POSTs with the same valid reset
token could both succeed in setting different new passwords (last-write-
wins). Lower severity than the magic-link race because the attacker
would need the reset token AND to race the legitimate user, but the
asymmetry was a polish gap.

Mirrors app/auth/providers/email.py::_consume_token CAS exactly: write
unique CONSUMED:<random> marker via UPDATE...WHERE token=old_token, then
SELECT to verify our marker won, then proceed. Only the winner clears
the marker and applies the password change.

New regression test_concurrent_reset_only_one_wins in
tests/test_password_flows.py::TestResetConfirm pins the contract: two
ThreadPoolExecutor workers + Barrier hit /reset/confirm with the same
token; exactly one gets 302 (password applied), the other gets 200 with
'Invalid or expired'. Sanity-checked against the pre-CAS code — both
POSTs got 302 (race confirmed).

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-04-28 19:57:30 +02:00

178 lines
5.9 KiB
Python

"""Auth endpoints — login, token generation, bootstrap."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import duckdb
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.auth.jwt import create_access_token
from app.auth.access import is_user_admin
from app.auth.dependencies import _get_db
from src.db import SYSTEM_ADMIN_GROUP
from src.repositories.users import UserRepository
from src.repositories.user_group_members import UserGroupMembersRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
class TokenRequest(BaseModel):
email: str
password: str = ""
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
email: str
role: str
class BootstrapRequest(BaseModel):
email: str
name: str = ""
password: str = ""
def _audit(user_id: str, action: str, result: str | None = None) -> None:
"""Fire-and-forget audit log entry. Swallows all errors."""
try:
from src.db import get_system_db
from src.repositories.audit import AuditRepository
audit_conn = get_system_db()
AuditRepository(audit_conn).log(
user_id=user_id,
action=action,
resource="auth",
result=result,
)
audit_conn.close()
except Exception:
pass # Audit failure must not block auth
@router.post("/token", response_model=TokenResponse)
async def create_token(
request: TokenRequest,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Issue a JWT token. Requires password authentication."""
repo = UserRepository(conn)
user = repo.get_by_email(request.email)
if not user:
raise HTTPException(status_code=401, detail="User not found")
if not bool(user.get("active", True)):
_audit(user["id"], "login_failed", result="deactivated")
raise HTTPException(status_code=401, detail="Account deactivated")
# If user has password_hash, require and verify it
if user.get("password_hash"):
if not request.password:
raise HTTPException(status_code=401, detail="Password required")
try:
ph = PasswordHasher()
ph.verify(user["password_hash"], request.password)
except VerifyMismatchError:
_audit(user["id"], "login_failed", result="invalid_password")
raise HTTPException(status_code=401, detail="Invalid password")
except Exception:
logger.exception("Unexpected error during password verification")
raise HTTPException(status_code=500, detail="Internal server error")
else:
# No password set — must use their auth provider (Google OAuth, magic link)
raise HTTPException(
status_code=401,
detail="This account uses external authentication. Please log in via your configured provider.",
)
role_label = "admin" if is_user_admin(user["id"], conn) else (user.get("role") or "user")
token = create_access_token(
user_id=user["id"],
email=user["email"],
role=role_label,
)
_audit(user["id"], "token_created")
return TokenResponse(
access_token=token,
user_id=user["id"],
email=user["email"],
role=role_label,
)
@router.post("/bootstrap", response_model=TokenResponse)
async def bootstrap(
request: BootstrapRequest,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Bootstrap the first admin account.
Allowed when no user has a password_hash yet. This covers:
(a) No users exist at all.
(b) Only seed users (created by SEED_ADMIN_EMAIL at startup) exist, which
have no password and cannot log in — bootstrap lets the operator
activate them with a password.
If a user with the given email already exists (e.g. as a seed), this
endpoint sets its password_hash (or clears it, if no password was supplied —
useful for OAuth-only flows) and promotes it to admin.
Deactivates as soon as any user has a password_hash.
"""
repo = UserRepository(conn)
existing = repo.list_all()
# Bootstrap is locked once anyone has a password set.
users_with_password = [u for u in existing if u.get("password_hash")]
if users_with_password:
raise HTTPException(
status_code=403,
detail="Bootstrap disabled — a user with a password already exists. Use /auth/password/login.",
)
password_hash = PasswordHasher().hash(request.password) if request.password else None
# If a matching user already exists (e.g. seed), update it; else create fresh.
existing_user = next((u for u in existing if u.get("email") == request.email), None)
if existing_user:
user_id = existing_user["id"]
repo.update(id=user_id, password_hash=password_hash, role="admin")
_audit(user_id, "bootstrap_activated_seed")
else:
user_id = str(uuid.uuid4())
repo.create(
id=user_id,
email=request.email,
name=request.name or request.email.split("@")[0],
role="admin",
password_hash=password_hash,
)
_audit(user_id, "bootstrap_completed")
# Promote the bootstrap user to the Admin system group — replaces the v9
# ``user_role_grants`` write that the old bootstrap path relied on.
admin_group = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP],
).fetchone()
if admin_group:
UserGroupMembersRepository(conn).add_member(
user_id=user_id,
group_id=admin_group[0],
source="system_seed",
added_by="auth.bootstrap",
)
token = create_access_token(user_id=user_id, email=request.email, role="admin")
return TokenResponse(
access_token=token,
user_id=user_id,
email=request.email,
role="admin",
)