webauthn: add controllers and transformers
This commit is contained in:
parent
cdd07fa275
commit
28146f5bb6
7 changed files with 329 additions and 90 deletions
125
app/Http/Controllers/Api/Client/WebauthnController.php
Normal file
125
app/Http/Controllers/Api/Client/WebauthnController.php
Normal file
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Api\Client;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use LaravelWebauthn\Facades\Webauthn;
|
||||
use LaravelWebauthn\Models\WebauthnKey;
|
||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Pterodactyl\Transformers\Api\Client\WebauthnKeyTransformer;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class WebauthnController extends ClientApiController
|
||||
{
|
||||
private const SESSION_PUBLICKEY_CREATION = 'webauthn.publicKeyCreation';
|
||||
|
||||
/**
|
||||
* ?
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
public function index(Request $request): array
|
||||
{
|
||||
return $this->fractal->collection(WebauthnKey::query()->where('user_id', '=', $request->user()->id)->get())
|
||||
->transformWith($this->getTransformer(WebauthnKeyTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* ?
|
||||
*/
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
if (!Webauthn::canRegister($request->user())) {
|
||||
return new JsonResponse([
|
||||
'error' => [
|
||||
'message' => trans('webauthn::errors.cannot_register_new_key'),
|
||||
],
|
||||
], JsonResponse::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$publicKey = Webauthn::getRegisterData($request->user());
|
||||
|
||||
$request->session()->put(self::SESSION_PUBLICKEY_CREATION, $publicKey);
|
||||
$request->session()->save();
|
||||
|
||||
return new JsonResponse([
|
||||
'public_key' => $publicKey,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ?
|
||||
*
|
||||
* @return array|JsonResponse
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
if (!Webauthn::canRegister($request->user())) {
|
||||
return new JsonResponse([
|
||||
'error' => [
|
||||
'message' => trans('webauthn::errors.cannot_register_new_key'),
|
||||
],
|
||||
], JsonResponse::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
if ($request->input('register') === null) {
|
||||
throw new BadRequestHttpException('Missing register data in request body.');
|
||||
}
|
||||
|
||||
if ($request->input('name') === null) {
|
||||
throw new BadRequestHttpException('Missing name in request body.');
|
||||
}
|
||||
|
||||
try {
|
||||
$publicKey = $request->session()->pull(self::SESSION_PUBLICKEY_CREATION);
|
||||
if (!$publicKey instanceof PublicKeyCredentialCreationOptions) {
|
||||
throw new ModelNotFoundException(trans('webauthn::errors.create_data_not_found'));
|
||||
}
|
||||
|
||||
$webauthnKey = Webauthn::doRegister(
|
||||
$request->user(),
|
||||
$publicKey,
|
||||
$request->input('register'),
|
||||
$request->input('name'),
|
||||
);
|
||||
|
||||
return $this->fractal->item($webauthnKey)
|
||||
->transformWith($this->getTransformer(WebauthnKeyTransformer::class))
|
||||
->toArray();
|
||||
} catch (Exception $e) {
|
||||
return new JsonResponse([
|
||||
'error' => [
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
], JsonResponse::HTTP_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ?
|
||||
*/
|
||||
public function deleteKey(Request $request, int $webauthnKeyId): JsonResponse
|
||||
{
|
||||
try {
|
||||
WebauthnKey::query()
|
||||
->where('user_id', $request->user()->getAuthIdentifier())
|
||||
->findOrFail($webauthnKeyId)
|
||||
->delete();
|
||||
|
||||
return new JsonResponse([
|
||||
'deleted' => true,
|
||||
'id' => $webauthnKeyId,
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return new JsonResponse([
|
||||
'error' => [
|
||||
'message' => trans('webauthn::errors.object_not_found'),
|
||||
],
|
||||
], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,55 +11,29 @@ use Illuminate\Contracts\Encryption\Encrypter;
|
|||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
|
||||
use Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository;
|
||||
|
||||
class LoginCheckpointController extends AbstractLoginController
|
||||
{
|
||||
/**
|
||||
* @var \Illuminate\Contracts\Cache\Repository
|
||||
*/
|
||||
private $cache;
|
||||
private CacheRepository $cache;
|
||||
private Encrypter $encrypter;
|
||||
private Google2FA $google2FA;
|
||||
|
||||
/**
|
||||
* @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface
|
||||
*/
|
||||
private $repository;
|
||||
|
||||
/**
|
||||
* @var \PragmaRX\Google2FA\Google2FA
|
||||
*/
|
||||
private $google2FA;
|
||||
|
||||
/**
|
||||
* @var \Illuminate\Contracts\Encryption\Encrypter
|
||||
*/
|
||||
private $encrypter;
|
||||
|
||||
/**
|
||||
* @var \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository
|
||||
*/
|
||||
private $recoveryTokenRepository;
|
||||
|
||||
/**
|
||||
* LoginCheckpointController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
AuthManager $auth,
|
||||
Encrypter $encrypter,
|
||||
Google2FA $google2FA,
|
||||
Repository $config,
|
||||
CacheRepository $cache,
|
||||
RecoveryTokenRepository $recoveryTokenRepository,
|
||||
UserRepositoryInterface $repository
|
||||
Encrypter $encrypter,
|
||||
Google2FA $google2FA
|
||||
) {
|
||||
parent::__construct($auth, $config);
|
||||
|
||||
$this->google2FA = $google2FA;
|
||||
$this->cache = $cache;
|
||||
$this->repository = $repository;
|
||||
$this->encrypter = $encrypter;
|
||||
$this->recoveryTokenRepository = $recoveryTokenRepository;
|
||||
$this->google2FA = $google2FA;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -72,13 +46,13 @@ class LoginCheckpointController extends AbstractLoginController
|
|||
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
|
||||
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
|
||||
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
|
||||
* @throws \Exception
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function __invoke(LoginCheckpointRequest $request): JsonResponse
|
||||
{
|
||||
if ($this->hasTooManyLoginAttempts($request)) {
|
||||
$this->sendLockoutResponse($request);
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $request->input('confirmation_token');
|
||||
|
@ -88,11 +62,12 @@ class LoginCheckpointController extends AbstractLoginController
|
|||
} catch (ModelNotFoundException $exception) {
|
||||
$this->incrementLoginAttempts($request);
|
||||
|
||||
return $this->sendFailedLoginResponse(
|
||||
$this->sendFailedLoginResponse(
|
||||
$request,
|
||||
null,
|
||||
'The authentication token provided has expired, please refresh the page and try again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Recovery tokens go through a slightly different pathway for usage.
|
||||
|
@ -111,8 +86,7 @@ class LoginCheckpointController extends AbstractLoginController
|
|||
}
|
||||
|
||||
$this->incrementLoginAttempts($request);
|
||||
|
||||
return $this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
|
||||
$this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -8,6 +8,7 @@ use Illuminate\Http\Request;
|
|||
use Illuminate\Auth\AuthManager;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use LaravelWebauthn\Facades\Webauthn;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Contracts\View\Factory as ViewFactory;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
|
@ -17,19 +18,13 @@ use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
|
|||
class LoginController extends AbstractLoginController
|
||||
{
|
||||
/**
|
||||
* @var \Illuminate\Contracts\View\Factory
|
||||
* @var string
|
||||
*/
|
||||
private $view;
|
||||
private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest';
|
||||
|
||||
/**
|
||||
* @var \Illuminate\Contracts\Cache\Repository
|
||||
*/
|
||||
private $cache;
|
||||
|
||||
/**
|
||||
* @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface
|
||||
*/
|
||||
private $repository;
|
||||
private CacheRepository $cache;
|
||||
private UserRepositoryInterface $repository;
|
||||
private ViewFactory $view;
|
||||
|
||||
/**
|
||||
* LoginController constructor.
|
||||
|
@ -43,14 +38,14 @@ class LoginController extends AbstractLoginController
|
|||
) {
|
||||
parent::__construct($auth, $config);
|
||||
|
||||
$this->view = $view;
|
||||
$this->cache = $cache;
|
||||
$this->repository = $repository;
|
||||
$this->view = $view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle all incoming requests for the authentication routes and render the
|
||||
* base authentication view component. Vuejs will take over at this point and
|
||||
* base authentication view component. React will take over at this point and
|
||||
* turn the login area into a SPA.
|
||||
*/
|
||||
public function index(): View
|
||||
|
@ -74,31 +69,57 @@ class LoginController extends AbstractLoginController
|
|||
if ($this->hasTooManyLoginAttempts($request)) {
|
||||
$this->fireLockoutEvent($request);
|
||||
$this->sendLockoutResponse($request);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var \Pterodactyl\Models\User $user */
|
||||
$user = $this->repository->findFirstWhere([[$useColumn, '=', $username]]);
|
||||
} catch (RecordNotFoundException $exception) {
|
||||
return $this->sendFailedLoginResponse($request);
|
||||
$this->sendFailedLoginResponse($request);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure that the account is using a valid username and password before trying to
|
||||
// continue. Previously this was handled in the 2FA checkpoint, however that has
|
||||
// a flaw in which you can discover if an account exists simply by seeing if you
|
||||
// can proceede to the next step in the login process.
|
||||
// can proceed to the next step in the login process.
|
||||
if (!password_verify($request->input('password'), $user->password)) {
|
||||
return $this->sendFailedLoginResponse($request, $user);
|
||||
$this->sendFailedLoginResponse($request, $user);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->use_totp) {
|
||||
$webauthnKeys = $user->webauthnKeys()->get();
|
||||
|
||||
if (sizeof($webauthnKeys) > 0) {
|
||||
$token = Str::random(64);
|
||||
$this->cache->put($token, $user->id, CarbonImmutable::now()->addMinutes(5));
|
||||
|
||||
$publicKey = Webauthn::getAuthenticateData($user);
|
||||
$request->session()->put(self::SESSION_PUBLICKEY_REQUEST, $publicKey);
|
||||
$request->session()->save();
|
||||
|
||||
$methods = ['webauthn'];
|
||||
if ($user->use_totp) {
|
||||
$methods[] = 'totp';
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'complete' => false,
|
||||
'methods' => $methods,
|
||||
'confirmation_token' => $token,
|
||||
'webauthn' => [
|
||||
'public_key' => $publicKey,
|
||||
],
|
||||
]);
|
||||
} else if ($user->use_totp) {
|
||||
$token = Str::random(64);
|
||||
$this->cache->put($token, $user->id, CarbonImmutable::now()->addMinutes(5));
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => [
|
||||
'complete' => false,
|
||||
'confirmation_token' => $token,
|
||||
],
|
||||
'complete' => false,
|
||||
'methods' => ['totp'],
|
||||
'confirmation_token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
99
app/Http/Controllers/Auth/WebauthnController.php
Normal file
99
app/Http/Controllers/Auth/WebauthnController.php
Normal file
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Auth;
|
||||
|
||||
use Exception;
|
||||
use Pterodactyl\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Auth\AuthManager;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use LaravelWebauthn\Facades\Webauthn;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
use Illuminate\Contracts\Config\Repository as ConfigRepository;
|
||||
|
||||
class WebauthnController extends AbstractLoginController
|
||||
{
|
||||
private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest';
|
||||
|
||||
private CacheRepository $cache;
|
||||
|
||||
public function __construct(AuthManager $auth, ConfigRepository $config, CacheRepository $cache)
|
||||
{
|
||||
parent::__construct($auth, $config);
|
||||
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return JsonResponse|void
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||
*/
|
||||
public function auth(Request $request): JsonResponse
|
||||
{
|
||||
if ($this->hasTooManyLoginAttempts($request)) {
|
||||
$this->sendLockoutResponse($request);
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $request->input('confirmation_token');
|
||||
try {
|
||||
/** @var \Pterodactyl\Models\User $user */
|
||||
$user = User::query()->findOrFail($this->cache->get($token, 0));
|
||||
} catch (ModelNotFoundException $exception) {
|
||||
$this->incrementLoginAttempts($request);
|
||||
|
||||
$this->sendFailedLoginResponse(
|
||||
$request,
|
||||
null,
|
||||
'The authentication token provided has expired, please refresh the page and try again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
$this->auth->guard()->onceUsingId($user->id);
|
||||
|
||||
try {
|
||||
$publicKey = $request->session()->pull(self::SESSION_PUBLICKEY_REQUEST);
|
||||
if (!$publicKey instanceof PublicKeyCredentialRequestOptions) {
|
||||
throw new ModelNotFoundException(trans('webauthn::errors.auth_data_not_found'));
|
||||
}
|
||||
|
||||
$result = Webauthn::doAuthenticate(
|
||||
$user,
|
||||
$publicKey,
|
||||
$this->input($request, 'data'),
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
return new JsonResponse([
|
||||
'error' => [
|
||||
'message' => 'Nice attempt, you didn\'t pass the challenge.',
|
||||
],
|
||||
], JsonResponse::HTTP_I_AM_A_TEAPOT);
|
||||
}
|
||||
|
||||
$this->cache->delete($token);
|
||||
|
||||
return $this->sendLoginResponse($user, $request);
|
||||
} catch (Exception $e) {
|
||||
return new JsonResponse([
|
||||
'error' => [
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
], JsonResponse::HTTP_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the input with a string result.
|
||||
*/
|
||||
private function input(Request $request, string $name, string $default = ''): string
|
||||
{
|
||||
$result = $request->input($name);
|
||||
|
||||
return is_string($result) ? $result : $default;
|
||||
}
|
||||
}
|
|
@ -4,13 +4,15 @@ namespace Pterodactyl\Models;
|
|||
|
||||
use Pterodactyl\Rules\Username;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Illuminate\Auth\Authenticatable;
|
||||
use LaravelWebauthn\Models\WebauthnKey;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Auth\Passwords\CanResetPassword;
|
||||
use Pterodactyl\Traits\Helpers\AvailableLanguages;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\Access\Authorizable;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
||||
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
|
||||
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
|
||||
|
@ -35,9 +37,11 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
|
|||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property string $name
|
||||
* @property \Pterodactyl\Models\AdminRole $adminRole
|
||||
* @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys
|
||||
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
|
||||
* @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryTokens
|
||||
* @property \LaravelWebauthn\Models\WebauthnKey[]|\Illuminate\Database\Eloquent\Collection $webauthnKeys
|
||||
*/
|
||||
class User extends Model implements
|
||||
AuthenticatableContract,
|
||||
|
@ -227,50 +231,37 @@ class User extends Model implements
|
|||
return $role->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the admin role associated with a user.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
*/
|
||||
public function adminRole()
|
||||
public function adminRole(): HasOne
|
||||
{
|
||||
return $this->hasOne(AdminRole::class, 'id', 'admin_role_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all servers that a user owns.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function servers()
|
||||
{
|
||||
return $this->hasMany(Server::class, 'owner_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function apiKeys()
|
||||
public function apiKeys(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApiKey::class)
|
||||
->where('key_type', ApiKey::TYPE_ACCOUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function recoveryTokens()
|
||||
public function servers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Server::class, 'owner_id');
|
||||
}
|
||||
|
||||
public function recoveryTokens(): HasMany
|
||||
{
|
||||
return $this->hasMany(RecoveryToken::class);
|
||||
}
|
||||
|
||||
public function webauthnKeys(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebauthnKey::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all of the servers that a user can access by way of being the owner of the
|
||||
* server, or because they are assigned as a subuser for that server.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function accessibleServers()
|
||||
public function accessibleServers(): Builder
|
||||
{
|
||||
return Server::query()
|
||||
->select('servers.*')
|
||||
|
|
29
app/Transformers/Api/Client/WebauthnKeyTransformer.php
Normal file
29
app/Transformers/Api/Client/WebauthnKeyTransformer.php
Normal file
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Transformers\Api\Client;
|
||||
|
||||
use LaravelWebauthn\Models\WebauthnKey;
|
||||
|
||||
class WebauthnKeyTransformer extends BaseClientTransformer
|
||||
{
|
||||
/**
|
||||
* Return the resource name for the JSONAPI output.
|
||||
*/
|
||||
public function getResourceName(): string
|
||||
{
|
||||
return 'webauthn_key';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return basic information about the currently logged in user.
|
||||
*/
|
||||
public function transform(WebauthnKey $model): array
|
||||
{
|
||||
return [
|
||||
'id' => $model->id,
|
||||
'name' => $model->name,
|
||||
'created_at' => $model->created_at->toIso8601String(),
|
||||
'last_used_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -18,18 +18,18 @@ class AddWebauthn extends Migration
|
|||
$table->unsignedInteger('user_id');
|
||||
|
||||
$table->string('name')->default('key');
|
||||
$table->string('credential_id', 255);
|
||||
$table->string('credentialId', 255);
|
||||
$table->string('type', 255);
|
||||
$table->text('transports');
|
||||
$table->string('attestation_type', 255);
|
||||
$table->text('trust_path');
|
||||
$table->string('attestationType', 255);
|
||||
$table->text('trustPath');
|
||||
$table->text('aaguid');
|
||||
$table->text('credential_public_key');
|
||||
$table->text('credentialPublicKey');
|
||||
$table->integer('counter');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
|
||||
$table->index('credential_id');
|
||||
$table->index('credentialId');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue