Complete Phase 3, Phase 5 polish and hardening

Phase 3 — Investments:
- Multi-currency support: holdings track purchase currency, FX rates convert to base for totals
- Capital gains report using UK Section 104 pool method, grouped by tax year
- Capital Gains tab added to Reports page

Phase 5 — Polish & Hardening:
- Mobile-responsive layout: bottom nav, sidebar hidden on mobile, logo in TopBar, compact header buttons, hover-only actions now always visible on touch
- Backup system: encrypted GPG backups via backup.sh, nightly scheduler job, admin API (list/trigger/download/restore), Settings UI with drag-to-restore confirmation
- Docker entrypoint with gosu privilege drop to fix bind-mount ownership on fresh deployments
- OWASP fixes: refresh token now bound to its session (new refresh_token_hash column + migration), CSRF secure flag tied to environment, IP-level rate limiting on login, TOTPEnableRequest Pydantic schema replaces raw dict
- AES-256-GCM key rotation script (rotate_keys.py) with dry-run mode and atomic DB transaction
- CLAUDE.md added for AI-assisted development context
- README updated: correct reverse proxy port, accurate backup/restore commands, key rotation instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-22 14:59:11 +00:00
parent 74e57a35c0
commit fe4e69b9ad
40 changed files with 2079 additions and 127 deletions

View file

@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions
from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin
router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["auth"])
@ -12,3 +12,4 @@ router.include_router(budgets.router)
router.include_router(reports.router)
router.include_router(investments.router)
router.include_router(predictions.router)
router.include_router(admin.router)

152
backend/app/api/v1/admin.py Normal file
View file

@ -0,0 +1,152 @@
import asyncio
import os
import re
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.dependencies import get_current_user
from app.db.models.user import User
router = APIRouter(prefix="/admin", tags=["admin"])
BACKUP_DIR = Path(os.environ.get("BACKUP_DIR", "/app/backups"))
BACKUP_PATTERN = re.compile(r"^\d{8}_\d{6}\.sql\.gz\.gpg$")
class BackupFile(BaseModel):
filename: str
size_bytes: int
created_at: str
class BackupResult(BaseModel):
ok: bool
message: str
def _list_backup_files() -> list[BackupFile]:
if not BACKUP_DIR.exists():
return []
files = []
for f in sorted(BACKUP_DIR.glob("*.sql.gz.gpg"), reverse=True):
stat = f.stat()
files.append(BackupFile(
filename=f.name,
size_bytes=stat.st_size,
created_at=datetime.fromtimestamp(stat.st_mtime).isoformat(),
))
return files
@router.get("/backups", response_model=list[BackupFile])
async def list_backups(current_user: User = Depends(get_current_user)):
return _list_backup_files()
@router.post("/backup", response_model=BackupResult)
async def trigger_backup(current_user: User = Depends(get_current_user)):
try:
proc = await asyncio.create_subprocess_exec(
"bash", "/app/scripts/backup.sh",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
stdout, _ = await proc.communicate()
output = stdout.decode().strip() if stdout else ""
if proc.returncode == 0:
return BackupResult(ok=True, message=output or "Backup completed")
raise HTTPException(status_code=500, detail=f"Backup failed: {output}")
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/backups/{filename}")
async def download_backup(
filename: str,
current_user: User = Depends(get_current_user),
):
if not BACKUP_PATTERN.match(filename):
raise HTTPException(status_code=400, detail="Invalid filename")
path = BACKUP_DIR / filename
if not path.exists():
raise HTTPException(status_code=404, detail="Backup not found")
return FileResponse(
path=str(path),
filename=filename,
media_type="application/octet-stream",
)
@router.post("/restore/{filename}", response_model=BackupResult)
async def restore_backup(
filename: str,
current_user: User = Depends(get_current_user),
):
if not BACKUP_PATTERN.match(filename):
raise HTTPException(status_code=400, detail="Invalid filename")
path = BACKUP_DIR / filename
if not path.exists():
raise HTTPException(status_code=404, detail="Backup not found")
passphrase = os.environ.get("BACKUP_PASSPHRASE", "")
if not passphrase:
raise HTTPException(status_code=500, detail="BACKUP_PASSPHRASE not configured")
database_url = os.environ.get("DATABASE_URL", "")
pg_url = database_url.replace("postgresql+asyncpg", "postgresql")
# Ensure GPG has a writable home (appuser has no real home directory)
gnupg_home = "/tmp/.gnupg"
os.makedirs(gnupg_home, mode=0o700, exist_ok=True)
gpg_env = {**os.environ, "GNUPGHOME": gnupg_home}
try:
# Decrypt and decompress into psql non-interactively
gpg_proc = await asyncio.create_subprocess_exec(
"gpg", "--batch", "--yes", "--no-symkey-cache",
"--pinentry-mode", "loopback",
"--decrypt", "--passphrase", passphrase,
str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=gpg_env,
)
gpg_out, gpg_err = await gpg_proc.communicate()
if gpg_proc.returncode != 0:
raise HTTPException(status_code=500, detail=f"Decryption failed: {gpg_err.decode()}")
gunzip_proc = await asyncio.create_subprocess_exec(
"gunzip",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
sql_data, gunzip_err = await gunzip_proc.communicate(input=gpg_out)
if gunzip_proc.returncode != 0:
raise HTTPException(status_code=500, detail=f"Decompression failed: {gunzip_err.decode()}")
psql_proc = await asyncio.create_subprocess_exec(
"psql",
"--single-transaction",
"-v", "ON_ERROR_STOP=1",
pg_url,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
psql_out, psql_err = await psql_proc.communicate(input=sql_data)
if psql_proc.returncode != 0:
detail = (psql_err.decode().strip() or psql_out.decode().strip() or "psql exited with no output")
raise HTTPException(status_code=500, detail=f"Restore failed: {detail}")
return BackupResult(ok=True, message=f"Restored from {filename}")
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))

View file

@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import write_audit
from app.core.rate_limiter import is_rate_limited
from app.core.security import create_refresh_token, decode_token, generate_csrf_token, hash_token
from app.schemas.auth import TOTPEnableRequest
from app.dependencies import get_current_user, get_db, get_redis
from app.schemas.auth import (
LoginRequest,
@ -96,6 +97,11 @@ async def login(
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
):
ip = _ip(request) or "unknown"
limited, _ = await is_rate_limited(redis, f"rate:login:{ip}", limit=20, window_seconds=60)
if limited:
raise HTTPException(status_code=429, detail="Too many login attempts — try again shortly")
try:
user, access_token, refresh_token = await authenticate_user(
db, redis, body.email, body.password, _ip(request), _ua(request)
@ -171,24 +177,27 @@ async def refresh_token(
user_id = uuid.UUID(payload["sub"])
now = datetime.now(timezone.utc)
refresh_hash = hash_token(token)
# Find and update session
# Find the specific session this refresh token was issued for
result = await db.execute(
select(Session).where(
Session.user_id == user_id,
Session.refresh_token_hash == refresh_hash,
Session.revoked_at.is_(None),
Session.expires_at > now,
)
)
session = result.scalars().first()
if not session:
raise HTTPException(status_code=401, detail="Session not found")
raise HTTPException(status_code=401, detail="Session not found or refresh token already used")
new_access = create_access_token(str(user_id))
new_refresh = create_refresh_token(str(user_id))
# Rotate session token hash
# Rotate both token hashes — old refresh token is now invalid
session.token_hash = hash_token(new_access)
session.refresh_token_hash = hash_token(new_refresh)
session.last_active_at = now
await db.commit()
@ -305,17 +314,13 @@ async def totp_verify(
@router.post("/totp/enable", status_code=200)
async def totp_enable(
body: dict,
body: TOTPEnableRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
secret = body.get("secret")
code = body.get("code")
if not secret or not code:
raise HTTPException(status_code=422, detail="secret and code required")
try:
await enable_totp(user, db, secret, code)
await enable_totp(user, db, body.secret, body.code)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
await write_audit(db, user_id=user.id, action="totp_enable", ip_address=_ip(request))

View file

@ -1,7 +1,9 @@
import uuid
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import select, delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
@ -9,6 +11,7 @@ from app.db.models.user import User
from app.schemas.investment import (
AssetSearch,
AssetPricePoint,
CapitalGainsReport,
HoldingCreate,
HoldingResponse,
InvestmentTxnCreate,
@ -29,7 +32,7 @@ async def get_portfolio(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await investment_service.get_portfolio(db, current_user.id)
return await investment_service.get_portfolio(db, current_user.id, current_user.base_currency)
@router.get("/investments/performance", response_model=PerformanceMetrics)
@ -37,7 +40,15 @@ async def get_performance(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await investment_service.get_performance(db, current_user.id)
return await investment_service.get_performance(db, current_user.id, current_user.base_currency)
@router.get("/investments/capital-gains", response_model=CapitalGainsReport)
async def get_capital_gains(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await investment_service.get_capital_gains(db, current_user.id, current_user.base_currency)
# ── Holdings ───────────────────────────────────────────────────────────────
@ -61,6 +72,27 @@ async def create_holding(
return investment_service._holding_to_response(holding, asset)
@router.patch("/investments/holdings/{holding_id}", response_model=HoldingResponse)
async def update_holding(
holding_id: uuid.UUID,
quantity: Decimal = Body(...),
avg_cost_basis: Decimal = Body(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
holding = await investment_service.get_holding(db, current_user.id, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
holding.quantity = quantity
holding.avg_cost_basis = avg_cost_basis
await db.commit()
await db.refresh(holding)
from app.db.models.asset import Asset
result = await db.execute(select(Asset).where(Asset.id == holding.asset_id))
asset = result.scalar_one_or_none()
return investment_service._holding_to_response(holding, asset)
@router.delete("/investments/holdings/{holding_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_holding(
holding_id: uuid.UUID,
@ -70,6 +102,8 @@ async def delete_holding(
holding = await investment_service.get_holding(db, current_user.id, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
from app.db.models.investment_transaction import InvestmentTransaction
await db.execute(sa_delete(InvestmentTransaction).where(InvestmentTransaction.holding_id == holding_id))
await db.delete(holding)
await db.commit()

View file

@ -12,6 +12,7 @@ from app.schemas.report import (
CategoryBreakdownReport,
IncomeExpenseReport,
NetWorthReport,
SavingsRateReport,
SpendingTrendsReport,
)
from app.services import report_service
@ -83,6 +84,15 @@ async def spending_trends(
return await report_service.get_spending_trends(db, current_user.id, months)
@router.get("/savings-rate", response_model=SavingsRateReport)
async def savings_rate(
months: int = Query(default=12, ge=1, le=60),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await report_service.get_savings_rate_report(db, current_user.id, months)
@router.get("/balance-sheet", response_model=BalanceSheetReport)
async def balance_sheet(
db: AsyncSession = Depends(get_db),

View file

@ -48,6 +48,7 @@ async def get_transactions(
date_from: str | None = None,
date_to: str | None = None,
search: str | None = None,
is_recurring: bool | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
@ -62,6 +63,7 @@ async def get_transactions(
date_from=date.fromisoformat(date_from) if date_from else None,
date_to=date.fromisoformat(date_to) if date_to else None,
search=search,
is_recurring=is_recurring,
page=page,
page_size=page_size,
)