Initial commit: MyMidas personal finance tracker

Full-stack self-hosted finance app with FastAPI backend and React frontend.

Features:
- Accounts, transactions, budgets, investments with GBP base currency
- CSV import with auto-detection for 10 UK bank formats
- ML predictions: spending forecast, net worth projection, Monte Carlo
- 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger)
- Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF)
- AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log
- Encrypted nightly backups + key rotation script
- Mobile-responsive layout with bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

View file

38
backend/app/core/audit.py Normal file
View file

@ -0,0 +1,38 @@
"""
Append-only audit log writer.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def write_audit(
db: "AsyncSession",
*,
user_id: uuid.UUID | None,
action: str,
resource_type: str | None = None,
resource_id: uuid.UUID | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
metadata: dict[str, Any] | None = None,
success: bool = True,
) -> None:
from app.db.models.audit_log import AuditLog
log = AuditLog(
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
meta=metadata or {},
success=success,
created_at=datetime.now(timezone.utc),
)
db.add(log)
# Note: caller is responsible for committing

View file

@ -0,0 +1,24 @@
"""
Helpers for re-encrypting all sensitive DB fields during key rotation.
"""
from app.core.security import decrypt_field, encrypt_field
def reencrypt(data: bytes, old_key_hex: str, new_key_hex: str) -> bytes:
"""Re-encrypt a bytea field from old key to new key."""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
old_key = bytes.fromhex(old_key_hex)
new_key = bytes.fromhex(new_key_hex)
# Decrypt with old key
iv = data[:12]
ciphertext_with_tag = data[12:]
aesgcm_old = AESGCM(old_key)
plaintext = aesgcm_old.decrypt(iv, ciphertext_with_tag, None)
# Encrypt with new key
new_iv = os.urandom(12)
aesgcm_new = AESGCM(new_key)
return new_iv + aesgcm_new.encrypt(new_iv, plaintext, None)

View file

@ -0,0 +1,144 @@
"""
AES-256-GCM key rotation: decrypt all encrypted fields with OLD key, re-encrypt with NEW key.
Run while the application is STOPPED:
docker compose exec \
-e ENCRYPTION_KEY="$OLD_ENCRYPTION_KEY" \
-e NEW_ENCRYPTION_KEY="$NEW_ENCRYPTION_KEY" \
backend python -m app.core.key_rotation
On success, update ENCRYPTION_KEY in .env to the new value and restart.
"""
import os
import sys
import logging
from typing import Callable
import psycopg2
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
logging.basicConfig(level=logging.INFO, format="[rotate] %(message)s")
log = logging.getLogger(__name__)
def _make_cipher(key_hex: str) -> AESGCM:
key = bytes.fromhex(key_hex)
if len(key) != 32:
raise ValueError("Key must be 32 bytes (64 hex chars)")
return AESGCM(key)
def _decrypt(cipher: AESGCM, data: bytes) -> bytes:
"""Return plaintext bytes given IV(12)||ciphertext+tag."""
if not data:
return b""
return cipher.decrypt(data[:12], data[12:], None)
def _encrypt(cipher: AESGCM, plaintext: bytes) -> bytes:
"""Encrypt plaintext bytes → IV(12)||ciphertext+tag."""
if not plaintext:
return b""
iv = os.urandom(12)
return iv + cipher.encrypt(iv, plaintext, None)
def _reencrypt(old: AESGCM, new: AESGCM, data: bytes | None) -> bytes | None:
if not data:
return data
plaintext = _decrypt(old, data)
return _encrypt(new, plaintext)
def _reencrypt_hex(old: AESGCM, new: AESGCM, hex_str: str | None) -> str | None:
"""For fields stored as hex strings (e.g. totp_secret_enc)."""
if not hex_str:
return hex_str
data = bytes.fromhex(hex_str)
plaintext = _decrypt(old, data)
return _encrypt(new, plaintext).hex()
def rotate(db_url: str, old_key_hex: str, new_key_hex: str) -> None:
old = _make_cipher(old_key_hex)
new = _make_cipher(new_key_hex)
conn = psycopg2.connect(db_url)
conn.autocommit = False
cur = conn.cursor()
try:
# ------------------------------------------------------------------ accounts
cur.execute("SELECT id, name, institution, notes FROM accounts WHERE deleted_at IS NULL")
rows = cur.fetchall()
log.info(f"Rotating {len(rows)} account row(s)…")
for row_id, name, institution, notes in rows:
cur.execute(
"UPDATE accounts SET name=%s, institution=%s, notes=%s WHERE id=%s",
(
_reencrypt(old, new, bytes(name) if name else None),
_reencrypt(old, new, bytes(institution) if institution else None),
_reencrypt(old, new, bytes(notes) if notes else None),
row_id,
),
)
# -------------------------------------------------------------- transactions
cur.execute(
"SELECT id, description, merchant, notes FROM transactions WHERE deleted_at IS NULL"
)
rows = cur.fetchall()
log.info(f"Rotating {len(rows)} transaction row(s)…")
for row_id, description, merchant, notes in rows:
cur.execute(
"UPDATE transactions SET description=%s, merchant=%s, notes=%s WHERE id=%s",
(
_reencrypt(old, new, bytes(description) if description else None),
_reencrypt(old, new, bytes(merchant) if merchant else None),
_reencrypt(old, new, bytes(notes) if notes else None),
row_id,
),
)
# -------------------------------------------------------------------- users
cur.execute("SELECT id, totp_secret FROM users WHERE deleted_at IS NULL")
rows = cur.fetchall()
log.info(f"Rotating {len(rows)} user row(s)…")
for row_id, totp_secret in rows:
cur.execute(
"UPDATE users SET totp_secret=%s WHERE id=%s",
(_reencrypt_hex(old, new, totp_secret), row_id),
)
conn.commit()
log.info("Key rotation complete — all fields re-encrypted.")
log.info("Now update ENCRYPTION_KEY in .env and restart the application.")
except Exception:
conn.rollback()
log.exception("Rotation FAILED — rolled back, no data changed.")
sys.exit(1)
finally:
cur.close()
conn.close()
if __name__ == "__main__":
old_key = os.environ.get("ENCRYPTION_KEY", "")
new_key = os.environ.get("NEW_ENCRYPTION_KEY", "")
db_url = os.environ.get("DATABASE_URL", "").replace("postgresql+asyncpg://", "postgresql://")
if not old_key:
log.error("ENCRYPTION_KEY (current/old key) is not set")
sys.exit(1)
if not new_key:
log.error("NEW_ENCRYPTION_KEY is not set")
sys.exit(1)
if not db_url:
log.error("DATABASE_URL is not set")
sys.exit(1)
if old_key == new_key:
log.error("NEW_ENCRYPTION_KEY is the same as ENCRYPTION_KEY — nothing to do")
sys.exit(1)
rotate(db_url, old_key, new_key)

View file

@ -0,0 +1,81 @@
"""
Security middleware: headers, CSRF double-submit, request ID, RLS user context.
"""
import uuid
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
SECURITY_HEADERS = {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
"Content-Security-Policy": (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
),
}
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request)
for header, value in SECURITY_HEADERS.items():
response.headers[header] = value
response.headers["X-Request-ID"] = str(uuid.uuid4())
return response
class CSRFMiddleware(BaseHTTPMiddleware):
"""Double-submit cookie CSRF protection for mutating requests."""
EXEMPT_PATHS = {"/api/v1/auth/login", "/api/v1/auth/refresh", "/api/v1/auth/register", "/health"}
async def dispatch(self, request: Request, call_next):
# Always set the csrf_token cookie if it doesn't exist yet
existing_csrf = request.cookies.get("csrf_token")
if request.method in SAFE_METHODS:
response: Response = await call_next(request)
if not existing_csrf:
token = str(uuid.uuid4())
response.set_cookie(
"csrf_token", token,
httponly=False, # must be readable by JS
samesite="lax",
secure=False, # set True if TLS is terminated at this service
)
return response
if request.url.path in self.EXEMPT_PATHS:
response = await call_next(request)
if not existing_csrf:
token = str(uuid.uuid4())
response.set_cookie("csrf_token", token, httponly=False, samesite="lax", secure=False)
return response
if request.url.path in {"/api/v1/auth/login", "/api/v1/auth/login/totp"}:
return await call_next(request)
cookie_token = existing_csrf
header_token = request.headers.get("X-CSRF-Token")
if not cookie_token or not header_token or cookie_token != header_token:
return JSONResponse(
status_code=403,
content={"detail": "CSRF token missing or invalid"},
)
return await call_next(request)

View file

@ -0,0 +1,28 @@
"""
Redis sliding window rate limiter.
"""
import time
from redis.asyncio import Redis
async def is_rate_limited(
redis: Redis,
key: str,
limit: int,
window_seconds: int = 60,
) -> tuple[bool, int]:
"""
Returns (is_limited, requests_remaining).
Uses a sorted set with timestamps as scores for sliding window.
"""
now = time.time()
window_start = now - window_seconds
pipe = redis.pipeline()
pipe.zremrangebyscore(key, 0, window_start)
pipe.zadd(key, {str(now): now})
pipe.zcard(key)
pipe.expire(key, window_seconds + 1)
results = await pipe.execute()
count = results[2]
remaining = max(0, limit - count)
return count > limit, remaining

View file

@ -0,0 +1,197 @@
"""
Cryptographic primitives: Argon2id password hashing, RS256 JWT, AES-256-GCM field encryption, TOTP.
"""
import base64
import os
import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import pyotp
import qrcode
import qrcode.image.svg
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from jose import JWTError, jwt
from app.config import get_settings
# Argon2id — OWASP recommended parameters
_ph = PasswordHasher(
time_cost=3,
memory_cost=65536,
parallelism=4,
hash_len=32,
salt_len=16,
)
# ---------------------------------------------------------------------------
# Password hashing
# ---------------------------------------------------------------------------
def hash_password(password: str) -> str:
return _ph.hash(password)
def verify_password(password: str, hashed: str) -> bool:
try:
return _ph.verify(hashed, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
def password_needs_rehash(hashed: str) -> bool:
return _ph.check_needs_rehash(hashed)
# ---------------------------------------------------------------------------
# JWT (RS256)
# ---------------------------------------------------------------------------
def _load_private_key() -> str:
settings = get_settings()
return Path(settings.jwt_private_key_file).read_text()
def _load_public_key() -> str:
settings = get_settings()
return Path(settings.jwt_public_key_file).read_text()
def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str:
settings = get_settings()
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": subject,
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
"type": "access",
}
if extra:
payload.update(extra)
return jwt.encode(payload, _load_private_key(), algorithm=settings.jwt_algorithm)
def create_refresh_token(subject: str) -> str:
settings = get_settings()
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": subject,
"iat": now,
"exp": now + timedelta(days=settings.refresh_token_expire_days),
"type": "refresh",
"jti": secrets.token_hex(16),
}
return jwt.encode(payload, _load_private_key(), algorithm=settings.jwt_algorithm)
def decode_token(token: str, token_type: str = "access") -> dict[str, Any]:
settings = get_settings()
payload = jwt.decode(
token,
_load_public_key(),
algorithms=[settings.jwt_algorithm],
options={"verify_exp": True},
)
if payload.get("type") != token_type:
raise JWTError("Invalid token type")
return payload
# ---------------------------------------------------------------------------
# AES-256-GCM field encryption
# ---------------------------------------------------------------------------
def _get_aes_key() -> bytes:
"""Derive 32-byte key from hex ENCRYPTION_KEY env var."""
settings = get_settings()
key_hex = settings.encryption_key
key = bytes.fromhex(key_hex)
if len(key) != 32:
raise ValueError("ENCRYPTION_KEY must be a 32-byte hex string (64 hex chars)")
return key
def encrypt_field(plaintext: str) -> bytes:
"""Encrypt a string field. Returns IV(12) || ciphertext || tag(16) as bytes."""
if not plaintext:
return b""
key = _get_aes_key()
iv = os.urandom(12)
aesgcm = AESGCM(key)
ciphertext_with_tag = aesgcm.encrypt(iv, plaintext.encode(), None)
return iv + ciphertext_with_tag
def decrypt_field(data: bytes) -> str:
"""Decrypt bytes produced by encrypt_field."""
if not data:
return ""
key = _get_aes_key()
iv = data[:12]
ciphertext_with_tag = data[12:]
aesgcm = AESGCM(key)
return aesgcm.decrypt(iv, ciphertext_with_tag, None).decode()
def encrypt_field_b64(plaintext: str) -> str:
"""Convenience: encrypt and return base64 string (for JSON/text contexts)."""
return base64.b64encode(encrypt_field(plaintext)).decode()
def decrypt_field_b64(data: str) -> str:
return decrypt_field(base64.b64decode(data))
# ---------------------------------------------------------------------------
# TOTP (RFC 6238)
# ---------------------------------------------------------------------------
def generate_totp_secret() -> str:
return pyotp.random_base32()
def get_totp_uri(secret: str, email: str) -> str:
return pyotp.totp.TOTP(secret).provisioning_uri(
name=email, issuer_name="Finance Tracker"
)
def generate_totp_qr_png(secret: str, email: str) -> bytes:
uri = get_totp_uri(secret, email)
img = qrcode.make(uri)
from io import BytesIO
buf = BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def verify_totp(secret: str, code: str) -> bool:
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
# ---------------------------------------------------------------------------
# CSRF token
# ---------------------------------------------------------------------------
def generate_csrf_token() -> str:
return secrets.token_hex(32)
# ---------------------------------------------------------------------------
# Misc helpers
# ---------------------------------------------------------------------------
def generate_backup_codes(count: int = 8) -> list[str]:
"""Generate one-time backup codes."""
return [secrets.token_hex(4).upper() + "-" + secrets.token_hex(4).upper() for _ in range(count)]
def hash_token(token: str) -> str:
"""SHA-256 hash of a bearer token for DB storage."""
import hashlib
return hashlib.sha256(token.encode()).hexdigest()