Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ See `backend/app/db/schema.sql`. Key tables:
## API Endpoints
OpenAPI: `backend/app/openapi.yaml`
- Auth: `/auth/register`, `/auth/login`, `/auth/refresh`
- Trusted devices: successful login records device metadata from `device_id`/`device_name` (or a User-Agent/IP fingerprint). Authenticated users can list devices with `GET /auth/devices`, rename/trust devices with `PATCH /auth/devices/{id}`, and revoke lost devices with `DELETE /auth/devices/{id}`.
- Expenses: CRUD `/expenses`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
Expand Down
23 changes: 23 additions & 0 deletions packages/backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,29 @@ def _ensure_schema_compatibility(app: Flask) -> None:
NOT NULL DEFAULT 'INR'
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS trusted_devices (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
device_id VARCHAR(128) NOT NULL,
name VARCHAR(120) NOT NULL,
trusted BOOLEAN NOT NULL DEFAULT FALSE,
first_seen_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_ip VARCHAR(64),
user_agent VARCHAR(500),
revoked_at TIMESTAMP,
CONSTRAINT uq_trusted_devices_user_device UNIQUE (user_id, device_id)
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS ix_trusted_devices_user_last_seen
ON trusted_devices (user_id, last_seen_at DESC)
"""
)
conn.commit()
except Exception:
app.logger.exception(
Expand Down
70 changes: 68 additions & 2 deletions packages/backend/app/extensions.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,77 @@
from flask_sqlalchemy import SQLAlchemy
import fnmatch
import time

from flask_jwt_extended import JWTManager
from flask_sqlalchemy import SQLAlchemy
import redis
from redis.exceptions import RedisError

from .config import Settings


db = SQLAlchemy()
jwt = JWTManager()


class ResilientRedis:
"""Redis facade with an in-process fallback for local/free-tier outages."""

def __init__(self, url: str):
self._client = redis.Redis.from_url(url, decode_responses=True)
self._fallback: dict[str, tuple[str, float | None]] = {}

def get(self, key: str):
try:
return self._client.get(key)
except RedisError:
return self._fallback_get(key)

def set(self, key: str, value):
try:
return self._client.set(key, value)
except RedisError:
self._fallback[key] = (str(value), None)
return True

def setex(self, key: str, ttl_seconds: int, value):
try:
return self._client.setex(key, ttl_seconds, value)
except RedisError:
self._fallback[key] = (str(value), time.time() + int(ttl_seconds))
return True

def delete(self, *keys: str):
try:
return self._client.delete(*keys)
except RedisError:
deleted = 0
for key in keys:
if key in self._fallback:
deleted += 1
self._fallback.pop(key, None)
return deleted

def scan(self, cursor=0, match=None, count=100):
try:
return self._client.scan(cursor=cursor, match=match, count=count)
except RedisError:
self._purge_expired()
keys = list(self._fallback.keys())
if match:
keys = [key for key in keys if fnmatch.fnmatch(key, match)]
return 0, keys

def _fallback_get(self, key: str):
self._purge_expired()
entry = self._fallback.get(key)
return entry[0] if entry else None

def _purge_expired(self):
now = time.time()
expired = [key for key, (_, expires_at) in self._fallback.items() if expires_at and expires_at <= now]
for key in expired:
self._fallback.pop(key, None)


_settings = Settings()
redis_client = redis.Redis.from_url(_settings.redis_url, decode_responses=True)
redis_client = ResilientRedis(_settings.redis_url)
18 changes: 18 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ class User(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class TrustedDevice(db.Model):
__tablename__ = "trusted_devices"
__table_args__ = (
db.UniqueConstraint("user_id", "device_id", name="uq_trusted_devices_user_device"),
)

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
device_id = db.Column(db.String(128), nullable=False)
name = db.Column(db.String(120), nullable=False)
trusted = db.Column(db.Boolean, default=False, nullable=False)
first_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_ip = db.Column(db.String(64), nullable=True)
user_agent = db.Column(db.String(500), nullable=True)
revoked_at = db.Column(db.DateTime, nullable=True)


class Category(db.Model):
__tablename__ = "categories"
id = db.Column(db.Integer, primary_key=True)
Expand Down
120 changes: 118 additions & 2 deletions packages/backend/app/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from datetime import datetime
from hashlib import sha256

from flask import Blueprint, request, jsonify
from werkzeug.security import generate_password_hash, check_password_hash
from flask_jwt_extended import (
Expand All @@ -9,7 +12,7 @@
get_jwt_identity,
)
from ..extensions import db, redis_client
from ..models import User
from ..models import User, TrustedDevice
import logging
import time

Expand Down Expand Up @@ -62,8 +65,13 @@ def login():
access = create_access_token(identity=str(user.id))
refresh = create_refresh_token(identity=str(user.id))
_store_refresh_session(refresh, str(user.id))
device = _record_login_device(user.id, data)
logger.info("Login success user_id=%s", user.id)
return jsonify(access_token=access, refresh_token=refresh)
return jsonify(
access_token=access,
refresh_token=refresh,
device=_device_payload(device),
)


@bp.get("/me")
Expand Down Expand Up @@ -101,6 +109,54 @@ def update_me():
)


@bp.get("/devices")
@jwt_required()
def list_devices():
uid = int(get_jwt_identity())
devices = (
db.session.query(TrustedDevice)
.filter_by(user_id=uid)
.order_by(TrustedDevice.last_seen_at.desc())
.all()
)
return jsonify(devices=[_device_payload(device) for device in devices])


@bp.patch("/devices/<int:device_pk>")
@jwt_required()
def update_device(device_pk: int):
uid = int(get_jwt_identity())
device = db.session.get(TrustedDevice, device_pk)
if not device or device.user_id != uid:
return jsonify(error="device not found"), 404
if device.revoked_at:
return jsonify(error="device revoked"), 409

data = request.get_json() or {}
if "name" in data:
name = _clean_device_name(data.get("name"))
if not name:
return jsonify(error="device name required"), 400
device.name = name
if "trusted" in data:
device.trusted = bool(data.get("trusted"))
db.session.commit()
return jsonify(device=_device_payload(device))


@bp.delete("/devices/<int:device_pk>")
@jwt_required()
def revoke_device(device_pk: int):
uid = int(get_jwt_identity())
device = db.session.get(TrustedDevice, device_pk)
if not device or device.user_id != uid:
return jsonify(error="device not found"), 404
device.trusted = False
device.revoked_at = datetime.utcnow()
db.session.commit()
return jsonify(device=_device_payload(device), message="device revoked"), 200


@bp.post("/refresh")
@jwt_required(refresh=True)
def refresh():
Expand All @@ -125,6 +181,66 @@ def logout():
return jsonify(message="logged out"), 200


def _device_payload(device: TrustedDevice) -> dict:
return {
"id": device.id,
"device_id": device.device_id,
"name": device.name,
"trusted": bool(device.trusted),
"first_seen_at": device.first_seen_at.isoformat() if device.first_seen_at else None,
"last_seen_at": device.last_seen_at.isoformat() if device.last_seen_at else None,
"last_ip": device.last_ip,
"user_agent": device.user_agent,
"revoked_at": device.revoked_at.isoformat() if device.revoked_at else None,
}


def _record_login_device(user_id: int, data: dict) -> TrustedDevice:
device_id = _device_identifier(data)
now = datetime.utcnow()
device = (
db.session.query(TrustedDevice)
.filter_by(user_id=user_id, device_id=device_id)
.first()
)
if not device:
device = TrustedDevice(
user_id=user_id,
device_id=device_id,
name=_clean_device_name(data.get("device_name")) or _default_device_name(),
first_seen_at=now,
)
db.session.add(device)
device.last_seen_at = now
device.last_ip = _client_ip()
device.user_agent = (request.headers.get("User-Agent") or "")[:500] or None
device.revoked_at = None
db.session.commit()
return device


def _device_identifier(data: dict) -> str:
supplied = str(data.get("device_id") or "").strip()
if supplied:
return supplied[:128]
raw = f"{request.headers.get('User-Agent', '')}|{_client_ip()}"
return sha256(raw.encode("utf-8")).hexdigest()


def _clean_device_name(value) -> str:
return str(value or "").strip()[:120]


def _default_device_name() -> str:
agent = (request.headers.get("User-Agent") or "Unknown device").split(" ")[0]
return _clean_device_name(agent) or "Unknown device"


def _client_ip() -> str:
forwarded = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
return (forwarded or request.remote_addr or "")[:64]


def _refresh_key(jti: str) -> str:
return f"auth:refresh:{jti}"

Expand Down
1 change: 1 addition & 0 deletions packages/backend/app/routes/expenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def create_expense():
[
monthly_summary_key(uid, e.spent_at.strftime("%Y-%m")),
f"insights:{uid}:*",
f"user:{uid}:dashboard_summary:*",
]
)
return jsonify(_expense_to_dict(e)), 201
Expand Down
76 changes: 76 additions & 0 deletions packages/backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,79 @@ def test_auth_me_and_update_preferred_currency(client):

r = client.patch("/auth/me", json={"preferred_currency": "ZZZ"}, headers=auth)
assert r.status_code == 400

def test_device_trust_management_flow(client):
email = "devices@test.com"
password = "secret123"
r = client.post("/auth/register", json={"email": email, "password": password})
assert r.status_code in (201, 409)

r = client.post(
"/auth/login",
json={"email": email, "password": password, "device_id": "laptop-1", "device_name": "Work Laptop"},
headers={"User-Agent": "FinMindTest/1.0", "X-Forwarded-For": "203.0.113.10"},
)
assert r.status_code == 200
payload = r.get_json()
access = payload["access_token"]
device = payload["device"]
assert device["device_id"] == "laptop-1"
assert device["name"] == "Work Laptop"
assert device["trusted"] is False
assert device["last_ip"] == "203.0.113.10"
auth = {"Authorization": f"Bearer {access}"}

r = client.get("/auth/devices", headers=auth)
assert r.status_code == 200
devices = r.get_json()["devices"]
assert len(devices) == 1
device_pk = devices[0]["id"]

r = client.patch(
f"/auth/devices/{device_pk}",
json={"trusted": True, "name": "Primary Laptop"},
headers=auth,
)
assert r.status_code == 200
updated = r.get_json()["device"]
assert updated["trusted"] is True
assert updated["name"] == "Primary Laptop"

r = client.post(
"/auth/login",
json={"email": email, "password": password, "device_id": "laptop-1"},
)
assert r.status_code == 200
assert r.get_json()["device"]["trusted"] is True

r = client.delete(f"/auth/devices/{device_pk}", headers=auth)
assert r.status_code == 200
revoked = r.get_json()["device"]
assert revoked["trusted"] is False
assert revoked["revoked_at"] is not None

r = client.patch(
f"/auth/devices/{device_pk}",
json={"trusted": True},
headers=auth,
)
assert r.status_code == 409


def test_device_listing_is_user_scoped(client):
for email, device_id in [("owner@test.com", "owner-phone"), ("other@test.com", "other-phone")]:
r = client.post("/auth/register", json={"email": email, "password": "secret123"})
assert r.status_code in (201, 409)
r = client.post(
"/auth/login",
json={"email": email, "password": "secret123", "device_id": device_id},
)
assert r.status_code == 200
if email == "owner@test.com":
owner_access = r.get_json()["access_token"]

r = client.get("/auth/devices", headers={"Authorization": f"Bearer {owner_access}"})
assert r.status_code == 200
devices = r.get_json()["devices"]
assert [device["device_id"] for device in devices] == ["owner-phone"]