mirror of
https://github.com/pelican-dev/panel.git
synced 2025-09-09 00:48:44 +02:00
switch to filaments 2fa
This commit is contained in:
parent
9a1b999199
commit
b396f3e339
@ -25,10 +25,12 @@ class DisableTwoFactorCommand extends Command
|
||||
|
||||
$email = $this->option('email') ?? $this->ask(trans('command/messages.user.ask_email'));
|
||||
|
||||
$user = User::query()->where('email', $email)->firstOrFail();
|
||||
$user->use_totp = false;
|
||||
$user->totp_secret = null;
|
||||
$user->save();
|
||||
$user = User::where('email', $email)->firstOrFail();
|
||||
$user->update([
|
||||
'mfa_app_secret' => null,
|
||||
'mfa_app_recovery_codes' => null,
|
||||
'mfa_email_enabled' => false,
|
||||
]);
|
||||
|
||||
$this->info(trans('command/messages.user.2fa_disabled', ['email' => $user->email]));
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Extensions\Captcha\Schemas;
|
||||
|
||||
use Filament\Support\Components\Component;
|
||||
use Filament\Schemas\Components\Component;
|
||||
|
||||
interface CaptchaSchemaInterface
|
||||
{
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Extensions\Captcha\Schemas\Turnstile;
|
||||
|
||||
use Filament\Support\Components\Component;
|
||||
use App\Extensions\Captcha\Schemas\CaptchaSchemaInterface;
|
||||
use App\Extensions\Captcha\Schemas\BaseSchema;
|
||||
use Exception;
|
||||
@ -39,7 +38,7 @@ class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Component[]
|
||||
* @return \Filament\Support\Components\Component[]
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
|
@ -23,12 +23,12 @@ use Filament\Resources\Resource;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
|
||||
class UserResource extends Resource
|
||||
{
|
||||
@ -88,11 +88,11 @@ class UserResource extends Resource
|
||||
->label(trans('admin/user.email'))
|
||||
->icon('tabler-mail')
|
||||
->searchable(),
|
||||
IconColumn::make('use_totp')
|
||||
IconColumn::make('2fa')
|
||||
->label('2FA')
|
||||
->visibleFrom('lg')
|
||||
->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off')
|
||||
->boolean(),
|
||||
->icon(fn (User $user) => filled($user->mfa_app_secret) ? 'tabler-qrcode' : ($user->mfa_email_enabled ? 'tabler-mail' : 'tabler-lock-open-off'))
|
||||
->tooltip(fn (User $user) => filled($user->mfa_app_secret) ? 'App' : ($user->mfa_email_enabled ? 'E-Mail' : 'None')),
|
||||
TextColumn::make('roles.name')
|
||||
->label(trans('admin/user.roles'))
|
||||
->badge()
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Filament\Pages\Auth;
|
||||
|
||||
use App\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid;
|
||||
use App\Extensions\OAuth\OAuthService;
|
||||
use App\Facades\Activity;
|
||||
use App\Models\ActivityLog;
|
||||
@ -11,19 +10,16 @@ use App\Models\User;
|
||||
use App\Models\UserSSHKey;
|
||||
use App\Services\Helpers\LanguageService;
|
||||
use App\Services\Ssh\KeyCreationService;
|
||||
use App\Services\Users\ToggleTwoFactorService;
|
||||
use App\Services\Users\TwoFactorSetupService;
|
||||
use App\Services\Users\UserUpdateService;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use chillerlan\QRCode\Common\EccLevel;
|
||||
use chillerlan\QRCode\Common\Version;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use DateTimeZone;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Auth\MultiFactor\Contracts\MultiFactorAuthenticationProvider;
|
||||
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
@ -39,12 +35,12 @@ use Filament\Forms\Components\ToggleButtons;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Group;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Support\Exceptions\Halt;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\HtmlString;
|
||||
@ -54,18 +50,15 @@ use Laravel\Socialite\Facades\Socialite;
|
||||
/**
|
||||
* @method User getUser()
|
||||
*/
|
||||
class EditProfile extends \Filament\Auth\Pages\EditProfile
|
||||
class EditProfile extends BaseEditProfile
|
||||
{
|
||||
use CanCustomizeHeaderActions;
|
||||
use CanCustomizeHeaderWidgets;
|
||||
|
||||
private ToggleTwoFactorService $toggleTwoFactorService;
|
||||
|
||||
protected OAuthService $oauthService;
|
||||
|
||||
public function boot(ToggleTwoFactorService $toggleTwoFactorService, OAuthService $oauthService): void
|
||||
public function boot(OAuthService $oauthService): void
|
||||
{
|
||||
$this->toggleTwoFactorService = $toggleTwoFactorService;
|
||||
$this->oauthService = $oauthService;
|
||||
}
|
||||
|
||||
@ -74,6 +67,14 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile
|
||||
return config('panel.filament.display-width', 'screen-2xl');
|
||||
}
|
||||
|
||||
public function content(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
$this->getFormContentComponent(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@ -88,70 +89,67 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile
|
||||
Tab::make(trans('profile.tabs.account'))
|
||||
->icon('tabler-user')
|
||||
->schema([
|
||||
Tab::make(trans('profile.tabs.account'))
|
||||
->icon('tabler-user')
|
||||
->schema([
|
||||
TextInput::make('username')
|
||||
->label(trans('profile.username'))
|
||||
->disabled()
|
||||
->readOnly()
|
||||
->dehydrated(false)
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true)
|
||||
->autofocus(),
|
||||
TextInput::make('email')
|
||||
->prefixIcon('tabler-mail')
|
||||
->label(trans('profile.email'))
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true),
|
||||
TextInput::make('password')
|
||||
->label(trans('profile.password'))
|
||||
->password()
|
||||
->prefixIcon('tabler-password')
|
||||
->revealable(filament()->arePasswordsRevealable())
|
||||
->rule(Password::default())
|
||||
->autocomplete('new-password')
|
||||
->dehydrated(fn ($state): bool => filled($state))
|
||||
->dehydrateStateUsing(fn ($state): string => Hash::make($state))
|
||||
->live(debounce: 500)
|
||||
->same('passwordConfirmation'),
|
||||
TextInput::make('passwordConfirmation')
|
||||
->label(trans('profile.password_confirmation'))
|
||||
->password()
|
||||
->prefixIcon('tabler-password-fingerprint')
|
||||
->revealable(filament()->arePasswordsRevealable())
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => filled($get('password')))
|
||||
->dehydrated(false),
|
||||
Select::make('timezone')
|
||||
->label(trans('profile.timezone'))
|
||||
->required()
|
||||
->prefixIcon('tabler-clock-pin')
|
||||
->default('UTC')
|
||||
->selectablePlaceholder(false)
|
||||
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
|
||||
->searchable()
|
||||
->native(false),
|
||||
Select::make('language')
|
||||
->label(trans('profile.language'))
|
||||
->required()
|
||||
->prefixIcon('tabler-flag')
|
||||
->live()
|
||||
->default('en')
|
||||
->selectablePlaceholder(false)
|
||||
->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? ''
|
||||
: trans('profile.language_help', ['state' => $state]) . ' <u><a href="https://crowdin.com/project/pelican-dev/">Update On Crowdin</a></u>'))
|
||||
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages())
|
||||
->native(false),
|
||||
FileUpload::make('avatar')
|
||||
->visible(fn () => config('panel.filament.uploadable-avatars'))
|
||||
->avatar()
|
||||
->acceptedFileTypes(['image/png'])
|
||||
->directory('avatars')
|
||||
->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png')
|
||||
->hintAction(function (FileUpload $fileUpload) {
|
||||
TextInput::make('username')
|
||||
->label(trans('profile.username'))
|
||||
->disabled()
|
||||
->readOnly()
|
||||
->dehydrated(false)
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true)
|
||||
->autofocus(),
|
||||
TextInput::make('email')
|
||||
->prefixIcon('tabler-mail')
|
||||
->label(trans('profile.email'))
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true),
|
||||
TextInput::make('password')
|
||||
->label(trans('profile.password'))
|
||||
->password()
|
||||
->prefixIcon('tabler-password')
|
||||
->revealable(filament()->arePasswordsRevealable())
|
||||
->rule(Password::default())
|
||||
->autocomplete('new-password')
|
||||
->dehydrated(fn ($state): bool => filled($state))
|
||||
->dehydrateStateUsing(fn ($state): string => Hash::make($state))
|
||||
->live(debounce: 500)
|
||||
->same('passwordConfirmation'),
|
||||
TextInput::make('passwordConfirmation')
|
||||
->label(trans('profile.password_confirmation'))
|
||||
->password()
|
||||
->prefixIcon('tabler-password-fingerprint')
|
||||
->revealable(filament()->arePasswordsRevealable())
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => filled($get('password')))
|
||||
->dehydrated(false),
|
||||
Select::make('timezone')
|
||||
->label(trans('profile.timezone'))
|
||||
->required()
|
||||
->prefixIcon('tabler-clock-pin')
|
||||
->default('UTC')
|
||||
->selectablePlaceholder(false)
|
||||
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
|
||||
->searchable()
|
||||
->native(false),
|
||||
Select::make('language')
|
||||
->label(trans('profile.language'))
|
||||
->required()
|
||||
->prefixIcon('tabler-flag')
|
||||
->live()
|
||||
->default('en')
|
||||
->selectablePlaceholder(false)
|
||||
->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? ''
|
||||
: trans('profile.language_help', ['state' => $state]) . ' <u><a href="https://crowdin.com/project/pelican-dev/">Update On Crowdin</a></u>'))
|
||||
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages())
|
||||
->native(false),
|
||||
FileUpload::make('avatar')
|
||||
->visible(fn () => config('panel.filament.uploadable-avatars'))
|
||||
->avatar()
|
||||
->acceptedFileTypes(['image/png'])
|
||||
->directory('avatars')
|
||||
->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png'),
|
||||
/*->hintAction(function (FileUpload $fileUpload) {
|
||||
$path = $fileUpload->getDirectory() . '/' . $this->getUser()->id . '.png';
|
||||
|
||||
return Action::make('remove_avatar')
|
||||
@ -159,208 +157,135 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile
|
||||
->iconButton()
|
||||
->hidden(fn () => !$fileUpload->getDisk()->exists($path))
|
||||
->action(fn () => $fileUpload->getDisk()->delete($path));
|
||||
}),
|
||||
]),
|
||||
}),*/
|
||||
]),
|
||||
Tab::make(trans('profile.tabs.oauth'))
|
||||
->icon('tabler-brand-oauth')
|
||||
->visible(count($oauthSchemas) > 0)
|
||||
->schema(function () use ($oauthSchemas) {
|
||||
$actions = [];
|
||||
|
||||
Tab::make(trans('profile.tabs.oauth'))
|
||||
->icon('tabler-brand-oauth')
|
||||
->visible(count($oauthSchemas) > 0)
|
||||
->schema(function () use ($oauthSchemas) {
|
||||
$actions = [];
|
||||
foreach ($oauthSchemas as $schema) {
|
||||
|
||||
foreach ($oauthSchemas as $schema) {
|
||||
$id = $schema->getId();
|
||||
$name = $schema->getName();
|
||||
|
||||
$id = $schema->getId();
|
||||
$name = $schema->getName();
|
||||
$unlink = array_key_exists($id, $this->getUser()->oauth ?? []);
|
||||
|
||||
$unlink = array_key_exists($id, $this->getUser()->oauth ?? []);
|
||||
$actions[] = Action::make("oauth_$id")
|
||||
->label(($unlink ? trans('profile.unlink') : trans('profile.link')) . $name)
|
||||
->icon($unlink ? 'tabler-unlink' : 'tabler-link')
|
||||
->color(Color::generateV3Palette($schema->getHexColor()))
|
||||
->action(function (UserUpdateService $updateService) use ($id, $name, $unlink) {
|
||||
if ($unlink) {
|
||||
$oauth = auth()->user()->oauth;
|
||||
unset($oauth[$id]);
|
||||
|
||||
$actions[] = Action::make("oauth_$id")
|
||||
->label(($unlink ? trans('profile.unlink') : trans('profile.link')) . $name)
|
||||
->icon($unlink ? 'tabler-unlink' : 'tabler-link')
|
||||
->color(Color::generateV3Palette($schema->getHexColor()))
|
||||
->action(function (UserUpdateService $updateService) use ($id, $name, $unlink) {
|
||||
if ($unlink) {
|
||||
$oauth = auth()->user()->oauth;
|
||||
unset($oauth[$id]);
|
||||
$updateService->handle(auth()->user(), ['oauth' => $oauth]);
|
||||
|
||||
$updateService->handle(auth()->user(), ['oauth' => $oauth]);
|
||||
$this->fillForm();
|
||||
|
||||
$this->fillForm();
|
||||
Notification::make()
|
||||
->title(trans('profile.unlinked', ['name' => $name]))
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
redirect(Socialite::with($id)->redirect()->getTargetUrl());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(trans('profile.unlinked', ['name' => $name]))
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
redirect(Socialite::with($id)->redirect()->getTargetUrl());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return [Actions::make($actions)];
|
||||
}),
|
||||
|
||||
Tab::make(trans('profile.tabs.2fa'))
|
||||
->icon('tabler-shield-lock')
|
||||
->schema(function (TwoFactorSetupService $setupService) {
|
||||
if ($this->getUser()->use_totp) {
|
||||
return [
|
||||
TextEntry::make('2fa-already-enabled')
|
||||
->label(trans('profile.2fa_enabled')),
|
||||
Textarea::make('backup-tokens')
|
||||
->hidden(fn () => !cache()->get("users.{$this->getUser()->id}.2fa.tokens"))
|
||||
->rows(10)
|
||||
->readOnly()
|
||||
->dehydrated(false)
|
||||
->formatStateUsing(fn () => cache()->get("users.{$this->getUser()->id}.2fa.tokens"))
|
||||
->helperText(trans('profile.backup_help'))
|
||||
->label(trans('profile.backup_codes')),
|
||||
TextInput::make('2fa-disable-code')
|
||||
->label(trans('profile.disable_2fa'))
|
||||
->helperText(trans('profile.disable_2fa_help')),
|
||||
];
|
||||
}
|
||||
|
||||
['image_url_data' => $url, 'secret' => $secret] = cache()->remember(
|
||||
"users.{$this->getUser()->id}.2fa.state",
|
||||
now()->addMinutes(5), fn () => $setupService->handle($this->getUser())
|
||||
);
|
||||
|
||||
$options = new QROptions([
|
||||
'svgLogo' => public_path('pelican.svg'),
|
||||
'svgLogoScale' => 0.05,
|
||||
'addLogoSpace' => true,
|
||||
'logoSpaceWidth' => 13,
|
||||
'logoSpaceHeight' => 13,
|
||||
'version' => Version::AUTO,
|
||||
// 'outputInterface' => QRSvgWithLogo::class,
|
||||
'outputBase64' => false,
|
||||
'eccLevel' => EccLevel::H, // ECC level H is necessary when using logos
|
||||
'addQuietzone' => true,
|
||||
// 'drawLightModules' => true,
|
||||
'connectPaths' => true,
|
||||
'drawCircularModules' => true,
|
||||
// 'circleRadius' => 0.45,
|
||||
'svgDefs' => '
|
||||
<linearGradient id="gradient" x1="100%" y2="100%">
|
||||
<stop stop-color="#7dd4fc" offset="0"/>
|
||||
<stop stop-color="#38bdf8" offset="0.5"/>
|
||||
<stop stop-color="#0369a1" offset="1"/>
|
||||
</linearGradient>
|
||||
<style><![CDATA[
|
||||
.dark{fill: url(#gradient);}
|
||||
.light{fill: #000;}
|
||||
]]></style>
|
||||
',
|
||||
]);
|
||||
|
||||
// https://github.com/chillerlan/php-qrcode/blob/main/examples/svgWithLogo.php
|
||||
|
||||
$image = (new QRCode($options))->render($url);
|
||||
|
||||
return [
|
||||
TextEntry::make('qr')
|
||||
->label(trans('profile.scan_qr'))
|
||||
->state(fn () => new HtmlString("
|
||||
<div style='width: 300px; background-color: rgb(24, 24, 27);'>$image</div>
|
||||
"))
|
||||
->helperText(trans('profile.setup_key') .': '. $secret),
|
||||
TextInput::make('2facode')
|
||||
->label(trans('profile.code'))
|
||||
->requiredWith('2fapassword')
|
||||
->helperText(trans('profile.code_help')),
|
||||
TextInput::make('2fapassword')
|
||||
->label(trans('profile.current_password'))
|
||||
->requiredWith('2facode')
|
||||
->currentPassword()
|
||||
->password(),
|
||||
];
|
||||
}),
|
||||
|
||||
Tab::make(trans('profile.tabs.api_keys'))
|
||||
->icon('tabler-key')
|
||||
return [Actions::make($actions)];
|
||||
}),
|
||||
Tab::make(trans('profile.tabs.2fa'))
|
||||
->icon('tabler-shield-lock')
|
||||
->visible(fn () => Filament::hasMultiFactorAuthentication())
|
||||
->schema(collect(Filament::getMultiFactorAuthenticationProviders())
|
||||
->sort(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider) => $multiFactorAuthenticationProvider->isEnabled(Filament::auth()->user()) ? 0 : 1)
|
||||
->map(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider) => Group::make($multiFactorAuthenticationProvider->getManagementSchemaComponents())
|
||||
->statePath($multiFactorAuthenticationProvider->getId()))
|
||||
->all()),
|
||||
Tab::make(trans('profile.tabs.api_keys'))
|
||||
->icon('tabler-key')
|
||||
->schema([
|
||||
Grid::make(5)
|
||||
->schema([
|
||||
Grid::make(5)
|
||||
->schema([
|
||||
Section::make(trans('profile.create_api_key'))->columnSpan(3)->schema([
|
||||
TextInput::make('description')
|
||||
->label(trans('profile.description'))
|
||||
->live(),
|
||||
TagsInput::make('allowed_ips')
|
||||
->label(trans('profile.allowed_ips'))
|
||||
->live()
|
||||
->splitKeys([',', ' ', 'Tab'])
|
||||
->placeholder('127.0.0.1 or 192.168.1.1')
|
||||
->helperText(trans('profile.allowed_ips_help'))
|
||||
->columnSpanFull(),
|
||||
])->headerActions([
|
||||
Action::make('create')
|
||||
->label(trans('filament-actions::create.single.modal.actions.create.label'))
|
||||
->disabled(fn (Get $get) => empty($get('description')))
|
||||
->successRedirectUrl(self::getUrl(['tab' => '-api-keys-tab'], panel: 'app'))
|
||||
->action(function (Get $get, Action $action, User $user) {
|
||||
$token = $user->createToken(
|
||||
$get('description'),
|
||||
$get('allowed_ips'),
|
||||
);
|
||||
Section::make(trans('profile.create_api_key'))->columnSpan(3)->schema([
|
||||
TextInput::make('description')
|
||||
->label(trans('profile.description'))
|
||||
->live(),
|
||||
TagsInput::make('allowed_ips')
|
||||
->label(trans('profile.allowed_ips'))
|
||||
->live()
|
||||
->splitKeys([',', ' ', 'Tab'])
|
||||
->placeholder('127.0.0.1 or 192.168.1.1')
|
||||
->helperText(trans('profile.allowed_ips_help'))
|
||||
->columnSpanFull(),
|
||||
])->headerActions([
|
||||
Action::make('create')
|
||||
->label(trans('filament-actions::create.single.modal.actions.create.label'))
|
||||
->disabled(fn (Get $get) => empty($get('description')))
|
||||
->successRedirectUrl(self::getUrl(['tab' => '-api-keys-tab'], panel: 'app'))
|
||||
->action(function (Get $get, Action $action, User $user) {
|
||||
$token = $user->createToken(
|
||||
$get('description'),
|
||||
$get('allowed_ips'),
|
||||
);
|
||||
|
||||
Activity::event('user:api-key.create')
|
||||
Activity::event('user:api-key.create')
|
||||
->actor($user)
|
||||
->subject($user)
|
||||
->subject($token->accessToken)
|
||||
->property('identifier', $token->accessToken->identifier)
|
||||
->log();
|
||||
|
||||
Notification::make()
|
||||
->title(trans('profile.api_key_created'))
|
||||
->body($token->accessToken->identifier . $token->plainTextToken)
|
||||
->persistent()
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$action->success();
|
||||
}),
|
||||
]),
|
||||
Section::make(trans('profile.api_keys'))->columnSpan(2)->schema([
|
||||
Repeater::make('api_keys')
|
||||
->hiddenLabel()
|
||||
->relationship('apiKeys')
|
||||
->addable(false)
|
||||
->itemLabel(fn ($state) => $state['identifier'])
|
||||
->deleteAction(function (Action $action) {
|
||||
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) {
|
||||
$items = $component->getState();
|
||||
$key = $items[$arguments['item']];
|
||||
|
||||
$apiKey = ApiKey::find($key['id'] ?? null);
|
||||
if ($apiKey->exists()) {
|
||||
$apiKey->delete();
|
||||
|
||||
Activity::event('user:api-key.delete')
|
||||
->actor($user)
|
||||
->subject($user)
|
||||
->subject($token->accessToken)
|
||||
->property('identifier', $token->accessToken->identifier)
|
||||
->subject($apiKey)
|
||||
->property('identifier', $apiKey->identifier)
|
||||
->log();
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(trans('profile.api_key_created'))
|
||||
->body($token->accessToken->identifier . $token->plainTextToken)
|
||||
->persistent()
|
||||
->success()
|
||||
->send();
|
||||
unset($items[$arguments['item']]);
|
||||
|
||||
$action->success();
|
||||
}),
|
||||
$component->state($items);
|
||||
|
||||
$component->callAfterStateUpdated();
|
||||
});
|
||||
})
|
||||
->schema(fn () => [
|
||||
TextEntry::make('memo')
|
||||
->state(fn (ApiKey $key) => $key->memo),
|
||||
]),
|
||||
Section::make(trans('profile.api_keys'))->columnSpan(2)->schema([
|
||||
Repeater::make('api_keys')
|
||||
->hiddenLabel()
|
||||
->relationship('apiKeys')
|
||||
->addable(false)
|
||||
->itemLabel(fn ($state) => $state['identifier'])
|
||||
->deleteAction(function (Action $action) {
|
||||
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) {
|
||||
$items = $component->getState();
|
||||
$key = $items[$arguments['item']];
|
||||
|
||||
$apiKey = ApiKey::find($key['id'] ?? null);
|
||||
if ($apiKey->exists()) {
|
||||
$apiKey->delete();
|
||||
|
||||
Activity::event('user:api-key.delete')
|
||||
->actor($user)
|
||||
->subject($user)
|
||||
->subject($apiKey)
|
||||
->property('identifier', $apiKey->identifier)
|
||||
->log();
|
||||
}
|
||||
|
||||
unset($items[$arguments['item']]);
|
||||
|
||||
$component->state($items);
|
||||
|
||||
$component->callAfterStateUpdated();
|
||||
});
|
||||
})
|
||||
->schema(fn () => [
|
||||
TextEntry::make('memo')
|
||||
->state(fn (ApiKey $key) => $key->memo),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make(trans('profile.tabs.ssh_keys'))
|
||||
->icon('tabler-lock-code')
|
||||
->schema([
|
||||
@ -444,74 +369,82 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make(trans('profile.tabs.activity'))
|
||||
->icon('tabler-history')
|
||||
->schema([
|
||||
TextEntry::make('activity!')->label('')->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make(trans('profile.tabs.customization'))
|
||||
->icon('tabler-adjustments')
|
||||
->schema([
|
||||
Section::make(trans('profile.dashboard'))
|
||||
->collapsible()
|
||||
->icon('tabler-dashboard')
|
||||
->schema([
|
||||
ToggleButtons::make('dashboard_layout')
|
||||
->label(trans('profile.dashboard_layout'))
|
||||
->inline()
|
||||
->required()
|
||||
->options([
|
||||
'grid' => trans('profile.grid'),
|
||||
'table' => trans('profile.table'),
|
||||
]),
|
||||
Section::make(trans('profile.console'))
|
||||
->collapsible()
|
||||
->icon('tabler-brand-tabler')
|
||||
->columns(4)
|
||||
Repeater::make('activity')
|
||||
->hiddenLabel()
|
||||
->deletable(false)
|
||||
->addable(false)
|
||||
->relationship(null, function (Builder $query) {
|
||||
$query->orderBy('timestamp', 'desc');
|
||||
})
|
||||
->schema([
|
||||
TextInput::make('console_font_size')
|
||||
->label(trans('profile.font_size'))
|
||||
->columnSpan(1)
|
||||
->minValue(1)
|
||||
->numeric()
|
||||
TextEntry::make('log')
|
||||
->label('')
|
||||
->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
|
||||
]),
|
||||
]),
|
||||
Tab::make(trans('profile.tabs.customization'))
|
||||
->icon('tabler-adjustments')
|
||||
->schema([
|
||||
Section::make(trans('profile.dashboard'))
|
||||
->collapsible()
|
||||
->icon('tabler-dashboard')
|
||||
->schema([
|
||||
ToggleButtons::make('dashboard_layout')
|
||||
->label(trans('profile.dashboard_layout'))
|
||||
->inline()
|
||||
->required()
|
||||
->default(14),
|
||||
Select::make('console_font')
|
||||
->label(trans('profile.font'))
|
||||
->required()
|
||||
->options(function () {
|
||||
$fonts = [
|
||||
'monospace' => 'monospace', //default
|
||||
];
|
||||
->options([
|
||||
'grid' => trans('profile.grid'),
|
||||
'table' => trans('profile.table'),
|
||||
]),
|
||||
Section::make(trans('profile.console'))
|
||||
->collapsible()
|
||||
->icon('tabler-brand-tabler')
|
||||
->columns(4)
|
||||
->schema([
|
||||
TextInput::make('console_font_size')
|
||||
->label(trans('profile.font_size'))
|
||||
->columnSpan(1)
|
||||
->minValue(1)
|
||||
->numeric()
|
||||
->required()
|
||||
->default(14),
|
||||
Select::make('console_font')
|
||||
->label(trans('profile.font'))
|
||||
->required()
|
||||
->options(function () {
|
||||
$fonts = [
|
||||
'monospace' => 'monospace', //default
|
||||
];
|
||||
|
||||
if (!Storage::disk('public')->exists('fonts')) {
|
||||
Storage::disk('public')->makeDirectory('fonts');
|
||||
$this->fillForm();
|
||||
}
|
||||
if (!Storage::disk('public')->exists('fonts')) {
|
||||
Storage::disk('public')->makeDirectory('fonts');
|
||||
$this->fillForm();
|
||||
}
|
||||
|
||||
foreach (Storage::disk('public')->allFiles('fonts') as $file) {
|
||||
$fileInfo = pathinfo($file);
|
||||
foreach (Storage::disk('public')->allFiles('fonts') as $file) {
|
||||
$fileInfo = pathinfo($file);
|
||||
|
||||
if ($fileInfo['extension'] === 'ttf') {
|
||||
$fonts[$fileInfo['filename']] = $fileInfo['filename'];
|
||||
}
|
||||
}
|
||||
if ($fileInfo['extension'] === 'ttf') {
|
||||
$fonts[$fileInfo['filename']] = $fileInfo['filename'];
|
||||
}
|
||||
}
|
||||
|
||||
return $fonts;
|
||||
})
|
||||
->reactive()
|
||||
->default('monospace')
|
||||
->afterStateUpdated(fn ($state, Set $set) => $set('font_preview', $state)),
|
||||
TextEntry::make('font_preview')
|
||||
->label(trans('profile.font_preview'))
|
||||
->columnSpan(2)
|
||||
->state(function (Get $get) {
|
||||
$fontName = $get('console_font') ?? 'monospace';
|
||||
$fontSize = $get('console_font_size') . 'px';
|
||||
$style = <<<CSS
|
||||
return $fonts;
|
||||
})
|
||||
->reactive()
|
||||
->default('monospace')
|
||||
->afterStateUpdated(fn ($state, Set $set) => $set('font_preview', $state)),
|
||||
TextEntry::make('font_preview')
|
||||
->label(trans('profile.font_preview'))
|
||||
->columnSpan(2)
|
||||
->state(function (Get $get) {
|
||||
$fontName = $get('console_font') ?? 'monospace';
|
||||
$fontSize = $get('console_font_size') . 'px';
|
||||
$style = <<<CSS
|
||||
.preview-text {
|
||||
font-family: $fontName;
|
||||
font-size: $fontSize;
|
||||
@ -519,45 +452,47 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile
|
||||
display: block;
|
||||
}
|
||||
CSS;
|
||||
if ($fontName !== 'monospace') {
|
||||
$fontUrl = asset("storage/fonts/$fontName.ttf");
|
||||
$style = <<<CSS
|
||||
if ($fontName !== 'monospace') {
|
||||
$fontUrl = asset("storage/fonts/$fontName.ttf");
|
||||
$style = <<<CSS
|
||||
@font-face {
|
||||
font-family: $fontName;
|
||||
src: url("$fontUrl");
|
||||
}
|
||||
$style
|
||||
CSS;
|
||||
}
|
||||
}
|
||||
|
||||
return new HtmlString(<<<HTML
|
||||
return new HtmlString(<<<HTML
|
||||
<style>
|
||||
{$style}
|
||||
</style>
|
||||
<span class="preview-text">The quick blue pelican jumps over the lazy pterodactyl. :)</span>
|
||||
HTML);
|
||||
}),
|
||||
TextInput::make('console_graph_period')
|
||||
->label(trans('profile.graph_period'))
|
||||
->suffix(trans('profile.seconds'))
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip(trans('profile.graph_period_helper'))
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->default(30)
|
||||
->minValue(10)
|
||||
->maxValue(120)
|
||||
->required(),
|
||||
TextInput::make('console_rows')
|
||||
->label(trans('profile.rows'))
|
||||
->minValue(1)
|
||||
->numeric()
|
||||
->required()
|
||||
->columnSpan(2)
|
||||
->default(30),
|
||||
}),
|
||||
TextInput::make('console_graph_period')
|
||||
->label(trans('profile.graph_period'))
|
||||
->suffix(trans('profile.seconds'))
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip(trans('profile.graph_period_helper'))
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->default(30)
|
||||
->minValue(10)
|
||||
->maxValue(120)
|
||||
->required(),
|
||||
TextInput::make('console_rows')
|
||||
->label(trans('profile.rows'))
|
||||
->minValue(1)
|
||||
->numeric()
|
||||
->required()
|
||||
->columnSpan(2)
|
||||
->default(30),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
])
|
||||
->operation('edit')
|
||||
->model($this->getUser())
|
||||
@ -565,40 +500,6 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile
|
||||
->inlineLabel(!static::isSimple());
|
||||
}
|
||||
|
||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
{
|
||||
if (!$record instanceof User) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
if ($token = $data['2facode'] ?? null) {
|
||||
$tokens = $this->toggleTwoFactorService->handle($record, $token, true);
|
||||
cache()->put("users.$record->id.2fa.tokens", implode("\n", $tokens), now()->addSeconds(15));
|
||||
|
||||
$this->redirect(self::getUrl(['tab' => '-2fa-tab'], panel: 'app'));
|
||||
}
|
||||
|
||||
if ($token = $data['2fa-disable-code'] ?? null) {
|
||||
try {
|
||||
$this->toggleTwoFactorService->handle($record, $token, false);
|
||||
} catch (TwoFactorAuthenticationTokenInvalid $exception) {
|
||||
Notification::make()
|
||||
->title(trans('profile.invalid_code'))
|
||||
->body($exception->getMessage())
|
||||
->color('danger')
|
||||
->icon('tabler-2fa')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
throw new Halt();
|
||||
}
|
||||
|
||||
cache()->forget("users.$record->id.2fa.state");
|
||||
}
|
||||
|
||||
return parent::handleRecordUpdate($record, $data);
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [];
|
||||
|
@ -2,116 +2,29 @@
|
||||
|
||||
namespace App\Filament\Pages\Auth;
|
||||
|
||||
use App\Events\Auth\ProvidedAuthenticationToken;
|
||||
use App\Extensions\Captcha\CaptchaService;
|
||||
use App\Extensions\OAuth\OAuthService;
|
||||
use App\Facades\Activity;
|
||||
use App\Models\User;
|
||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Auth\Pages\Login as BaseLogin;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
class Login extends \Filament\Auth\Pages\Login
|
||||
class Login extends BaseLogin
|
||||
{
|
||||
private Google2FA $google2FA;
|
||||
|
||||
public bool $verifyTwoFactor = false;
|
||||
|
||||
protected OAuthService $oauthService;
|
||||
|
||||
protected CaptchaService $captchaService;
|
||||
|
||||
public function boot(Google2FA $google2FA, OAuthService $oauthService, CaptchaService $captchaService): void
|
||||
public function boot(OAuthService $oauthService, CaptchaService $captchaService): void
|
||||
{
|
||||
$this->google2FA = $google2FA;
|
||||
$this->oauthService = $oauthService;
|
||||
$this->captchaService = $captchaService;
|
||||
}
|
||||
|
||||
public function authenticate(): ?LoginResponse
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
Filament::auth()->once($this->getCredentialsFromFormData($data));
|
||||
|
||||
/** @var ?User $user */
|
||||
$user = Filament::auth()->user();
|
||||
|
||||
// Make sure that rate limits apply
|
||||
if (!$user) {
|
||||
return parent::authenticate();
|
||||
}
|
||||
|
||||
// 2FA disabled
|
||||
if (!$user->use_totp) {
|
||||
return parent::authenticate();
|
||||
}
|
||||
|
||||
$token = $data['2fa'] ?? null;
|
||||
|
||||
// 2FA not shown yet
|
||||
if ($token === null) {
|
||||
$this->verifyTwoFactor = true;
|
||||
|
||||
Activity::event('auth:checkpoint')
|
||||
->withRequestMetadata()
|
||||
->subject($user)
|
||||
->log();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$isValidToken = false;
|
||||
if (strlen($token) === $this->google2FA->getOneTimePasswordLength()) {
|
||||
$isValidToken = $this->google2FA->verifyKey(
|
||||
$user->totp_secret,
|
||||
$token,
|
||||
Config::integer('panel.auth.2fa.window'),
|
||||
);
|
||||
|
||||
if ($isValidToken) {
|
||||
event(new ProvidedAuthenticationToken($user));
|
||||
}
|
||||
} else {
|
||||
foreach ($user->recoveryTokens as $recoveryToken) {
|
||||
if (password_verify($token, $recoveryToken->token)) {
|
||||
$isValidToken = true;
|
||||
$recoveryToken->delete();
|
||||
|
||||
event(new ProvidedAuthenticationToken($user, true));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isValidToken) {
|
||||
// Buffer to prevent bruteforce
|
||||
Sleep::sleep(1);
|
||||
|
||||
Notification::make()
|
||||
->title(trans('auth.failed-two-factor'))
|
||||
->body(trans('auth.failed'))
|
||||
->color('danger')
|
||||
->icon('tabler-auth-2fa')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return parent::authenticate();
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
$components = [
|
||||
@ -119,28 +32,16 @@ class Login extends \Filament\Auth\Pages\Login
|
||||
$this->getPasswordFormComponent(),
|
||||
$this->getRememberFormComponent(),
|
||||
$this->getOAuthFormComponent(),
|
||||
$this->getTwoFactorAuthenticationComponent(),
|
||||
];
|
||||
|
||||
if ($captchaComponent = $this->getCaptchaComponent()) {
|
||||
$schema = array_merge($schema, [$captchaComponent]);
|
||||
$components[] = $captchaComponent;
|
||||
}
|
||||
|
||||
return $schema
|
||||
->components($components);
|
||||
}
|
||||
|
||||
private function getTwoFactorAuthenticationComponent(): Component
|
||||
{
|
||||
return TextInput::make('2fa')
|
||||
->label(trans('auth.two-factor-code'))
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip(trans('auth.two-factor-hint'))
|
||||
->visible(fn () => $this->verifyTwoFactor)
|
||||
->required()
|
||||
->live();
|
||||
}
|
||||
|
||||
private function getCaptchaComponent(): ?Component
|
||||
{
|
||||
return $this->captchaService->getActiveSchema()?->getFormComponent();
|
||||
@ -178,7 +79,7 @@ class Login extends \Filament\Auth\Pages\Login
|
||||
$actions[] = Action::make("oauth_$id")
|
||||
->label($schema->getName())
|
||||
->icon($schema->getIcon())
|
||||
//TODO ->color(Color::hex($oauthProvider->getHexColor()))
|
||||
->color(Color::generateV3Palette($schema->getHexColor()))
|
||||
->url(route('auth.oauth.redirect', ['driver' => $id], false));
|
||||
}
|
||||
|
||||
|
@ -540,8 +540,8 @@ class ListFiles extends ListRecords
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<Action|ActionGroup>
|
||||
* @throws Exception
|
||||
/**
|
||||
* @return array<Action|ActionGroup>
|
||||
*/
|
||||
protected function getDefaultHeaderActions(): array
|
||||
{
|
||||
|
@ -1,110 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Client;
|
||||
|
||||
use App\Exceptions\Model\DataValidationException;
|
||||
use Throwable;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Facades\Activity;
|
||||
use App\Services\Users\TwoFactorSetupService;
|
||||
use App\Services\Users\ToggleTwoFactorService;
|
||||
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class TwoFactorController extends ClientApiController
|
||||
{
|
||||
/**
|
||||
* TwoFactorController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private ToggleTwoFactorService $toggleTwoFactorService,
|
||||
private TwoFactorSetupService $setupService,
|
||||
private ValidationFactory $validation
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup 2fa
|
||||
*
|
||||
* Returns two-factor token credentials that allow a user to configure
|
||||
* it on their account. If two-factor is already enabled this endpoint
|
||||
* will return a 400 error.
|
||||
*
|
||||
* @throws DataValidationException
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->user()->use_totp) {
|
||||
throw new BadRequestHttpException('Two-factor authentication is already enabled on this account.');
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => $this->setupService->handle($request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable 2fa
|
||||
*
|
||||
* Updates a user's account to have two-factor enabled.
|
||||
*
|
||||
* @throws Throwable
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validator = $this->validation->make($request->all(), [
|
||||
'code' => ['required', 'string', 'size:6'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$data = $validator->validate();
|
||||
if (!password_verify($data['password'], $request->user()->password)) {
|
||||
throw new BadRequestHttpException('The password provided was not valid.');
|
||||
}
|
||||
|
||||
$tokens = $this->toggleTwoFactorService->handle($request->user(), $data['code'], true);
|
||||
|
||||
Activity::event('user:two-factor.create')->log();
|
||||
|
||||
return new JsonResponse([
|
||||
'object' => 'recovery_tokens',
|
||||
'attributes' => [
|
||||
'tokens' => $tokens,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable 2fa
|
||||
*
|
||||
* Disables two-factor authentication on an account if the password provided
|
||||
* is valid.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function delete(Request $request): JsonResponse
|
||||
{
|
||||
if (!password_verify($request->input('password') ?? '', $request->user()->password)) {
|
||||
throw new BadRequestHttpException('The password provided was not valid.');
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
$user->update([
|
||||
'totp_authenticated_at' => Carbon::now(),
|
||||
'use_totp' => false,
|
||||
]);
|
||||
|
||||
Activity::event('user:two-factor.delete')->log();
|
||||
|
||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Exceptions\Http\TwoFactorAuthRequiredException;
|
||||
use App\Filament\Pages\Auth\EditProfile;
|
||||
use App\Livewire\AlertBanner;
|
||||
use App\Models\User;
|
||||
|
||||
class RequireTwoFactorAuthentication
|
||||
{
|
||||
public const LEVEL_NONE = 0;
|
||||
|
||||
public const LEVEL_ADMIN = 1;
|
||||
|
||||
public const LEVEL_ALL = 2;
|
||||
|
||||
/**
|
||||
* Check the user state on the incoming request to determine if they should be allowed to
|
||||
* proceed or not. This checks if the Panel is configured to require 2FA on an account in
|
||||
* order to perform actions. If so, we check the level at which it is required (all users
|
||||
* or just admins) and then check if the user has enabled it for their account.
|
||||
*
|
||||
* @throws TwoFactorAuthRequiredException
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): mixed
|
||||
{
|
||||
/** @var ?User $user */
|
||||
$user = $request->user();
|
||||
|
||||
$uri = rtrim($request->getRequestUri(), '/') . '/';
|
||||
$current = $request->route()->getName();
|
||||
|
||||
if (!$user || Str::startsWith($uri, ['/auth/', '/profile']) || Str::startsWith($current, ['auth.', 'account.', 'filament.app.auth.'])) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$level = (int) config('panel.auth.2fa_required');
|
||||
|
||||
if ($level === self::LEVEL_NONE || $user->use_totp) {
|
||||
// If this setting is not configured, or the user is already using 2FA then we can just send them right through, nothing else needs to be checked.
|
||||
return $next($request);
|
||||
} elseif ($level === self::LEVEL_ADMIN && !$user->isAdmin()) {
|
||||
// If the level is set as admin and the user is not an admin, pass them through as well.
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// For API calls return an exception which gets rendered nicely in the API response...
|
||||
if ($request->isJson() || Str::startsWith($uri, '/api/')) {
|
||||
throw new TwoFactorAuthRequiredException();
|
||||
}
|
||||
|
||||
// ... otherwise display banner and redirect to profile
|
||||
AlertBanner::make('2fa_must_be_enabled')
|
||||
->body(trans('auth.2fa_must_be_enabled'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return redirect(EditProfile::getUrl(['tab' => '-2fa-tab'], panel: 'app'));
|
||||
}
|
||||
}
|
@ -4,7 +4,6 @@ namespace App\Jobs;
|
||||
|
||||
use Exception;
|
||||
use App\Models\WebhookConfiguration;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
|
@ -29,6 +29,9 @@ use Illuminate\Auth\Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use App\Models\Traits\HasAccessTokens;
|
||||
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
|
||||
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
|
||||
use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication;
|
||||
use Filament\Models\Contracts\HasAvatar;
|
||||
use Illuminate\Auth\Passwords\CanResetPassword;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@ -52,10 +55,10 @@ use Spatie\Permission\Traits\HasRoles;
|
||||
* @property string|null $remember_token
|
||||
* @property string $language
|
||||
* @property string $timezone
|
||||
* @property bool $use_totp
|
||||
* @property string|null $totp_secret
|
||||
* @property Carbon|null $totp_authenticated_at
|
||||
* @property string[]|null $oauth
|
||||
* @property string|null $mfa_app_secret
|
||||
* @property string[]|null $mfa_app_recovery_codes
|
||||
* @property bool $mfa_email_enabled
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property \Illuminate\Database\Eloquent\Collection|ApiKey[] $apiKeys
|
||||
@ -87,14 +90,11 @@ use Spatie\Permission\Traits\HasRoles;
|
||||
* @method static Builder|User whereTimezone($value)
|
||||
* @method static Builder|User wherePassword($value)
|
||||
* @method static Builder|User whereRememberToken($value)
|
||||
* @method static Builder|User whereTotpAuthenticatedAt($value)
|
||||
* @method static Builder|User whereTotpSecret($value)
|
||||
* @method static Builder|User whereUpdatedAt($value)
|
||||
* @method static Builder|User whereUseTotp($value)
|
||||
* @method static Builder|User whereUsername($value)
|
||||
* @method static Builder|User whereUuid($value)
|
||||
*/
|
||||
class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, FilamentUser, HasAvatar, HasName, HasTenants, Validatable
|
||||
class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasAvatar, HasEmailAuthentication, HasName, HasTenants, Validatable
|
||||
{
|
||||
use Authenticatable;
|
||||
use Authorizable { can as protected canned; }
|
||||
@ -125,9 +125,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
|
||||
'password',
|
||||
'language',
|
||||
'timezone',
|
||||
'use_totp',
|
||||
'totp_secret',
|
||||
'totp_authenticated_at',
|
||||
'mfa_app_secret',
|
||||
'mfa_app_recovery_codes',
|
||||
'oauth',
|
||||
'customization',
|
||||
];
|
||||
@ -135,7 +134,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
|
||||
/**
|
||||
* The attributes excluded from the model's JSON form.
|
||||
*/
|
||||
protected $hidden = ['password', 'remember_token', 'totp_secret', 'totp_authenticated_at', 'oauth'];
|
||||
protected $hidden = ['password', 'remember_token', 'mfa_app_secret', 'mfa_app_recovery_codes', 'oauth'];
|
||||
|
||||
/**
|
||||
* Default values for specific fields in the database.
|
||||
@ -144,8 +143,9 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
|
||||
'external_id' => null,
|
||||
'language' => 'en',
|
||||
'timezone' => 'UTC',
|
||||
'use_totp' => false,
|
||||
'totp_secret' => null,
|
||||
'mfa_app_secret' => null,
|
||||
'mfa_app_recovery_codes' => null,
|
||||
'mfa_email_enabled' => false,
|
||||
'oauth' => '[]',
|
||||
'customization' => null,
|
||||
];
|
||||
@ -159,8 +159,10 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
|
||||
'password' => ['sometimes', 'nullable', 'string'],
|
||||
'language' => ['string'],
|
||||
'timezone' => ['string'],
|
||||
'use_totp' => ['boolean'],
|
||||
'totp_secret' => ['nullable', 'string'],
|
||||
'mfa_app_secret' => ['nullable', 'string'],
|
||||
'mfa_app_recovery_codes' => ['nullable', 'array'],
|
||||
'mfa_app_recovery_codes.*' => ['string'],
|
||||
'mfa_email_enabled' => ['boolean'],
|
||||
'oauth' => ['array', 'nullable'],
|
||||
'customization' => ['array', 'nullable'],
|
||||
'customization.console_rows' => ['integer', 'min:1'],
|
||||
@ -171,9 +173,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'use_totp' => 'boolean',
|
||||
'totp_authenticated_at' => 'datetime',
|
||||
'totp_secret' => 'encrypted',
|
||||
'mfa_app_secret' => 'encrypted',
|
||||
'mfa_app_recovery_codes' => 'encrypted:array',
|
||||
'oauth' => 'array',
|
||||
'customization' => 'array',
|
||||
];
|
||||
@ -320,6 +321,12 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
|
||||
return $this->belongsToMany(Server::class, 'subusers');
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function getCustomization(): array
|
||||
{
|
||||
return json_decode($this->customization, true) ?? [];
|
||||
}
|
||||
|
||||
protected function checkPermission(Server $server, string $permission = ''): bool
|
||||
{
|
||||
if ($this->canned('update', $server) || $server->owner_id === $this->id) {
|
||||
@ -444,9 +451,44 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function getCustomization(): array
|
||||
public function getAppAuthenticationSecret(): ?string
|
||||
{
|
||||
return json_decode($this->customization, true) ?? [];
|
||||
return $this->mfa_app_secret;
|
||||
}
|
||||
|
||||
public function saveAppAuthenticationSecret(?string $secret): void
|
||||
{
|
||||
$this->update(['mfa_app_secret' => $secret]);
|
||||
}
|
||||
|
||||
public function getAppAuthenticationHolderName(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>|null
|
||||
*/
|
||||
public function getAppAuthenticationRecoveryCodes(): ?array
|
||||
{
|
||||
return $this->mfa_app_recovery_codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string>|null $codes
|
||||
*/
|
||||
public function saveAppAuthenticationRecoveryCodes(?array $codes): void
|
||||
{
|
||||
$this->update(['mfa_app_recovery_codes' => $codes]);
|
||||
}
|
||||
|
||||
public function hasEmailAuthentication(): bool
|
||||
{
|
||||
return $this->mfa_email_enabled;
|
||||
}
|
||||
|
||||
public function toggleEmailAuthentication(bool $condition): void
|
||||
{
|
||||
$this->update(['mfa_email_enabled' => $condition]);
|
||||
}
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ use App\Checks\NodeVersionsCheck;
|
||||
use App\Checks\PanelVersionCheck;
|
||||
use App\Checks\ScheduleCheck;
|
||||
use App\Checks\UsedDiskSpaceCheck;
|
||||
use App\Filament\Components\Actions\CopyAction;
|
||||
use App\Services\Helpers\SoftwareVersionService;
|
||||
use Dedoc\Scramble\Scramble;
|
||||
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||
|
@ -5,8 +5,9 @@ namespace App\Providers\Filament;
|
||||
use App\Filament\Pages\Auth\EditProfile;
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Http\Middleware\LanguageMiddleware;
|
||||
use App\Http\Middleware\RequireTwoFactorAuthentication;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Auth\MultiFactor\App\AppAuthentication;
|
||||
use Filament\Auth\MultiFactor\Email\EmailAuthentication;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
@ -42,6 +43,10 @@ class AdminPanelProvider extends PanelProvider
|
||||
->login(Login::class)
|
||||
->profile(EditProfile::class, false)
|
||||
->passwordReset()
|
||||
->multiFactorAuthentication([
|
||||
AppAuthentication::make()->recoverable(),
|
||||
EmailAuthentication::make(),
|
||||
])
|
||||
->userMenuItems([
|
||||
'profile' => fn (Action $action) => $action->label(auth()->user()->username),
|
||||
Action::make('exitAdmin')
|
||||
@ -72,7 +77,6 @@ class AdminPanelProvider extends PanelProvider
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
LanguageMiddleware::class,
|
||||
RequireTwoFactorAuthentication::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
|
@ -5,8 +5,9 @@ namespace App\Providers\Filament;
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Pages\Auth\EditProfile;
|
||||
use App\Http\Middleware\LanguageMiddleware;
|
||||
use App\Http\Middleware\RequireTwoFactorAuthentication;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Auth\MultiFactor\App\AppAuthentication;
|
||||
use Filament\Auth\MultiFactor\Email\EmailAuthentication;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -40,6 +41,10 @@ class AppPanelProvider extends PanelProvider
|
||||
->profile(EditProfile::class, false)
|
||||
->login(Login::class)
|
||||
->passwordReset()
|
||||
->multiFactorAuthentication([
|
||||
AppAuthentication::make()->recoverable(),
|
||||
EmailAuthentication::make(),
|
||||
])
|
||||
->userMenuItems([
|
||||
'profile' => fn (Action $action) => $action->label(auth()->user()->username),
|
||||
Action::make('toAdmin')
|
||||
@ -61,7 +66,6 @@ class AppPanelProvider extends PanelProvider
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
LanguageMiddleware::class,
|
||||
RequireTwoFactorAuthentication::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
|
@ -8,9 +8,10 @@ use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Admin\Resources\ServerResource\Pages\EditServer;
|
||||
use App\Http\Middleware\Activity\ServerSubject;
|
||||
use App\Http\Middleware\LanguageMiddleware;
|
||||
use App\Http\Middleware\RequireTwoFactorAuthentication;
|
||||
use App\Models\Server;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Auth\MultiFactor\App\AppAuthentication;
|
||||
use Filament\Auth\MultiFactor\Email\EmailAuthentication;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -46,6 +47,10 @@ class ServerPanelProvider extends PanelProvider
|
||||
->login(Login::class)
|
||||
->profile(EditProfile::class, false)
|
||||
->passwordReset()
|
||||
->multiFactorAuthentication([
|
||||
AppAuthentication::make()->recoverable(),
|
||||
EmailAuthentication::make(),
|
||||
])
|
||||
->userMenuItems([
|
||||
'profile' => fn (Action $action) => $action->label(auth()->user()->username)->url(fn () => EditProfile::getUrl(panel: 'app')),
|
||||
Action::make('toServerList')
|
||||
@ -81,7 +86,6 @@ class ServerPanelProvider extends PanelProvider
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
LanguageMiddleware::class,
|
||||
RequireTwoFactorAuthentication::class,
|
||||
ServerSubject::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
|
@ -7,7 +7,6 @@ use Illuminate\Foundation\Http\Middleware\TrimStrings;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use App\Http\Middleware\RequireTwoFactorAuthentication;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
@ -29,17 +28,17 @@ class RouteServiceProvider extends ServiceProvider
|
||||
|
||||
$this->routes(function () {
|
||||
Route::middleware('web')->group(function () {
|
||||
Route::middleware(['auth.session', RequireTwoFactorAuthentication::class])
|
||||
Route::middleware(['auth.session'])
|
||||
->prefix('docs')
|
||||
->group(base_path('routes/docs.php'));
|
||||
|
||||
Route::middleware(['auth.session', RequireTwoFactorAuthentication::class])
|
||||
Route::middleware(['auth.session'])
|
||||
->group(base_path('routes/base.php'));
|
||||
|
||||
Route::middleware('guest')->prefix('/auth')->group(base_path('routes/auth.php'));
|
||||
});
|
||||
|
||||
Route::middleware(['api', RequireTwoFactorAuthentication::class])->group(function () {
|
||||
Route::middleware(['api'])->group(function () {
|
||||
Route::middleware(['application-api', 'throttle:api.application'])
|
||||
->prefix('/api/application')
|
||||
->scopeBindings()
|
||||
|
@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Users;
|
||||
|
||||
use Throwable;
|
||||
use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
|
||||
use PragmaRX\Google2FA\Exceptions\InvalidCharactersException;
|
||||
use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException;
|
||||
use App\Models\User;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use App\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid;
|
||||
|
||||
class ToggleTwoFactorService
|
||||
{
|
||||
/**
|
||||
* ToggleTwoFactorService constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private ConnectionInterface $connection,
|
||||
private Google2FA $google2FA,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Toggle 2FA on an account only if the token provided is valid.
|
||||
*
|
||||
* @return string[]
|
||||
*
|
||||
* @throws Throwable
|
||||
* @throws IncompatibleWithGoogleAuthenticatorException
|
||||
* @throws InvalidCharactersException
|
||||
* @throws SecretKeyTooShortException
|
||||
* @throws TwoFactorAuthenticationTokenInvalid
|
||||
*/
|
||||
public function handle(User $user, string $token, ?bool $toggleState = null): array
|
||||
{
|
||||
$isValidToken = $this->google2FA->verifyKey($user->totp_secret, $token, config()->get('panel.auth.2fa.window'));
|
||||
|
||||
if (!$isValidToken) {
|
||||
throw new TwoFactorAuthenticationTokenInvalid();
|
||||
}
|
||||
|
||||
return $this->connection->transaction(function () use ($user, $toggleState) {
|
||||
// Now that we're enabling 2FA on the account, generate 10 recovery tokens for the account
|
||||
// and store them hashed in the database. We'll return them to the caller so that the user
|
||||
// can see and save them.
|
||||
//
|
||||
// If a user is unable to login with a 2FA token they can provide one of these backup codes
|
||||
// which will then be marked as deleted from the database and will also bypass 2FA protections
|
||||
// on their account.
|
||||
$tokens = [];
|
||||
if ((!$toggleState && !$user->use_totp) || $toggleState) {
|
||||
$user->recoveryTokens()->delete();
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$token = str_random(10);
|
||||
$user->recoveryTokens()->forceCreate([
|
||||
'token' => password_hash($token, PASSWORD_DEFAULT),
|
||||
]);
|
||||
$tokens[] = $token;
|
||||
}
|
||||
}
|
||||
|
||||
$user->totp_authenticated_at = now();
|
||||
$user->use_totp = (is_null($toggleState) ? !$user->use_totp : $toggleState);
|
||||
$user->save();
|
||||
|
||||
return $tokens;
|
||||
});
|
||||
}
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Users;
|
||||
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
use App\Exceptions\Model\DataValidationException;
|
||||
use App\Models\User;
|
||||
|
||||
class TwoFactorSetupService
|
||||
{
|
||||
public const VALID_BASE32_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
/**
|
||||
* Generate a 2FA token and store it in the database before returning the
|
||||
* QR code URL. This URL will need to be attached to a QR generating service in
|
||||
* order to function.
|
||||
*
|
||||
* @return array{image_url_data: string, secret: string}
|
||||
*
|
||||
* @throws DataValidationException
|
||||
*/
|
||||
public function handle(User $user): array
|
||||
{
|
||||
$secret = '';
|
||||
try {
|
||||
for ($i = 0; $i < config('panel.auth.2fa.bytes', 16); $i++) {
|
||||
$secret .= substr(self::VALID_BASE32_CHARACTERS, random_int(0, 31), 1);
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
throw new RuntimeException($exception->getMessage(), 0, $exception);
|
||||
}
|
||||
|
||||
$user->totp_secret = $secret;
|
||||
$user->save();
|
||||
|
||||
$company = urlencode(preg_replace('/\s/', '', config('app.name')));
|
||||
|
||||
return [
|
||||
'image_url_data' => sprintf(
|
||||
'otpauth://totp/%1$s:%2$s?secret=%3$s&issuer=%1$s',
|
||||
rawurlencode($company),
|
||||
rawurlencode($user->email),
|
||||
rawurlencode($secret),
|
||||
),
|
||||
'secret' => $secret,
|
||||
];
|
||||
}
|
||||
}
|
@ -36,8 +36,8 @@ class UserTransformer extends BaseTransformer
|
||||
'email' => $user->email,
|
||||
'language' => $user->language,
|
||||
'root_admin' => $user->isRootAdmin(),
|
||||
'2fa_enabled' => (bool) $user->use_totp,
|
||||
'2fa' => (bool) $user->use_totp, // deprecated, use "2fa_enabled"
|
||||
'2fa_enabled' => filled($user->mfa_app_secret),
|
||||
'2fa' => filled($user->mfa_app_secret), // deprecated, use "2fa_enabled"
|
||||
'created_at' => $this->formatTimestamp($user->created_at),
|
||||
'updated_at' => $this->formatTimestamp($user->updated_at),
|
||||
];
|
||||
|
@ -30,7 +30,7 @@ class UserTransformer extends BaseClientTransformer
|
||||
'image' => 'https://gravatar.com/avatar/' . md5(Str::lower($user->email)), // deprecated
|
||||
'admin' => $user->isRootAdmin(), // deprecated, use "root_admin"
|
||||
'root_admin' => $user->isRootAdmin(),
|
||||
'2fa_enabled' => (bool) $user->use_totp,
|
||||
'2fa_enabled' => filled($user->mfa_app_secret),
|
||||
'created_at' => $this->formatTimestamp($user->created_at),
|
||||
'updated_at' => $this->formatTimestamp($user->updated_at),
|
||||
];
|
||||
|
@ -26,7 +26,6 @@
|
||||
"league/flysystem-aws-s3-v3": "^3.29",
|
||||
"league/flysystem-memory": "^3.29",
|
||||
"phpseclib/phpseclib": "~3.0.18",
|
||||
"pragmarx/google2fa": "~8.0.0",
|
||||
"predis/predis": "^2.3",
|
||||
"s1lentium/iptools": "~1.2.0",
|
||||
"secondnetwork/blade-tabler-icons": "^3.26",
|
||||
|
@ -31,7 +31,6 @@ class UserFactory extends Factory
|
||||
'email' => Str::random(32) . '@example.com',
|
||||
'password' => $password ?: $password = bcrypt('password'),
|
||||
'language' => 'en',
|
||||
'use_totp' => false,
|
||||
'oauth' => [],
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
|
47
database/migrations/2025_07_22_091435_update_users_totp.php
Normal file
47
database/migrations/2025_07_22_091435_update_users_totp.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::transaction(function () {
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->text('mfa_app_secret')->nullable();
|
||||
$table->text('mfa_app_recovery_codes')->nullable();
|
||||
$table->boolean('mfa_email_enabled')->default(false);
|
||||
});
|
||||
|
||||
$users = User::all();
|
||||
foreach ($users as $user) {
|
||||
$user->update([
|
||||
'mfa_app_secret' => $user->totp_secret,
|
||||
'mfa_app_recovery_codes' => null,
|
||||
'mfa_email_enabled' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('use_totp');
|
||||
$table->dropColumn('totp_secret');
|
||||
$table->dropColumn('totp_authenticated_at');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Not needed
|
||||
}
|
||||
};
|
@ -38,7 +38,7 @@ class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase
|
||||
'email' => $user->email,
|
||||
'language' => $user->language,
|
||||
'root_admin' => (bool) $user->isRootAdmin(),
|
||||
'2fa' => (bool) $user->totp_enabled,
|
||||
'2fa' => filled($user->mfa_app_secret),
|
||||
'created_at' => $this->formatTimestamp($user->created_at),
|
||||
'updated_at' => $this->formatTimestamp($user->updated_at),
|
||||
],
|
||||
|
@ -56,8 +56,8 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase
|
||||
'email' => $this->getApiUser()->email,
|
||||
'language' => $this->getApiUser()->language,
|
||||
'root_admin' => $this->getApiUser()->isRootAdmin(),
|
||||
'2fa_enabled' => (bool) $this->getApiUser()->totp_enabled,
|
||||
'2fa' => (bool) $this->getApiUser()->totp_enabled,
|
||||
'2fa_enabled' => filled($this->getApiUser()->mfa_app_secret),
|
||||
'2fa' => filled($this->getApiUser()->mfa_app_secret),
|
||||
'created_at' => $this->formatTimestamp($this->getApiUser()->created_at),
|
||||
'updated_at' => $this->formatTimestamp($this->getApiUser()->updated_at),
|
||||
],
|
||||
@ -72,8 +72,8 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase
|
||||
'email' => $user->email,
|
||||
'language' => $user->language,
|
||||
'root_admin' => (bool) $user->isRootAdmin(),
|
||||
'2fa_enabled' => (bool) $user->totp_enabled,
|
||||
'2fa' => (bool) $user->totp_enabled,
|
||||
'2fa_enabled' => filled($user->mfa_app_secret),
|
||||
'2fa' => filled($user->mfa_app_secret),
|
||||
'created_at' => $this->formatTimestamp($user->created_at),
|
||||
'updated_at' => $this->formatTimestamp($user->updated_at),
|
||||
],
|
||||
@ -105,7 +105,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase
|
||||
'email' => $user->email,
|
||||
'language' => $user->language,
|
||||
'root_admin' => (bool) $user->root_admin,
|
||||
'2fa' => (bool) $user->totp_enabled,
|
||||
'2fa' => filled($user->mfa_app_secret),
|
||||
'created_at' => $this->formatTimestamp($user->created_at),
|
||||
'updated_at' => $this->formatTimestamp($user->updated_at),
|
||||
],
|
||||
|
@ -1,200 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Integration\Api\Client;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Response;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use App\Models\RecoveryToken;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
|
||||
class TwoFactorControllerTest extends ClientApiIntegrationTestCase
|
||||
{
|
||||
/**
|
||||
* Test that image data for enabling 2FA is returned by the endpoint and that the user
|
||||
* record in the database is updated as expected.
|
||||
*/
|
||||
public function test_two_factor_image_data_is_returned(): void
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create(['use_totp' => false]);
|
||||
|
||||
$this->assertFalse($user->use_totp);
|
||||
$this->assertEmpty($user->totp_secret);
|
||||
$this->assertEmpty($user->totp_authenticated_at);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/client/account/two-factor');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure(['data' => ['image_url_data']]);
|
||||
|
||||
$user = $user->refresh();
|
||||
|
||||
$this->assertFalse($user->use_totp);
|
||||
$this->assertNotEmpty($user->totp_secret);
|
||||
$this->assertEmpty($user->totp_authenticated_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that an error is returned if the user's account already has 2FA enabled on it.
|
||||
*/
|
||||
public function test_error_is_returned_when_two_factor_is_already_enabled(): void
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create(['use_totp' => true]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/client/account/two-factor');
|
||||
|
||||
$response->assertStatus(Response::HTTP_BAD_REQUEST);
|
||||
$response->assertJsonPath('errors.0.code', 'BadRequestHttpException');
|
||||
$response->assertJsonPath('errors.0.detail', 'Two-factor authentication is already enabled on this account.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a validation error is thrown if invalid data is passed to the 2FA endpoint.
|
||||
*/
|
||||
public function test_validation_error_is_returned_if_invalid_data_is_passed_to_enabled2_fa(): void
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create(['use_totp' => false]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/api/client/account/two-factor', ['code' => ''])
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('errors.0.meta.rule', 'required')
|
||||
->assertJsonPath('errors.0.meta.source_field', 'code')
|
||||
->assertJsonPath('errors.1.meta.rule', 'required')
|
||||
->assertJsonPath('errors.1.meta.source_field', 'password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that 2FA can be enabled on an account for the user.
|
||||
*/
|
||||
public function test_two_factor_can_be_enabled_on_account(): void
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create(['use_totp' => false]);
|
||||
|
||||
// Make the initial call to get the account setup for 2FA.
|
||||
$this->actingAs($user)->getJson('/api/client/account/two-factor')->assertOk();
|
||||
|
||||
$user = $user->refresh();
|
||||
$this->assertNotNull($user->totp_secret);
|
||||
|
||||
/** @var \PragmaRX\Google2FA\Google2FA $service */
|
||||
$service = $this->app->make(Google2FA::class);
|
||||
|
||||
$token = $service->getCurrentOtp($user->totp_secret);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/client/account/two-factor', [
|
||||
'code' => $token,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('object', 'recovery_tokens');
|
||||
|
||||
$user = $user->refresh();
|
||||
$this->assertTrue($user->use_totp);
|
||||
|
||||
$tokens = RecoveryToken::query()->where('user_id', $user->id)->get();
|
||||
$this->assertCount(10, $tokens);
|
||||
$this->assertStringStartsWith('$2y$', $tokens[0]->token);
|
||||
|
||||
// Ensure the recovery tokens that were created include a "created_at" timestamp value on them.
|
||||
$this->assertNotNull($tokens[0]->created_at);
|
||||
|
||||
$tokens = $tokens->pluck('token')->toArray();
|
||||
|
||||
$rawTokens = $response->json('attributes.tokens');
|
||||
$rawToken = reset($rawTokens);
|
||||
$hashed = reset($tokens);
|
||||
|
||||
throw_unless(password_verify($rawToken, $hashed), new ExpectationFailedException(sprintf('Failed asserting that token [%s] exists as a hashed value in recovery_tokens table.', $rawToken)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that two-factor authentication can be disabled on an account as long as the password
|
||||
* provided is valid for the account.
|
||||
*/
|
||||
public function test_two_factor_can_be_disabled_on_account(): void
|
||||
{
|
||||
Carbon::setTestNow(Carbon::now());
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create(['use_totp' => true]);
|
||||
|
||||
$response = $this->actingAs($user)->deleteJson('/api/client/account/two-factor', [
|
||||
'password' => 'invalid',
|
||||
]);
|
||||
|
||||
$response->assertStatus(Response::HTTP_BAD_REQUEST);
|
||||
$response->assertJsonPath('errors.0.code', 'BadRequestHttpException');
|
||||
$response->assertJsonPath('errors.0.detail', 'The password provided was not valid.');
|
||||
|
||||
$response = $this->actingAs($user)->deleteJson('/api/client/account/two-factor', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(Response::HTTP_NO_CONTENT);
|
||||
|
||||
$user = $user->refresh();
|
||||
$this->assertFalse($user->use_totp);
|
||||
$this->assertNotNull($user->totp_authenticated_at);
|
||||
$this->assertTrue(now()->isSameAs('Y-m-d H:i:s', $user->totp_authenticated_at));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that no error is returned when trying to disabled two factor on an account where it
|
||||
* was not enabled in the first place.
|
||||
*/
|
||||
public function test_no_error_is_returned_if_two_factor_is_not_enabled(): void
|
||||
{
|
||||
Carbon::setTestNow(Carbon::now());
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create(['use_totp' => false]);
|
||||
|
||||
$response = $this->actingAs($user)->deleteJson('/api/client/account/two-factor', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a valid account password is required when enabling two-factor.
|
||||
*/
|
||||
public function test_enabling_two_factor_requires_valid_password(): void
|
||||
{
|
||||
$user = User::factory()->create(['use_totp' => false]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/api/client/account/two-factor', [
|
||||
'code' => '123456',
|
||||
'password' => 'foo',
|
||||
])
|
||||
->assertStatus(Response::HTTP_BAD_REQUEST)
|
||||
->assertJsonPath('errors.0.detail', 'The password provided was not valid.');
|
||||
|
||||
$this->assertFalse($user->refresh()->use_totp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a valid account password is required when disabling two-factor.
|
||||
*/
|
||||
public function test_disabling_two_factor_requires_valid_password(): void
|
||||
{
|
||||
$user = User::factory()->create(['use_totp' => true]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->deleteJson('/api/client/account/two-factor', [
|
||||
'password' => 'foo',
|
||||
])
|
||||
->assertStatus(Response::HTTP_BAD_REQUEST)
|
||||
->assertJsonPath('errors.0.detail', 'The password provided was not valid.');
|
||||
|
||||
$this->assertTrue($user->refresh()->use_totp);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user