Compare commits

...

11 Commits

22 changed files with 345 additions and 298 deletions

View File

@ -44,7 +44,7 @@ class WebAuthnRecoveryController extends Controller
true,
);
Auth::guard()->setProvider($provider);
Auth::setProvider($provider);
if (Auth::attempt($request->only('email', 'password'))) {
if ($this->shouldRevokeAllCredentials($request)) {

View File

@ -90,12 +90,14 @@ class User extends Authenticatable implements HasLocalePreference, WebAuthnAuthe
];
/**
* User exposed observable events.
*
* These are extra user-defined events observers may subscribe to.
*
* @var array
*/
protected $observables = [
'demoting',
];
protected $observables = ['demoting'];
/**
* Get the user's preferred locale.
*/
@ -130,7 +132,9 @@ class User extends Authenticatable implements HasLocalePreference, WebAuthnAuthe
*/
public function promoteToAdministrator(bool $promote = true) : bool
{
if ($promote == false && $this->fireModelEvent('demoting') === false) {
$eventResult = $promote ? $this->fireModelEvent('promoting') : $this->fireModelEvent('demoting');
if ($promote == false && $eventResult === false) {
return false;
}

View File

@ -54,7 +54,7 @@ class FailedLogin extends Notification implements ShouldQueue
'account' => $notifiable,
'time' => $this->authLog->login_at,
'ipAddress' => $this->authLog->ip_address,
'browser' => $this->authLog->user_agent,
'browser' => $this->agent->browser(),
'platform' => $this->agent->platform(),
]);
}

View File

@ -107,7 +107,7 @@ class LogoService
? Log::info('Fresh tfa.json saved to logos dir')
: Log::notice('Cannot save tfa.json to logos dir');
} catch (\Exception $e) {
Log::error('Caching of tfa.json failed');
Log::error('Caching of tfa.json failed:' . $e->getMessage());
}
}

View File

@ -45,7 +45,7 @@ class GoogleAuthMigrator extends Migrator
$parameters['otp_type'] = GAuthValueMapping::OTP_TYPE[OtpType::name($otp_parameters->getType())];
$parameters['service'] = $otp_parameters->getIssuer();
$parameters['account'] = str_replace($parameters['service'] . ':', '', $otp_parameters->getName());
$parameters['secret'] = Base32::encodeUpper($otp_parameters->getSecret());
$parameters['secret'] = $this->toBase32($otp_parameters->getSecret());
$parameters['algorithm'] = GAuthValueMapping::ALGORITHM[Algorithm::name($otp_parameters->getAlgorithm())];
$parameters['digits'] = GAuthValueMapping::DIGIT_COUNT[DigitCount::name($otp_parameters->getDigits())];
$parameters['counter'] = $parameters['otp_type'] === TwoFAccount::HOTP ? $otp_parameters->getCounter() : null;
@ -73,4 +73,11 @@ class GoogleAuthMigrator extends Migrator
return collect($twofaccounts);
}
/**
* Encode into uppercase Base32
*/
protected function toBase32(string $str) {
return Base32::encodeUpper($str);
}
}

View File

@ -29,7 +29,7 @@ class QrCodeService
Log::info('data encoded to QR code');
return $qrcode->render($data);
return $qrcode->render('stringToEncode');
}
/**

View File

@ -8,7 +8,7 @@
],
"license": "MIT",
"require": {
"php": "^8.1",
"php": "^8.2",
"ext-bcmath": "*",
"ext-ctype": "*",
"ext-dom": "*",
@ -20,10 +20,10 @@
"ext-session": "*",
"ext-tokenizer": "*",
"ext-xml": "*",
"chillerlan/php-qrcode": "^4.3",
"chillerlan/php-qrcode": "^5.0",
"doctormckay/steam-totp": "^1.0",
"doctrine/dbal": "^3.4",
"google/protobuf": "^3.21",
"google/protobuf": "^4.26",
"guzzlehttp/guzzle": "^7.2",
"jackiedo/dotenv-editor": "^2.1",
"jenssegers/agent": "^2.6",

473
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@ return [
|
*/
'version' => '5.1.1',
'version' => '5.2.0',
'repository' => 'https://github.com/Bubka/2FAuth',
'latestReleaseUrl' => 'https://api.github.com/repos/Bubka/2FAuth/releases/latest',
'installDocUrl' => 'https://docs.2fauth.app/getting-started/installation/self-hosted-server/',
@ -123,7 +123,7 @@ return [
'lang' => 'browser',
'getOtpOnRequest' => true,
'notifyOnNewAuthDevice' => true,
'notifyOnFailedLogin' => true,
'notifyOnFailedLogin' => false,
'timezone' => env('APP_TIMEZONE', 'UTC'),
],

View File

@ -34,6 +34,7 @@
</testsuite>
</testsuites>
<php>
<ini name="memory_limit" value="256M" />
<env name="APP_ENV" value="testing"/>
<!-- following values override .env.testing vars -->
</php>

View File

@ -161,6 +161,13 @@
<FormCheckbox v-model="user.preferences.showOtpAsDot" @update:model-value="val => savePreference('showOtpAsDot', val)" fieldName="showOtpAsDot" label="settings.forms.show_otp_as_dot.label" help="settings.forms.show_otp_as_dot.help" />
<!-- reveal dotted OTPs -->
<FormCheckbox v-model="user.preferences.revealDottedOTP" @update:model-value="val => savePreference('revealDottedOTP', val)" fieldName="revealDottedOTP" label="settings.forms.reveal_dotted_otp.label" help="settings.forms.reveal_dotted_otp.help" :isDisabled="!user.preferences.showOtpAsDot" :isIndented="true" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.notifications') }}</h4>
<!-- on new device -->
<FormCheckbox v-model="user.preferences.notifyOnNewAuthDevice" @update:model-value="val => savePreference('notifyOnNewAuthDevice', val)" fieldName="notifyOnNewAuthDevice" label="settings.forms.notify_on_new_auth_device.label" help="settings.forms.notify_on_new_auth_device.help" />
<!-- on failed login -->
<FormCheckbox v-model="user.preferences.notifyOnFailedLogin" @update:model-value="val => savePreference('notifyOnFailedLogin', val)" fieldName="notifyOnFailedLogin" label="settings.forms.notify_on_failed_login.label" help="settings.forms.notify_on_failed_login.help" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.data_input') }}</h4>
<!-- basic qrcode -->
<FormCheckbox v-model="user.preferences.useBasicQrcodeReader" @update:model-value="val => savePreference('useBasicQrcodeReader', val)" fieldName="useBasicQrcodeReader" label="settings.forms.use_basic_qrcode_reader.label" help="settings.forms.use_basic_qrcode_reader.help" />

View File

@ -22,7 +22,7 @@ return [
'success' => 'Good news, it works :)'
],
'new_device' => [
'subject' => 'New connection to 2FAuth',
'subject' => 'Connection to 2FAuth from a new device',
'resume' => 'A new device has just connected to your 2FAuth account.',
'connection_details' => 'Here are the details of this connection',
'recommandations' => 'If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.'

View File

@ -29,6 +29,7 @@ return [
'account_linked_to_sso_x_provider' => 'You signed-in via SSO using your :provider account. Your information cannot be changed here but on :provider.',
'general' => 'General',
'security' => 'Security',
'notifications' => 'Notifications',
'profile' => 'Profile',
'change_password' => 'Change password',
'personal_access_tokens' => 'Personal access tokens',
@ -139,6 +140,14 @@ return [
'label' => 'Show Password',
'help' => 'Set how and when <abbr title="One-Time Passwords">OTPs</abbr> are displayed.<br/>',
],
'notify_on_new_auth_device' => [
'label' => 'On new device',
'help' => 'Get an email when a new device connects to your 2FAuth account for the first time'
],
'notify_on_failed_login' => [
'label' => 'On failed login',
'help' => 'Get an email each time an attempt to connect to your 2FAuth account fails'
],
'otp_generation_on_request' => 'After a click/tap',
'otp_generation_on_request_legend' => 'Alone, in its own view',
'otp_generation_on_request_title' => 'Click an account to get a password in a dedicated view',

View File

@ -1,4 +1,3 @@
@component('mail::message')
@lang('notifications.hello_user', ['username' => $account->name])
<br/><br/>

View File

@ -473,4 +473,20 @@ class GroupControllerTest extends FeatureTestCase
$this->assertEquals(0, $this->user->preferences['defaultGroup']);
$this->assertEquals(0, $this->user->preferences['activeGroup']);
}
/**
* @test
*/
public function test_twofaccount_is_released_on_group_destroy()
{
$this->actingAs($this->user, 'api-guard')
->json('DELETE', '/api/v1/groups/' . $this->userGroupA->id)
->assertNoContent();
$this->twofaccountA->refresh();
$this->twofaccountB->refresh();
$this->assertNull($this->twofaccountA->group_id);
$this->assertNull($this->twofaccountB->group_id);
}
}

View File

@ -61,7 +61,7 @@ class QrCodeControllerTest extends FeatureTestCase
])
->assertOk();
$this->assertStringStartsWith('data:image/png;base64', $response->getData()->qrcode);
$this->assertStringStartsWith('data:image/svg+xml;base64', $response->getData()->qrcode);
}
/**

View File

@ -84,6 +84,9 @@ class LoginTest extends FeatureTestCase
{
Notification::fake();
$this->user['preferences->notifyOnNewAuthDevice'] = 1;
$this->user->save();
$this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::PASSWORD,
@ -235,6 +238,9 @@ class LoginTest extends FeatureTestCase
{
Notification::fake();
$this->user['preferences->notifyOnFailedLogin'] = 1;
$this->user->save();
$this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::WRONG_PASSWORD,

View File

@ -73,11 +73,15 @@ class UserModelTest extends FeatureTestCase
*/
public function test_promoteToAdministrator_demote_administrator_status()
{
$user = User::factory()->administrator()->create();
$admin = User::factory()->administrator()->create();
// We need another admin to prevent demoting event returning false
// and blocking the demotion
$another_admin = User::factory()->administrator()->create();
$user->promoteToAdministrator(false);
$admin->promoteToAdministrator(false);
$admin->save();
$this->assertEquals($user->isAdministrator(), false);
$this->assertFalse($admin->isAdministrator());
}
/**

File diff suppressed because one or more lines are too long

View File

@ -4,14 +4,8 @@ namespace Tests\Unit\Listeners;
use App\Events\GroupDeleting;
use App\Listeners\DissociateTwofaccountFromGroup;
use App\Models\Group;
use App\Models\TwoFAccount;
use Illuminate\Support\Facades\Event;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RequiresPhp;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use Tests\TestCase;
/**
@ -20,27 +14,6 @@ use Tests\TestCase;
#[CoversClass(DissociateTwofaccountFromGroup::class)]
class DissociateTwofaccountFromGroupTest extends TestCase
{
/**
* @test
*/
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
#[RequiresPhp('< 8.3.0')]
public function test_twofaccount_is_released_on_group_deletion()
{
$this->mock('alias:' . TwoFAccount::class, function (MockInterface $twoFAccount) {
$twoFAccount->shouldReceive('where->update')
->once()
->andReturn(1);
});
$group = Group::factory()->make();
$event = new GroupDeleting($group);
$listener = new DissociateTwofaccountFromGroup();
$this->assertNull($listener->handle($event));
}
/**
* @test
*/

View File

@ -18,12 +18,8 @@ use App\Services\SettingService;
use Illuminate\Support\Facades\Storage;
use Mockery;
use Mockery\MockInterface;
use ParagonIE\ConstantTime\Base32;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RequiresPhp;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\Data\MigrationTestData;
use Tests\Data\OtpTestData;
@ -142,6 +138,20 @@ class MigratorTest extends TestCase
$this->fakeTwofaccount->id = TwoFAccount::FAKE_ID;
}
/**
* Clean up the testing environment before the next test.
*
* @return void
*
* @throws \Mockery\Exception\InvalidCountException
*/
protected function tearDown() : void
{
$this->forgetMock(SettingService::class);
parent::tearDown();
}
/**
* @test
*/
@ -332,17 +342,14 @@ class MigratorTest extends TestCase
/**
* @test
*/
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
#[RequiresPhp('< 8.3.0')]
public function test_migrate_gauth_returns_fake_accounts()
{
$this->mock('alias:' . Base32::class, function (MockInterface $baseEncoder) {
$baseEncoder->shouldReceive('encodeUpper')
$migrator = $this->partialMock(GoogleAuthMigrator::class, function (MockInterface $migrator) {
$migrator->shouldAllowMockingProtectedMethods()->shouldReceive('toBase32')
->andThrow(new \Exception());
});
$migrator = new GoogleAuthMigrator();
/** @disregard Undefined function */
$accounts = $migrator->migrate(MigrationTestData::GOOGLE_AUTH_MIGRATION_URI);
$this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
@ -352,6 +359,8 @@ class MigratorTest extends TestCase
// in the migration payload) so we do not use get() to retrieve items
$this->assertEquals($this->fakeTwofaccount->id, $accounts->first()->id);
$this->assertEquals($this->fakeTwofaccount->id, $accounts->last()->id);
$this->forgetMock(GoogleAuthMigrator::class);
}
/**
@ -548,9 +557,4 @@ class MigratorTest extends TestCase
],
];
}
protected function tearDown() : void
{
Mockery::close();
}
}

View File

@ -11,9 +11,6 @@ use Illuminate\Support\Facades\Crypt;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RequiresPhp;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use Tests\ModelTestCase;
/**
@ -63,6 +60,7 @@ class TwoFAccountModelTest extends ModelTestCase
]);
$this->assertEquals('STRING==', Crypt::decryptString($twofaccount->getAttributes()[$attribute]));
$this->forgetMock(SettingService::class);
}
/**
@ -98,6 +96,7 @@ class TwoFAccountModelTest extends ModelTestCase
$twofaccount = TwoFAccount::factory()->make();
$this->assertEquals($twofaccount->getAttributes()[$attribute], $twofaccount->$attribute);
$this->forgetMock(SettingService::class);
}
/**
@ -118,14 +117,12 @@ class TwoFAccountModelTest extends ModelTestCase
$twofaccount = TwoFAccount::factory()->make();
$this->assertEquals(__('errors.indecipherable'), $twofaccount->$attribute);
$this->forgetMock(SettingService::class);
}
/**
* @test
*/
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
#[RequiresPhp('< 8.3.0')]
public function test_secret_is_uppercased_and_padded_at_setup()
{
$settingService = $this->mock(SettingService::class, function (MockInterface $settingService) {
@ -134,7 +131,7 @@ class TwoFAccountModelTest extends ModelTestCase
->andReturn(false);
});
$helpers = $this->mock('alias:' . Helpers::class, function (MockInterface $helpers) {
$helpers = $this->mock(Helpers::class, function (MockInterface $helpers) {
$helpers->shouldReceive('PadToBase32Format')
->andReturn('YYYY====');
});
@ -144,6 +141,7 @@ class TwoFAccountModelTest extends ModelTestCase
]);
$this->assertEquals('YYYY====', $twofaccount->secret);
$this->forgetMock(SettingService::class);
}
/**