diff --git a/docs/authentication.md b/docs/authentication.md index 4eeef20bc..933335f33 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -70,7 +70,7 @@ user_id(); ## Authenticator Responses Many of the authenticator methods will return a `CodeIgniter\Shield\Result` class. This provides a consistent -way of checking the results and can have additional information return along with it. The class +way of checking the results and can have additional information returned along with it. The class has the following methods: ### isOK() diff --git a/docs/authorization.md b/docs/authorization.md index 977884adc..4268cc81d 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -20,6 +20,10 @@ - [removeGroup()](#removegroup) - [syncGroups()](#syncgroups) - [getGroups()](#getgroups) + - [User Activation](#user-activation) + - [Checking Activation Status](#checking-activation-status) + - [Activating a User](#activating-a-user) + - [Deactivating a User](#deactivating-a-user) Authorization happens once a user has been identified through authentication. It is the process of determining what actions a user is allowed to do within your site. @@ -233,3 +237,43 @@ Returns all groups this user is a part of. ```php $user->getGroups(); ``` + +## User Activation + +All users have an `active` flag. This is only used when the [`EmailActivation` action](./auth_actions.md), or a custom action used to activate a user, is enabled. + +### Checking Activation Status + +You can determine if a user has been activated with the `isActivated()` method. + +```php +if ($user->isActivated()) { + // +} +``` + +> **Note** If no activator is specified in the `Auth` config file, `actions['register']` property, then this will always return `true`. + +You can check if a user has not been activated yet via the `isNotActivated()` method. + +```php +if ($user->isNotActivated()) { + // +} +``` + +## Activating a User + +Users are automatically activated withih the `EmailActivator` action. They can be manually activated via the `activate()` method on the User entity. + +```php +$user->activate(); +``` + +## Deactivating a User + +Users can be manually deactivated via the `deactivate()` method on the User entity. + +```php +$user->deactivate(); +``` diff --git a/src/Auth.php b/src/Auth.php index fae81dff3..35e5bd30f 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -12,7 +12,6 @@ use CodeIgniter\Shield\Models\UserModel; /** - * @method void activateUser(User $user) [Session] * @method Result attempt(array $credentials) * @method Result check(array $credentials) * @method bool checkAction(string $token, string $type) [Session] diff --git a/src/Authentication/Actions/EmailActivator.php b/src/Authentication/Actions/EmailActivator.php index e64493d1e..8e5216074 100644 --- a/src/Authentication/Actions/EmailActivator.php +++ b/src/Authentication/Actions/EmailActivator.php @@ -111,7 +111,7 @@ public function verify(IncomingRequest $request) $user = $authenticator->getUser(); // Set the user active now - $authenticator->activateUser($user); + $user->activate(); // Success! return redirect()->to(config('Auth')->registerRedirect()) diff --git a/src/Authentication/Authenticators/Session.php b/src/Authentication/Authenticators/Session.php index a4ada2473..923d04cac 100644 --- a/src/Authentication/Authenticators/Session.php +++ b/src/Authentication/Authenticators/Session.php @@ -254,14 +254,6 @@ public function completeLogin(User $user): void Events::trigger('login', $user); } - /** - * Activate a User - */ - public function activateUser(User $user): void - { - $this->provider->activate($user); - } - /** * @param int|string|null $userId */ diff --git a/src/Controllers/RegisterController.php b/src/Controllers/RegisterController.php index 5b921e13b..784f785bd 100644 --- a/src/Controllers/RegisterController.php +++ b/src/Controllers/RegisterController.php @@ -114,7 +114,7 @@ public function registerAction(): RedirectResponse } // Set the user active - $authenticator->activateUser($user); + $user->activate(); $authenticator->completeLogin($user); diff --git a/src/Entities/User.php b/src/Entities/User.php index 7dfba8c89..ca0fc96ae 100644 --- a/src/Entities/User.php +++ b/src/Entities/User.php @@ -11,6 +11,7 @@ use CodeIgniter\Shield\Authorization\Traits\Authorizable; use CodeIgniter\Shield\Models\LoginModel; use CodeIgniter\Shield\Models\UserIdentityModel; +use CodeIgniter\Shield\Traits\Activatable; use CodeIgniter\Shield\Traits\Resettable; /** @@ -27,6 +28,7 @@ class User extends Entity use Authorizable; use HasAccessTokens; use Resettable; + use Activatable; /** * @var UserIdentity[]|null diff --git a/src/Filters/SessionAuth.php b/src/Filters/SessionAuth.php index e518ced19..294773c7d 100644 --- a/src/Filters/SessionAuth.php +++ b/src/Filters/SessionAuth.php @@ -49,6 +49,15 @@ public function before(RequestInterface $request, $arguments = null) $authenticator->recordActiveDate(); } + // Block inactive users when Email Activation is enabled + $user = $authenticator->getUser(); + if ($user !== null && ! $user->isActivated()) { + $authenticator->logout(); + + return redirect()->route('login') + ->with('error', lang('Auth.activationBlocked')); + } + return; } diff --git a/src/Filters/TokenAuth.php b/src/Filters/TokenAuth.php index a877f9064..ee504cbd5 100644 --- a/src/Filters/TokenAuth.php +++ b/src/Filters/TokenAuth.php @@ -49,12 +49,24 @@ public function before(RequestInterface $request, $arguments = null) ]); if (! $result->isOK() || (! empty($arguments) && $result->extraInfo()->tokenCant($arguments[0]))) { - return redirect()->to('/login'); + return service('response') + ->setStatusCode(Response::HTTP_UNAUTHORIZED) + ->setJson(['message' => lang('Auth.badToken')]); } if (setting('Auth.recordActiveDate')) { $authenticator->recordActiveDate(); } + + // Block inactive users when Email Activation is enabled + $user = $authenticator->getUser(); + if ($user !== null && ! $user->isActivated()) { + $authenticator->logout(); + + return service('response') + ->setStatusCode(Response::HTTP_FORBIDDEN) + ->setJson(['message' => lang('Auth.activationBlocked')]); + } } /** diff --git a/src/Language/de/Auth.php b/src/Language/de/Auth.php index 5fb930501..1afb6f91b 100644 --- a/src/Language/de/Auth.php +++ b/src/Language/de/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => 'Bitte verwenden Sie den unten stehenden Code, um Ihr Konto zu aktivieren und die Website zu nutzen.', 'invalidActivateToken' => 'Der Code war falsch.', 'needActivate' => '(To be translated) You must complete your registration by confirming the code sent to your email address.', + 'activationBlocked' => '(to be translated) You must activate your account before logging in.', // Groups 'unknownGroup' => '{0} ist eine ungültige Gruppe.', diff --git a/src/Language/en/Auth.php b/src/Language/en/Auth.php index 26dcce1aa..1e58cc6e0 100644 --- a/src/Language/en/Auth.php +++ b/src/Language/en/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => 'Please use the code below to activate your account and start using the site.', 'invalidActivateToken' => 'The code was incorrect.', 'needActivate' => 'You must complete your registration by confirming the code sent to your email address.', + 'activationBlocked' => 'You must activate your account before logging in.', // Groups 'unknownGroup' => '{0} is not a valid group.', diff --git a/src/Language/es/Auth.php b/src/Language/es/Auth.php index 9df966a33..869b0853f 100644 --- a/src/Language/es/Auth.php +++ b/src/Language/es/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => 'Por favor, usa el código de abajo para activar tu cuenta y empezar a usar el sitio.', 'invalidActivateToken' => 'El código no es correcto.', 'needActivate' => '(To be translated) You must complete your registration by confirming the code sent to your email address.', + 'activationBlocked' => '(to be translated) You must activate your account before logging in.', // Grupos 'unknownGroup' => '{0} no es un grupo válido.', diff --git a/src/Language/fa/Auth.php b/src/Language/fa/Auth.php index 2463da295..62a80d8e5 100644 --- a/src/Language/fa/Auth.php +++ b/src/Language/fa/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => 'لطفا برای فعالسازی حساب کاربری و استفاده از سایت از کد زیر استفاده کنید.', 'invalidActivateToken' => 'کد صحیح نمی باشد.', 'needActivate' => 'شما باید با ارائه کد ارسال شده به ایمیلتان، ثبت نام را تکمیل کنید.', + 'activationBlocked' => 'قبل از تلاش برای ورود، باید اکانت خود را فعال کنید.', // Groups 'unknownGroup' => '{0} گروهی معتبر نیست.', diff --git a/src/Language/fr/Auth.php b/src/Language/fr/Auth.php index a80b77ad1..ee80e4d0e 100644 --- a/src/Language/fr/Auth.php +++ b/src/Language/fr/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => 'Veuillez utiliser le code suivant pour activer votre compte et commencer à utiliser le site.', 'invalidActivateToken' => 'Le code était incorrect.', 'needActivate' => 'Complétez votre inscription en confirmant le code envoyé à votre email.', + 'activationBlocked' => '(to be translated) You must activate your account before logging in.', // Groups 'unknownGroup' => '{0} n\'est pas un groupe valide.', diff --git a/src/Language/id/Auth.php b/src/Language/id/Auth.php index e407df6a5..b5afa7918 100644 --- a/src/Language/id/Auth.php +++ b/src/Language/id/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => 'Silahkan gunakan kode dibawah ini untuk mengaktivasi akun Anda.', 'invalidActivateToken' => 'Kode tidak sesuai.', 'needActivate' => 'Anda harus menyelesaikan registrasi Anda dengan mengonfirmasi kode yang dikirim ke alamat email Anda.', + 'activationBlocked' => '(to be translated) You must activate your account before logging in.', // Groups 'unknownGroup' => '{0} bukan grup yang sah.', diff --git a/src/Language/it/Auth.php b/src/Language/it/Auth.php index 641713de8..d3fb2b432 100644 --- a/src/Language/it/Auth.php +++ b/src/Language/it/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => 'Perfavore usa il codice qui sotto per attivare il tuo acccount ed iniziare ad usare il sito.', 'invalidActivateToken' => 'Il codice era sbagliato.', 'needActivate' => 'Devi completare la registrazione confermando il codice inviato al tuo indrizzo email.', + 'activationBlocked' => '(to be translated) You must activate your account before logging in.', // Groups 'unknownGroup' => '{0} non è un gruppo valido.', diff --git a/src/Language/ja/Auth.php b/src/Language/ja/Auth.php index a30ef6755..eff2edccf 100644 --- a/src/Language/ja/Auth.php +++ b/src/Language/ja/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => '以下のコードを使用してアカウントを有効化し、サイトの利用を開始してください。', // 'Please use the code below to activate your account and start using the site.', 'invalidActivateToken' => 'コードが間違っています。', // 'The code was incorrect.', 'needActivate' => 'メールアドレスに送信されたコードを確認し、登録を完了する必要があります。', // 'You must complete your registration by confirming the code sent to your email address.', + 'activationBlocked' => 'ログインする前にアカウントを有効化する必要があります。', // Groups 'unknownGroup' => '{0} は有効なグループではありません。', // '{0} is not a valid group.', diff --git a/src/Language/pt-BR/Auth.php b/src/Language/pt-BR/Auth.php index 3afcb3837..7e078617a 100644 --- a/src/Language/pt-BR/Auth.php +++ b/src/Language/pt-BR/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => 'Use o código abaixo para ativar sua conta e começar a usar o site.', 'invalidActivateToken' => 'O código estava incorreto.', 'needActivate' => 'Você deve concluir seu registro confirmando o código enviado para seu endereço de e-mail.', + 'activationBlocked' => '(to be translated) You must activate your account before logging in.', // Grupos 'unknownGroup' => '{0} não é um grupo válido.', diff --git a/src/Language/sk/Auth.php b/src/Language/sk/Auth.php index abea16fd3..74df1dbe6 100644 --- a/src/Language/sk/Auth.php +++ b/src/Language/sk/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => 'Pomocou nižšie uvedeného kódu aktivujte svoj účet a môžete začať používať stránku.', 'invalidActivateToken' => 'Kód bol nesprávny', 'needActivate' => 'Registráciu musíte dokončiť potvrdením kódu zaslaného na vašu e-mailovú adresu.', + 'activationBlocked' => '(to be translated) You must activate your account before logging in.', // Groups 'unknownGroup' => '{0} nie je platná skupina.', diff --git a/src/Language/tr/Auth.php b/src/Language/tr/Auth.php index e65c75a3f..70b924652 100644 --- a/src/Language/tr/Auth.php +++ b/src/Language/tr/Auth.php @@ -87,6 +87,7 @@ 'emailActivateMailBody' => 'Hesabınızı etkinleştirmek ve siteyi kullanmaya başlamak için lütfen aşağıdaki kodu kullanın.', 'invalidActivateToken' => 'Kod yanlıştı.', 'needActivate' => 'E-posta adresinize gönderilen kodu onaylayarak kaydınızı tamamlamanız gerekmektedir.', + 'activationBlocked' => '(to be translated) You must activate your account before logging in.', // Groups 'unknownGroup' => '{0} geçerli bir grup değil.', diff --git a/src/Traits/Activatable.php b/src/Traits/Activatable.php new file mode 100644 index 000000000..f7629ec2c --- /dev/null +++ b/src/Traits/Activatable.php @@ -0,0 +1,56 @@ +shouldActivate() || $this->active; + } + + /** + * Returns true if the user has not been activated. + */ + public function isNotActivated(): bool + { + return ! $this->isActivated(); + } + + /** + * Activates the user. + */ + public function activate(): bool + { + $model = auth()->getProvider(); + + return $model->update($this->id, ['active' => 1]); + } + + /** + * Deactivates the user. + */ + public function deactivate(): bool + { + $model = auth()->getProvider(); + + return $model->update($this->id, ['active' => 0]); + } + + /** + * Does the Auth actions require activation? + * Check for the generic 'Activator' class name to allow + * for custom implementations, provided they follow the naming convention. + */ + private function shouldActivate(): bool + { + return strpos(setting('Auth.actions')['register'] ?? '', 'Activator') !== false; + } +} diff --git a/tests/Authentication/Filters/SessionFilterTest.php b/tests/Authentication/Filters/SessionFilterTest.php index a42609864..b9ec087eb 100644 --- a/tests/Authentication/Filters/SessionFilterTest.php +++ b/tests/Authentication/Filters/SessionFilterTest.php @@ -57,4 +57,30 @@ public function testRecordActiveDate(): void // Last Active should be greater than 'updated_at' column $this->assertGreaterThan(auth('session')->user()->updated_at, auth('session')->user()->last_active); } + + public function testBlocksInactiveUsers(): void + { + $user = fake(UserModel::class, ['active' => false]); + + // Activation only required with email activation + setting('Auth.actions', ['register' => null]); + + $result = $this->actingAs($user) + ->get('protected-route'); + + $result->assertStatus(200); + $result->assertSee('Protected'); + + // Now require user activation and try again + setting('Auth.actions', ['register' => '\CodeIgniter\Shield\Authentication\Actions\EmailActivator']); + + $result = $this->actingAs($user) + ->get('protected-route'); + + $result->assertRedirectTo('/login'); + // User should be logged out + $this->assertNull(auth('session')->id()); + + setting('Auth.actions', ['register' => null]); + } } diff --git a/tests/Authentication/Filters/TokenFilterTest.php b/tests/Authentication/Filters/TokenFilterTest.php index a7cc8080b..6efcfc93a 100644 --- a/tests/Authentication/Filters/TokenFilterTest.php +++ b/tests/Authentication/Filters/TokenFilterTest.php @@ -24,7 +24,7 @@ public function testFilterNotAuthorized(): void { $result = $this->call('get', 'protected-route'); - $result->assertRedirectTo('/login'); + $result->assertStatus(401); $result = $this->get('open-route'); $result->assertStatus(200); @@ -84,6 +84,32 @@ public function testFiltersProtectsWithScopes(): void $result = $this->withHeaders(['Authorization' => 'Bearer ' . $token2->raw_token]) ->get('protected-user-route'); - $result->assertRedirectTo('/login'); + $result->assertStatus(401); + } + + public function testBlocksInactiveUsers(): void + { + /** @var User $user */ + $user = fake(UserModel::class, ['active' => false]); + $token = $user->generateAccessToken('foo'); + + // Activation only required with email activation + setting('Auth.actions', ['register' => null]); + + $result = $this->withHeaders(['Authorization' => 'Bearer ' . $token->raw_token]) + ->get('protected-route'); + + $result->assertStatus(200); + $result->assertSee('Protected'); + + // Now require user activation and try again + setting('Auth.actions', ['register' => '\CodeIgniter\Shield\Authentication\Actions\EmailActivator']); + + $result = $this->withHeaders(['Authorization' => 'Bearer ' . $token->raw_token]) + ->get('protected-route'); + + $result->assertStatus(403); + + setting('Auth.actions', ['register' => null]); } } diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php index 494476f24..311105db7 100644 --- a/tests/Unit/UserTest.php +++ b/tests/Unit/UserTest.php @@ -7,6 +7,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Entities\Login; +use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Entities\UserIdentity; use CodeIgniter\Shield\Models\LoginModel; use CodeIgniter\Shield\Models\UserIdentityModel; @@ -256,4 +257,90 @@ public function testSaveEmailIdentity(): void $identity = $this->user->getEmailIdentity(); $this->assertSame('foo@example.com', $identity->secret); } + + public function testActivate(): void + { + $this->user->active = false; + model(UserModel::class)->save($this->user); + + $this->seeInDatabase('users', [ + 'id' => $this->user->id, + 'active' => 0, + ]); + + $this->user->activate(); + + // Refresh user + $this->user = model(UserModel::class)->find($this->user->id); + + $this->assertTrue($this->user->active); + $this->seeInDatabase('users', [ + 'id' => $this->user->id, + 'active' => 1, + ]); + } + + public function testDeactivate(): void + { + $this->user->active = true; + model(UserModel::class)->save($this->user); + + $this->seeInDatabase('users', [ + 'id' => $this->user->id, + 'active' => 1, + ]); + + $this->user->deactivate(); + + // Refresh user + $this->user = model(UserModel::class)->find($this->user->id); + + $this->assertFalse($this->user->active); + $this->seeInDatabase('users', [ + 'id' => $this->user->id, + 'active' => 0, + ]); + } + + public function testIsActivatedSuccessWhenNotRequired(): void + { + $this->user->active = false; + model(UserModel::class)->save($this->user); + + setting('Auth.actions', ['register' => null]); + + $this->assertTrue($this->user->isActivated()); + } + + public function testIsActivatedWhenRequired(): void + { + setting('Auth.actions', ['register' => '\CodeIgniter\Shield\Authentication\Actions\EmailActivator']); + $user = $this->user; + + $user->deactivate(); + /** @var User $user */ + $user = model(UserModel::class)->find($user->id); + + $this->assertFalse($user->isActivated()); + + $user->activate(); + /** @var User $user */ + $user = model(UserModel::class)->find($user->id); + + $this->assertTrue($user->isActivated()); + } + + public function testIsNotActivated(): void + { + setting('Auth.actions', ['register' => '\CodeIgniter\Shield\Authentication\Actions\EmailActivator']); + $user = $this->user; + + $user->active = false; + model(UserModel::class)->save($user); + + /** @var User $user */ + $user = model(UserModel::class)->find($user->id); + + $this->assertFalse($user->isActivated()); + } }