switch to filaments 2fa

This commit is contained in:
Boy132 2025-07-22 13:24:06 +02:00
parent 9a1b999199
commit b396f3e339
26 changed files with 440 additions and 1032 deletions

View File

@ -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]));
}

View File

@ -2,7 +2,7 @@
namespace App\Extensions\Captcha\Schemas;
use Filament\Support\Components\Component;
use Filament\Schemas\Components\Component;
interface CaptchaSchemaInterface
{

View File

@ -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
*/

View File

@ -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()

View File

@ -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 [];

View File

@ -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));
}

View File

@ -540,8 +540,8 @@ class ListFiles extends ListRecords
]);
}
/** @return array<Action|ActionGroup>
* @throws Exception
/**
* @return array<Action|ActionGroup>
*/
protected function getDefaultHeaderActions(): array
{

View File

@ -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);
}
}

View File

@ -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'));
}
}

View File

@ -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;

View File

@ -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]);
}
}

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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([

View File

@ -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()

View File

@ -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;
});
}
}

View File

@ -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,
];
}
}

View File

@ -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),
];

View File

@ -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),
];

View File

@ -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",

View File

@ -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(),

View 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
}
};

View File

@ -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),
],

View File

@ -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),
],

View File

@ -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);
}
}