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:
parent
74e57a35c0
commit
fe4e69b9ad
40 changed files with 2079 additions and 127 deletions
|
|
@ -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
152
backend/app/api/v1/admin.py
Normal 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))
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue