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
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue