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
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="")
104 changes: 104 additions & 0 deletions packages/backend/app/routes/device_trust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Routes for Device Trust Management (issue #125)."""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.services.device_trust import (
register_device,
verify_device_token,
revoke_device,
revoke_all_devices,
get_user_devices,
recognize_device,
)

bp = Blueprint("device_trust", __name__)


@bp.route("/devices", methods=["GET"])
@jwt_required()
def list_devices():
"""List trusted devices for the current user."""
user_id = int(get_jwt_identity())
include_revoked = request.args.get("include_revoked", "false").lower() == "true"
devices = get_user_devices(user_id, include_revoked=include_revoked)
return jsonify({"devices": devices, "count": len(devices)}), 200


@bp.route("/devices", methods=["POST"])
@jwt_required()
def add_device():
"""Register a new trusted device."""
user_id = int(get_jwt_identity())
data = request.get_json() or {}

device_name = (data.get("device_name") or "").strip()
if not device_name:
return jsonify({"error": "device_name is required"}), 400

trust_days = data.get("trust_days", 30)
try:
trust_days = int(trust_days) if trust_days is not None else None
except (ValueError, TypeError):
return jsonify({"error": "trust_days must be an integer or null"}), 400

user_agent = request.headers.get("User-Agent") or data.get("user_agent")
ip_address = request.remote_addr or data.get("ip_address")

device = register_device(
user_id=user_id,
device_name=device_name,
user_agent=user_agent,
ip_address=ip_address,
trust_days=trust_days,
)
return jsonify(device.to_dict()), 201


@bp.route("/devices/<int:device_id>", methods=["DELETE"])
@jwt_required()
def revoke_device_route(device_id: int):
"""Revoke trust for a specific device."""
user_id = int(get_jwt_identity())
device = revoke_device(device_id=device_id, user_id=user_id)
if not device:
return jsonify({"error": "Device not found"}), 404
return jsonify({"message": "Device trust revoked", "device": device.to_dict()}), 200


@bp.route("/devices/revoke-all", methods=["POST"])
@jwt_required()
def revoke_all_route():
"""Revoke all trusted devices (except current if token provided)."""
user_id = int(get_jwt_identity())
data = request.get_json() or {}
except_token = data.get("except_token")
count = revoke_all_devices(user_id=user_id, except_token=except_token)
return jsonify({"revoked_count": count, "message": f"Revoked {count} device(s)"}), 200


@bp.route("/devices/verify", methods=["POST"])
@jwt_required()
def verify_device():
"""Verify a device token and mark device as seen."""
user_id = int(get_jwt_identity())
data = request.get_json() or {}
token = data.get("device_token", "")
if not token:
return jsonify({"error": "device_token is required"}), 400
device = verify_device_token(token=token, user_id=user_id)
if not device:
return jsonify({"trusted": False, "message": "Device not recognized or expired"}), 200
return jsonify({"trusted": True, "device": device.to_dict()}), 200


@bp.route("/devices/recognize", methods=["POST"])
@jwt_required()
def recognize_device_route():
"""Auto-recognize a device by fingerprint (user-agent + IP)."""
user_id = int(get_jwt_identity())
data = request.get_json() or {}
user_agent = request.headers.get("User-Agent") or data.get("user_agent")
ip_address = request.remote_addr or data.get("ip_address")
device = recognize_device(user_id=user_id, user_agent=user_agent, ip_address=ip_address)
if not device:
return jsonify({"recognized": False}), 200
return jsonify({"recognized": True, "device": device.to_dict()}), 200
187 changes: 187 additions & 0 deletions packages/backend/app/services/device_trust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""
Device Trust Management & Recognition (issue #125)

Allow users to view and manage trusted devices.
Trusted devices skip additional auth challenges.
"""
from __future__ import annotations

import hashlib
import secrets
import uuid
from datetime import datetime, timedelta
from typing import Optional
from app.extensions import db


class TrustedDevice(db.Model):
"""A device trusted by a user for authentication."""

__tablename__ = "trusted_devices"

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
device_token = db.Column(db.String(64), unique=True, nullable=False)
device_name = db.Column(db.String(200), nullable=False)
device_fingerprint = db.Column(db.String(64), nullable=True)
user_agent = db.Column(db.String(500), nullable=True)
ip_address = db.Column(db.String(45), nullable=True)
is_trusted = db.Column(db.Boolean, default=True, nullable=False)
last_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
trusted_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
expires_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"device_name": self.device_name,
"device_token": self.device_token,
"device_fingerprint": self.device_fingerprint,
"user_agent": self.user_agent,
"ip_address": self.ip_address,
"is_trusted": self.is_trusted,
"last_seen_at": self.last_seen_at.isoformat() if self.last_seen_at else None,
"trusted_at": self.trusted_at.isoformat() if self.trusted_at else None,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"is_expired": self._is_expired(),
}

def _is_expired(self) -> bool:
if self.expires_at is None:
return False
return datetime.utcnow() > self.expires_at


def _generate_device_token() -> str:
"""Generate a cryptographically secure device token."""
return secrets.token_hex(32) # 64-char hex string


def _fingerprint_device(user_agent: str, ip_address: str) -> str:
"""Create a deterministic fingerprint from device characteristics."""
raw = f"{user_agent}|{ip_address}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]


def register_device(
user_id: int,
device_name: str,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
trust_days: Optional[int] = 30,
) -> TrustedDevice:
"""Register a new trusted device for a user."""
device_token = _generate_device_token()
fingerprint = None
if user_agent and ip_address:
fingerprint = _fingerprint_device(user_agent, ip_address)

expires_at = None
if trust_days is not None:
expires_at = datetime.utcnow() + timedelta(days=trust_days)

device = TrustedDevice(
user_id=user_id,
device_token=device_token,
device_name=device_name,
device_fingerprint=fingerprint,
user_agent=user_agent,
ip_address=ip_address,
is_trusted=True,
expires_at=expires_at,
)
db.session.add(device)
db.session.commit()
return device


def verify_device_token(token: str, user_id: Optional[int] = None) -> Optional[TrustedDevice]:
"""
Verify a device token.
Returns the TrustedDevice if valid, trusted, and not expired. None otherwise.
"""
query = TrustedDevice.query.filter_by(device_token=token, is_trusted=True)
if user_id is not None:
query = query.filter_by(user_id=user_id)
device = query.first()

if not device:
return None

if device._is_expired():
return None

# Update last seen
device.last_seen_at = datetime.utcnow()
db.session.commit()
return device


def revoke_device(device_id: int, user_id: int) -> Optional[TrustedDevice]:
"""
Revoke trust for a specific device.
Returns the device if revoked, None if not found or not owned by user.
"""
device = TrustedDevice.query.filter_by(id=device_id, user_id=user_id).first()
if not device:
return None
device.is_trusted = False
db.session.commit()
return device


def revoke_all_devices(user_id: int, except_token: Optional[str] = None) -> int:
"""
Revoke all trusted devices for a user.
If except_token is given, that device is kept.
Returns count of revoked devices.
"""
query = TrustedDevice.query.filter_by(user_id=user_id, is_trusted=True)
if except_token:
query = query.filter(TrustedDevice.device_token != except_token)
devices = query.all()
for d in devices:
d.is_trusted = False
db.session.commit()
return len(devices)


def get_user_devices(user_id: int, include_revoked: bool = False) -> list[dict]:
"""Get all devices for a user."""
query = TrustedDevice.query.filter_by(user_id=user_id)
if not include_revoked:
query = query.filter_by(is_trusted=True)
devices = query.order_by(TrustedDevice.last_seen_at.desc()).all()
return [d.to_dict() for d in devices]


def recognize_device(
user_id: int,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> Optional[TrustedDevice]:
"""
Auto-recognize a device by fingerprint without requiring a token.
Returns a trusted, non-expired device if fingerprint matches.
"""
if not user_agent or not ip_address:
return None

fingerprint = _fingerprint_device(user_agent, ip_address)
device = TrustedDevice.query.filter_by(
user_id=user_id,
device_fingerprint=fingerprint,
is_trusted=True,
).first()

if device and device._is_expired():
return None

if device:
device.last_seen_at = datetime.utcnow()
db.session.commit()

return device
Loading