mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-04-18 12:10:50 +00:00
Add passkey authentication endpoints and storage.
Implement WebAuthn registration/authentication flow and related API and login test updates so CI can validate the new passkey auth paths.
This commit is contained in:
@@ -38,6 +38,7 @@ use App\Jobs\Company\CreateCompanyToken;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use App\Http\Requests\Login\LoginRequest;
|
||||
use App\Services\Auth\Passkeys\PasskeyService;
|
||||
use App\Libraries\OAuth\Providers\Google;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use App\DataMapper\Analytics\LoginFailure;
|
||||
@@ -108,7 +109,34 @@ class LoginController extends BaseController
|
||||
->header('X-Api-Version', config('ninja.minimum_client_version'));
|
||||
}
|
||||
|
||||
if ($this->attemptLogin($request)) {
|
||||
$passkeyService = app(PasskeyService::class);
|
||||
$passkeyPayload = $request->input('passkey_authentication');
|
||||
$passkeyToken = $request->input('passkey_challenge_token');
|
||||
|
||||
$passwordlessPasskeyAttempt = !$request->filled('password') && $request->filled('passkey_challenge_token') && is_array($passkeyPayload);
|
||||
|
||||
if ($passwordlessPasskeyAttempt) {
|
||||
$user = MultiDB::hasUser(['email' => $request->input('email')]);
|
||||
|
||||
if (!$user) {
|
||||
return response()
|
||||
->json(['message' => ctrans('texts.invalid_credentials')], 401)
|
||||
->header('X-App-Version', config('ninja.app_version'))
|
||||
->header('X-Api-Version', config('ninja.minimum_client_version'));
|
||||
}
|
||||
|
||||
try {
|
||||
$passkeyUser = $passkeyService->authenticate($user, (string) $passkeyToken, $passkeyPayload);
|
||||
Auth::login($passkeyUser, false);
|
||||
} catch (\Throwable $e) {
|
||||
return response()
|
||||
->json(['message' => ctrans('texts.invalid_credentials')], 422)
|
||||
->header('X-App-Version', config('ninja.app_version'))
|
||||
->header('X-Api-Version', config('ninja.minimum_client_version'));
|
||||
}
|
||||
}
|
||||
|
||||
if ($passwordlessPasskeyAttempt || $this->attemptLogin($request)) {
|
||||
LightLogs::create(new LoginSuccess())
|
||||
->increment()
|
||||
->batch();
|
||||
@@ -129,8 +157,26 @@ class LoginController extends BaseController
|
||||
/** @var \App\Models\User $user */
|
||||
$user = $this->guard()->user();
|
||||
|
||||
//2FA
|
||||
if ($user->google_2fa_secret && $request->has('one_time_password')) {
|
||||
$hasPasskeys = $user->passkey_credentials()->exists();
|
||||
$hasOneTimePassword = $request->filled('one_time_password');
|
||||
$hasPasskeyAssertion = is_array($passkeyPayload) && $request->filled('passkey_challenge_token');
|
||||
$requiresSecondFactor = (bool) $user->google_2fa_secret || $hasPasskeys;
|
||||
|
||||
if ($requiresSecondFactor && !$passwordlessPasskeyAttempt && !$hasOneTimePassword && !$hasPasskeyAssertion) {
|
||||
$passkeyOptions = $hasPasskeys ? $passkeyService->getAuthenticationOptions($user) : null;
|
||||
|
||||
return response()
|
||||
->json([
|
||||
'message' => ctrans('texts.invalid_one_time_password'),
|
||||
'requires_second_factor' => true,
|
||||
'passkey_options' => $passkeyOptions,
|
||||
], 422)
|
||||
->header('X-App-Version', config('ninja.app_version'))
|
||||
->header('X-Api-Version', config('ninja.minimum_client_version'));
|
||||
}
|
||||
|
||||
// TOTP fallback for existing users
|
||||
if ($user->google_2fa_secret && $hasOneTimePassword) {
|
||||
$google2fa = new Google2FA();
|
||||
|
||||
if (strlen($request->input('one_time_password')) == 0 || !$google2fa->verifyKey(decrypt($user->google_2fa_secret), $request->input('one_time_password'))) {
|
||||
@@ -139,9 +185,24 @@ class LoginController extends BaseController
|
||||
->header('X-App-Version', config('ninja.app_version'))
|
||||
->header('X-Api-Version', config('ninja.minimum_client_version'));
|
||||
}
|
||||
} elseif (strlen($user->google_2fa_secret ?? '') > 2 && !$request->has('one_time_password')) {
|
||||
} elseif ($requiresSecondFactor && !$passwordlessPasskeyAttempt && !$hasOneTimePassword && $hasPasskeyAssertion) {
|
||||
try {
|
||||
$passkeyService->authenticate($user, (string) $passkeyToken, $passkeyPayload);
|
||||
} catch (\Throwable $e) {
|
||||
return response()
|
||||
->json(['message' => ctrans('texts.invalid_one_time_password')], 422)
|
||||
->header('X-App-Version', config('ninja.app_version'))
|
||||
->header('X-Api-Version', config('ninja.minimum_client_version'));
|
||||
}
|
||||
} elseif (strlen($user->google_2fa_secret ?? '') > 2 && !$hasOneTimePassword) {
|
||||
$passkeyOptions = $hasPasskeys ? $passkeyService->getAuthenticationOptions($user) : null;
|
||||
|
||||
return response()
|
||||
->json(['message' => ctrans('texts.invalid_one_time_password')], 422)
|
||||
->json([
|
||||
'message' => ctrans('texts.invalid_one_time_password'),
|
||||
'requires_second_factor' => true,
|
||||
'passkey_options' => $passkeyOptions,
|
||||
], 422)
|
||||
->header('X-App-Version', config('ninja.app_version'))
|
||||
->header('X-Api-Version', config('ninja.minimum_client_version'));
|
||||
}
|
||||
|
||||
126
app/Http/Controllers/PasskeyController.php
Normal file
126
app/Http/Controllers/PasskeyController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Libraries\MultiDB;
|
||||
use App\Models\PasskeyCredential;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Services\Auth\Passkeys\PasskeyService;
|
||||
|
||||
class PasskeyController extends BaseController
|
||||
{
|
||||
public function __construct(private readonly PasskeyService $passkeyService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
return response()->json([
|
||||
'data' => $user->passkey_credentials
|
||||
->map(fn (PasskeyCredential $credential) => [
|
||||
'id' => $credential->hashed_id,
|
||||
'name' => $credential->name,
|
||||
'created_at' => (int) $credential->created_at,
|
||||
'last_used_at' => $credential->last_used_at?->timestamp,
|
||||
])
|
||||
->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function registrationOptions(Request $request): JsonResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$data = $this->passkeyService->getRegistrationOptions(
|
||||
$user,
|
||||
$request->string('name')->toString() ?: null
|
||||
);
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'challenge_token' => ['required', 'string'],
|
||||
'credential' => ['required', 'array'],
|
||||
'credential.clientDataJSON' => ['required', 'string'],
|
||||
'credential.attestationObject' => ['required', 'string'],
|
||||
'credential.transports' => ['nullable', 'array'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$credential = $this->passkeyService->registerCredential(
|
||||
$user,
|
||||
$validated['challenge_token'],
|
||||
$validated['credential'],
|
||||
$validated['name'] ?? null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'id' => $credential->hashed_id,
|
||||
'name' => $credential->name,
|
||||
],
|
||||
'message' => 'Passkey added',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(PasskeyCredential $passkey): JsonResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
if ($passkey->user_id !== $user->id) {
|
||||
return response()->json(['message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$passkey->is_deleted = true;
|
||||
$passkey->save();
|
||||
$passkey->delete();
|
||||
|
||||
return response()->json(['message' => 'Passkey removed']);
|
||||
}
|
||||
|
||||
public function loginOptions(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email:rfc'],
|
||||
]);
|
||||
|
||||
if (!MultiDB::userFindAndSetDb($validated['email'])) {
|
||||
return response()->json(['message' => ctrans('texts.invalid_credentials')], 422);
|
||||
}
|
||||
|
||||
$user = \App\Models\User::query()->where('email', $validated['email'])->first();
|
||||
|
||||
if (!$user) {
|
||||
return response()->json(['message' => ctrans('texts.invalid_credentials')], 422);
|
||||
}
|
||||
|
||||
$data = $this->passkeyService->getAuthenticationOptions($user, true);
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,9 @@ class LoginRequest extends Request
|
||||
|
||||
return [
|
||||
'email' => $email_rules,
|
||||
'password' => 'required|max:1000',
|
||||
'password' => 'required_without:passkey_challenge_token|max:1000',
|
||||
'passkey_challenge_token' => 'nullable|string|max:255',
|
||||
'passkey_authentication' => 'nullable|array',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
47
app/Models/PasskeyCredential.php
Normal file
47
app/Models/PasskeyCredential.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PasskeyCredential extends BaseModel
|
||||
{
|
||||
use SoftDeletes;
|
||||
use Filterable;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'transports',
|
||||
'last_used_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'transports' => 'array',
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getEntityType()
|
||||
{
|
||||
return self::class;
|
||||
}
|
||||
|
||||
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function account(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
}
|
||||
@@ -541,6 +541,11 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return $this->hasMany(Webhook::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function passkey_credentials(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(PasskeyCredential::class)->where('is_deleted', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a comma separated list of user permissions.
|
||||
*
|
||||
|
||||
217
app/Services/Auth/Passkeys/PasskeyService.php
Normal file
217
app/Services/Auth/Passkeys/PasskeyService.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Auth\Passkeys;
|
||||
|
||||
use App\Models\PasskeyCredential;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
use lbuchs\WebAuthn\WebAuthn;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class PasskeyService
|
||||
{
|
||||
private const CACHE_PREFIX = 'passkey:challenge:';
|
||||
|
||||
private const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
public function getRegistrationOptions(User $user, ?string $displayName = null): array
|
||||
{
|
||||
$name = $displayName ?: trim($user->first_name . ' ' . $user->last_name);
|
||||
|
||||
$webAuthn = $this->makeWebAuthn();
|
||||
$args = $webAuthn->getCreateArgs(
|
||||
(string) $user->id,
|
||||
$user->email,
|
||||
$name ?: $user->email,
|
||||
240,
|
||||
true,
|
||||
'preferred',
|
||||
null
|
||||
);
|
||||
|
||||
$token = $this->storeChallenge($webAuthn->getChallenge(), [
|
||||
'action' => 'registration',
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'publicKey' => $args,
|
||||
'challenge_token' => $token,
|
||||
];
|
||||
}
|
||||
|
||||
public function registerCredential(User $user, string $challengeToken, array $payload, ?string $name = null): PasskeyCredential
|
||||
{
|
||||
$challengeData = $this->getChallenge($challengeToken, 'registration', $user->id);
|
||||
$webAuthn = $this->makeWebAuthn();
|
||||
|
||||
$result = $webAuthn->processCreate(
|
||||
$this->decodeBase64Input($payload['clientDataJSON'] ?? null),
|
||||
$this->decodeBase64Input($payload['attestationObject'] ?? null),
|
||||
$challengeData['challenge'],
|
||||
false,
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
$credential = PasskeyCredential::query()->updateOrCreate(
|
||||
[
|
||||
'account_id' => $user->account_id,
|
||||
'user_id' => $user->id,
|
||||
'credential_id' => base64_encode($result->credentialId),
|
||||
],
|
||||
[
|
||||
'name' => $name ?: ctrans('texts.passkey'),
|
||||
'credential_public_key' => base64_encode($result->credentialPublicKey),
|
||||
'signature_counter' => (int) ($result->signatureCounter ?? 0),
|
||||
'transports' => $payload['transports'] ?? null,
|
||||
'is_deleted' => false,
|
||||
]
|
||||
);
|
||||
|
||||
return $credential;
|
||||
}
|
||||
|
||||
public function getAuthenticationOptions(?User $user = null, bool $passwordless = false): array
|
||||
{
|
||||
$webAuthn = $this->makeWebAuthn();
|
||||
|
||||
$credentialIds = [];
|
||||
if ($user) {
|
||||
$credentialIds = $user->passkey_credentials
|
||||
->pluck('credential_id')
|
||||
->map(fn (string $value) => base64_decode($value))
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$args = $webAuthn->getGetArgs(
|
||||
$credentialIds,
|
||||
240,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
'preferred'
|
||||
);
|
||||
|
||||
$token = $this->storeChallenge($webAuthn->getChallenge(), [
|
||||
'action' => 'authentication',
|
||||
'user_id' => $user?->id,
|
||||
'passwordless' => $passwordless,
|
||||
]);
|
||||
|
||||
return [
|
||||
'publicKey' => $args,
|
||||
'challenge_token' => $token,
|
||||
];
|
||||
}
|
||||
|
||||
public function authenticate(?User $user, string $challengeToken, array $payload): User
|
||||
{
|
||||
$challengeData = $this->getChallenge($challengeToken, 'authentication', $user?->id);
|
||||
$credentialId = base64_encode($this->decodeBase64Input($payload['id'] ?? null));
|
||||
|
||||
$credentialQuery = PasskeyCredential::query()
|
||||
->where('credential_id', $credentialId)
|
||||
->where('is_deleted', false);
|
||||
|
||||
if ($user) {
|
||||
$credentialQuery->where('user_id', $user->id);
|
||||
}
|
||||
|
||||
/** @var PasskeyCredential|null $credential */
|
||||
$credential = $credentialQuery->first();
|
||||
|
||||
if (!$credential) {
|
||||
throw new \RuntimeException('Passkey credential not found.');
|
||||
}
|
||||
|
||||
/** @var User $resolvedUser */
|
||||
$resolvedUser = $user ?: User::query()->findOrFail($credential->user_id);
|
||||
|
||||
$webAuthn = $this->makeWebAuthn();
|
||||
$webAuthn->processGet(
|
||||
$this->decodeBase64Input($payload['clientDataJSON'] ?? null),
|
||||
$this->decodeBase64Input($payload['authenticatorData'] ?? null),
|
||||
$this->decodeBase64Input($payload['signature'] ?? null),
|
||||
base64_decode($credential->credential_public_key),
|
||||
$challengeData['challenge'],
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
$credential->last_used_at = Carbon::now();
|
||||
$credential->save();
|
||||
|
||||
return $resolvedUser;
|
||||
}
|
||||
|
||||
private function makeWebAuthn(): WebAuthn
|
||||
{
|
||||
$rpId = parse_url(config('ninja.app_url'), PHP_URL_HOST) ?: request()->getHost();
|
||||
|
||||
return new WebAuthn(config('ninja.app_name'), $rpId, ['none', 'packed', 'fido-u2f', 'android-key', 'android-safetynet', 'apple', 'tpm']);
|
||||
}
|
||||
|
||||
private function storeChallenge(string $challenge, array $meta): string
|
||||
{
|
||||
$token = Str::random(40);
|
||||
Cache::put(self::CACHE_PREFIX . $token, array_merge($meta, [
|
||||
'challenge' => $challenge,
|
||||
]), self::CACHE_TTL_SECONDS);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function getChallenge(string $token, string $action, ?int $userId): array
|
||||
{
|
||||
/** @var array<string, mixed>|null $payload */
|
||||
$payload = Cache::pull(self::CACHE_PREFIX . $token);
|
||||
|
||||
if (!$payload || ($payload['action'] ?? null) !== $action) {
|
||||
throw new \RuntimeException('Invalid passkey challenge.');
|
||||
}
|
||||
|
||||
if (!is_null($userId) && (int) ($payload['user_id'] ?? 0) !== $userId) {
|
||||
throw new \RuntimeException('Passkey challenge does not match user.');
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function decodeBase64Input(?string $value): string
|
||||
{
|
||||
if (!$value) {
|
||||
throw new \RuntimeException('Invalid passkey payload.');
|
||||
}
|
||||
|
||||
$normalized = str_replace(['-', '_'], ['+', '/'], $value);
|
||||
$padding = strlen($normalized) % 4;
|
||||
if ($padding > 0) {
|
||||
$normalized .= str_repeat('=', 4 - $padding);
|
||||
}
|
||||
|
||||
$decoded = base64_decode($normalized, true);
|
||||
if ($decoded === false) {
|
||||
throw new \RuntimeException('Invalid base64 value.');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,8 @@ class UserTransformer extends EntityTransformer
|
||||
'oauth_provider_id' => (string) $user->oauth_provider_id,
|
||||
'last_confirmed_email_address' => (string) $user->last_confirmed_email_address ?: '',
|
||||
'google_2fa_secret' => (bool) $user->google_2fa_secret,
|
||||
'passkey_enabled' => $user->passkey_credentials()->exists(),
|
||||
'passkey_count' => $user->passkey_credentials()->count(),
|
||||
'has_password' => (bool) empty($user->password) ? false : true,
|
||||
'oauth_user_token' => empty($user->oauth_user_token) ? '' : '***',
|
||||
'verified_phone_number' => (bool) $user->verified_phone_number,
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"laravel/socialite": "^5",
|
||||
"laravel/tinker": "^2.7",
|
||||
"laravel/ui": "^4.0",
|
||||
"lbuchs/webauthn": "^2.2",
|
||||
"league/csv": "^9.6",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"league/fractal": "^0.20.0",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('passkey_credentials')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('passkey_credentials', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('account_id')->index();
|
||||
$table->unsignedInteger('user_id')->index();
|
||||
$table->string('name')->nullable();
|
||||
$table->text('credential_id');
|
||||
$table->longText('credential_public_key');
|
||||
$table->unsignedBigInteger('signature_counter')->default(0);
|
||||
$table->json('transports')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->boolean('is_deleted')->default(false);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
$table->index(['user_id', 'is_deleted']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('passkey_credentials');
|
||||
}
|
||||
};
|
||||
@@ -99,6 +99,16 @@
|
||||
type: string
|
||||
example: '123456'
|
||||
readOnly: true
|
||||
passkey_enabled:
|
||||
description: 'Boolean flag if passkey auth is enabled for this user'
|
||||
type: boolean
|
||||
example: true
|
||||
readOnly: true
|
||||
passkey_count:
|
||||
description: 'The number of registered passkeys for this user'
|
||||
type: integer
|
||||
example: 2
|
||||
readOnly: true
|
||||
company_user:
|
||||
$ref: '#/components/schemas/CompanyUserRef'
|
||||
type: object
|
||||
@@ -203,6 +213,16 @@
|
||||
type: string
|
||||
example: '123456'
|
||||
readOnly: true
|
||||
passkey_enabled:
|
||||
description: 'Boolean flag if passkey auth is enabled for this user'
|
||||
type: boolean
|
||||
example: true
|
||||
readOnly: true
|
||||
passkey_count:
|
||||
description: 'The number of registered passkeys for this user'
|
||||
type: integer
|
||||
example: 2
|
||||
readOnly: true
|
||||
oauth_user_refresh_token:
|
||||
description: 'OAuth refresh token for the user'
|
||||
type: string
|
||||
|
||||
@@ -151,6 +151,122 @@ paths:
|
||||
default:
|
||||
$ref: "#/components/responses/default"
|
||||
|
||||
/api/v1/passkeys/login/options:
|
||||
post:
|
||||
tags:
|
||||
- auth
|
||||
summary: "Passkey login options"
|
||||
operationId: postPasskeyLoginOptions
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
example: "demo@invoiceninja.com"
|
||||
required:
|
||||
- email
|
||||
responses:
|
||||
200:
|
||||
description: "Returns passkey authentication options and challenge token"
|
||||
401:
|
||||
$ref: "#/components/responses/401"
|
||||
422:
|
||||
$ref: "#/components/responses/422"
|
||||
default:
|
||||
$ref: "#/components/responses/default"
|
||||
|
||||
/api/v1/settings/passkeys:
|
||||
get:
|
||||
tags:
|
||||
- settings
|
||||
summary: "List passkeys"
|
||||
operationId: getSettingsPasskeys
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/X-API-TOKEN"
|
||||
responses:
|
||||
200:
|
||||
description: "Returns passkeys for the current user"
|
||||
401:
|
||||
$ref: "#/components/responses/401"
|
||||
default:
|
||||
$ref: "#/components/responses/default"
|
||||
post:
|
||||
tags:
|
||||
- settings
|
||||
summary: "Create passkey"
|
||||
operationId: postSettingsPasskeys
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/X-API-TOKEN"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
challenge_token:
|
||||
type: string
|
||||
credential:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- challenge_token
|
||||
- credential
|
||||
responses:
|
||||
200:
|
||||
description: "Passkey added"
|
||||
401:
|
||||
$ref: "#/components/responses/401"
|
||||
422:
|
||||
$ref: "#/components/responses/422"
|
||||
default:
|
||||
$ref: "#/components/responses/default"
|
||||
|
||||
/api/v1/settings/passkeys/options:
|
||||
post:
|
||||
tags:
|
||||
- settings
|
||||
summary: "Passkey registration options"
|
||||
operationId: postSettingsPasskeysOptions
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/X-API-TOKEN"
|
||||
responses:
|
||||
200:
|
||||
description: "Returns passkey registration options and challenge token"
|
||||
401:
|
||||
$ref: "#/components/responses/401"
|
||||
default:
|
||||
$ref: "#/components/responses/default"
|
||||
|
||||
/api/v1/settings/passkeys/{passkey}:
|
||||
delete:
|
||||
tags:
|
||||
- settings
|
||||
summary: "Delete passkey"
|
||||
operationId: deleteSettingsPasskey
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/X-API-TOKEN"
|
||||
- name: passkey
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Passkey removed"
|
||||
401:
|
||||
$ref: "#/components/responses/401"
|
||||
403:
|
||||
$ref: "#/components/responses/403"
|
||||
default:
|
||||
$ref: "#/components/responses/default"
|
||||
|
||||
/api/v1/login:
|
||||
post:
|
||||
x-codeSamples:
|
||||
@@ -201,16 +317,23 @@ paths:
|
||||
type: string
|
||||
example: "demo@invoiceninja.com"
|
||||
password:
|
||||
description: "The user password. Must meet minimum criteria ~ > 6 characters"
|
||||
description: "The user password. Optional when performing passkey passwordless login."
|
||||
type: string
|
||||
example: "Password0"
|
||||
one_time_password:
|
||||
description: "The one time password if 2FA is enabled"
|
||||
type: string
|
||||
example: "123456"
|
||||
passkey_challenge_token:
|
||||
description: "Challenge token returned by passkey login options or second-factor challenge."
|
||||
type: string
|
||||
example: "f8f0cf1f4f6c4de5b6d6c5d4a0f8e5b2b89e1f1a"
|
||||
passkey_authentication:
|
||||
description: "WebAuthn assertion payload."
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
type: object
|
||||
responses:
|
||||
200:
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\SNSController;
|
||||
use App\Http\Controllers\BaseController;
|
||||
use App\Http\Controllers\PasskeyController;
|
||||
use App\Http\Controllers\PingController;
|
||||
use App\Http\Controllers\SmtpController;
|
||||
use App\Http\Controllers\TaskController;
|
||||
@@ -141,6 +142,7 @@ Route::group(['middleware' => ['throttle:api', 'api_secret_check']], function ()
|
||||
Route::group(['middleware' => ['throttle:login', 'api_secret_check', 'email_db']], function () {
|
||||
Route::post('api/v1/login', [LoginController::class, 'apiLogin'])->name('login.submit');
|
||||
Route::post('api/v1/reset_password', [ForgotPasswordController::class, 'sendResetLinkEmail']);
|
||||
Route::post('api/v1/passkeys/login/options', [PasskeyController::class, 'loginOptions'])->name('passkeys.login.options');
|
||||
});
|
||||
|
||||
Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','locale'], 'prefix' => 'api/v1', 'as' => 'api.'], function () {
|
||||
@@ -420,6 +422,10 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
|
||||
Route::get('settings/enable_two_factor', [TwoFactorController::class, 'setupTwoFactor']);
|
||||
Route::post('settings/enable_two_factor', [TwoFactorController::class, 'enableTwoFactor']);
|
||||
Route::post('settings/disable_two_factor', [TwoFactorController::class, 'disableTwoFactor']);
|
||||
Route::get('settings/passkeys', [PasskeyController::class, 'index'])->name('passkeys.index');
|
||||
Route::post('settings/passkeys/options', [PasskeyController::class, 'registrationOptions'])->name('passkeys.options');
|
||||
Route::post('settings/passkeys', [PasskeyController::class, 'store'])->name('passkeys.store');
|
||||
Route::delete('settings/passkeys/{passkey}', [PasskeyController::class, 'destroy'])->name('passkeys.destroy');
|
||||
|
||||
Route::post('verify', [TwilioController::class, 'generate'])->name('verify.generate')->middleware('throttle:daily-verify');
|
||||
Route::post('verify/confirm', [TwilioController::class, 'confirm'])->name('verify.confirm')->middleware('throttle:daily-verify');
|
||||
|
||||
@@ -16,10 +16,13 @@ use App\DataMapper\CompanySettings;
|
||||
use App\Models\Account;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyToken;
|
||||
use App\Models\PasskeyCredential;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\Passkeys\PasskeyService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -199,4 +202,69 @@ class LoginTest extends TestCase
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function testApiLoginRequiresSecondFactorWhenPasskeyExists()
|
||||
{
|
||||
Account::all()->each(function ($account) {
|
||||
$account->delete();
|
||||
});
|
||||
|
||||
$account = Account::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'email' => 'passkey@example.com',
|
||||
'password' => \Hash::make('123456'),
|
||||
]);
|
||||
|
||||
$company = Company::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$account->default_company_id = $company->id;
|
||||
$account->save();
|
||||
|
||||
CompanyToken::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'company_id' => $company->id,
|
||||
'account_id' => $account->id,
|
||||
'name' => $user->first_name.' '.$user->last_name,
|
||||
'token' => \Illuminate\Support\Str::random(64),
|
||||
'is_system' => true,
|
||||
]);
|
||||
|
||||
$user->companies()->attach($company->id, [
|
||||
'account_id' => $account->id,
|
||||
'is_owner' => 1,
|
||||
'notifications' => CompanySettings::notificationDefaults(),
|
||||
'is_admin' => 1,
|
||||
]);
|
||||
|
||||
PasskeyCredential::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'user_id' => $user->id,
|
||||
'name' => 'MacBook',
|
||||
'credential_id' => base64_encode('test-credential'),
|
||||
'credential_public_key' => base64_encode('test-public-key'),
|
||||
'signature_counter' => 0,
|
||||
'is_deleted' => false,
|
||||
]);
|
||||
|
||||
$passkeyService = Mockery::mock(PasskeyService::class);
|
||||
$passkeyService->shouldReceive('getAuthenticationOptions')->once()->andReturn([
|
||||
'publicKey' => ['challenge' => 'abc'],
|
||||
'challenge_token' => 'challenge-token',
|
||||
]);
|
||||
$this->app->instance(PasskeyService::class, $passkeyService);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
])->postJson('/api/v1/login', [
|
||||
'email' => 'passkey@example.com',
|
||||
'password' => '123456',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonPath('requires_second_factor', true);
|
||||
$response->assertJsonPath('passkey_options.challenge_token', 'challenge-token');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user