mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Add passkey support
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Passkeys;
|
||||
|
||||
use App\Support\Passkeys\RelyingPartyIdResolver;
|
||||
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
|
||||
|
||||
final class ConfigureCeremonyStepManagerFactoryAction extends \Spatie\LaravelPasskeys\Actions\ConfigureCeremonyStepManagerFactoryAction
|
||||
{
|
||||
public function execute(): CeremonyStepManagerFactory
|
||||
{
|
||||
$factory = parent::execute();
|
||||
|
||||
$request = request();
|
||||
$rpId = RelyingPartyIdResolver::resolve($request);
|
||||
$origins = [$request->getSchemeAndHttpHost()];
|
||||
|
||||
// HTTPS origins for normal environments.
|
||||
$origins[] = "https://{$rpId}";
|
||||
|
||||
// Local development allowance for localhost over HTTP.
|
||||
if ($rpId === 'localhost') {
|
||||
$origins[] = 'http://localhost';
|
||||
}
|
||||
|
||||
$factory->setAllowedOrigins(array_values(array_unique($origins)));
|
||||
|
||||
return $factory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Passkeys;
|
||||
|
||||
use App\Support\Passkeys\RelyingPartyIdResolver;
|
||||
use Spatie\LaravelPasskeys\Actions\ConfigureCeremonyStepManagerFactoryAction;
|
||||
use Spatie\LaravelPasskeys\Actions\FindPasskeyToAuthenticateAction as BaseFindPasskeyToAuthenticateAction;
|
||||
use Spatie\LaravelPasskeys\Models\Passkey;
|
||||
use Spatie\LaravelPasskeys\Support\Config;
|
||||
use Spatie\LaravelPasskeys\Support\CredentialRecordConverter;
|
||||
use Throwable;
|
||||
use Webauthn\AuthenticatorAssertionResponseValidator;
|
||||
use Webauthn\PublicKeyCredential;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
use Webauthn\PublicKeyCredentialSource;
|
||||
|
||||
final class FindPasskeyToAuthenticateAction extends BaseFindPasskeyToAuthenticateAction
|
||||
{
|
||||
protected function determinePublicKeyCredentialSource(
|
||||
PublicKeyCredential $publicKeyCredential,
|
||||
PublicKeyCredentialRequestOptions $passkeyOptions,
|
||||
Passkey $passkey,
|
||||
): ?PublicKeyCredentialSource {
|
||||
$configureCeremonyStepManagerFactoryAction = Config::getAction(
|
||||
'configure_ceremony_step_manager_factory',
|
||||
ConfigureCeremonyStepManagerFactoryAction::class
|
||||
);
|
||||
$csmFactory = $configureCeremonyStepManagerFactoryAction->execute();
|
||||
$requestCsm = $csmFactory->requestCeremony();
|
||||
|
||||
$host = RelyingPartyIdResolver::resolve();
|
||||
|
||||
try {
|
||||
$validator = AuthenticatorAssertionResponseValidator::create($requestCsm);
|
||||
|
||||
$publicKeyCredentialSource = $validator->check(
|
||||
$passkey->data,
|
||||
$publicKeyCredential->response,
|
||||
$passkeyOptions,
|
||||
$host,
|
||||
null,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CredentialRecordConverter::toPublicKeyCredentialSource($publicKeyCredentialSource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Passkeys;
|
||||
|
||||
use App\Support\Passkeys\RelyingPartyIdResolver;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyAuthenticationOptionsAction as BaseGeneratePasskeyAuthenticationOptionsAction;
|
||||
use Spatie\LaravelPasskeys\Support\Serializer;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
|
||||
final class GeneratePasskeyAuthenticationOptionsAction extends BaseGeneratePasskeyAuthenticationOptionsAction
|
||||
{
|
||||
public function execute(): string
|
||||
{
|
||||
$rpId = RelyingPartyIdResolver::resolve();
|
||||
|
||||
$options = new PublicKeyCredentialRequestOptions(
|
||||
challenge: Str::random(),
|
||||
rpId: $rpId,
|
||||
allowCredentials: [],
|
||||
);
|
||||
|
||||
$options = Serializer::make()->toJson($options);
|
||||
|
||||
Session::flash('passkey-authentication-options', $options);
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Passkeys;
|
||||
|
||||
use App\Support\Passkeys\RelyingPartyIdResolver;
|
||||
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyRegisterOptionsAction as BaseGeneratePasskeyRegisterOptionsAction;
|
||||
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
|
||||
use Spatie\LaravelPasskeys\Support\Config;
|
||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||
use Webauthn\PublicKeyCredentialRpEntity;
|
||||
|
||||
final class GeneratePasskeyRegisterOptionsAction extends BaseGeneratePasskeyRegisterOptionsAction
|
||||
{
|
||||
public function execute(
|
||||
HasPasskeys $authenticatable,
|
||||
bool $asJson = true,
|
||||
): string|PublicKeyCredentialCreationOptions {
|
||||
$options = parent::execute($authenticatable, $asJson);
|
||||
|
||||
if (! $asJson || ! is_string($options)) {
|
||||
return $options;
|
||||
}
|
||||
|
||||
$decoded = json_decode($options, true);
|
||||
if (! is_array($decoded)) {
|
||||
return $options;
|
||||
}
|
||||
|
||||
$supportedAlgorithms = [
|
||||
['type' => 'public-key', 'alg' => -7], // ES256
|
||||
['type' => 'public-key', 'alg' => -257], // RS256
|
||||
];
|
||||
|
||||
// Different serializer/browser integrations can use either shape:
|
||||
// - options.pubKeyCredParams (WebAuthn JSON)
|
||||
// - options.publicKey.pubKeyCredParams (navigator.credentials.create payload)
|
||||
// Enforce valid algorithms for both to prevent "alg undefined" errors.
|
||||
$decoded['pubKeyCredParams'] = $supportedAlgorithms;
|
||||
|
||||
if (isset($decoded['publicKey']) && is_array($decoded['publicKey'])) {
|
||||
$decoded['publicKey']['pubKeyCredParams'] = $supportedAlgorithms;
|
||||
}
|
||||
|
||||
return json_encode($decoded, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
protected function relatedPartyEntity(): PublicKeyCredentialRpEntity
|
||||
{
|
||||
$rpId = RelyingPartyIdResolver::resolve();
|
||||
|
||||
return new PublicKeyCredentialRpEntity(
|
||||
name: (string) Config::getRelyingPartyName(),
|
||||
id: $rpId,
|
||||
icon: Config::getRelyingPartyIcon(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,12 @@ use App\Models\User;
|
||||
use App\Models\UserDownload;
|
||||
use App\Models\UserRequest;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\LaravelPasskeys\Models\Passkey;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class AdminUserController extends BasePageController
|
||||
@@ -148,7 +151,7 @@ class AdminUserController extends BasePageController
|
||||
// Check if role is changing and get stack preference
|
||||
$roleChanged = $editedUser->roles_id != $request->input('role');
|
||||
$stackRole = $request->input('stack_role') ? true : false; // Check if checkbox is checked
|
||||
$changedBy = auth()->check() ? auth()->id() : null;
|
||||
$changedBy = Auth::check() ? Auth::id() : null;
|
||||
|
||||
// CRITICAL: Capture the ORIGINAL rolechangedate BEFORE any updates
|
||||
// This is needed for accurate role history tracking
|
||||
@@ -269,6 +272,8 @@ class AdminUserController extends BasePageController
|
||||
|
||||
// Add daily API and download counts
|
||||
if ($user) {
|
||||
$user->load('passkeys');
|
||||
|
||||
try {
|
||||
$user->daily_api_count = UserRequest::getApiRequests($user->id);
|
||||
$user->daily_download_count = UserDownload::getDownloadRequests($user->id);
|
||||
@@ -349,4 +354,63 @@ class AdminUserController extends BasePageController
|
||||
|
||||
return redirect()->back()->with('error', 'User is invalid');
|
||||
}
|
||||
|
||||
public function destroyPasskey(Request $request, Passkey $passkey): RedirectResponse|JsonResponse
|
||||
{
|
||||
$targetUser = $passkey->authenticatable;
|
||||
$targetUserId = $targetUser?->id;
|
||||
$targetUsername = $targetUser?->username;
|
||||
$passkeyName = $passkey->name;
|
||||
|
||||
$passkey->delete();
|
||||
|
||||
Log::channel('admin')->info('Admin deleted user passkey', [
|
||||
'admin_user_id' => Auth::id(),
|
||||
'target_user_id' => $targetUserId,
|
||||
'target_username' => $targetUsername,
|
||||
'passkey_id' => $passkey->id,
|
||||
'passkey_name' => $passkeyName,
|
||||
]);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
$redirectUrl = $targetUserId
|
||||
? 'admin/user-edit?id='.$targetUserId
|
||||
: 'admin/user-list';
|
||||
|
||||
return redirect()->to($redirectUrl)->with('success', 'Passkey deleted successfully.');
|
||||
}
|
||||
|
||||
public function wipePasskeys(Request $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'confirmation' => ['required', 'string', 'in:WIPE'],
|
||||
]);
|
||||
|
||||
$target = User::findOrFail((int) $validated['user_id']);
|
||||
$wipedCount = $target->passkeys()->count();
|
||||
$target->passkeys()->delete();
|
||||
|
||||
Log::channel('admin')->warning('Admin wiped user passkeys', [
|
||||
'admin_user_id' => Auth::id(),
|
||||
'target_user_id' => $target->id,
|
||||
'target_username' => $target->username,
|
||||
'wiped_count' => $wipedCount,
|
||||
]);
|
||||
|
||||
$message = "{$wipedCount} passkeys removed from {$target->username}. They can now regain access with their password or an admin-issued reset.";
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'wiped_count' => $wipedCount,
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->to('admin/user-edit?id='.$target->id)->with('success', $message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Events\UserLoggedIn;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Support\CaptchaHelper;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Spatie\LaravelPasskeys\Actions\FindPasskeyToAuthenticateAction;
|
||||
use Spatie\LaravelPasskeys\Events\PasskeyUsedToAuthenticateEvent;
|
||||
use Spatie\LaravelPasskeys\Http\Requests\AuthenticateUsingPasskeysRequest;
|
||||
use Spatie\LaravelPasskeys\Support\Config;
|
||||
|
||||
final class PasskeyLoginController extends Controller
|
||||
{
|
||||
public function __invoke(AuthenticateUsingPasskeysRequest $request): RedirectResponse
|
||||
{
|
||||
$captchaRules = CaptchaHelper::getValidationRules();
|
||||
$captchaField = CaptchaHelper::getResponseFieldName();
|
||||
$captchaValue = $request->input($captchaField);
|
||||
|
||||
// Passkey login should not be blocked when captcha widgets rotate/expire
|
||||
// during WebAuthn prompts. If a captcha token is present, still validate it.
|
||||
if ($captchaRules !== [] && is_string($captchaValue) && trim($captchaValue) !== '') {
|
||||
$validator = Validator::make($request->all(), $captchaRules);
|
||||
if ($validator->fails()) {
|
||||
Log::channel('failed_login')->error(
|
||||
'Failed passkey login captcha check from IP address: '.$request->ip()
|
||||
);
|
||||
|
||||
session()->flash('authenticatePasskey::message', $validator->errors()->first());
|
||||
session()->flash('authenticatePasskey::reason', 'captcha');
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
|
||||
$findAuthenticatableUsingPasskey = Config::getAction(
|
||||
'find_passkey',
|
||||
FindPasskeyToAuthenticateAction::class
|
||||
);
|
||||
|
||||
$passkey = $findAuthenticatableUsingPasskey->execute(
|
||||
$request->input('start_authentication_response'),
|
||||
Session::get('passkey-authentication-options'),
|
||||
);
|
||||
|
||||
if (! $passkey || ! $passkey->authenticatable instanceof User) {
|
||||
Log::channel('failed_login')->error(
|
||||
'Failed passkey login attempt from IP address: '.$request->ip()
|
||||
);
|
||||
|
||||
session()->flash('authenticatePasskey::message', __('passkeys::passkeys.invalid'));
|
||||
session()->flash('authenticatePasskey::reason', 'invalid_passkey');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
$user = $passkey->authenticatable;
|
||||
|
||||
if ($user->trashed()) {
|
||||
Log::channel('failed_login')->error(
|
||||
'Failed passkey login for deactivated user: '.$user->username.' from IP address: '.$request->ip()
|
||||
);
|
||||
|
||||
session()->flash(
|
||||
'authenticatePasskey::message',
|
||||
'This account has been deactivated. Please contact us through contact form to have your account reactivated.'
|
||||
);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
Log::channel('failed_login')->error(
|
||||
'Failed passkey login for unverified user: '.$user->username.' from IP address: '.$request->ip()
|
||||
);
|
||||
|
||||
session()->flash('authenticatePasskey::message', 'You have not verified your email address!');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
Auth::login($user, $request->boolean('remember'));
|
||||
$request->session()->regenerate();
|
||||
|
||||
// Passkey auth is treated as sufficient MFA, so skip additional OTP gate.
|
||||
session([config('google2fa.session_var') => true]);
|
||||
session([config('google2fa.session_var').'.auth.passed_at' => time()]);
|
||||
|
||||
$userIp = config('nntmux:settings.store_user_ip') ? ($request->ip() ?? $request->getClientIp()) : '';
|
||||
event(new UserLoggedIn($user, $userIp));
|
||||
event(new PasskeyUsedToAuthenticateEvent($passkey, $request));
|
||||
|
||||
$url = Session::has('passkeys.redirect')
|
||||
? (string) Session::pull('passkeys.redirect')
|
||||
: (string) config('passkeys.redirect_to_after_login', '/');
|
||||
|
||||
return redirect($url)->with('info', 'You have been logged in');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Support\Passkeys\RelyingPartyIdResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyRegisterOptionsAction;
|
||||
use Spatie\LaravelPasskeys\Actions\StorePasskeyAction;
|
||||
use Spatie\LaravelPasskeys\Models\Passkey;
|
||||
use Spatie\LaravelPasskeys\Support\Config;
|
||||
use Throwable;
|
||||
|
||||
final class PasskeyManagementController extends Controller
|
||||
{
|
||||
public function options(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$generatePassKeyOptionsAction = Config::getAction(
|
||||
'generate_passkey_register_options',
|
||||
GeneratePasskeyRegisterOptionsAction::class
|
||||
);
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$options = $generatePassKeyOptionsAction->execute($user);
|
||||
$request->session()->put('passkey-registration-options', $options);
|
||||
$request->session()->put('passkey-registration-name', $validated['name']);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'options' => json_decode($options, true, 512, JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'passkey' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$storePasskeyAction = Config::getAction('store_passkey', StorePasskeyAction::class);
|
||||
|
||||
try {
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
/** @var Passkey $passkey */
|
||||
$passkey = $storePasskeyAction->execute(
|
||||
$user,
|
||||
$validated['passkey'],
|
||||
$request->session()->pull('passkey-registration-options'),
|
||||
RelyingPartyIdResolver::resolve($request),
|
||||
['name' => $validated['name']]
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Passkey registration failed', [
|
||||
'user_id' => Auth::id(),
|
||||
'host' => $request->getHost(),
|
||||
'resolved_rp_id' => RelyingPartyIdResolver::resolve($request),
|
||||
'exception' => $exception::class,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => __('passkeys::passkeys.error_something_went_wrong_generating_the_passkey'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'passkey' => [
|
||||
'id' => $passkey?->id,
|
||||
'name' => $passkey?->name,
|
||||
'last_used_at' => $passkey?->last_used_at?->toIso8601String(),
|
||||
'created_at' => $passkey?->created_at?->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $passkey): JsonResponse|RedirectResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$deleted = $user->passkeys()->where('id', $passkey)->delete();
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['ok' => $deleted > 0]);
|
||||
}
|
||||
|
||||
return back()->with(
|
||||
$deleted > 0 ? 'success' : 'error',
|
||||
$deleted > 0 ? 'Passkey deleted.' : 'Passkey not found.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,7 @@ class ProfileController extends BasePageController
|
||||
$action = $request->input('action') ?? 'view';
|
||||
|
||||
$userid = $this->userdata->id;
|
||||
$this->userdata->loadMissing('passkeys');
|
||||
|
||||
$errorStr = '';
|
||||
$success_2fa = $request->session()->get('success');
|
||||
|
||||
+21
-1
@@ -34,6 +34,9 @@ use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
|
||||
use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys;
|
||||
use Spatie\LaravelPasskeys\Models\Passkey;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
@@ -104,6 +107,7 @@ use Spatie\Permission\Traits\HasRoles;
|
||||
* @property-read Collection<int, UserSerie> $series
|
||||
* @property-read Collection<int, RolePromotionStat> $promotionStats
|
||||
* @property-read Collection<int, UserRoleHistory> $roleHistory
|
||||
* @property-read Collection<int, Passkey> $passkeys
|
||||
* @property-read Role|null $role
|
||||
* @property-read PasswordSecurity|null $passwordSecurity
|
||||
*
|
||||
@@ -117,10 +121,11 @@ use Spatie\Permission\Traits\HasRoles;
|
||||
* @method static Builder|User expiringSoon(int $days = 7)
|
||||
* @method static Builder|User expired()
|
||||
*/
|
||||
final class User extends Authenticatable implements MustVerifyEmailContract
|
||||
final class User extends Authenticatable implements HasPasskeys, MustVerifyEmailContract
|
||||
{
|
||||
use HasFactory; // @phpstan-ignore missingType.generics
|
||||
use HasRoles;
|
||||
use InteractsWithPasskeys;
|
||||
use MustVerifyEmailTrait;
|
||||
use Notifiable;
|
||||
use SoftDeletes;
|
||||
@@ -192,6 +197,21 @@ final class User extends Authenticatable implements MustVerifyEmailContract
|
||||
];
|
||||
}
|
||||
|
||||
public function getPasskeyName(): string
|
||||
{
|
||||
return $this->email ?: $this->username;
|
||||
}
|
||||
|
||||
public function getPasskeyId(): string
|
||||
{
|
||||
return (string) $this->id;
|
||||
}
|
||||
|
||||
public function getPasskeyDisplayName(): string
|
||||
{
|
||||
return $this->full_name ?: $this->username;
|
||||
}
|
||||
|
||||
protected function getDefaultGuardName(): string
|
||||
{
|
||||
return 'web';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Passkeys;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class RelyingPartyIdResolver
|
||||
{
|
||||
public static function resolve(?Request $request = null): string
|
||||
{
|
||||
$request ??= request();
|
||||
|
||||
$candidates = [
|
||||
self::firstHost((string) $request->header('X-Forwarded-Host', '')),
|
||||
self::firstHost((string) $request->header('X-Original-Host', '')),
|
||||
(string) config('passkeys.relying_party.id', ''),
|
||||
(string) $request->getHost(),
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (self::isValidRelyingPartyId($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
private static function firstHost(string $value): string
|
||||
{
|
||||
$host = trim(explode(',', $value)[0] ?? '');
|
||||
|
||||
if ($host === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Strip optional port suffix.
|
||||
return trim((string) preg_replace('/:\d+$/', '', $host));
|
||||
}
|
||||
|
||||
private static function isValidRelyingPartyId(string $value): bool
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '' || filter_var($value, FILTER_VALIDATE_IP) !== false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($value === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains($value, '.');
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@
|
||||
"spatie/laravel-directory-cleanup": "^1.10",
|
||||
"spatie/laravel-fractal": "^6.3",
|
||||
"spatie/laravel-image-optimizer": "^1.8",
|
||||
"spatie/laravel-passkeys": "^1.7",
|
||||
"spatie/laravel-permission": "^7.0.0",
|
||||
"stechstudio/laravel-zipstream": "^5.4",
|
||||
"symfony/process": "^7.0",
|
||||
|
||||
Generated
+418
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "3a34364f86e7322cb77bf01b515aa6f6",
|
||||
"content-hash": "07cc99b3bba0a8670515889435ba9f56",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aharen/omdbapi",
|
||||
@@ -6599,6 +6599,84 @@
|
||||
],
|
||||
"time": "2026-02-21T12:49:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-passkeys",
|
||||
"version": "1.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/laravel-passkeys.git",
|
||||
"reference": "2d67943712243c23852c77d4c4679575bad5b9ec"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/laravel-passkeys/zipball/2d67943712243c23852c77d4c4679575bad5b9ec",
|
||||
"reference": "2d67943712243c23852c77d4c4679575bad5b9ec",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
||||
"php": "^8.2|^8.3|^8.4",
|
||||
"spatie/laravel-package-tools": "^1.16",
|
||||
"web-auth/webauthn-lib": "^5.0|5.3.x-dev as 5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^3.4",
|
||||
"laravel/pint": "^1.14",
|
||||
"livewire/livewire": "^3.5 || ^4.0",
|
||||
"nunomaduro/collision": "^8.1.1",
|
||||
"orchestra/testbench": "^10.0|^11.0",
|
||||
"pestphp/pest": "^3.0|^4.0",
|
||||
"pestphp/pest-plugin-arch": "^3.0|^4.0",
|
||||
"pestphp/pest-plugin-laravel": "^3.0|^4.0",
|
||||
"phpstan/extension-installer": "^1.3",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"spatie/laravel-ray": "^1.35"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Spatie\\LaravelPasskeys\\LaravelPasskeysServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\LaravelPasskeys\\": "src/",
|
||||
"Spatie\\LaravelPasskeys\\Database\\Factories\\": "database/factories/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Use passkeys in your Laravel app",
|
||||
"homepage": "https://github.com/spatie/laravel-passkeys",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"laravel-passkeys",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/laravel-passkeys/issues",
|
||||
"source": "https://github.com/spatie/laravel-passkeys/tree/1.7.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-08T07:32:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-permission",
|
||||
"version": "7.3.0",
|
||||
@@ -6747,6 +6825,187 @@
|
||||
],
|
||||
"time": "2026-01-12T07:42:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spomky-labs/cbor-php",
|
||||
"version": "3.2.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Spomky-Labs/cbor-php.git",
|
||||
"reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
|
||||
"reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
|
||||
"ext-mbstring": "*",
|
||||
"php": ">=8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-json": "*",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"symfony/error-handler": "^6.4|^7.1|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.1|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags",
|
||||
"ext-gmp": "GMP or BCMath extensions will drastically improve the library performance"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"CBOR\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky"
|
||||
},
|
||||
{
|
||||
"name": "All contributors",
|
||||
"homepage": "https://github.com/Spomky-Labs/cbor-php/contributors"
|
||||
}
|
||||
],
|
||||
"description": "CBOR Encoder/Decoder for PHP",
|
||||
"keywords": [
|
||||
"Concise Binary Object Representation",
|
||||
"RFC7049",
|
||||
"cbor"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Spomky-Labs/cbor-php/issues",
|
||||
"source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-01T12:15:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spomky-labs/pki-framework",
|
||||
"version": "1.4.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Spomky-Labs/pki-framework.git",
|
||||
"reference": "aa576cbd07128075bef97ac2f8af9854e67513d8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8",
|
||||
"reference": "aa576cbd07128075bef97ac2f8af9854e67513d8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
|
||||
"ext-mbstring": "*",
|
||||
"php": ">=8.1",
|
||||
"psr/clock": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
|
||||
"ext-gmp": "*",
|
||||
"ext-openssl": "*",
|
||||
"infection/infection": "^0.28|^0.29|^0.31|^0.32",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3",
|
||||
"phpstan/extension-installer": "^1.3|^2.0",
|
||||
"phpstan/phpstan": "^1.8|^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.1|^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.3|^2.0",
|
||||
"phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0",
|
||||
"rector/rector": "^1.0|^2.0",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"symfony/string": "^6.4|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0|^8.0",
|
||||
"symplify/easy-coding-standard": "^12.0|^13.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "For better performance (or GMP)",
|
||||
"ext-gmp": "For better performance (or BCMath)",
|
||||
"ext-openssl": "For OpenSSL based cyphering"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"SpomkyLabs\\Pki\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Joni Eskelinen",
|
||||
"email": "jonieske@gmail.com",
|
||||
"role": "Original developer"
|
||||
},
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"email": "florent.morselli@spomky-labs.com",
|
||||
"role": "Spomky-Labs PKI Framework developer"
|
||||
}
|
||||
],
|
||||
"description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
|
||||
"homepage": "https://github.com/spomky-labs/pki-framework",
|
||||
"keywords": [
|
||||
"DER",
|
||||
"Private Key",
|
||||
"ac",
|
||||
"algorithm identifier",
|
||||
"asn.1",
|
||||
"asn1",
|
||||
"attribute certificate",
|
||||
"certificate",
|
||||
"certification request",
|
||||
"cryptography",
|
||||
"csr",
|
||||
"decrypt",
|
||||
"ec",
|
||||
"encrypt",
|
||||
"pem",
|
||||
"pkcs",
|
||||
"public key",
|
||||
"rsa",
|
||||
"sign",
|
||||
"signature",
|
||||
"verify",
|
||||
"x.509",
|
||||
"x.690",
|
||||
"x509",
|
||||
"x690"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Spomky-Labs/pki-framework/issues",
|
||||
"source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-23T22:56:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "stechstudio/laravel-zipstream",
|
||||
"version": "5.10",
|
||||
@@ -10400,6 +10659,164 @@
|
||||
],
|
||||
"time": "2024-07-03T16:05:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "web-auth/cose-lib",
|
||||
"version": "4.5.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/web-auth/cose-lib.git",
|
||||
"reference": "3185af4df10dc537b65c140c315b88d15ae15b80"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3185af4df10dc537b65c140c315b88d15ae15b80",
|
||||
"reference": "3185af4df10dc537b65c140c315b88d15ae15b80",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"php": ">=8.1",
|
||||
"spomky-labs/pki-framework": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"spomky-labs/cbor-php": "^3.2.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension",
|
||||
"ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension",
|
||||
"spomky-labs/cbor-php": "For COSE Signature support"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Cose\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky"
|
||||
},
|
||||
{
|
||||
"name": "All contributors",
|
||||
"homepage": "https://github.com/web-auth/cose/contributors"
|
||||
}
|
||||
],
|
||||
"description": "CBOR Object Signing and Encryption (COSE) For PHP",
|
||||
"homepage": "https://github.com/web-auth",
|
||||
"keywords": [
|
||||
"COSE",
|
||||
"RFC8152"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/web-auth/cose-lib/issues",
|
||||
"source": "https://github.com/web-auth/cose-lib/tree/4.5.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-01T12:47:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "web-auth/webauthn-lib",
|
||||
"version": "5.3.x-dev",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/web-auth/webauthn-lib.git",
|
||||
"reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/1ea7e2cae320f04c75212bf019e845d8d7faf950",
|
||||
"reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"paragonie/constant_time_encoding": "^2.6|^3.0",
|
||||
"php": ">=8.2",
|
||||
"phpdocumentor/reflection-docblock": "^5.3|^6.0",
|
||||
"psr/clock": "^1.0",
|
||||
"psr/event-dispatcher": "^1.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"spomky-labs/cbor-php": "^3.0",
|
||||
"spomky-labs/pki-framework": "^1.0",
|
||||
"symfony/clock": "^6.4|^7.0|^8.0",
|
||||
"symfony/deprecation-contracts": "^3.2",
|
||||
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||
"symfony/property-info": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4|^7.0|^8.0",
|
||||
"symfony/uid": "^6.4|^7.0|^8.0",
|
||||
"web-auth/cose-lib": "^4.2.3"
|
||||
},
|
||||
"suggest": {
|
||||
"psr/log-implementation": "Recommended to receive logs from the library",
|
||||
"symfony/event-dispatcher": "Recommended to use dispatched events",
|
||||
"web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/web-auth/webauthn-framework",
|
||||
"name": "web-auth/webauthn-framework"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Webauthn\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky"
|
||||
},
|
||||
{
|
||||
"name": "All contributors",
|
||||
"homepage": "https://github.com/web-auth/webauthn-library/contributors"
|
||||
}
|
||||
],
|
||||
"description": "FIDO2/Webauthn Support For PHP",
|
||||
"homepage": "https://github.com/web-auth",
|
||||
"keywords": [
|
||||
"FIDO2",
|
||||
"fido",
|
||||
"webauthn"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.x"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-22T17:54:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "webmozart/assert",
|
||||
"version": "2.3.0",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Passkeys\ConfigureCeremonyStepManagerFactoryAction;
|
||||
use App\Actions\Passkeys\FindPasskeyToAuthenticateAction;
|
||||
use App\Actions\Passkeys\GeneratePasskeyAuthenticationOptionsAction;
|
||||
use App\Actions\Passkeys\GeneratePasskeyRegisterOptionsAction;
|
||||
use App\Models\User;
|
||||
use Spatie\LaravelPasskeys\Actions\StorePasskeyAction;
|
||||
use Spatie\LaravelPasskeys\Models\Passkey;
|
||||
|
||||
return [
|
||||
/*
|
||||
* After a successful authentication attempt using a passkey
|
||||
* we'll redirect to this URL.
|
||||
*/
|
||||
'redirect_to_after_login' => '/',
|
||||
|
||||
/*
|
||||
* These class are responsible for performing core tasks regarding passkeys.
|
||||
* You can customize them by creating a class that extends the default, and
|
||||
* by specifying your custom class name here.
|
||||
*/
|
||||
'actions' => [
|
||||
'generate_passkey_register_options' => GeneratePasskeyRegisterOptionsAction::class,
|
||||
'store_passkey' => StorePasskeyAction::class,
|
||||
'generate_passkey_authentication_options' => GeneratePasskeyAuthenticationOptionsAction::class,
|
||||
'find_passkey' => FindPasskeyToAuthenticateAction::class,
|
||||
'configure_ceremony_step_manager_factory' => ConfigureCeremonyStepManagerFactoryAction::class,
|
||||
],
|
||||
|
||||
/*
|
||||
* These properties will be used to generate the passkey.
|
||||
*/
|
||||
'relying_party' => [
|
||||
'name' => config('app.name'),
|
||||
'id' => env(
|
||||
'PASSKEY_RELYING_PARTY_ID',
|
||||
parse_url(config('app.url', 'http://localhost'), PHP_URL_HOST) ?: 'localhost'
|
||||
),
|
||||
'icon' => null,
|
||||
],
|
||||
|
||||
/*
|
||||
* The models used by the package.
|
||||
*
|
||||
* You can override this by specifying your own models.
|
||||
*/
|
||||
'models' => [
|
||||
'passkey' => Passkey::class,
|
||||
'authenticatable' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\LaravelPasskeys\Support\Config;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('passkeys')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$authenticatableClass = Config::getAuthenticatableModel();
|
||||
$authenticatableTableName = (new $authenticatableClass)->getTable();
|
||||
|
||||
Schema::create('passkeys', function (Blueprint $table) use ($authenticatableTableName): void {
|
||||
$table->id();
|
||||
$table->unsignedInteger('authenticatable_id');
|
||||
$table->foreign('authenticatable_id', 'passkeys_authenticatable_fk')
|
||||
->references('id')
|
||||
->on($authenticatableTableName)
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->text('name');
|
||||
$table->text('credential_id');
|
||||
$table->json('data');
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('passkeys');
|
||||
}
|
||||
};
|
||||
@@ -25,6 +25,7 @@
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@melloware/coloris": "^0.25.0",
|
||||
"@simonwep/pickr": "^1.9.1",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"feather-icons": "^4.29.2",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Alpine from '@alpinejs/csp';
|
||||
|
||||
Alpine.data('adminUserPasskeys', () => ({
|
||||
showDangerZone: false,
|
||||
wipeConfirm: '',
|
||||
|
||||
toggleDangerZone() {
|
||||
this.showDangerZone = !this.showDangerZone;
|
||||
},
|
||||
|
||||
canWipe() {
|
||||
return this.wipeConfirm === 'WIPE';
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,104 @@
|
||||
import Alpine from '@alpinejs/csp';
|
||||
|
||||
Alpine.data('passkeyLogin', () => ({
|
||||
supported: false,
|
||||
busy: false,
|
||||
error: '',
|
||||
showCreateHint: false,
|
||||
|
||||
init() {
|
||||
this.supported = typeof window.browserSupportsWebAuthn === 'function'
|
||||
&& window.browserSupportsWebAuthn();
|
||||
|
||||
// If backend already reported an invalid passkey login attempt,
|
||||
// immediately show the "sign in first, then create passkey" guidance.
|
||||
this.showCreateHint = this.$el.dataset.serverPasskeyError === '1';
|
||||
},
|
||||
|
||||
async authenticate() {
|
||||
this.error = '';
|
||||
this.showCreateHint = false;
|
||||
this.busy = true;
|
||||
|
||||
try {
|
||||
this.copyCaptchaResponse();
|
||||
|
||||
const rawOptionsUrl = this.$el.dataset.optionsUrl;
|
||||
const optionsUrl = (!rawOptionsUrl || rawOptionsUrl === 'undefined')
|
||||
? '/passkeys/authentication-options'
|
||||
: rawOptionsUrl;
|
||||
const optionsResponse = await fetch(optionsUrl, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
if (!optionsResponse.ok) {
|
||||
throw new Error('Unable to load passkey authentication options.');
|
||||
}
|
||||
|
||||
const optionsJson = await optionsResponse.json();
|
||||
const startAuthenticationResponse = await window.startAuthentication({
|
||||
optionsJSON: optionsJson,
|
||||
});
|
||||
|
||||
this.$refs.response.value = JSON.stringify(startAuthenticationResponse);
|
||||
document.getElementById('passkey-login-form')?.submit();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error
|
||||
? error.message
|
||||
: 'Passkey authentication failed.';
|
||||
|
||||
// Common browser errors when no passkey exists for this account/device.
|
||||
if (error instanceof Error && ['NotAllowedError', 'InvalidStateError', 'SecurityError'].includes(error.name)) {
|
||||
this.showCreateHint = true;
|
||||
}
|
||||
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
copyCaptchaResponse() {
|
||||
const turnstileValue = this.getFieldValue('cf-turnstile-response');
|
||||
const recaptchaValue = this.getFieldValue('g-recaptcha-response');
|
||||
|
||||
if (this.$refs.turnstileResponse) {
|
||||
this.$refs.turnstileResponse.value = turnstileValue;
|
||||
}
|
||||
|
||||
if (this.$refs.recaptchaResponse) {
|
||||
this.$refs.recaptchaResponse.value = recaptchaValue;
|
||||
}
|
||||
},
|
||||
|
||||
getFieldValue(fieldName) {
|
||||
const candidates = document.querySelectorAll(`[name="${fieldName}"]`);
|
||||
|
||||
for (const field of candidates) {
|
||||
if (field === this.$refs.turnstileResponse || field === this.$refs.recaptchaResponse) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof field.value === 'string' && field.value.trim() !== '') {
|
||||
return field.value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
|
||||
focusPasswordLogin() {
|
||||
const username = document.getElementById('username');
|
||||
if (username) {
|
||||
username.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const password = document.getElementById('password');
|
||||
if (password) {
|
||||
password.focus();
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,115 @@
|
||||
import Alpine from '@alpinejs/csp';
|
||||
|
||||
Alpine.data('passkeyManage', () => ({
|
||||
supported: false,
|
||||
busy: false,
|
||||
name: '',
|
||||
error: '',
|
||||
success: '',
|
||||
passkeys: [],
|
||||
|
||||
init() {
|
||||
this.supported = typeof window.browserSupportsWebAuthn === 'function'
|
||||
&& window.browserSupportsWebAuthn();
|
||||
|
||||
const rawPasskeys = this.$el.dataset.passkeys;
|
||||
if (rawPasskeys) {
|
||||
try {
|
||||
this.passkeys = JSON.parse(rawPasskeys);
|
||||
} catch (_error) {
|
||||
this.passkeys = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async createPasskey() {
|
||||
if (!this.supported) {
|
||||
this.error = 'Your browser does not support passkeys.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.busy = true;
|
||||
this.error = '';
|
||||
this.success = '';
|
||||
|
||||
try {
|
||||
const optionsUrl = this.$el.dataset.optionsUrl || '/passkeys/register-options';
|
||||
const storeUrl = this.$el.dataset.storeUrl || '/passkeys';
|
||||
|
||||
const optionsResponse = await window.axios.post(optionsUrl, {
|
||||
name: this.name,
|
||||
});
|
||||
const options = optionsResponse.data?.options;
|
||||
|
||||
const registration = await window.startRegistration({ optionsJSON: options });
|
||||
|
||||
const storeResponse = await window.axios.post(storeUrl, {
|
||||
name: this.name,
|
||||
passkey: JSON.stringify(registration),
|
||||
});
|
||||
|
||||
if (!storeResponse.data?.ok) {
|
||||
throw new Error(storeResponse.data?.message || 'Could not store passkey.');
|
||||
}
|
||||
|
||||
this.passkeys.unshift(storeResponse.data.passkey);
|
||||
this.success = 'Passkey created successfully.';
|
||||
this.name = '';
|
||||
} catch (error) {
|
||||
this.error = this.extractErrorMessage(error);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deletePasskey(passkeyId) {
|
||||
if (!window.confirm('Delete this passkey? This device will no longer work for passkey login.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.error = '';
|
||||
this.success = '';
|
||||
|
||||
try {
|
||||
const destroyUrl = `${this.$el.dataset.destroyBaseUrl}/${passkeyId}`;
|
||||
const response = await window.axios.delete(destroyUrl);
|
||||
|
||||
if (!response.data?.ok) {
|
||||
throw new Error('Could not delete passkey.');
|
||||
}
|
||||
|
||||
this.passkeys = this.passkeys.filter((passkey) => passkey.id !== passkeyId);
|
||||
this.success = 'Passkey deleted successfully.';
|
||||
} catch (error) {
|
||||
this.error = this.extractErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(value) {
|
||||
if (!value) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString();
|
||||
},
|
||||
|
||||
formatLastUsed(value) {
|
||||
if (!value) {
|
||||
return 'Not used yet';
|
||||
}
|
||||
|
||||
return this.formatDate(value);
|
||||
},
|
||||
|
||||
extractErrorMessage(error) {
|
||||
if (error?.response?.data?.message) {
|
||||
return error.response.data.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Something went wrong. Please try again.';
|
||||
},
|
||||
}));
|
||||
@@ -39,6 +39,9 @@ const lazyComponentMap = {
|
||||
'releaseMultiOps': () => import('./components/cart-page.js'), // same file
|
||||
'authPage': () => import('./components/auth-page.js'),
|
||||
'otpInput': () => import('./components/auth-page.js'), // same file
|
||||
'passkeyLogin': () => import('./components/passkey-login.js'),
|
||||
'passkeyManage': () => import('./components/passkey-manage.js'),
|
||||
'adminUserPasskeys': () => import('./components/admin-user-passkeys.js'),
|
||||
|
||||
// --- Admin components (includes Chart.js — heavy) ---
|
||||
'adminDashboard': () => import('./components/admin/dashboard.js'),
|
||||
|
||||
Vendored
+9
@@ -1,4 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from '@simplewebauthn/browser';
|
||||
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
window.browserSupportsWebAuthn = browserSupportsWebAuthn;
|
||||
window.startAuthentication = startAuthentication;
|
||||
window.startRegistration = startRegistration;
|
||||
|
||||
@@ -606,6 +606,101 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(!is_array($user))
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900" x-data="adminUserPasskeys">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
<i class="fas fa-fingerprint mr-2 text-blue-600 dark:text-blue-400"></i>Passkeys (WebAuthn)
|
||||
</h3>
|
||||
<span class="rounded-full bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
|
||||
{{ $user->passkeys->count() }} registered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if($user->passkeys->isEmpty())
|
||||
<div class="mt-4 rounded-lg border border-dashed border-gray-300 p-3 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-300">
|
||||
This user has no registered passkeys. They can create one from their profile security settings.
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4 overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Created</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Last used</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
@foreach($user->passkeys as $passkey)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-gray-100">{{ $passkey->name }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{{ optional($passkey->created_at)->diffForHumans() ?? 'Unknown' }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{{ optional($passkey->last_used_at)->diffForHumans() ?? 'Not used yet' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<form method="POST" action="{{ route('admin.user-passkey.destroy', $passkey) }}" class="inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button
|
||||
type="submit"
|
||||
data-confirm="Delete this passkey? The user will lose this device as a login method."
|
||||
class="confirm-link rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleDangerZone()"
|
||||
class="text-sm font-semibold text-red-700 hover:text-red-800 dark:text-red-300 dark:hover:text-red-200"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>Emergency actions
|
||||
</button>
|
||||
|
||||
<div x-show="showDangerZone" x-cloak class="mt-3 space-y-3">
|
||||
<p class="text-xs text-red-700 dark:text-red-300">
|
||||
Remove all passkeys if this user lost or replaced their passkey device.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ route('admin.user-passkeys.wipe') }}" class="space-y-3">
|
||||
@csrf
|
||||
<input type="hidden" name="user_id" value="{{ $user->id }}">
|
||||
|
||||
<div>
|
||||
<label for="passkey_wipe_confirmation" class="block text-xs font-medium text-red-700 dark:text-red-300">Type WIPE to confirm</label>
|
||||
<input
|
||||
id="passkey_wipe_confirmation"
|
||||
name="confirmation"
|
||||
x-model="wipeConfirm"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-red-300 px-3 py-2 text-sm text-gray-900 focus:border-red-500 focus:ring-2 focus:ring-red-500 dark:border-red-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!canWipe()"
|
||||
class="rounded-lg bg-red-600 px-4 py-2 text-xs font-semibold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-red-700 dark:hover:bg-red-800"
|
||||
>
|
||||
Remove ALL passkeys
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="submit" class="px-6 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800">
|
||||
|
||||
@@ -135,6 +135,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@include('partials.passkey-authenticate')
|
||||
</div>
|
||||
|
||||
<!-- Card Footer -->
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<div
|
||||
x-data="passkeyLogin"
|
||||
x-cloak
|
||||
x-show="supported"
|
||||
data-options-url="{{ route('passkeys.authentication_options') }}"
|
||||
data-server-passkey-error="{{ session('authenticatePasskey::reason') === 'invalid_passkey' ? '1' : '0' }}"
|
||||
data-captcha-enabled="{{ \App\Support\CaptchaHelper::isEnabled() ? '1' : '0' }}"
|
||||
data-captcha-field="{{ \App\Support\CaptchaHelper::isEnabled() ? \App\Support\CaptchaHelper::getResponseFieldName() : '' }}"
|
||||
class="mt-6"
|
||||
>
|
||||
<div class="relative flex items-center">
|
||||
<div class="flex-grow border-t border-gray-300 dark:border-gray-600"></div>
|
||||
<span class="mx-4 flex-shrink text-xs uppercase text-gray-500 dark:text-gray-400">or</span>
|
||||
<div class="flex-grow border-t border-gray-300 dark:border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
@if($message = session('authenticatePasskey::message'))
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700 dark:bg-red-900/20 dark:text-red-300" role="alert">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form id="passkey-login-form" method="POST" action="{{ route('passkeys.login') }}" class="mt-4">
|
||||
@csrf
|
||||
<input type="hidden" name="remember" x-ref="remember" value="0">
|
||||
<input type="hidden" name="start_authentication_response" x-ref="response" value="">
|
||||
<input type="hidden" name="cf-turnstile-response" x-ref="turnstileResponse" value="">
|
||||
<input type="hidden" name="g-recaptcha-response" x-ref="recaptchaResponse" value="">
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="authenticate()"
|
||||
:disabled="busy"
|
||||
class="mt-4 flex w-full items-center justify-center rounded-lg border border-blue-600 px-4 py-3 text-sm font-medium text-blue-700 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-blue-400 dark:text-blue-300 dark:hover:bg-blue-900/30"
|
||||
>
|
||||
<i class="fas fa-fingerprint mr-2"></i>
|
||||
<span x-text="busy ? 'Waiting for passkey...' : 'Sign in with passkey'"></span>
|
||||
</button>
|
||||
|
||||
<p x-show="error" x-text="error" class="mt-2 text-sm text-red-600 dark:text-red-400"></p>
|
||||
|
||||
<div
|
||||
x-show="showCreateHint"
|
||||
x-cloak
|
||||
class="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-200"
|
||||
>
|
||||
<p>
|
||||
No passkey was found for this login on this device/browser.
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
Sign in with your password first. After login, go to your profile security settings and create a passkey.
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="focusPasswordLogin()"
|
||||
class="rounded-md border border-blue-300 px-3 py-1.5 text-xs font-medium text-blue-800 hover:bg-blue-100 dark:border-blue-600 dark:text-blue-200 dark:hover:bg-blue-900/40"
|
||||
>
|
||||
Use password login
|
||||
</button>
|
||||
@if(Route::has('register'))
|
||||
<a
|
||||
href="{{ route('register') }}"
|
||||
class="rounded-md border border-blue-300 px-3 py-1.5 text-xs font-medium text-blue-800 hover:bg-blue-100 dark:border-blue-600 dark:text-blue-200 dark:hover:bg-blue-900/40"
|
||||
>
|
||||
Create account
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
@php
|
||||
$passkeyPayload = $user->passkeys->map(function ($passkey) {
|
||||
return [
|
||||
'id' => $passkey->id,
|
||||
'name' => $passkey->name,
|
||||
'created_at' => optional($passkey->created_at)?->toIso8601String(),
|
||||
'last_used_at' => optional($passkey->last_used_at)?->toIso8601String(),
|
||||
];
|
||||
});
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="passkeyManage"
|
||||
x-cloak
|
||||
data-options-url="{{ route('passkeys.register_options') }}"
|
||||
data-store-url="{{ route('passkeys.store') }}"
|
||||
data-destroy-base-url="{{ url('passkeys') }}"
|
||||
data-passkeys='@json($passkeyPayload)'
|
||||
>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Register a passkey from your security key, browser, or password manager.
|
||||
</p>
|
||||
|
||||
<template x-if="!supported">
|
||||
<div class="mt-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-200">
|
||||
Your browser does not support passkeys.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="supported">
|
||||
<div class="mt-4 space-y-4">
|
||||
<form @submit.prevent="createPasskey()" class="space-y-3">
|
||||
<label for="passkey_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Passkey name
|
||||
</label>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
id="passkey_name"
|
||||
x-model="name"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
required
|
||||
placeholder="Work laptop, iPhone, etc."
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="busy"
|
||||
class="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-primary-700 dark:hover:bg-primary-800"
|
||||
>
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<span x-text="busy ? 'Creating...' : 'Create passkey'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p x-show="error" x-text="error" class="text-sm text-red-600 dark:text-red-400"></p>
|
||||
<p x-show="success" x-text="success" class="text-sm text-green-600 dark:text-green-400"></p>
|
||||
|
||||
<template x-if="passkeys.length === 0">
|
||||
<div class="rounded-lg border border-dashed border-gray-300 p-4 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-300">
|
||||
No passkeys registered yet.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="passkeys.length > 0">
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Created</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Last used</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
<template x-for="passkey in passkeys" :key="passkey.id">
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-gray-100" x-text="passkey.name"></td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300" x-text="formatDate(passkey.created_at)"></td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300" x-text="formatLastUsed(passkey.last_used_at)"></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
@click="deletePasskey(passkey.id)"
|
||||
class="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -467,6 +467,15 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
<i class="fas fa-fingerprint mr-2 text-primary-600"></i>Passkeys
|
||||
</h3>
|
||||
<div class="mt-4">
|
||||
@include('partials.passkeys-manage')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
+16
-1
@@ -51,6 +51,8 @@ use App\Http\Controllers\ApiHelpController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationController;
|
||||
use App\Http\Controllers\Auth\ForgotPasswordController;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\Auth\PasskeyLoginController;
|
||||
use App\Http\Controllers\Auth\PasskeyManagementController;
|
||||
use App\Http\Controllers\Auth\RegisterController;
|
||||
use App\Http\Controllers\Auth\ResetPasswordController;
|
||||
use App\Http\Controllers\BooksController;
|
||||
@@ -83,6 +85,7 @@ use App\Http\Controllers\SearchSuggestController;
|
||||
use App\Http\Controllers\SeriesController;
|
||||
use App\Http\Controllers\StatusPageController;
|
||||
use App\Http\Controllers\TermsController;
|
||||
use Spatie\LaravelPasskeys\Http\Controllers\GeneratePasskeyAuthenticationOptionsController;
|
||||
|
||||
// Serve cover images from storage - Must be public (no auth required)
|
||||
Route::get('/covers/{type}/{filename}', [CoverController::class, 'show'])
|
||||
@@ -108,6 +111,9 @@ Route::match(['GET', 'POST'], 'privacy-policy', [PrivacyPolicyController::class,
|
||||
Route::get('login', [LoginController::class, 'showLoginForm'])->name('login');
|
||||
Route::post('login', [LoginController::class, 'login'])->name('login.post');
|
||||
Route::match(['GET', 'POST'], 'logout', [LoginController::class, 'logout'])->name('logout');
|
||||
Route::get('passkeys/authentication-options', GeneratePasskeyAuthenticationOptionsController::class)
|
||||
->name('passkeys.authentication_options');
|
||||
Route::post('passkeys/authenticate', PasskeyLoginController::class)->name('passkeys.login');
|
||||
|
||||
Route::get('2fa/verify', [PasswordSecurityController::class, 'getVerify2fa'])->name('2fa.verify');
|
||||
Route::post('2fa/verify', [PasswordSecurityController::class, 'verify2fa'])->name('2fa.post');
|
||||
@@ -123,7 +129,14 @@ Route::post('contact-us', [ContactUsController::class, 'contact']);
|
||||
|
||||
Route::get('status', [StatusPageController::class, 'showStatusPage'])->name('status');
|
||||
|
||||
Route::middleware('isVerified')->group(function () {
|
||||
Route::middleware(['auth', 'isVerified'])->group(function () {
|
||||
Route::post('passkeys/register-options', [PasskeyManagementController::class, 'options'])
|
||||
->name('passkeys.register_options');
|
||||
Route::post('passkeys', [PasskeyManagementController::class, 'store'])
|
||||
->name('passkeys.store');
|
||||
Route::delete('passkeys/{passkey}', [PasskeyManagementController::class, 'destroy'])
|
||||
->name('passkeys.destroy');
|
||||
|
||||
Route::match(['GET', 'POST'], 'resetpassword', [ResetPasswordController::class, 'reset'])->name('resetpassword');
|
||||
Route::match(['GET', 'POST'], 'profile', [ProfileController::class, 'show'])->name('profile');
|
||||
|
||||
@@ -224,6 +237,8 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () {
|
||||
Route::get('category-delete', [AdminCategoryController::class, 'destroy'])->name('admin.category-delete');
|
||||
Route::get('user-list', [AdminUserController::class, 'index'])->name('admin.user-list');
|
||||
Route::match(['GET', 'POST'], 'user-edit', [AdminUserController::class, 'edit'])->name('admin.user-edit');
|
||||
Route::delete('user-passkey/{passkey}', [AdminUserController::class, 'destroyPasskey'])->name('admin.user-passkey.destroy');
|
||||
Route::post('user-passkeys/wipe', [AdminUserController::class, 'wipePasskeys'])->name('admin.user-passkeys.wipe');
|
||||
Route::post('user-delete', [AdminUserController::class, 'destroy'])->name('admin.user-delete');
|
||||
Route::post('verify', [AdminUserController::class, 'verify'])->name('admin.verify');
|
||||
Route::post('resendverification', [AdminUserController::class, 'resendVerification'])->name('admin.resend-verification');
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Http\Middleware\Google2FAMiddleware;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasskeyAdminTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.default' => 'sqlite',
|
||||
'database.connections.sqlite.database' => ':memory:',
|
||||
'app.key' => 'base64:'.base64_encode(random_bytes(32)),
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
DB::purge();
|
||||
DB::reconnect();
|
||||
|
||||
$this->createSchema();
|
||||
$this->seedSettings();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->withoutMiddleware(Google2FAMiddleware::class);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_delete_another_users_passkey(): void
|
||||
{
|
||||
$regularUser = $this->createUserWithRole('User');
|
||||
$targetUser = $this->createUserWithRole('User');
|
||||
$passkeyId = $this->createPasskey($targetUser->id);
|
||||
|
||||
$response = $this->actingAs($regularUser)->delete(route('admin.user-passkey.destroy', ['passkey' => $passkeyId]));
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('passkeys', ['id' => $passkeyId]);
|
||||
}
|
||||
|
||||
public function test_admin_can_delete_single_passkey_for_another_user(): void
|
||||
{
|
||||
$admin = $this->createUserWithRole('Admin');
|
||||
$targetUser = $this->createUserWithRole('User');
|
||||
$passkeyId = $this->createPasskey($targetUser->id, 'Work Laptop');
|
||||
|
||||
$response = $this->actingAs($admin)->delete(route('admin.user-passkey.destroy', ['passkey' => $passkeyId]));
|
||||
|
||||
$response->assertRedirect('admin/user-edit?id='.$targetUser->id);
|
||||
$this->assertDatabaseMissing('passkeys', ['id' => $passkeyId]);
|
||||
}
|
||||
|
||||
public function test_admin_wipe_requires_wipe_confirmation_text(): void
|
||||
{
|
||||
$admin = $this->createUserWithRole('Admin');
|
||||
$targetUser = $this->createUserWithRole('User');
|
||||
$passkeyId = $this->createPasskey($targetUser->id);
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('admin.user-passkeys.wipe'), [
|
||||
'user_id' => $targetUser->id,
|
||||
'confirmation' => 'NOPE',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('confirmation');
|
||||
$this->assertDatabaseHas('passkeys', ['id' => $passkeyId]);
|
||||
}
|
||||
|
||||
public function test_admin_can_wipe_all_passkeys_for_target_user_only(): void
|
||||
{
|
||||
$admin = $this->createUserWithRole('Admin');
|
||||
$targetUser = $this->createUserWithRole('User');
|
||||
$otherUser = $this->createUserWithRole('User');
|
||||
$targetPasskeyOne = $this->createPasskey($targetUser->id, 'Target A');
|
||||
$targetPasskeyTwo = $this->createPasskey($targetUser->id, 'Target B');
|
||||
$otherPasskey = $this->createPasskey($otherUser->id, 'Other');
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('admin.user-passkeys.wipe'), [
|
||||
'user_id' => $targetUser->id,
|
||||
'confirmation' => 'WIPE',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('admin/user-edit?id='.$targetUser->id);
|
||||
$this->assertDatabaseMissing('passkeys', ['id' => $targetPasskeyOne]);
|
||||
$this->assertDatabaseMissing('passkeys', ['id' => $targetPasskeyTwo]);
|
||||
$this->assertDatabaseHas('passkeys', ['id' => $otherPasskey]);
|
||||
}
|
||||
|
||||
private function createSchema(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table): void {
|
||||
$table->string('name')->primary();
|
||||
$table->text('value')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('roles', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->boolean('isdefault')->default(false);
|
||||
$table->unsignedInteger('defaultinvites')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('permissions', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('username');
|
||||
$table->string('email')->unique();
|
||||
$table->string('password');
|
||||
$table->unsignedInteger('roles_id')->default(1);
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->string('api_token')->nullable();
|
||||
$table->boolean('verified')->default(true);
|
||||
$table->boolean('can_post')->default(true);
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->timestamp('lastlogin')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('model_has_roles', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('role_id');
|
||||
$table->string('model_type');
|
||||
$table->unsignedInteger('model_id');
|
||||
$table->primary(['role_id', 'model_id', 'model_type']);
|
||||
});
|
||||
|
||||
Schema::create('model_has_permissions', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('permission_id');
|
||||
$table->string('model_type');
|
||||
$table->unsignedInteger('model_id');
|
||||
$table->primary(['permission_id', 'model_id', 'model_type']);
|
||||
});
|
||||
|
||||
Schema::create('role_has_permissions', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('permission_id');
|
||||
$table->unsignedInteger('role_id');
|
||||
$table->primary(['permission_id', 'role_id']);
|
||||
});
|
||||
|
||||
Schema::create('user_activities', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('user_id')->nullable();
|
||||
$table->string('username');
|
||||
$table->string('activity_type', 50);
|
||||
$table->text('description');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('root_categories', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('title')->default('');
|
||||
$table->integer('status')->default(1);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('categories', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('title')->default('');
|
||||
$table->unsignedInteger('root_categories_id')->nullable();
|
||||
$table->integer('status')->default(1);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('user_excluded_categories', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('users_id');
|
||||
$table->unsignedInteger('categories_id');
|
||||
});
|
||||
|
||||
Schema::create('passkeys', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedInteger('authenticatable_id');
|
||||
$table->text('name');
|
||||
$table->text('credential_id');
|
||||
$table->json('data');
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
private function seedSettings(): void
|
||||
{
|
||||
DB::table('settings')->insert([
|
||||
['name' => 'title', 'value' => 'NNTmux Test'],
|
||||
['name' => 'home_link', 'value' => '/'],
|
||||
['name' => 'categorizeforeign', 'value' => '0'],
|
||||
['name' => 'catwebdl', 'value' => '0'],
|
||||
]);
|
||||
|
||||
DB::table('root_categories')->insert([
|
||||
'id' => 1,
|
||||
'title' => 'General',
|
||||
'status' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function createUserWithRole(string $roleName): User
|
||||
{
|
||||
$role = Role::query()->firstOrCreate(
|
||||
['name' => $roleName, 'guard_name' => 'web'],
|
||||
['rate_limit' => 60, 'isdefault' => $roleName === 'User', 'defaultinvites' => 1]
|
||||
);
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => strtolower($roleName).'_'.Str::random(8),
|
||||
'email' => Str::random(12).'@example.test',
|
||||
'password' => bcrypt('password'),
|
||||
'roles_id' => $role->id,
|
||||
'rate_limit' => 60,
|
||||
'api_token' => Str::random(32),
|
||||
'verified' => true,
|
||||
'email_verified_at' => now(),
|
||||
'lastlogin' => now(),
|
||||
]);
|
||||
$user->assignRole($role);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
private function createPasskey(int $userId, string $name = 'Key'): int
|
||||
{
|
||||
return (int) DB::table('passkeys')->insertGetId([
|
||||
'authenticatable_id' => $userId,
|
||||
'name' => $name,
|
||||
'credential_id' => 'credential-'.Str::random(8),
|
||||
'data' => json_encode(['origin' => 'tests'], JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Events\UserLoggedIn;
|
||||
use App\Http\Middleware\Google2FAMiddleware;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\LaravelPasskeys\Actions\FindPasskeyToAuthenticateAction;
|
||||
use Spatie\LaravelPasskeys\Models\Passkey;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasskeyAuthenticationTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.default' => 'sqlite',
|
||||
'database.connections.sqlite.database' => ':memory:',
|
||||
'app.key' => 'base64:'.base64_encode(random_bytes(32)),
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
DB::purge();
|
||||
DB::reconnect();
|
||||
|
||||
$this->createSchema();
|
||||
$this->seedSettings();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->withoutMiddleware(Google2FAMiddleware::class);
|
||||
}
|
||||
|
||||
public function test_passkey_authentication_logs_user_in_and_sets_2fa_session_flag(): void
|
||||
{
|
||||
Event::fake([UserLoggedIn::class]);
|
||||
$user = $this->createUser('passkey-user@example.test');
|
||||
|
||||
$passkey = new Passkey;
|
||||
$passkey->setRawAttributes([
|
||||
'id' => 1,
|
||||
'authenticatable_id' => $user->id,
|
||||
'name' => 'Laptop',
|
||||
'credential_id' => 'credential-1',
|
||||
'data' => '{}',
|
||||
], true);
|
||||
$passkey->setRelation('authenticatable', $user);
|
||||
|
||||
FakeFindPasskeyAction::$passkey = $passkey;
|
||||
config()->set('passkeys.actions.find_passkey', FakeFindPasskeyAction::class);
|
||||
|
||||
$response = $this
|
||||
->withSession(['passkey-authentication-options' => '{}'])
|
||||
->post(route('passkeys.login'), [
|
||||
'start_authentication_response' => json_encode(['id' => 'credential-1'], JSON_THROW_ON_ERROR),
|
||||
'remember' => false,
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertTrue((bool) session(config('google2fa.session_var')));
|
||||
Event::assertDispatched(UserLoggedIn::class);
|
||||
}
|
||||
|
||||
public function test_unverified_users_cannot_authenticate_with_passkeys(): void
|
||||
{
|
||||
$user = $this->createUser('unverified@example.test', false);
|
||||
$passkey = new Passkey;
|
||||
$passkey->setRawAttributes([
|
||||
'id' => 2,
|
||||
'authenticatable_id' => $user->id,
|
||||
'name' => 'Phone',
|
||||
'credential_id' => 'credential-2',
|
||||
'data' => '{}',
|
||||
], true);
|
||||
$passkey->setRelation('authenticatable', $user);
|
||||
|
||||
FakeFindPasskeyAction::$passkey = $passkey;
|
||||
config()->set('passkeys.actions.find_passkey', FakeFindPasskeyAction::class);
|
||||
|
||||
$response = $this
|
||||
->from(route('login'))
|
||||
->withSession(['passkey-authentication-options' => '{}'])
|
||||
->post(route('passkeys.login'), [
|
||||
'start_authentication_response' => json_encode(['id' => 'credential-2'], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$this->assertGuest();
|
||||
$this->assertSame('You have not verified your email address!', session('authenticatePasskey::message'));
|
||||
}
|
||||
|
||||
private function createSchema(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table): void {
|
||||
$table->string('name')->primary();
|
||||
$table->text('value')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('roles', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->boolean('isdefault')->default(false);
|
||||
$table->unsignedInteger('defaultinvites')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('permissions', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('username');
|
||||
$table->string('email')->unique();
|
||||
$table->string('password');
|
||||
$table->unsignedInteger('roles_id')->default(1);
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->string('api_token')->nullable();
|
||||
$table->boolean('verified')->default(true);
|
||||
$table->boolean('can_post')->default(true);
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->timestamp('lastlogin')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('model_has_roles', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('role_id');
|
||||
$table->string('model_type');
|
||||
$table->unsignedInteger('model_id');
|
||||
$table->primary(['role_id', 'model_id', 'model_type']);
|
||||
});
|
||||
|
||||
Schema::create('model_has_permissions', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('permission_id');
|
||||
$table->string('model_type');
|
||||
$table->unsignedInteger('model_id');
|
||||
$table->primary(['permission_id', 'model_id', 'model_type']);
|
||||
});
|
||||
|
||||
Schema::create('role_has_permissions', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('permission_id');
|
||||
$table->unsignedInteger('role_id');
|
||||
$table->primary(['permission_id', 'role_id']);
|
||||
});
|
||||
|
||||
Schema::create('user_activities', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('user_id')->nullable();
|
||||
$table->string('username');
|
||||
$table->string('activity_type', 50);
|
||||
$table->text('description');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('passkeys', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedInteger('authenticatable_id');
|
||||
$table->text('name');
|
||||
$table->text('credential_id');
|
||||
$table->json('data');
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
private function seedSettings(): void
|
||||
{
|
||||
DB::table('settings')->insert([
|
||||
['name' => 'title', 'value' => 'NNTmux Test'],
|
||||
['name' => 'home_link', 'value' => '/'],
|
||||
['name' => 'categorizeforeign', 'value' => '0'],
|
||||
['name' => 'catwebdl', 'value' => '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function createUser(string $email, bool $verified = true): User
|
||||
{
|
||||
$role = Role::query()->firstOrCreate(
|
||||
['name' => 'User', 'guard_name' => 'web'],
|
||||
['rate_limit' => 60, 'isdefault' => true, 'defaultinvites' => 1]
|
||||
);
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => 'user_'.md5($email),
|
||||
'email' => $email,
|
||||
'password' => bcrypt('password'),
|
||||
'roles_id' => $role->id,
|
||||
'rate_limit' => 60,
|
||||
'api_token' => md5($email),
|
||||
'verified' => $verified,
|
||||
'email_verified_at' => $verified ? now() : null,
|
||||
'lastlogin' => now(),
|
||||
]);
|
||||
|
||||
$user->assignRole($role);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeFindPasskeyAction extends FindPasskeyToAuthenticateAction
|
||||
{
|
||||
public static ?Passkey $passkey = null;
|
||||
|
||||
public function execute(string $publicKeyCredentialJson, string $passkeyOptionsJson): ?Passkey
|
||||
{
|
||||
return self::$passkey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Http\Middleware\Google2FAMiddleware;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyRegisterOptionsAction;
|
||||
use Spatie\LaravelPasskeys\Actions\StorePasskeyAction;
|
||||
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
|
||||
use Spatie\LaravelPasskeys\Models\Passkey;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasskeyManagementTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.default' => 'sqlite',
|
||||
'database.connections.sqlite.database' => ':memory:',
|
||||
'app.key' => 'base64:'.base64_encode(random_bytes(32)),
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
DB::purge();
|
||||
DB::reconnect();
|
||||
|
||||
$this->createSchema();
|
||||
$this->seedSettings();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->withoutMiddleware(Google2FAMiddleware::class);
|
||||
|
||||
config()->set('passkeys.actions.generate_passkey_register_options', FakeGeneratePasskeyRegisterOptionsAction::class);
|
||||
config()->set('passkeys.actions.store_passkey', FakeStorePasskeyAction::class);
|
||||
}
|
||||
|
||||
public function test_verified_user_can_generate_options_store_and_delete_a_passkey(): void
|
||||
{
|
||||
$user = $this->createUser('passkey-manage@example.test');
|
||||
|
||||
$optionsResponse = $this
|
||||
->actingAs($user)
|
||||
->postJson(route('passkeys.register_options'), ['name' => 'MacBook']);
|
||||
$optionsResponse->assertOk()->assertJsonPath('ok', true);
|
||||
|
||||
$storeResponse = $this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'passkey-registration-options' => '{}',
|
||||
])
|
||||
->postJson(route('passkeys.store'), [
|
||||
'name' => 'MacBook',
|
||||
'passkey' => json_encode(['id' => 'credential-1'], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$storeResponse->assertOk()->assertJsonPath('ok', true);
|
||||
$passkeyId = (int) $storeResponse->json('passkey.id');
|
||||
$this->assertDatabaseHas('passkeys', ['id' => $passkeyId, 'authenticatable_id' => $user->id]);
|
||||
|
||||
$deleteResponse = $this
|
||||
->actingAs($user)
|
||||
->deleteJson(route('passkeys.destroy', ['passkey' => $passkeyId]));
|
||||
|
||||
$deleteResponse->assertOk()->assertJson(['ok' => true]);
|
||||
$this->assertDatabaseMissing('passkeys', ['id' => $passkeyId]);
|
||||
}
|
||||
|
||||
public function test_guest_cannot_hit_passkey_management_routes(): void
|
||||
{
|
||||
$this->post(route('passkeys.register_options'), ['name' => 'Guest key'])->assertRedirect(route('login'));
|
||||
$this->post(route('passkeys.store'), ['name' => 'Guest key', 'passkey' => '{}'])->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
private function createSchema(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table): void {
|
||||
$table->string('name')->primary();
|
||||
$table->text('value')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('roles', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->boolean('isdefault')->default(false);
|
||||
$table->unsignedInteger('defaultinvites')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('permissions', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('username');
|
||||
$table->string('email')->unique();
|
||||
$table->string('password');
|
||||
$table->unsignedInteger('roles_id')->default(1);
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->string('api_token')->nullable();
|
||||
$table->boolean('verified')->default(true);
|
||||
$table->boolean('can_post')->default(true);
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->timestamp('lastlogin')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('model_has_roles', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('role_id');
|
||||
$table->string('model_type');
|
||||
$table->unsignedInteger('model_id');
|
||||
$table->primary(['role_id', 'model_id', 'model_type']);
|
||||
});
|
||||
|
||||
Schema::create('model_has_permissions', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('permission_id');
|
||||
$table->string('model_type');
|
||||
$table->unsignedInteger('model_id');
|
||||
$table->primary(['permission_id', 'model_id', 'model_type']);
|
||||
});
|
||||
|
||||
Schema::create('role_has_permissions', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('permission_id');
|
||||
$table->unsignedInteger('role_id');
|
||||
$table->primary(['permission_id', 'role_id']);
|
||||
});
|
||||
|
||||
Schema::create('user_activities', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('user_id')->nullable();
|
||||
$table->string('username');
|
||||
$table->string('activity_type', 50);
|
||||
$table->text('description');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('passkeys', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedInteger('authenticatable_id');
|
||||
$table->text('name');
|
||||
$table->text('credential_id');
|
||||
$table->json('data');
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
private function seedSettings(): void
|
||||
{
|
||||
DB::table('settings')->insert([
|
||||
['name' => 'title', 'value' => 'NNTmux Test'],
|
||||
['name' => 'home_link', 'value' => '/'],
|
||||
['name' => 'categorizeforeign', 'value' => '0'],
|
||||
['name' => 'catwebdl', 'value' => '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function createUser(string $email): User
|
||||
{
|
||||
$role = Role::query()->firstOrCreate(
|
||||
['name' => 'User', 'guard_name' => 'web'],
|
||||
['rate_limit' => 60, 'isdefault' => true, 'defaultinvites' => 1]
|
||||
);
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => 'user_'.md5($email),
|
||||
'email' => $email,
|
||||
'password' => bcrypt('password'),
|
||||
'roles_id' => $role->id,
|
||||
'rate_limit' => 60,
|
||||
'api_token' => md5($email),
|
||||
'verified' => true,
|
||||
'email_verified_at' => now(),
|
||||
'lastlogin' => now(),
|
||||
]);
|
||||
$user->assignRole($role);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeGeneratePasskeyRegisterOptionsAction extends GeneratePasskeyRegisterOptionsAction
|
||||
{
|
||||
public function execute(HasPasskeys $authenticatable, bool $asJson = true): string
|
||||
{
|
||||
return json_encode([
|
||||
'challenge' => 'test-challenge',
|
||||
'rp' => ['name' => 'NNTmux'],
|
||||
'user' => ['id' => (string) $authenticatable->id],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStorePasskeyAction extends StorePasskeyAction
|
||||
{
|
||||
public function execute(
|
||||
HasPasskeys $authenticatable,
|
||||
string $passkeyJson,
|
||||
string $passkeyOptionsJson,
|
||||
string $hostName,
|
||||
array $additionalProperties = []
|
||||
): Passkey {
|
||||
DB::table('passkeys')->insertGetId([
|
||||
'authenticatable_id' => $authenticatable->id,
|
||||
'name' => $additionalProperties['name'] ?? 'Unnamed',
|
||||
'credential_id' => 'credential-'.md5($passkeyJson),
|
||||
'data' => json_encode(['host' => $hostName], JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return Passkey::query()->where('authenticatable_id', $authenticatable->id)->latest('id')->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Support\Passkeys\RelyingPartyIdResolver;
|
||||
use Illuminate\Http\Request;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RelyingPartyIdResolverTest extends TestCase
|
||||
{
|
||||
public function test_prefers_forwarded_host_for_proxied_requests(): void
|
||||
{
|
||||
config()->set('passkeys.relying_party.id', 'example.com');
|
||||
|
||||
$request = Request::create('http://127.0.0.1/passkeys/authentication-options', 'GET', [], [], [], [
|
||||
'HTTP_X_FORWARDED_HOST' => 'auth.example.com',
|
||||
]);
|
||||
|
||||
$this->assertSame('auth.example.com', RelyingPartyIdResolver::resolve($request));
|
||||
}
|
||||
|
||||
public function test_prefers_configured_domain_over_localhost_request_host(): void
|
||||
{
|
||||
config()->set('passkeys.relying_party.id', 'example.com');
|
||||
|
||||
$request = Request::create('http://localhost/passkeys/authentication-options', 'GET');
|
||||
|
||||
$this->assertSame('example.com', RelyingPartyIdResolver::resolve($request));
|
||||
}
|
||||
|
||||
public function test_rejects_ip_hosts_and_falls_back_to_localhost(): void
|
||||
{
|
||||
config()->set('passkeys.relying_party.id', '127.0.0.1');
|
||||
|
||||
$request = Request::create('http://127.0.0.1/passkeys/authentication-options', 'GET');
|
||||
|
||||
$this->assertSame('localhost', RelyingPartyIdResolver::resolve($request));
|
||||
}
|
||||
|
||||
public function test_allows_localhost_for_development(): void
|
||||
{
|
||||
config()->set('passkeys.relying_party.id', 'localhost');
|
||||
|
||||
$request = Request::create('http://localhost/passkeys/authentication-options', 'GET');
|
||||
|
||||
$this->assertSame('localhost', RelyingPartyIdResolver::resolve($request));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user