Skip to content

Commit c29f5fe

Browse files
committed
Add passkey support
1 parent fecdd37 commit c29f5fe

30 files changed

Lines changed: 2347 additions & 4 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Passkeys;
6+
7+
use App\Support\Passkeys\RelyingPartyIdResolver;
8+
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
9+
10+
final class ConfigureCeremonyStepManagerFactoryAction extends \Spatie\LaravelPasskeys\Actions\ConfigureCeremonyStepManagerFactoryAction
11+
{
12+
public function execute(): CeremonyStepManagerFactory
13+
{
14+
$factory = parent::execute();
15+
16+
$request = request();
17+
$rpId = RelyingPartyIdResolver::resolve($request);
18+
$origins = [$request->getSchemeAndHttpHost()];
19+
20+
// HTTPS origins for normal environments.
21+
$origins[] = "https://{$rpId}";
22+
23+
// Local development allowance for localhost over HTTP.
24+
if ($rpId === 'localhost') {
25+
$origins[] = 'http://localhost';
26+
}
27+
28+
$factory->setAllowedOrigins(array_values(array_unique($origins)));
29+
30+
return $factory;
31+
}
32+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Passkeys;
6+
7+
use App\Support\Passkeys\RelyingPartyIdResolver;
8+
use Spatie\LaravelPasskeys\Actions\ConfigureCeremonyStepManagerFactoryAction;
9+
use Spatie\LaravelPasskeys\Actions\FindPasskeyToAuthenticateAction as BaseFindPasskeyToAuthenticateAction;
10+
use Spatie\LaravelPasskeys\Models\Passkey;
11+
use Spatie\LaravelPasskeys\Support\Config;
12+
use Spatie\LaravelPasskeys\Support\CredentialRecordConverter;
13+
use Throwable;
14+
use Webauthn\AuthenticatorAssertionResponseValidator;
15+
use Webauthn\PublicKeyCredential;
16+
use Webauthn\PublicKeyCredentialRequestOptions;
17+
use Webauthn\PublicKeyCredentialSource;
18+
19+
final class FindPasskeyToAuthenticateAction extends BaseFindPasskeyToAuthenticateAction
20+
{
21+
protected function determinePublicKeyCredentialSource(
22+
PublicKeyCredential $publicKeyCredential,
23+
PublicKeyCredentialRequestOptions $passkeyOptions,
24+
Passkey $passkey,
25+
): ?PublicKeyCredentialSource {
26+
$configureCeremonyStepManagerFactoryAction = Config::getAction(
27+
'configure_ceremony_step_manager_factory',
28+
ConfigureCeremonyStepManagerFactoryAction::class
29+
);
30+
$csmFactory = $configureCeremonyStepManagerFactoryAction->execute();
31+
$requestCsm = $csmFactory->requestCeremony();
32+
33+
$host = RelyingPartyIdResolver::resolve();
34+
35+
try {
36+
$validator = AuthenticatorAssertionResponseValidator::create($requestCsm);
37+
38+
$publicKeyCredentialSource = $validator->check(
39+
$passkey->data,
40+
$publicKeyCredential->response,
41+
$passkeyOptions,
42+
$host,
43+
null,
44+
);
45+
} catch (Throwable) {
46+
return null;
47+
}
48+
49+
return CredentialRecordConverter::toPublicKeyCredentialSource($publicKeyCredentialSource);
50+
}
51+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Passkeys;
6+
7+
use App\Support\Passkeys\RelyingPartyIdResolver;
8+
use Illuminate\Support\Facades\Session;
9+
use Illuminate\Support\Str;
10+
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyAuthenticationOptionsAction as BaseGeneratePasskeyAuthenticationOptionsAction;
11+
use Spatie\LaravelPasskeys\Support\Serializer;
12+
use Webauthn\PublicKeyCredentialRequestOptions;
13+
14+
final class GeneratePasskeyAuthenticationOptionsAction extends BaseGeneratePasskeyAuthenticationOptionsAction
15+
{
16+
public function execute(): string
17+
{
18+
$rpId = RelyingPartyIdResolver::resolve();
19+
20+
$options = new PublicKeyCredentialRequestOptions(
21+
challenge: Str::random(),
22+
rpId: $rpId,
23+
allowCredentials: [],
24+
);
25+
26+
$options = Serializer::make()->toJson($options);
27+
28+
Session::flash('passkey-authentication-options', $options);
29+
30+
return $options;
31+
}
32+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Passkeys;
6+
7+
use App\Support\Passkeys\RelyingPartyIdResolver;
8+
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyRegisterOptionsAction as BaseGeneratePasskeyRegisterOptionsAction;
9+
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
10+
use Spatie\LaravelPasskeys\Support\Config;
11+
use Webauthn\PublicKeyCredentialCreationOptions;
12+
use Webauthn\PublicKeyCredentialRpEntity;
13+
14+
final class GeneratePasskeyRegisterOptionsAction extends BaseGeneratePasskeyRegisterOptionsAction
15+
{
16+
public function execute(
17+
HasPasskeys $authenticatable,
18+
bool $asJson = true,
19+
): string|PublicKeyCredentialCreationOptions {
20+
$options = parent::execute($authenticatable, $asJson);
21+
22+
if (! $asJson || ! is_string($options)) {
23+
return $options;
24+
}
25+
26+
$decoded = json_decode($options, true);
27+
if (! is_array($decoded)) {
28+
return $options;
29+
}
30+
31+
$supportedAlgorithms = [
32+
['type' => 'public-key', 'alg' => -7], // ES256
33+
['type' => 'public-key', 'alg' => -257], // RS256
34+
];
35+
36+
// Different serializer/browser integrations can use either shape:
37+
// - options.pubKeyCredParams (WebAuthn JSON)
38+
// - options.publicKey.pubKeyCredParams (navigator.credentials.create payload)
39+
// Enforce valid algorithms for both to prevent "alg undefined" errors.
40+
$decoded['pubKeyCredParams'] = $supportedAlgorithms;
41+
42+
if (isset($decoded['publicKey']) && is_array($decoded['publicKey'])) {
43+
$decoded['publicKey']['pubKeyCredParams'] = $supportedAlgorithms;
44+
}
45+
46+
return json_encode($decoded, JSON_THROW_ON_ERROR);
47+
}
48+
49+
protected function relatedPartyEntity(): PublicKeyCredentialRpEntity
50+
{
51+
$rpId = RelyingPartyIdResolver::resolve();
52+
53+
return new PublicKeyCredentialRpEntity(
54+
name: (string) Config::getRelyingPartyName(),
55+
id: $rpId,
56+
icon: Config::getRelyingPartyIcon(),
57+
);
58+
}
59+
}

app/Http/Controllers/Admin/AdminUserController.php

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
use App\Models\UserDownload;
1313
use App\Models\UserRequest;
1414
use Illuminate\Contracts\View\View;
15+
use Illuminate\Http\JsonResponse;
1516
use Illuminate\Http\RedirectResponse;
1617
use Illuminate\Http\Request;
18+
use Illuminate\Support\Facades\Auth;
1719
use Illuminate\Support\Facades\Log;
20+
use Spatie\LaravelPasskeys\Models\Passkey;
1821
use Spatie\Permission\Models\Role;
1922

2023
class AdminUserController extends BasePageController
@@ -148,7 +151,7 @@ public function edit(Request $request)
148151
// Check if role is changing and get stack preference
149152
$roleChanged = $editedUser->roles_id != $request->input('role');
150153
$stackRole = $request->input('stack_role') ? true : false; // Check if checkbox is checked
151-
$changedBy = auth()->check() ? auth()->id() : null;
154+
$changedBy = Auth::check() ? Auth::id() : null;
152155

153156
// CRITICAL: Capture the ORIGINAL rolechangedate BEFORE any updates
154157
// This is needed for accurate role history tracking
@@ -269,6 +272,8 @@ public function edit(Request $request)
269272

270273
// Add daily API and download counts
271274
if ($user) {
275+
$user->load('passkeys');
276+
272277
try {
273278
$user->daily_api_count = UserRequest::getApiRequests($user->id);
274279
$user->daily_download_count = UserDownload::getDownloadRequests($user->id);
@@ -349,4 +354,63 @@ public function verify(Request $request): RedirectResponse
349354

350355
return redirect()->back()->with('error', 'User is invalid');
351356
}
357+
358+
public function destroyPasskey(Request $request, Passkey $passkey): RedirectResponse|JsonResponse
359+
{
360+
$targetUser = $passkey->authenticatable;
361+
$targetUserId = $targetUser?->id;
362+
$targetUsername = $targetUser?->username;
363+
$passkeyName = $passkey->name;
364+
365+
$passkey->delete();
366+
367+
Log::channel('admin')->info('Admin deleted user passkey', [
368+
'admin_user_id' => Auth::id(),
369+
'target_user_id' => $targetUserId,
370+
'target_username' => $targetUsername,
371+
'passkey_id' => $passkey->id,
372+
'passkey_name' => $passkeyName,
373+
]);
374+
375+
if ($request->expectsJson()) {
376+
return response()->json(['ok' => true]);
377+
}
378+
379+
$redirectUrl = $targetUserId
380+
? 'admin/user-edit?id='.$targetUserId
381+
: 'admin/user-list';
382+
383+
return redirect()->to($redirectUrl)->with('success', 'Passkey deleted successfully.');
384+
}
385+
386+
public function wipePasskeys(Request $request): RedirectResponse|JsonResponse
387+
{
388+
$validated = $request->validate([
389+
'user_id' => ['required', 'integer', 'exists:users,id'],
390+
'confirmation' => ['required', 'string', 'in:WIPE'],
391+
]);
392+
393+
$target = User::findOrFail((int) $validated['user_id']);
394+
$wipedCount = $target->passkeys()->count();
395+
$target->passkeys()->delete();
396+
397+
Log::channel('admin')->warning('Admin wiped user passkeys', [
398+
'admin_user_id' => Auth::id(),
399+
'target_user_id' => $target->id,
400+
'target_username' => $target->username,
401+
'wiped_count' => $wipedCount,
402+
]);
403+
404+
$message = "{$wipedCount} passkeys removed from {$target->username}. They can now regain access with their password or an admin-issued reset.";
405+
406+
if ($request->expectsJson()) {
407+
return response()->json([
408+
'ok' => true,
409+
'wiped_count' => $wipedCount,
410+
'message' => $message,
411+
]);
412+
}
413+
414+
return redirect()->to('admin/user-edit?id='.$target->id)->with('success', $message);
415+
}
352416
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Auth;
6+
7+
use App\Events\UserLoggedIn;
8+
use App\Http\Controllers\Controller;
9+
use App\Models\User;
10+
use App\Support\CaptchaHelper;
11+
use Illuminate\Http\RedirectResponse;
12+
use Illuminate\Support\Facades\Auth;
13+
use Illuminate\Support\Facades\Log;
14+
use Illuminate\Support\Facades\Session;
15+
use Illuminate\Support\Facades\Validator;
16+
use Spatie\LaravelPasskeys\Actions\FindPasskeyToAuthenticateAction;
17+
use Spatie\LaravelPasskeys\Events\PasskeyUsedToAuthenticateEvent;
18+
use Spatie\LaravelPasskeys\Http\Requests\AuthenticateUsingPasskeysRequest;
19+
use Spatie\LaravelPasskeys\Support\Config;
20+
21+
final class PasskeyLoginController extends Controller
22+
{
23+
public function __invoke(AuthenticateUsingPasskeysRequest $request): RedirectResponse
24+
{
25+
$captchaRules = CaptchaHelper::getValidationRules();
26+
$captchaField = CaptchaHelper::getResponseFieldName();
27+
$captchaValue = $request->input($captchaField);
28+
29+
// Passkey login should not be blocked when captcha widgets rotate/expire
30+
// during WebAuthn prompts. If a captcha token is present, still validate it.
31+
if ($captchaRules !== [] && is_string($captchaValue) && trim($captchaValue) !== '') {
32+
$validator = Validator::make($request->all(), $captchaRules);
33+
if ($validator->fails()) {
34+
Log::channel('failed_login')->error(
35+
'Failed passkey login captcha check from IP address: '.$request->ip()
36+
);
37+
38+
session()->flash('authenticatePasskey::message', $validator->errors()->first());
39+
session()->flash('authenticatePasskey::reason', 'captcha');
40+
41+
return back();
42+
}
43+
}
44+
45+
$findAuthenticatableUsingPasskey = Config::getAction(
46+
'find_passkey',
47+
FindPasskeyToAuthenticateAction::class
48+
);
49+
50+
$passkey = $findAuthenticatableUsingPasskey->execute(
51+
$request->input('start_authentication_response'),
52+
Session::get('passkey-authentication-options'),
53+
);
54+
55+
if (! $passkey || ! $passkey->authenticatable instanceof User) {
56+
Log::channel('failed_login')->error(
57+
'Failed passkey login attempt from IP address: '.$request->ip()
58+
);
59+
60+
session()->flash('authenticatePasskey::message', __('passkeys::passkeys.invalid'));
61+
session()->flash('authenticatePasskey::reason', 'invalid_passkey');
62+
63+
return back();
64+
}
65+
66+
$user = $passkey->authenticatable;
67+
68+
if ($user->trashed()) {
69+
Log::channel('failed_login')->error(
70+
'Failed passkey login for deactivated user: '.$user->username.' from IP address: '.$request->ip()
71+
);
72+
73+
session()->flash(
74+
'authenticatePasskey::message',
75+
'This account has been deactivated. Please contact us through contact form to have your account reactivated.'
76+
);
77+
78+
return back();
79+
}
80+
81+
if (! $user->hasVerifiedEmail()) {
82+
Log::channel('failed_login')->error(
83+
'Failed passkey login for unverified user: '.$user->username.' from IP address: '.$request->ip()
84+
);
85+
86+
session()->flash('authenticatePasskey::message', 'You have not verified your email address!');
87+
88+
return back();
89+
}
90+
91+
Auth::login($user, $request->boolean('remember'));
92+
$request->session()->regenerate();
93+
94+
// Passkey auth is treated as sufficient MFA, so skip additional OTP gate.
95+
session([config('google2fa.session_var') => true]);
96+
session([config('google2fa.session_var').'.auth.passed_at' => time()]);
97+
98+
$userIp = config('nntmux:settings.store_user_ip') ? ($request->ip() ?? $request->getClientIp()) : '';
99+
event(new UserLoggedIn($user, $userIp));
100+
event(new PasskeyUsedToAuthenticateEvent($passkey, $request));
101+
102+
$url = Session::has('passkeys.redirect')
103+
? (string) Session::pull('passkeys.redirect')
104+
: (string) config('passkeys.redirect_to_after_login', '/');
105+
106+
return redirect($url)->with('info', 'You have been logged in');
107+
}
108+
}

0 commit comments

Comments
 (0)