Files
Ten31-Portal/backend/ten31portal/routers/auth_router.py
T
Jonathan KirkwoodandClaude Opus 4.8 4215d4478f 0.2.27-0.2.32: LP portal polish, brand palette, default investor logins
Cumulative checkpoint since 0.2.26:
- 0.2.27/28: entity valuation-history table; investor gain/loss = NAV +
  distributions vs paid-in
- 0.2.29: Reset Fund Partners (endpoint, Partners-tab button, CLI, action)
- 0.2.30: "Current Capital Balance" label, %-only gain/loss
- 0.2.31: Management Entities rename, Carry Vehicle type, chart
  distributions-line gate
- 0.2.32: LP-facing polish pass
  * Ten31 brand palette from the logo (navy/mint); orange retired
  * portfolio summary card across funds; gain labeled "net of paid-in"
  * whole-dollar headline figures; "History · N quarters" toggle
  * documents grouped by year with a "New" badge (users.docs_seen_at)
  * eNAV-created members start on default password with login enabled;
    enable-investor-logins CLI + StartOS action for existing accounts
  * password minimum raised to 8 chars; login help line (Portal@ten31.xyz)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:40:40 -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) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 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"}