diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index d856e80..7793af3 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Events\QuickCodeLogin; +use App\Http\Requests\NfcLoginRequest; use App\Http\Requests\QuickCodeRequest; use App\Models\Kiosk; use App\Models\QuickCode; @@ -112,6 +113,45 @@ public function postQuickcode(QuickCodeRequest $request): JsonResponse|RedirectR : redirect()->route('volunteer.index'); } + /** + * Logs the user in via an NFC badge tap, relayed from a workstation-local ConcatNFCValidator instance. Only usable + * from sessions already authorized as a Kiosk - trusting a client-submitted attendeeId for login is only + * reasonable in that physically-trusted context. + */ + public function postNfc(NfcLoginRequest $request): JsonResponse|RedirectResponse { + if (!Kiosk::isSessionAuthorized(true)) { + $error = 'NFC login is only available on authorized kiosks.'; + return $request->expectsJson() + ? response()->json(['error' => $error], 403) + : redirect()->back()->withErrors(['attendeeId' => $error]); + } + + // Prevent too many rapid failed attempts + $rateLimitKey = "nfc:{$request->ip()}"; + if (RateLimiter::tooManyAttempts($rateLimitKey, $perMinute = 5)) { + $error = 'Too many failed NFC login attempts have been made from this location. Try again in a minute.'; + return $request->expectsJson() + ? response()->json(['error' => $error], 429) + : redirect()->back()->withErrors(['attendeeId' => $error]); + } + + $user = User::whereBadgeId($request->attendeeId)->first(); + + if (!$user) { + RateLimiter::hit($rateLimitKey); + $error = 'No Tracker account is linked to this badge.'; + return $request->expectsJson() + ? response()->json(['errors' => $error], 401) + : redirect()->back()->withErrors(['attendeeId' => $error]); + } + + Auth::login($user); + + return $request->expectsJson() + ? response()->json(null, 205) + : redirect()->route('volunteer.index'); + } + /** * Display the banned notice */ diff --git a/app/Http/Requests/NfcLoginRequest.php b/app/Http/Requests/NfcLoginRequest.php new file mode 100644 index 0000000..43374c3 --- /dev/null +++ b/app/Http/Requests/NfcLoginRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array { + return [ + 'attendeeId' => 'required|integer', + ]; + } +} diff --git a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue index 27f80c3..4f70f8c 100644 --- a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue +++ b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue @@ -14,6 +14,7 @@ Return ⏎ key press after the badge number. +

@@ -55,9 +56,10 @@ diff --git a/resources/js/Components/User/UsersTable.vue b/resources/js/Components/User/UsersTable.vue index 88d1d1e..e08df71 100644 --- a/resources/js/Components/User/UsersTable.vue +++ b/resources/js/Components/User/UsersTable.vue @@ -80,12 +80,13 @@ diff --git a/resources/js/Components/Volunteer/VolunteerSearchTable.vue b/resources/js/Components/Volunteer/VolunteerSearchTable.vue index 9e648fb..32e83de 100644 --- a/resources/js/Components/Volunteer/VolunteerSearchTable.vue +++ b/resources/js/Components/Volunteer/VolunteerSearchTable.vue @@ -87,9 +87,10 @@ diff --git a/resources/js/Pages/Login.vue b/resources/js/Pages/Login.vue index 9311de7..7d18d57 100644 --- a/resources/js/Pages/Login.vue +++ b/resources/js/Pages/Login.vue @@ -58,13 +58,29 @@
+ + + + + + + + + {{ nfcStatusText }} + + diff --git a/resources/js/lib/nfcLogin.ts b/resources/js/lib/nfcLogin.ts new file mode 100644 index 0000000..5d6fb4e --- /dev/null +++ b/resources/js/lib/nfcLogin.ts @@ -0,0 +1,27 @@ +import { useInertiaRequest } from './request'; +import { useNfcTap, type NfcTap } from './nfcTap'; + +/** + * Logs in the Tracker user matching a badge tapped on a workstation-local ConcatNFCValidator instance. Only meaningful on + * sessions authorized as a Kiosk (callers should gate on that separately - this composable has no opinion on it + * beyond that the backend login endpoint enforces it too). + */ +export function useNfcLogin() { + const inertiaRequest = useInertiaRequest(); + + return useNfcTap( + (tap: NfcTap) => + new Promise((resolve, reject) => { + inertiaRequest.post( + 'auth.nfc.post', + { attendeeId: tap.attendeeId }, + { + onSuccess: () => resolve(), + onError(errors) { + reject(new Error(errors.attendeeId ?? Object.values(errors)[0] ?? 'NFC login failed.')); + }, + }, + ); + }), + ); +} diff --git a/resources/js/lib/nfcTap.ts b/resources/js/lib/nfcTap.ts new file mode 100644 index 0000000..1cf35ae --- /dev/null +++ b/resources/js/lib/nfcTap.ts @@ -0,0 +1,146 @@ +import { ref } from 'vue'; + +const VALIDATOR_BASE_URL = 'http://localhost:7071'; + +const HEALTH_POLL_MS = 5000; +const STATUS_POLL_MS = 1500; + +export type NfcTapStage = 'idle' | 'searching' | 'waiting' | 'processing' | 'error'; + +export interface NfcTap { + uid: string; + attendeeId: number; + conventionId?: number; + scannedAt?: string; +} + +interface HealthResponse { + alive: boolean; + readerReady: boolean; +} + +interface StatusResponse { + valid: boolean; + uid?: string; + attendeeId?: number; + conventionId?: number; + scannedAt?: string; +} + +/** + * Polls a workstation-local ConcatNFCValidator instance for a validated badge tap and invokes onTap for + * each one. onTap is awaited - the stage is 'processing' while it's pending, and if it throws, the stage becomes + * 'error' with the message exposed via `error` until the next tap comes in. The scan is acknowledged (so the + * validator clears it and accepts the next one) regardless of whether onTap succeeds. + */ +export function useNfcTap(onTap: (tap: NfcTap) => void | Promise) { + const stage = ref('idle'); + const error = ref(null); + + let generation = 0; + + /** + * Begins polling the validator. Safe to call again to restart after stop(). + */ + function start() { + if (stage.value !== 'idle' && stage.value !== 'error') return; + error.value = null; + stage.value = 'searching'; + const myGeneration = ++generation; + void pollHealth(myGeneration); + } + + /** + * Stops polling. Any in-flight request's result will be ignored once it resolves. + */ + function stop() { + generation++; + stage.value = 'idle'; + } + + /** + * Polls /health until the validator answers, then switches to polling /status + */ + async function pollHealth(myGeneration: number) { + if (myGeneration !== generation) return; + + try { + const res = await fetch(`${VALIDATOR_BASE_URL}/health`, { signal: AbortSignal.timeout(3000) }); + if (!res.ok) throw new Error(`Unexpected status ${res.status}`); + await (res.json() as Promise); + + if (myGeneration !== generation) return; + stage.value = 'waiting'; + void pollStatus(myGeneration); + return; + } catch { + // Validator not reachable - fall through to marking it as searching and retrying below + } + + if (myGeneration !== generation) return; + stage.value = 'searching'; + setTimeout(() => void pollHealth(myGeneration), HEALTH_POLL_MS); + } + + /** + * Polls /status for a validated badge tap. Falls back to pollHealth if the validator stops responding. + */ + async function pollStatus(myGeneration: number) { + if (myGeneration !== generation) return; + + let status: StatusResponse; + try { + const res = await fetch(`${VALIDATOR_BASE_URL}/status`, { signal: AbortSignal.timeout(3000) }); + if (!res.ok) throw new Error(`Unexpected status ${res.status}`); + status = await res.json(); + } catch { + if (myGeneration !== generation) return; + // A single missed poll doesn't necessarily mean the validator is down - recheck health right away + // instead of dropping to 'searching' (and hiding the tap-in UI) over one transient blip. + void pollHealth(myGeneration); + return; + } + + if (myGeneration !== generation) return; + + if (!status.valid || status.attendeeId == null || !status.uid) { + setTimeout(() => void pollStatus(myGeneration), STATUS_POLL_MS); + return; + } + + const tap: NfcTap = { + uid: status.uid, + attendeeId: status.attendeeId, + conventionId: status.conventionId, + scannedAt: status.scannedAt, + }; + + error.value = null; + stage.value = 'processing'; + + // Acknowledge the scan so the validator can clear it and accept the next one, regardless of whether + // onTap below succeeds. + fetch(`${VALIDATOR_BASE_URL}/ack`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uid: tap.uid }), + }).catch(() => { + // Non-fatal - the validator will expire the pending scan on its own after a while + }); + + try { + await onTap(tap); + if (myGeneration !== generation) return; + stage.value = 'waiting'; + } catch (err) { + if (myGeneration !== generation) return; + error.value = err instanceof Error ? err.message : 'Failed to handle badge tap.'; + stage.value = 'error'; + } + + if (myGeneration !== generation) return; + setTimeout(() => void pollStatus(myGeneration), STATUS_POLL_MS); + } + + return { stage, error, start, stop }; +} diff --git a/routes/web.php b/routes/web.php index b41132c..03670f4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ Route::get('/auth/redirect', 'getRedirect')->name('auth.redirect'); Route::get('/auth/callback', 'getCallback')->name('auth.callback'); Route::post('/auth/quickcode', 'postQuickcode')->name('auth.quickcode.post'); + Route::post('/auth/nfc', 'postNfc')->name('auth.nfc.post'); Route::get('/disabled/account', 'getBanned')->name('auth.banned'); });