Skip to content

Commit

Permalink
Add users.email_validated_at, split from users.confirmed_at
Browse files Browse the repository at this point in the history
Flag `confirmed_at` should be used for actual user account
confirmation. This can be done either via confirmation link
sent by email, via event, or manually via admin.

Flag `email_validated_at` should be used for email deliverability
settings.

- Add API endpoints to set email address validated/invalidated
by external services (i.e. Mailer).
- Flag email_validated_at is reset if the user's email address
is changed.

remp/crm#1739
  • Loading branch information
Martin-Ha authored and rootpd committed Jun 16, 2021
1 parent 7383cee commit 2a78d20
Show file tree
Hide file tree
Showing 13 changed files with 415 additions and 25 deletions.
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,100 @@ Success response:
"device_token": "bfc6191c1837ec3600c23036edf35590"
}
```
---

#### POST `/api/v1/users/set-email-validated`

API call validates email address for user, if the user exists.

##### *Params:*

| Name | Value | Required | Description |
| --- |---| --- | --- |
| Email | *String* | yes | User's email address. |

##### *Example:*

```shell
curl 'http://crm.press/api/v1/users/set-email-validated' \
-H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
-H 'Accept: application/json' \
--data 'email=admin%40admin.sk'
```

Success response:

```json5
{
"status": "ok",
"message": "Email has been validated",
"code": "success"
}
```

Invalid request response:

```json5
{
"status": "error",
"message": "Details about problem",
"code": "invalid_request"
}
```

Invalid email response:

```json5
{
"status": "error",
"message": "Email not valid",
"code": "invalid_param"
}
```

Email not assigned response:

```json5
{
"status": "error",
"message": "Email isn't assigned to any user",
"code": "email_not_found",
}
```
---

#### POST `/api/v1/users/set-email-invalidated`

API call invalidates email address for user, if the user exists.

##### *Params:*

| Name | Value | Required | Description |
| --- |---| --- | --- |
| Email | *String* | yes | User's email address. |

##### *Example:*

```shell
curl 'http://crm.press/api/v1/users/set-email-invalidated' \
-H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
-H 'Accept: application/json' \
--data 'email=admin%40admin.sk'
```

Success response:

```json5
{
"status": "ok",
"message": "Email has been invalidated",
"code": "success"
}
```

All other responses are the same as for /validateMail method above

---

---

Expand Down
20 changes: 19 additions & 1 deletion src/UsersModule.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Crm\UsersModule;

use Crm\ApiModule\Api\ApiRoutersContainerInterface;
use Crm\ApiModule\Authorization\BearerTokenAuthorization;
use Crm\ApiModule\Router\ApiIdentifier;
use Crm\ApiModule\Router\ApiRoute;
use Crm\ApplicationModule\Authenticator\AuthenticatorManagerInterface;
Expand All @@ -19,6 +20,7 @@
use Crm\ApplicationModule\User\UserDataRegistrator;
use Crm\ApplicationModule\Widget\WidgetManagerInterface;
use Crm\UsersModule\Auth\AutoLogin\Repository\AutoLoginTokensRepository;
use Crm\UsersModule\Api\EmailValidationApiHandler;
use Crm\UsersModule\Auth\Permissions;
use Crm\UsersModule\DataProvider\UsersClaimUserDataProvider;
use Crm\UsersModule\Repository\ChangePasswordsLogsRepository;
Expand Down Expand Up @@ -356,6 +358,21 @@ public function registerApiCalls(ApiRoutersContainerInterface $apiRoutersContain
\Crm\ApiModule\Authorization\NoAuthorization::class
)
);

$apiRoutersContainer->attachRouter(
new ApiRoute(
new ApiIdentifier('1', 'users', 'set-email-validated'),
EmailValidationApiHandler::class,
BearerTokenAuthorization::class
)
);
$apiRoutersContainer->attachRouter(
new ApiRoute(
new ApiIdentifier('1', 'users', 'set-email-invalidated'),
EmailValidationApiHandler::class,
BearerTokenAuthorization::class
)
);
}

public function registerUserData(UserDataRegistrator $dataRegistrator)
Expand Down Expand Up @@ -387,8 +404,9 @@ public function registerSegmentCriteria(CriteriaStorage $criteriaStorage)
'active',
'source',
'confirmed_at',
'email_validated_at',
'last_sign_in_at',
'created_at',
'created_at'
]);
}

Expand Down
118 changes: 118 additions & 0 deletions src/api/EmailValidationApiHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

namespace Crm\UsersModule\Api;

use Crm\ApiModule\Api\ApiHandler;
use Crm\ApiModule\Api\JsonResponse;
use Crm\ApiModule\Authorization\ApiAuthorizationInterface;
use Crm\ApiModule\Params\InputParam;
use Crm\ApiModule\Params\ParamsProcessor;
use Crm\UsersModule\Repository\UsersRepository;
use Nette\Http\Request;
use Nette\Http\Response;
use Nette\Utils\Validators;

class EmailValidationApiHandler extends ApiHandler
{
private $request;

private $usersRepository;

private $action = 'validate';

public function __construct(
Request $request,
UsersRepository $usersRepository
) {
$this->request = $request;
$this->usersRepository = $usersRepository;
}

public function params()
{
return [
new InputParam(InputParam::TYPE_POST, 'email', InputParam::REQUIRED),
];
}

public function setAction(string $action)
{
$this->action = $action;
}

/**
* @param ApiAuthorizationInterface $authorization
* @return \Nette\Application\IResponse
*/
public function handle(ApiAuthorizationInterface $authorization)
{
$paramsProcessor = new ParamsProcessor($this->params());

$error = $paramsProcessor->isError();
if ($error) {
$response = new JsonResponse([
'status' => 'error',
'message' => $error,
'code' => 'invalid_request',
]);
$response->setHttpCode(Response::S400_BAD_REQUEST);
return $response;
}

$params = $paramsProcessor->getValues();
if (!Validators::isEmail($params['email'])) {
$response = new JsonResponse([
'status' => 'error',
'message' => 'Email is not valid',
'code' => 'invalid_param',
]);
$response->setHttpCode(Response::S400_BAD_REQUEST);
return $response;
}

$user = $this->usersRepository->getByEmail($params['email']);
if (!$user) {
$result = [
'status' => 'error',
'message' => 'Email isn\'t assigned to any user',
'code' => 'email_not_found',
];
$response = new JsonResponse($result);
$response->setHttpCode(Response::S404_NOT_FOUND);
return $response;
}

$action = $this->getAction();
if ($action === 'validate') {
$this->usersRepository->setEmailValidated($user, new \DateTime());
$message = 'Email has been validated';
} elseif ($action === 'invalidate') {
$this->usersRepository->setEmailInvalidated($user);
$message = 'Email has been invalidated';
} else {
throw new \Exception('invalid action resolved: ' . $action);
}

$result = [
'status' => 'ok',
'message' => $message,
'code' => 'success',
];

$response = new JsonResponse($result);
$response->setHttpCode(Response::S200_OK);

return $response;
}

private function getAction(): string
{
if (isset($this->action)) {
return $this->action;
}
if (strpos($this->request->getUrl()->getPath(), "invalidate") !== false) {
return 'invalidate';
}
return 'validate';
}
}
3 changes: 0 additions & 3 deletions src/authenticator/UsernameAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,6 @@ private function process() : IRow
}

$this->usersRepository->addSignIn($user);
if (!$user->confirmed_at) {
$this->userManager->confirmUser($user);
}

return $user;
}
Expand Down
1 change: 1 addition & 0 deletions src/config/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ services:
- Crm\UsersModule\Api\UserDataHandler
- Crm\UsersModule\Api\AutoLoginTokenLoginApiHandler
- Crm\UsersModule\Api\GoogleTokenSignInHandler
- Crm\UsersModule\Api\EmailValidationApiHandler
- Crm\UsersModule\Events\LoginAttemptHandler
- Crm\UsersModule\Events\UserUpdatedHandler
- Crm\UsersModule\Hermes\UserTokenUsageHandler
Expand Down
17 changes: 17 additions & 0 deletions src/migrations/20210224160115_add_email_validated_at_flag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

use Phinx\Migration\AbstractMigration;

class AddEmailValidatedAtFlag extends AbstractMigration
{
public function change()
{
$this->table('users')
->addColumn('email_validated_at', 'datetime', [ 'null' => true, 'after' => 'confirmed_at' ])
->update();

// use current "confirmed_at" values as default values
// any new user should get a null as default
$this->execute('UPDATE users SET email_validated_at = confirmed_at');
}
}
25 changes: 16 additions & 9 deletions src/model/Auth/UserManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -256,20 +256,27 @@ public function requestResetPassword($email, $source = null)

public function confirmUser(IRow $user, ?DateTime $date = null, $byAdmin = false)
{
if ($user->confirmed_at) {
return;
}

if (!$date) {
$date = new DateTime();
}
if (!$user->confirmed_at) {
$this->usersRepository->update($user, [
'modified_at' => $date,
'confirmed_at' => $date,
]);

if ($byAdmin) {
$this->userMetaRepository->add($user, 'confirmed_by_admin', true);
}
$this->usersRepository->update($user, ['confirmed_at' => $date]);
$this->emitter->emit(new UserConfirmedEvent($user, $byAdmin));
if ($byAdmin) {
$this->userMetaRepository->add($user, 'confirmed_by_admin', true);
}
}

$this->emitter->emit(new UserConfirmedEvent($user, $byAdmin));
public function setEmailValidated($user, ?DateTime $validated)
{
if ($validated) {
$this->usersRepository->setEmailValidated($user, $validated);
} else {
$this->usersRepository->setEmailInvalidated($user);
}
}

Expand Down
9 changes: 5 additions & 4 deletions src/model/Repositories/UserEmailConfirmationsRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Crm\UsersModule\Repository;

use Crm\ApplicationModule\ActiveRow;
use Crm\ApplicationModule\Repository;
use Crm\UsersModule\Auth\Access\TokenGenerator;
use Nette\Utils\DateTime;
Expand All @@ -18,18 +19,18 @@ public function generate(int $userId)
]);
}

public function verify(string $token): bool
public function confirm(string $token): ?ActiveRow
{
$emailConfirmationRow = $this->getTable()->where('token', $token)->fetch();
if (!$emailConfirmationRow) {
return false;
return null;
}

if ($emailConfirmationRow->confirmed_at === null) {
return $this->update($emailConfirmationRow, ['confirmed_at' => new DateTime()]);
$this->update($emailConfirmationRow, ['confirmed_at' => new DateTime()]);
}

return true;
return $emailConfirmationRow;
}

public function getToken(int $userId): ?string
Expand Down
Loading

0 comments on commit 2a78d20

Please sign in to comment.