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:
commit
61a7884ee5
127 changed files with 13323 additions and 0 deletions
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
38
backend/app/core/audit.py
Normal file
38
backend/app/core/audit.py
Normal 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
|
||||
24
backend/app/core/encryption.py
Normal file
24
backend/app/core/encryption.py
Normal 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)
|
||||
144
backend/app/core/key_rotation.py
Normal file
144
backend/app/core/key_rotation.py
Normal 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)
|
||||
81
backend/app/core/middleware.py
Normal file
81
backend/app/core/middleware.py
Normal 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)
|
||||
28
backend/app/core/rate_limiter.py
Normal file
28
backend/app/core/rate_limiter.py
Normal 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
|
||||
197
backend/app/core/security.py
Normal file
197
backend/app/core/security.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue