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

@ -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))