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
40 changes: 40 additions & 0 deletions app/Http/Controllers/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down
25 changes: 25 additions & 0 deletions app/Http/Requests/NfcLoginRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class NfcLoginRequest extends FormRequest {
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool {
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array {
return [
'attendeeId' => 'required|integer',
];
}
}
17 changes: 16 additions & 1 deletion resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Return &#9166;
</kbd>
key press after the badge number.
<template v-if="nfcReady">NFC badge taps on a workstation-local reader are submitted the same way.</template>
</p>

<form @submit.prevent="create" @input="form.clearErrors()">
Expand Down Expand Up @@ -55,9 +56,10 @@
</template>

<script setup lang="ts">
import { useId, useTemplateRef } from 'vue';
import { useId, useTemplateRef, onMounted, onUnmounted, computed } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { useRoute } from '@/lib/route';
import { useNfcTap, type NfcTap } from '@/lib/nfcTap';
import type AttendeeLog from '@/data/AttendeeLog';

import { faUserPlus } from '@fortawesome/free-solid-svg-icons';
Expand Down Expand Up @@ -111,4 +113,17 @@ function create() {
},
});
}

// Submits like a badge scanner's Return keypress would, for stations with an NFC reader attached. Silent/
// best-effort - if no workstation-local ConcatNFCValidator is reachable this just quietly never fires. Not wired up
// for "Empower Gatekeeper" - that shouldn't happen from a stray tap with no separate confirmation.
const { stage: nfcStage, start: startNfcTap, stop: stopNfcTap } = useNfcTap((tap: NfcTap) => {
form.badge_id = String(tap.attendeeId);
create();
});
const nfcReady = computed(() => nfcStage.value !== 'idle' && nfcStage.value !== 'searching');
onMounted(() => {
if (!gatekeeper) startNfcTap();
});
onUnmounted(() => stopNfcTap());
</script>
12 changes: 11 additions & 1 deletion resources/js/Components/User/UsersTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
import vueDebounce from 'vue-debounce';
import { FilterMatchMode } from '@primevue/core/api';
import type { DataTableFilterEvent, DataTablePageEvent, DataTableSortEvent } from 'primevue/datatable';

import { useInertiaRequest } from '@/lib/request';
import { useNfcTap, type NfcTap } from '@/lib/nfcTap';
import { roleNames, useUser } from '@/lib/user';
import User from '@/data/impl/User';
import type RawUser from '@/data/User';
Expand Down Expand Up @@ -168,4 +169,13 @@ async function loadPage(evt: DataTablePageEvent | DataTableSortEvent | DataTable
role: filters.value.role.value ?? undefined,
});
}

// Autofill the ID filter with a tapped badge's ID, for lookups on a workstation with an NFC reader attached.
// Silent/best-effort - if no workstation-local ConcatNFCValidator is reachable this just quietly never fires.
const { start: startNfcTap, stop: stopNfcTap } = useNfcTap((tap: NfcTap) => {
filters.value.badge_id.value = tap.attendeeId;
return request.get('users.index', { badge_id: tap.attendeeId });
});
onMounted(() => startNfcTap());
onUnmounted(() => stopNfcTap());
</script>
12 changes: 11 additions & 1 deletion resources/js/Components/Volunteer/VolunteerSearchTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ref, onMounted, onUnmounted } from 'vue';
import vueDebounce from 'vue-debounce';
import { useRequest } from '@/lib/request';
import { useNfcTap, type NfcTap } from '@/lib/nfcTap';
import User from '@/data/impl/User';
import type RawUser from '@/data/User';
import type { UserId } from '@/data/User';
Expand Down Expand Up @@ -140,4 +141,13 @@ async function searchUsers() {
function getDepartment(user: User): Department | undefined {
return user.time_entries?.[0]?.department;
}

// Autofill the search field with a tapped badge's ID, for lookups on a workstation with an NFC reader attached.
// Silent/best-effort - if no workstation-local ConcatNFCValidator is reachable this just quietly never fires.
const { start: startNfcTap, stop: stopNfcTap } = useNfcTap((tap: NfcTap) => {
query.value = String(tap.attendeeId);
return searchUsers();
});
onMounted(() => startNfcTap());
onUnmounted(() => stopNfcTap());
</script>
43 changes: 43 additions & 0 deletions resources/js/Pages/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,29 @@
</Button>
</form>
</Panel>

<Panel v-if="isKiosk && nfcReady" class="mt-4">
<template #header><h2 class="grow ms-4 text-center text-xl font-thin">Badge Tap-In</h2></template>

<Message :severity="nfcMessageSeverity" :pt="{ content: { class: 'flex min-h-12 items-center justify-center gap-2' } }">
<FontAwesomeIcon v-if="nfcStage === 'processing'" :icon="faSpinner" spin />
<FontAwesomeIcon v-else-if="nfcStage === 'waiting'" :icon="faIdCardClip" />
<FontAwesomeIcon v-else :icon="faTriangleExclamation" />
<span>{{ nfcStatusText }}</span>
</Message>
</Panel>
</Panel>
</div>
</template>

<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faIdCardClip, faSpinner, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { useRoute } from '@/lib/route';
import { useAppSettings } from '@/lib/settings';
import { useNfcLogin } from '@/lib/nfcLogin';

import BaseLayout from '@/Layouts/BaseLayout.vue';
import NavlessLayout from '@/Layouts/NavlessLayout.vue';
Expand All @@ -82,4 +98,31 @@ const qcForm = useForm({ code: '' });
function submitQuickCode() {
qcForm.post(route('auth.quickcode.post'), { replace: true });
}

const { isKiosk } = useAppSettings();
const { stage: nfcStage, error: nfcError, start: startNfcLogin, stop: stopNfcLogin } = useNfcLogin();

// Only show the panel once the validator has actually answered a health check - no point showing a "sign in
// with your badge" option that's silently unusable because no reader is plugged in/running on this kiosk.
const nfcReady = computed(() => nfcStage.value !== 'idle' && nfcStage.value !== 'searching');

const nfcStatusText = computed(() => {
switch (nfcStage.value) {
case 'waiting':
return 'Tap your badge on the reader to sign in.';
case 'processing':
return 'Badge read, signing you in...';
case 'error':
return nfcError.value ?? 'NFC login failed.';
default:
return '';
}
});

const nfcMessageSeverity = computed(() => (nfcStage.value === 'error' ? 'error' : 'secondary'));

onMounted(() => {
if (isKiosk.value) startNfcLogin();
});
onUnmounted(() => stopNfcLogin());
</script>
27 changes: 27 additions & 0 deletions resources/js/lib/nfcLogin.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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.'));
},
},
);
}),
);
}
Loading