Skip to content

Commit

Permalink
feat: support gdpr, add basic login tests (#17)
Browse files Browse the repository at this point in the history
* feat: support gdpr, add basic login tests

* Apply fixes from StyleCI

---------

Co-authored-by: StyleCI Bot <[email protected]>
  • Loading branch information
imorland and StyleCIBot committed Nov 1, 2023
1 parent b088088 commit 6186d8b
Show file tree
Hide file tree
Showing 8 changed files with 200 additions and 14 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"require-dev": {
"flarum/testing": "^1.8.0",
"fof/oauth": "*",
"flarum/phpstan": "^1.8"
"flarum/phpstan": "^1.8",
"blomstra/gdpr": "@beta"
}
}
11 changes: 7 additions & 4 deletions extend.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace IanM\TwoFactor;

use Blomstra\Gdpr\Extend\UserData;
use Flarum\Api\Controller\ShowUserController;
use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\CurrentUserSerializer;
Expand All @@ -35,7 +36,8 @@

new Extend\Locales(__DIR__.'/locale'),

(new Extend\Model(Group::class))->cast('tfa_required', 'bool'),
(new Extend\Model(Group::class))
->cast('tfa_required', 'bool'),

(new Extend\Routes('api'))
->get('/users/{id}/twofactor/qrcode', 'user.twofactor.get-qr', Api\Controller\ShowQrCodeController::class)
Expand All @@ -60,9 +62,6 @@
(new Extend\Model(User::class))
->hasOne('twoFactor', TwoFactor::class, 'user_id'),

(new Extend\Model(TwoFactor::class))
->cast('is_active', 'bool'),

(new Extend\ApiSerializer(CurrentUserSerializer::class))
->attributes(Api\AddCurrentUserAttributes::class),

Expand Down Expand Up @@ -103,6 +102,10 @@
(new Extend\Routes('forum'))
->get('/twofactor/oauth/verify', 'twoFactor.oauth', Api\Controller\TwoFactorOAuthController::class)
->post('/twofactor/oauth/verify', 'twoFactor.oauth.verify', Api\Controller\TwoFactorOAuthVerifyController::class),
])
->whenExtensionEnabled('blomstra-gdpr', fn () => [
(new UserData())
->addType(Data\TwoFactorData::class),
]),

];
7 changes: 7 additions & 0 deletions locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,10 @@ ianm-twofactor:
two_factor_token_label: 2FA Token
two_factor_token:
submit_button: Submit Token

blomstra-gdpr:
lib:
data:
twofactordata:
export_description: Exports 2FA status and encrypted backup codes.
delete_description: Deletes all data related to 2FA.
2 changes: 1 addition & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ parameters:
# The level will be increased in Flarum 2.0
level: 5
paths:
- src
- extend.php
- src
excludePaths:
- *.blade.php
checkMissingIterableValueType: false
Expand Down
54 changes: 54 additions & 0 deletions src/Data/TwoFactorData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/*
* This file is part of ianm/twofactor.
*
* Copyright (c) 2023 IanM.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*/

namespace IanM\TwoFactor\Data;

use Blomstra\Gdpr\Data\Type;
use IanM\TwoFactor\Model\TwoFactor;
use Illuminate\Support\Arr;

class TwoFactorData extends Type
{
public function export(): ?array
{
$record = TwoFactor::query()
->where('user_id', $this->user->id)
->first();

if ($record) {
return ["2fa/{$this->user->id}.json" => $this->encodeForExport($this->sanitize($record))];
}

return [];
}

protected function sanitize(TwoFactor $twoFactor): array
{
return Arr::except($twoFactor->toArray(), ['id', 'user_id', 'secret']);
}

public static function anonymizeDescription(): string
{
return self::deleteDescription();
}

public function anonymize(): void
{
$this->delete();
}

public function delete(): void
{
TwoFactor::query()
->where('user_id', $this->user->id)
->delete();
}
}
12 changes: 4 additions & 8 deletions src/Trait/TwoFactorAuthenticationTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,16 @@ trait TwoFactorAuthenticationTrait
{
protected TotpInterface $totp;

protected function twoFactorActive(User &$user): bool
protected function twoFactorActive(User &$user): ?bool
{
if ($user->isGuest()) {
return false;
}

$twoFactor = $user->twoFactor ?? TwoFactor::getForUser($user);
$active = $twoFactor->is_active;
/** @var TwoFactor|null $twoFactor */
$twoFactor = $user->twoFactor;

if ($active === null) {
$active = false;
}

return $active;
return $twoFactor?->is_active;
}

protected function retrieveTwoFactorTokenFrom(?string $source): ?string
Expand Down
25 changes: 25 additions & 0 deletions tests/integration/api/CurrentUserSerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace IanM\TwoFactor\tests\integration\api;

use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;

Expand All @@ -28,6 +29,9 @@ public function setUp(): void
'users' => [
$this->normalUser(),
],
'two_factor' => [
['id' => 1, 'user_id' => 1, 'secret' => 'abcdef123456', 'backup_codes' => '["$2y$10$8UDXx3Fbx\/K9uKHs.4wq8OIP3\/q.0PghYhX\/v9ckHmvXwY2yUI.IC","$2y$10$KWw6OT18AMWa\/T1NcS1hjOiMfuzq45L1KKsFUBXAIjKTsvXJcUEOW"]', 'is_active' => true, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now()]
]
]);
}

Expand Down Expand Up @@ -72,4 +76,25 @@ public function it_does_not_include_two_factor_properties_in_current_user_attrib
$this->assertArrayNotHasKey('mustEnable2FA', $json['data']['attributes']);
$this->assertArrayNotHasKey('backupCodesRemaining', $json['data']['attributes']);
}

/**
* @test
*/
public function two_factor_is_enabled_for_given_user_and_returns_correct_properties()
{
$response = $this->send(
$this->request('GET', '/api/users/1', [
'authenticatedAs' => 1,
])
);

$this->assertEquals(200, $response->getStatusCode());

$json = json_decode($response->getBody()->getContents(), true);

$this->assertTrue($json['data']['attributes']['twoFactorEnabled']);
$this->assertFalse($json['data']['attributes']['canDisable2FA']);
$this->assertFalse($json['data']['attributes']['mustEnable2FA']);
$this->assertEquals(2, $json['data']['attributes']['backupCodesRemaining']);
}
}
100 changes: 100 additions & 0 deletions tests/integration/api/LoginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

/*
* This file is part of ianm/twofactor.
*
* Copyright (c) 2023 IanM.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*/

namespace IanM\TwoFactor\tests\integration\api;

use Carbon\Carbon;
use Flarum\Extend;
use Flarum\Http\AccessToken;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;

class LoginTest extends TestCase
{
use RetrievesAuthorizedUsers;

public function setUp(): void
{
parent::setUp();

$this->extend(
(new Extend\Csrf)->exemptRoute('login')
);

$this->extension('ianm-twofactor');

$this->prepareDatabase([
'users' => [
$this->normalUser(),
['id' => 3, 'username' => 'normal2', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => '[email protected]', 'is_email_confirmed' => 1,
]
],
'two_factor' => [
['id' => 1, 'user_id' => 2, 'secret' => 'OIZ2R42HL2ZNUJNJU72P4EK26CQSD5JLEC7AVH7BCBJKRCUBUPLHXQ4TCAYVFZPDAGH3QDPHWABLMT36QAKTIFPNL5NKTR2BGVIY3GY', 'backup_codes' => '["$2y$10$8UDXx3Fbx\/K9uKHs.4wq8OIP3\/q.0PghYhX\/v9ckHmvXwY2yUI.IC","$2y$10$KWw6OT18AMWa\/T1NcS1hjOiMfuzq45L1KKsFUBXAIjKTsvXJcUEOW"]', 'is_active' => true, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now()]
]
]);
}

/**
* @test
*/
public function it_does_not_require_2fa_token_if_2fa_is_not_enabled()
{
$response = $this->send(
$this->request('POST', '/login', [
'json' => [
'identification' => 'normal2',
'password' => 'too-obscure',
],
])
);

$this->assertEquals(200, $response->getStatusCode());

// The response body should contain the user ID...
$body = (string) $response->getBody();
$this->assertJson($body);

$data = json_decode($body, true);
$this->assertEquals(3, $data['userId']);

// ...and an access token belonging to this user.
$token = $data['token'];
$this->assertEquals(3, AccessToken::whereToken($token)->firstOrFail()->user_id);
}

/**
* @test
*/
public function it_requires_2fa_token_if_2fa_is_enabled()
{
$response = $this->send(
$this->request('POST', '/login', [
'json' => [
'identification' => 'normal',
'password' => 'too-obscure',
],
])
);

$this->assertEquals(422, $response->getStatusCode());

$body = (string) $response->getBody();
$this->assertJson($body);

$data = json_decode($body, true);
$this->assertEquals('validation_error', $data['errors'][0]['code']);
$this->assertEquals('two_factor_required', $data['errors'][0]['detail']);
$this->assertEquals('/data/attributes/twoFactorToken', $data['errors'][0]['source']['pointer']);
}

// TODO: Add tests for 2FA token validation, backup codes, etc
}

0 comments on commit 6186d8b

Please sign in to comment.