From 6eb6b4716cec46b8e040e95e349fc0feb1aa039c Mon Sep 17 00:00:00 2001 From: kianiadee Date: Fri, 29 May 2026 00:08:56 +0300 Subject: [PATCH] Fix camera/tracker dedup (device_type at provision + backfill) and finish refresh-token flow Co-Authored-By: Claude Opus 4.8 --- app/auth.py | 60 ++++++++++++++ app/projectors/live_positions.py | 29 +++++-- app/routers/auth.py | 13 +++ ...0601000021_backfill_camera_device_type.sql | 32 ++++++++ tests/test_projector.py | 19 +++++ web/fleet-core.js | 79 +++++++++++++++---- 6 files changed, 209 insertions(+), 23 deletions(-) create mode 100644 db/migrations/20260601000021_backfill_camera_device_type.sql create mode 100644 tests/test_projector.py diff --git a/app/auth.py b/app/auth.py index 22c4ba3..08a4aee 100644 --- a/app/auth.py +++ b/app/auth.py @@ -112,6 +112,66 @@ async def store_refresh_token(account_id: int, token_hash: str, expires_at: date ) +async def touch_last_login(account_id: int) -> None: + pool = await get_pool() + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + "UPDATE auth.accounts SET last_login_at = now() WHERE account_id = %s", + (account_id,), + ) + + +async def rotate_refresh_token(raw_token: str) -> TokenPair | None: + """Redeem a refresh token and rotate it. + + Returns a fresh access+refresh pair, or None when the token is unknown, + expired, already revoked, or belongs to a deactivated account. The old + token is revoked in the same transaction (single-use rotation), so a + replayed refresh token never yields a second valid pair — that also gives + us reuse detection if we want to act on it later. + """ + token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest() + pool = await get_pool() + async with pool.connection() as conn, conn.transaction(), conn.cursor() as cur: + await cur.execute( + """ + SELECT t.token_id, t.account_id, a.scopes + FROM auth.tokens t + JOIN auth.accounts a ON a.account_id = t.account_id + WHERE t.token_hash = %s + AND t.token_type = 'refresh' + AND t.revoked_at IS NULL + AND t.expires_at > now() + AND a.is_active = true + FOR UPDATE OF t + """, + (token_hash,), + ) + row = await cur.fetchone() + if row is None: + return None + token_id, account_id, scopes = int(row[0]), int(row[1]), list(row[2]) + + await cur.execute( + "UPDATE auth.tokens SET revoked_at = now() WHERE token_id = %s", + (token_id,), + ) + + access, ttl = issue_access_token(account_id, scopes) + new_raw, expires_at, new_hash = issue_refresh_token(account_id) + await cur.execute( + "INSERT INTO auth.tokens (account_id, token_type, token_hash, expires_at) " + "VALUES (%s, 'refresh', %s, %s)", + (account_id, new_hash, expires_at), + ) + await cur.execute( + "UPDATE auth.accounts SET last_login_at = now() WHERE account_id = %s", + (account_id,), + ) + + return TokenPair(access_token=access, refresh_token=new_raw, expires_in=ttl) + + async def current_account( token: Annotated[str, Depends(oauth2_scheme)], ) -> AuthAccount: diff --git a/app/projectors/live_positions.py b/app/projectors/live_positions.py index c0898c1..ebcce82 100644 --- a/app/projectors/live_positions.py +++ b/app/projectors/live_positions.py @@ -26,6 +26,21 @@ PROJECTED_FLAG_KEY = "live_positions_projected_at" _PLATE_FROM_DEVICE_NAME = re.compile(r"^.* - (.+?)(?:_cam|_CAM)?$") +_CAMERA_NAME_RE = re.compile(r"_cam$", re.IGNORECASE) + + +def _classify_device_type(device_name: str | None) -> str: + """Tracksolid device_name carries a '_cam' suffix for camera units + (e.g. "John Mbugua - KDW 573B_cam"). Everything else is a GPS tracker. + + The tracker-first dedup in serve.fn_live_view (PRD F1.6) depends on this + classification: when a camera and a tracker share one vehicle_id, the + accurate tracker fix must win. Provisioning every device as 'tracker' + silently defeats that tie-break, so classify at first sight here. + """ + if device_name and _CAMERA_NAME_RE.search(device_name): + return "camera" + return "tracker" def _extract_plate_from_device_name(device_name: str | None) -> str | None: @@ -105,16 +120,18 @@ async def _resolve_device( imei=imei, vehicle_id=vehicle_id, plate=plate, ) + device_type = _classify_device_type(device_name) await cur.execute( """INSERT INTO domain.devices (imei, account_id, vehicle_id, device_type, lifecycle, activation_at) - VALUES (%s, %s, %s, 'tracker', 'active', now()) + VALUES (%s, %s, %s, %s, 'active', now()) ON CONFLICT (imei) DO UPDATE - SET account_id = EXCLUDED.account_id, - vehicle_id = COALESCE(domain.devices.vehicle_id, EXCLUDED.vehicle_id), - lifecycle = CASE WHEN domain.devices.lifecycle = 'provisioned' - THEN 'active' ELSE domain.devices.lifecycle END""", - (imei, account_id, vehicle_id), + SET account_id = EXCLUDED.account_id, + vehicle_id = COALESCE(domain.devices.vehicle_id, EXCLUDED.vehicle_id), + device_type = EXCLUDED.device_type, + lifecycle = CASE WHEN domain.devices.lifecycle = 'provisioned' + THEN 'active' ELSE domain.devices.lifecycle END""", + (imei, account_id, vehicle_id, device_type), ) return vehicle_id diff --git a/app/routers/auth.py b/app/routers/auth.py index dd0a95d..5d65df6 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -5,7 +5,9 @@ from app.auth import ( fetch_account, issue_access_token, issue_refresh_token, + rotate_refresh_token, store_refresh_token, + touch_last_login, verify_password, ) @@ -27,9 +29,20 @@ async def issue_token( access, ttl = issue_access_token(account_id, scopes) refresh, expires_at, refresh_hash = issue_refresh_token(account_id) await store_refresh_token(account_id, refresh_hash, expires_at) + await touch_last_login(account_id) return TokenPair( access_token=access, refresh_token=refresh, expires_in=ttl, ) + + +@router.post("/refresh", response_model=TokenPair) +async def refresh( + refresh_token: str = Form(...), +) -> TokenPair: + pair = await rotate_refresh_token(refresh_token) + if pair is None: + raise HTTPException(status_code=401, detail="invalid or expired refresh token") + return pair diff --git a/db/migrations/20260601000021_backfill_camera_device_type.sql b/db/migrations/20260601000021_backfill_camera_device_type.sql new file mode 100644 index 0000000..f97b74e --- /dev/null +++ b/db/migrations/20260601000021_backfill_camera_device_type.sql @@ -0,0 +1,32 @@ +-- migrate:up +-- +-- Fix historical mis-classification of camera units. +-- +-- The auto-provisioner used to insert every device as device_type='tracker' +-- (app/projectors/live_positions.py). That silently defeated the tracker-first +-- dedup in serve.fn_live_view (PRD F1.6): when a '_cam' camera and an X3 +-- tracker were consolidated onto one vehicle_id, both rows read 'tracker', so +-- the dedup tie-break fell through to occurred_at DESC and the map flipped +-- between the tracker's accurate fix and the camera's. This re-tags any device +-- whose Tracksolid device_name carries the '_cam' suffix as a camera, matching +-- the new _classify_device_type() logic in the projector. +-- +-- Idempotent: only flips rows that aren't already 'camera'. + +UPDATE domain.devices d + SET device_type = 'camera', + updated_at = now() + FROM state.live_positions lp + WHERE lp.imei = d.imei + AND lp.device_name ~* '_cam$' + AND d.device_type <> 'camera'; + +-- migrate:down +-- +-- Revert to the prior (incorrect) state: everything a tracker. Kept only so the +-- migration is reversible; running it re-introduces the dedup bug. + +UPDATE domain.devices + SET device_type = 'tracker', + updated_at = now() + WHERE device_type = 'camera'; diff --git a/tests/test_projector.py b/tests/test_projector.py new file mode 100644 index 0000000..28aaca3 --- /dev/null +++ b/tests/test_projector.py @@ -0,0 +1,19 @@ +import pytest + +from app.projectors.live_positions import _classify_device_type + + +@pytest.mark.parametrize( + "device_name,expected", + [ + ("John Mbugua - KDW 573B_cam", "camera"), + ("John Mbugua - KDW 573B_CAM", "camera"), + ("John Mbugua - KDW 573B", "tracker"), + ("Parked - KMGK 596V", "tracker"), + ("JC400P-92732", "tracker"), + ("", "tracker"), + (None, "tracker"), + ], +) +def test_classify_device_type(device_name: str | None, expected: str) -> None: + assert _classify_device_type(device_name) == expected diff --git a/web/fleet-core.js b/web/fleet-core.js index 7fbbdf9..6eef16c 100644 --- a/web/fleet-core.js +++ b/web/fleet-core.js @@ -18,6 +18,13 @@ const VEHICLE_SOURCE = 'vehicles'; /* ---------- authClient ---------- */ +function _saveTokens(payload) { + const expiresAt = Math.floor(Date.now() / 1000) + Number(payload.expires_in || 900); + localStorage.setItem(STORAGE_ACCESS, payload.access_token); + localStorage.setItem(STORAGE_REFRESH, payload.refresh_token); + localStorage.setItem(STORAGE_EXPIRES, String(expiresAt)); +} + export const authClient = { isAuthenticated() { const expiresAt = Number(localStorage.getItem(STORAGE_EXPIRES) || 0); @@ -35,11 +42,37 @@ export const authClient = { const detail = await res.json().catch(() => ({ detail: 'login failed' })); throw new Error(detail.detail || 'login failed'); } - const payload = await res.json(); - const expiresAt = Math.floor(Date.now() / 1000) + Number(payload.expires_in || 900); - localStorage.setItem(STORAGE_ACCESS, payload.access_token); - localStorage.setItem(STORAGE_REFRESH, payload.refresh_token); - localStorage.setItem(STORAGE_EXPIRES, String(expiresAt)); + _saveTokens(await res.json()); + }, + + // Exchange the stored refresh token for a fresh access+refresh pair. + // Returns true on success; on any failure the session is cleared so the + // caller can fall back to the login screen. Single-flight: concurrent + // callers share one in-flight request so a burst of 401s rotates once. + async refresh() { + const refreshToken = localStorage.getItem(STORAGE_REFRESH); + if (!refreshToken) return false; + if (this._refreshing) return this._refreshing; + this._refreshing = (async () => { + try { + const res = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ refresh_token: refreshToken }), + }); + if (!res.ok) { + this.logout(); + return false; + } + _saveTokens(await res.json()); + return true; + } catch { + return false; + } finally { + this._refreshing = null; + } + })(); + return this._refreshing; }, logout() { @@ -70,15 +103,24 @@ export async function apiFetch(path, { params, ...opts } = {}) { } } } - const token = localStorage.getItem(STORAGE_ACCESS); - const res = await fetch(url.toString(), { - ...opts, - headers: { - ...(opts.headers || {}), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - Accept: 'application/json', - }, - }); + const send = () => { + const token = localStorage.getItem(STORAGE_ACCESS); + return fetch(url.toString(), { + ...opts, + headers: { + ...(opts.headers || {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + Accept: 'application/json', + }, + }); + }; + + let res = await send(); + // Access tokens are short-lived (15 min). On expiry, rotate the refresh + // token once and retry transparently rather than dumping the user at /login. + if (res.status === 401 && (await authClient.refresh())) { + res = await send(); + } if (res.status === 401) { authClient.logout(); window.location.href = '/login.html'; @@ -1121,10 +1163,13 @@ function _fmtNum(v, digits) { async function _downloadTripsCsv(vehicleId, dateStr) { const url = `/api/views/vehicle/${vehicleId}/trips.csv?date=${encodeURIComponent(dateStr)}`; + const send = () => + fetch(url, { headers: { Authorization: `Bearer ${authClient.getToken()}` } }); try { - const r = await fetch(url, { - headers: { Authorization: `Bearer ${authClient.getToken()}` }, - }); + let r = await send(); + if (r.status === 401 && (await authClient.refresh())) { + r = await send(); + } if (!r.ok) throw new Error(`HTTP ${r.status}`); const blob = await r.blob(); const cd = r.headers.get('Content-Disposition') || '';