Fix camera/tracker dedup (device_type at provision + backfill) and finish refresh-token flow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
cbf40bd32a
commit
6eb6b4716c
6 changed files with 209 additions and 23 deletions
60
app/auth.py
60
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(
|
async def current_account(
|
||||||
token: Annotated[str, Depends(oauth2_scheme)],
|
token: Annotated[str, Depends(oauth2_scheme)],
|
||||||
) -> AuthAccount:
|
) -> AuthAccount:
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,21 @@ PROJECTED_FLAG_KEY = "live_positions_projected_at"
|
||||||
|
|
||||||
|
|
||||||
_PLATE_FROM_DEVICE_NAME = re.compile(r"^.* - (.+?)(?:_cam|_CAM)?$")
|
_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:
|
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,
|
imei=imei, vehicle_id=vehicle_id, plate=plate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
device_type = _classify_device_type(device_name)
|
||||||
await cur.execute(
|
await cur.execute(
|
||||||
"""INSERT INTO domain.devices
|
"""INSERT INTO domain.devices
|
||||||
(imei, account_id, vehicle_id, device_type, lifecycle, activation_at)
|
(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
|
ON CONFLICT (imei) DO UPDATE
|
||||||
SET account_id = EXCLUDED.account_id,
|
SET account_id = EXCLUDED.account_id,
|
||||||
vehicle_id = COALESCE(domain.devices.vehicle_id, EXCLUDED.vehicle_id),
|
vehicle_id = COALESCE(domain.devices.vehicle_id, EXCLUDED.vehicle_id),
|
||||||
lifecycle = CASE WHEN domain.devices.lifecycle = 'provisioned'
|
device_type = EXCLUDED.device_type,
|
||||||
THEN 'active' ELSE domain.devices.lifecycle END""",
|
lifecycle = CASE WHEN domain.devices.lifecycle = 'provisioned'
|
||||||
(imei, account_id, vehicle_id),
|
THEN 'active' ELSE domain.devices.lifecycle END""",
|
||||||
|
(imei, account_id, vehicle_id, device_type),
|
||||||
)
|
)
|
||||||
|
|
||||||
return vehicle_id
|
return vehicle_id
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ from app.auth import (
|
||||||
fetch_account,
|
fetch_account,
|
||||||
issue_access_token,
|
issue_access_token,
|
||||||
issue_refresh_token,
|
issue_refresh_token,
|
||||||
|
rotate_refresh_token,
|
||||||
store_refresh_token,
|
store_refresh_token,
|
||||||
|
touch_last_login,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -27,9 +29,20 @@ async def issue_token(
|
||||||
access, ttl = issue_access_token(account_id, scopes)
|
access, ttl = issue_access_token(account_id, scopes)
|
||||||
refresh, expires_at, refresh_hash = issue_refresh_token(account_id)
|
refresh, expires_at, refresh_hash = issue_refresh_token(account_id)
|
||||||
await store_refresh_token(account_id, refresh_hash, expires_at)
|
await store_refresh_token(account_id, refresh_hash, expires_at)
|
||||||
|
await touch_last_login(account_id)
|
||||||
|
|
||||||
return TokenPair(
|
return TokenPair(
|
||||||
access_token=access,
|
access_token=access,
|
||||||
refresh_token=refresh,
|
refresh_token=refresh,
|
||||||
expires_in=ttl,
|
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
|
||||||
|
|
|
||||||
32
db/migrations/20260601000021_backfill_camera_device_type.sql
Normal file
32
db/migrations/20260601000021_backfill_camera_device_type.sql
Normal file
|
|
@ -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';
|
||||||
19
tests/test_projector.py
Normal file
19
tests/test_projector.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -18,6 +18,13 @@ const VEHICLE_SOURCE = 'vehicles';
|
||||||
|
|
||||||
/* ---------- authClient ---------- */
|
/* ---------- 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 = {
|
export const authClient = {
|
||||||
isAuthenticated() {
|
isAuthenticated() {
|
||||||
const expiresAt = Number(localStorage.getItem(STORAGE_EXPIRES) || 0);
|
const expiresAt = Number(localStorage.getItem(STORAGE_EXPIRES) || 0);
|
||||||
|
|
@ -35,11 +42,37 @@ export const authClient = {
|
||||||
const detail = await res.json().catch(() => ({ detail: 'login failed' }));
|
const detail = await res.json().catch(() => ({ detail: 'login failed' }));
|
||||||
throw new Error(detail.detail || 'login failed');
|
throw new Error(detail.detail || 'login failed');
|
||||||
}
|
}
|
||||||
const payload = await res.json();
|
_saveTokens(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);
|
// Exchange the stored refresh token for a fresh access+refresh pair.
|
||||||
localStorage.setItem(STORAGE_EXPIRES, String(expiresAt));
|
// 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() {
|
logout() {
|
||||||
|
|
@ -70,15 +103,24 @@ export async function apiFetch(path, { params, ...opts } = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const token = localStorage.getItem(STORAGE_ACCESS);
|
const send = () => {
|
||||||
const res = await fetch(url.toString(), {
|
const token = localStorage.getItem(STORAGE_ACCESS);
|
||||||
...opts,
|
return fetch(url.toString(), {
|
||||||
headers: {
|
...opts,
|
||||||
...(opts.headers || {}),
|
headers: {
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(opts.headers || {}),
|
||||||
Accept: 'application/json',
|
...(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) {
|
if (res.status === 401) {
|
||||||
authClient.logout();
|
authClient.logout();
|
||||||
window.location.href = '/login.html';
|
window.location.href = '/login.html';
|
||||||
|
|
@ -1121,10 +1163,13 @@ function _fmtNum(v, digits) {
|
||||||
|
|
||||||
async function _downloadTripsCsv(vehicleId, dateStr) {
|
async function _downloadTripsCsv(vehicleId, dateStr) {
|
||||||
const url = `/api/views/vehicle/${vehicleId}/trips.csv?date=${encodeURIComponent(dateStr)}`;
|
const url = `/api/views/vehicle/${vehicleId}/trips.csv?date=${encodeURIComponent(dateStr)}`;
|
||||||
|
const send = () =>
|
||||||
|
fetch(url, { headers: { Authorization: `Bearer ${authClient.getToken()}` } });
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url, {
|
let r = await send();
|
||||||
headers: { Authorization: `Bearer ${authClient.getToken()}` },
|
if (r.status === 401 && (await authClient.refresh())) {
|
||||||
});
|
r = await send();
|
||||||
|
}
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
const blob = await r.blob();
|
const blob = await r.blob();
|
||||||
const cd = r.headers.get('Content-Disposition') || '';
|
const cd = r.headers.get('Content-Disposition') || '';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue