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
24 changes: 24 additions & 0 deletions app/src/api/device_trust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { api } from './client';

export type TrustedDevice = {
id: number;
action: string;
trusted_at: string;
};

export type TrustDeviceResponse = {
id: number;
fingerprint: string;
};

export async function listDevices(): Promise<TrustedDevice[]> {
return api<TrustedDevice[]>('/devices');
}

export async function trustDevice(): Promise<TrustDeviceResponse> {
return api<TrustDeviceResponse>('/devices/trust', { method: 'POST' });
}

export async function revokeDevice(deviceId: number): Promise<{ message: string }> {
return api<{ message: string }>(`/devices/${deviceId}/revoke`, { method: 'DELETE' });
}
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .device_trust import bp as device_trust_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +19,4 @@ def register_routes(app: Flask):
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(device_trust_bp, url_prefix="/devices")
90 changes: 90 additions & 0 deletions packages/backend/app/routes/device_trust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import hashlib
from datetime import datetime
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..extensions import db
from ..models import AuditLog
import logging

bp = Blueprint("device_trust", __name__)
logger = logging.getLogger("finmind.device_trust")

DEVICE_TRUST_ACTION = "device_trust"
DEVICE_REVOKE_ACTION = "device_revoke"


def _device_fingerprint(user_agent: str, ip: str) -> str:
"""Create a stable hash from user-agent + IP for device identification."""
raw = f"{user_agent}|{ip}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]


@bp.get("")
@jwt_required()
def list_devices():
"""List trusted devices for the current user."""
uid = int(get_jwt_identity())
entries = (
db.session.query(AuditLog)
.filter(AuditLog.user_id == uid, AuditLog.action == DEVICE_TRUST_ACTION)
.order_by(AuditLog.created_at.desc())
.all()
)
logger.info("Listed devices user=%s count=%s", uid, len(entries))
return jsonify(
[
{
"id": e.id,
"action": e.action,
"trusted_at": e.created_at.isoformat(),
}
for e in entries
]
)


@bp.post("/trust")
@jwt_required()
def trust_device():
"""Register the current device as trusted."""
uid = int(get_jwt_identity())
user_agent = request.headers.get("User-Agent", "unknown")
ip = request.remote_addr or "0.0.0.0"
fingerprint = _device_fingerprint(user_agent, ip)

# Check if already trusted
existing = (
db.session.query(AuditLog)
.filter(
AuditLog.user_id == uid,
AuditLog.action == DEVICE_TRUST_ACTION,
)
.all()
)
for entry in existing:
if fingerprint in (entry.action + str(entry.id)):
pass # allow re-trust for simplicity

entry = AuditLog(
user_id=uid,
action=DEVICE_TRUST_ACTION,
)
db.session.add(entry)
db.session.commit()
logger.info("Trusted device user=%s fingerprint=%s id=%s", uid, fingerprint, entry.id)
return jsonify(id=entry.id, fingerprint=fingerprint), 201


@bp.delete("/<int:device_id>/revoke")
@jwt_required()
def revoke_device(device_id: int):
"""Remove trusted status from a device."""
uid = int(get_jwt_identity())
entry = db.session.get(AuditLog, device_id)
if not entry or entry.user_id != uid or entry.action != DEVICE_TRUST_ACTION:
return jsonify(error="not found"), 404

entry.action = DEVICE_REVOKE_ACTION
db.session.commit()
logger.info("Revoked device user=%s device_id=%s", uid, device_id)
return jsonify(message="device revoked"), 200
48 changes: 48 additions & 0 deletions packages/backend/tests/test_device_trust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
def test_list_devices_requires_auth(client):
r = client.get("/devices")
assert r.status_code == 401


def test_trust_device(client, auth_header):
r = client.post("/devices/trust", headers=auth_header)
assert r.status_code == 201
data = r.get_json()
assert "id" in data
assert "fingerprint" in data


def test_list_devices(client, auth_header):
# Trust a device first
r = client.post("/devices/trust", headers=auth_header)
assert r.status_code == 201

r = client.get("/devices", headers=auth_header)
assert r.status_code == 200
data = r.get_json()
assert isinstance(data, list)
assert len(data) >= 1
assert "id" in data[0]
assert "trusted_at" in data[0]


def test_revoke_device(client, auth_header):
# Trust a device first
r = client.post("/devices/trust", headers=auth_header)
assert r.status_code == 201
device_id = r.get_json()["id"]

# Revoke it
r = client.delete(f"/devices/{device_id}/revoke", headers=auth_header)
assert r.status_code == 200
assert r.get_json()["message"] == "device revoked"

# Should no longer appear in list
r = client.get("/devices", headers=auth_header)
assert r.status_code == 200
ids = [d["id"] for d in r.get_json()]
assert device_id not in ids


def test_revoke_nonexistent_device(client, auth_header):
r = client.delete("/devices/99999/revoke", headers=auth_header)
assert r.status_code == 404