Files
Ten31-Portal/backend/ten31portal/routers/auth_router.py
T
Jonathan KirkwoodandClaude Opus 4.8 69f12b0519 0.2.26: security hardening from audit (P2/P3 fixes)
Address the security-auditor findings the user selected (items 2, 3, 4):

Default admin credentials (P2): remove the fixed `Ten31` default. First
boot now generates a strong random admin password (secrets.token_urlsafe),
records it 0600 at /data/.admin-password, and surfaces it once via a new
"Show Initial Admin Password" StartOS action (CLI `show-admin-password`).
The stored password is cleared when the admin is reset (CLI reset-password)
or self-changes it (change-password endpoint).

Login hardening (P2): add a per-IP in-memory sliding-window rate limiter
(10 failures / 5 min -> 429 + Retry-After) in ratelimit.py; run a dummy
argon2 verify when the user is unknown so timing can't enumerate usernames;
keep a single generic 401 for unknown-user and wrong-password.

Hardening (P3): server process now runs unprivileged -- Dockerfile adds
uid 10001 appuser; start.sh (still root) chowns the mounted /data then
drops via `setpriv` before exec'ing uvicorn. Spreadsheet imports are
size-capped via storage.read_capped (413 past MAX_UPLOAD_SIZE) in the
schedule, capital preview, and batch paths. batch_import no longer returns
raw exception text (generic per-file messages).

Verified in the packed amd64 container: PID1 uvicorn runs as uid 10001,
/data owned 10001 with 0600 secrets; generated admin password retrievable
via CLI and logs in (200); 11th bad login -> 429; admin reset clears the
stored password. Tests: test_auth_hardening.py (4). Full suite 21 passed;
frontend tsc + StartOS bundle clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:09:27 -05:00

102 lines
4.1 KiB
Python

"""Authentication endpoints."""
import os
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
from ten31portal import config
from ten31portal.auth import get_current_user, hash_password, verify_password
from ten31portal.database import get_session
from ten31portal.models import User
from ten31portal.ratelimit import SlidingWindowLimiter
from ten31portal.schemas import ChangePasswordRequest, LoginRequest, UserResponse
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Throttle password guessing per client IP: at most 10 failed attempts per 5 minutes.
_login_limiter = SlidingWindowLimiter(max_attempts=10, window_seconds=300)
# A throwaway hash verified when no user matches, so a missing account costs the same argon2
# time as a real one — otherwise the timing difference reveals which usernames exist.
_DUMMY_HASH = hash_password(secrets.token_urlsafe(16))
@router.post("/login")
def login(
body: LoginRequest,
request: Request,
session: Session = Depends(get_session),
) -> UserResponse:
client_ip = request.client.host if request.client else "unknown"
retry_after = _login_limiter.retry_after(client_ip)
if retry_after > 0:
raise HTTPException(
status_code=429,
detail="Too many login attempts. Please wait a moment and try again.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Accept either a username or an email in the login field.
handle = body.login.strip()
user = session.exec(select(User).where(User.username == handle)).first()
if user is None:
user = session.exec(select(User).where(User.email == handle)).first()
# Always run a verify (dummy hash when the user is unknown) so success/failure take the
# same time, and give one generic message so neither branch leaks whether the user exists.
if user is None:
verify_password(body.password, _DUMMY_HASH)
_login_limiter.record_failure(client_ip)
raise HTTPException(status_code=401, detail="Invalid username or password")
if not verify_password(body.password, user.password_hash):
_login_limiter.record_failure(client_ip)
raise HTTPException(status_code=401, detail="Invalid username or password")
# Past this point the password was correct, so these messages don't aid guessing.
if user.primary_account_id is not None:
raise HTTPException(
status_code=401,
detail="This account signs in under another login. Use that account's credentials.",
)
if not user.login_enabled:
raise HTTPException(status_code=401, detail="This account does not have a login yet.")
if not user.is_active:
raise HTTPException(status_code=401, detail="Account disabled")
_login_limiter.reset(client_ip)
request.session["user_id"] = user.id
return UserResponse.model_validate(user, from_attributes=True)
@router.post("/logout")
def logout(request: Request) -> dict[str, str]:
request.session.clear()
return {"status": "ok"}
@router.get("/me")
def me(user: User = Depends(get_current_user)) -> UserResponse:
return UserResponse.model_validate(user, from_attributes=True)
@router.post("/change-password")
def change_password(
body: ChangePasswordRequest,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> dict[str, str]:
"""Let the signed-in user set their own password (after confirming the current one)."""
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect.")
if len(body.new_password) < 4:
raise HTTPException(status_code=400, detail="New password must be at least 4 characters.")
user.password_hash = hash_password(body.new_password)
session.add(user)
session.commit()
# Once the built-in admin sets their own password, the initial one from first boot is stale.
if user.is_service_admin:
try:
os.remove(config.ADMIN_PASSWORD_FILE)
except OSError:
pass
return {"status": "ok"}