90a4459e63
Pair code: - POST /api/auth/owner/pair-code (owner-gated) mints a single-use, 5-minute, 6-digit code held in memory. - POST /api/auth/owner/pair-redeem swaps the code for an owner cookie, with basic IP-based rate limiting (max 8 attempts/min). - OwnerLoginModal gains a tab: "Use 6-digit code" (default) vs the long secret. The PIN input is big, monospaced, numeric-only. - Small smartphone icon next to the connected Tesla chip opens OwnerPairCodeDisplay, which shows the code in 56px type with a live countdown so it's tappable on the touchscreen of the other device while you stare at it. Responsive layout: - TopBar wraps when narrower than the chip row. minWidth on OD strip drops 380→280 and chat composer drops 360→240 so they stack cleanly. - Main body switches to flex-col below the lg breakpoint (Tailwind 1024px), so the map sits on top of the stops rail on Tesla 3/Y portrait and any window not maximised. Map gets 40vh min-height so it never disappears.
109 lines
3.6 KiB
TypeScript
109 lines
3.6 KiB
TypeScript
import { Router } from 'express';
|
|
import crypto from 'node:crypto';
|
|
import { env } from '../config/env.js';
|
|
import { createLogger } from '../lib/logger.js';
|
|
import {
|
|
clearOwnerCookie,
|
|
isOwnerAuthenticated,
|
|
setOwnerCookie,
|
|
verifyOwnerSecret,
|
|
} from '../lib/ownerAuth.js';
|
|
|
|
const log = createLogger('owner');
|
|
const router = Router();
|
|
|
|
// ─── Device pair-code store (in-memory) ─────────────────────────────────────
|
|
// 6-digit short codes minted by an already-logged-in owner session so a
|
|
// touchscreen device (the Tesla in-car browser) can log in without retyping
|
|
// the long OWNER_SECRET. 5-minute TTL, single use, rate-limited.
|
|
const PAIR_CODE_TTL_MS = 5 * 60 * 1000;
|
|
const pairCodes = new Map<string, { expiresAt: number }>();
|
|
// IP → recent redeem attempt timestamps (oldest first).
|
|
const redeemAttempts = new Map<string, number[]>();
|
|
const MAX_REDEEM_PER_MIN = 8;
|
|
|
|
function cleanPairCodes(): void {
|
|
const now = Date.now();
|
|
for (const [k, v] of pairCodes) if (v.expiresAt < now) pairCodes.delete(k);
|
|
}
|
|
|
|
function rateLimited(ip: string): boolean {
|
|
const now = Date.now();
|
|
const arr = (redeemAttempts.get(ip) || []).filter(t => now - t < 60_000);
|
|
arr.push(now);
|
|
redeemAttempts.set(ip, arr);
|
|
return arr.length > MAX_REDEEM_PER_MIN;
|
|
}
|
|
|
|
router.get('/api/auth/owner/status', (req, res) => {
|
|
res.json({
|
|
authenticated: isOwnerAuthenticated(req),
|
|
required: !!env.ownerSecret,
|
|
});
|
|
});
|
|
|
|
router.post('/api/auth/owner', (req, res) => {
|
|
if (!env.ownerSecret) {
|
|
res.status(503).json({ ok: false, reason: 'owner_auth_not_configured' });
|
|
return;
|
|
}
|
|
const { secret } = (req.body || {}) as { secret?: unknown };
|
|
if (!verifyOwnerSecret(secret)) {
|
|
res.status(401).json({ ok: false, reason: 'invalid_secret' });
|
|
return;
|
|
}
|
|
setOwnerCookie(res);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.post('/api/auth/owner/logout', (_req, res) => {
|
|
clearOwnerCookie(res);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ─── Pair code: mint one (requires owner session) ──────────────────────────
|
|
router.post('/api/auth/owner/pair-code', (req, res) => {
|
|
if (!isOwnerAuthenticated(req)) {
|
|
res.status(401).json({ ok: false, reason: 'auth_required' });
|
|
return;
|
|
}
|
|
cleanPairCodes();
|
|
// Cap concurrent codes per host to a sane number.
|
|
if (pairCodes.size > 32) {
|
|
res.status(429).json({ ok: false, reason: 'too_many_codes' });
|
|
return;
|
|
}
|
|
let code: string;
|
|
do {
|
|
code = String(crypto.randomInt(100_000, 1_000_000));
|
|
} while (pairCodes.has(code));
|
|
pairCodes.set(code, { expiresAt: Date.now() + PAIR_CODE_TTL_MS });
|
|
res.json({ ok: true, code, expiresInS: Math.floor(PAIR_CODE_TTL_MS / 1000) });
|
|
});
|
|
|
|
// ─── Pair code: redeem on a new device ──────────────────────────────────────
|
|
router.post('/api/auth/owner/pair-redeem', (req, res) => {
|
|
const ip = (req.ip || (req.headers['x-forwarded-for'] as string) || 'unknown').toString();
|
|
if (rateLimited(ip)) {
|
|
res.status(429).json({ ok: false, reason: 'rate_limited' });
|
|
return;
|
|
}
|
|
const { code } = (req.body || {}) as { code?: unknown };
|
|
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
|
|
res.status(400).json({ ok: false, reason: 'bad_code' });
|
|
return;
|
|
}
|
|
cleanPairCodes();
|
|
const entry = pairCodes.get(code);
|
|
if (!entry) {
|
|
res.status(401).json({ ok: false, reason: 'invalid_or_expired' });
|
|
return;
|
|
}
|
|
pairCodes.delete(code);
|
|
setOwnerCookie(res);
|
|
log.info({ ip }, 'Device paired via owner pair code');
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
export default router;
|