feat(monorepo): migrate to typescript monorepo

This commit is contained in:
2025-11-07 17:09:29 +02:00
committed by BernardNganduDev
parent 3e09956f05
commit 075a388ccb
745 changed files with 2341 additions and 5082 deletions
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\EventListener;
use Basango\IdentityAndAccess\Application\Mailing\AccountConfirmedEmail;
use Basango\IdentityAndAccess\Domain\Event\AccountConfirmed;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Mailing\Mailer;
use Basango\SharedKernel\Domain\EventListener\EventListener;
/**
* Class AccountConfirmedListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class AccountConfirmedListener implements EventListener
{
public function __construct(
private Mailer $mailer,
private UserRepository $userRepository
) {
}
public function __invoke(AccountConfirmed $event): void
{
$user = $this->userRepository->getById($event->userId);
$email = new AccountConfirmedEmail($user->email, false, null);
$this->mailer->send($email);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\EventListener;
use Basango\IdentityAndAccess\Application\Mailing\AccountLockedEmail;
use Basango\IdentityAndAccess\Domain\Event\AccountLocked;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Mailing\Mailer;
use Basango\SharedKernel\Domain\EventListener\EventListener;
/**
* Class AccountLockedListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class AccountLockedListener implements EventListener
{
public function __construct(
private Mailer $mailer,
private UserRepository $userRepository
) {
}
public function __invoke(AccountLocked $event): void
{
$user = $this->userRepository->getById($event->userId);
$email = new AccountLockedEmail($user->email, $event->token);
$this->mailer->send($email);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\EventListener;
use Basango\IdentityAndAccess\Application\Mailing\AccountUnlockedEmail;
use Basango\IdentityAndAccess\Domain\Event\AccountUnlocked;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Mailing\Mailer;
use Basango\SharedKernel\Domain\EventListener\EventListener;
/**
* Class AccountUnlockedListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class AccountUnlockedListener implements EventListener
{
public function __construct(
private Mailer $mailer,
private UserRepository $userRepository
) {
}
public function __invoke(AccountUnlocked $event): void
{
$user = $this->userRepository->getById($event->userId);
$email = new AccountUnlockedEmail($user->email);
$this->mailer->send($email);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\EventListener;
use Basango\IdentityAndAccess\Application\Mailing\ConfirmationRequestedEmail;
use Basango\IdentityAndAccess\Domain\Event\ConfirmationRequested;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Mailing\Mailer;
use Basango\SharedKernel\Domain\EventListener\EventListener;
/**
* Class UserRegisteredListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ConfirmationRequestedListener implements EventListener
{
public function __construct(
private Mailer $mailer,
private UserRepository $userRepository
) {
}
public function __invoke(ConfirmationRequested $event): void
{
$user = $this->userRepository->getById($event->userId);
$email = new ConfirmationRequestedEmail($user->email, $user->name, $event->token);
$this->mailer->send($email);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\EventListener;
use Basango\IdentityAndAccess\Application\Mailing\LoginProfileChangedEmail;
use Basango\IdentityAndAccess\Domain\Event\LoginProfileChanged;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Mailing\Mailer;
use Basango\SharedKernel\Domain\EventListener\EventListener;
/**
* Class LoginProfileChangedListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class LoginProfileChangedListener implements EventListener
{
public function __construct(
private Mailer $mailer,
private UserRepository $userRepository
) {
}
public function __invoke(LoginProfileChanged $event): void
{
$user = $this->userRepository->getById($event->userId);
$email = new LoginProfileChangedEmail($user->email, $event->device, $event->location);
$this->mailer->send($email);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\EventListener;
use Basango\IdentityAndAccess\Application\Mailing\PasswordCreatedEmail;
use Basango\IdentityAndAccess\Domain\Event\PasswordCreated;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Mailing\Mailer;
use Basango\SharedKernel\Domain\EventListener\EventListener;
/**
* Class PasswordCreatedListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordCreatedListener implements EventListener
{
public function __construct(
private Mailer $mailer,
private UserRepository $userRepository
) {
}
public function __invoke(PasswordCreated $event): void
{
$user = $this->userRepository->getById($event->userId);
$email = new PasswordCreatedEmail($user->email, $event->password);
$this->mailer->send($email);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\EventListener;
use Basango\IdentityAndAccess\Application\Mailing\PasswordForgottenEmail;
use Basango\IdentityAndAccess\Domain\Event\PasswordForgotten;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Mailing\Mailer;
use Basango\SharedKernel\Domain\EventListener\EventListener;
/**
* Class PasswordForgottenListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordForgottenListener implements EventListener
{
public function __construct(
private Mailer $mailer,
private UserRepository $userRepository
) {
}
public function __invoke(PasswordForgotten $event): void
{
$user = $this->userRepository->getById($event->userId);
$email = new PasswordForgottenEmail($user->email, $event->token);
$this->mailer->send($email);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\EventListener;
use Basango\IdentityAndAccess\Application\Mailing\PasswordResetEmail;
use Basango\IdentityAndAccess\Domain\Event\PasswordReset;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Mailing\Mailer;
use Basango\SharedKernel\Domain\EventListener\EventListener;
/**
* Class PasswordForgottenListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordResetListener implements EventListener
{
public function __construct(
private Mailer $mailer,
private UserRepository $userRepository
) {
}
public function __invoke(PasswordReset $event): void
{
$user = $this->userRepository->getById($event->userId);
$email = new PasswordResetEmail($user->email);
$this->mailer->send($email);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\EventListener;
use Basango\IdentityAndAccess\Application\Mailing\PasswordUpdatedEmail;
use Basango\IdentityAndAccess\Domain\Event\PasswordUpdated;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Mailing\Mailer;
use Basango\SharedKernel\Domain\EventListener\EventListener;
/**
* Class PasswordUpdatedListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordUpdatedListener implements EventListener
{
public function __construct(
private Mailer $mailer,
private UserRepository $userRepository
) {
}
public function __invoke(PasswordUpdated $event): void
{
$user = $this->userRepository->getById($event->userId);
$email = new PasswordUpdatedEmail($user->email);
$this->mailer->send($email);
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\Mailing;
use Basango\SharedKernel\Application\Mailing\EmailDefinition;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class UserConfirmedEmail.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class AccountConfirmedEmail implements EmailDefinition
{
public function __construct(
private EmailAddress $recipient,
private bool $isSocialLogin,
private ?string $socialLoginService
) {
}
#[\Override]
public function recipient(): EmailAddress
{
return $this->recipient;
}
#[\Override]
public function subject(): string
{
return 'identity_and_access.emails.subjects.account_confirmed';
}
#[\Override]
public function subjectVariables(): array
{
return [];
}
#[\Override]
public function template(): string
{
return 'identity_and_access/account_confirmed';
}
#[\Override]
public function templateVariables(): array
{
return [
'is_social_login' => $this->isSocialLogin,
'social_login_service' => $this->socialLoginService,
];
}
#[\Override]
public function locale(): string
{
return 'fr';
}
#[\Override]
public function getDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\Mailing;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\SharedKernel\Application\Mailing\EmailDefinition;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class AccountBlockedEmail.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class AccountLockedEmail implements EmailDefinition
{
public function __construct(
private EmailAddress $recipient,
private GeneratedToken $token
) {
}
#[\Override]
public function recipient(): EmailAddress
{
return $this->recipient;
}
#[\Override]
public function subject(): string
{
return 'identity_and_access.emails.subjects.account_locked';
}
#[\Override]
public function subjectVariables(): array
{
return [];
}
#[\Override]
public function template(): string
{
return 'identity_and_access/account_locked';
}
#[\Override]
public function templateVariables(): array
{
return [
'token' => $this->token,
];
}
#[\Override]
public function locale(): string
{
return 'fr';
}
#[\Override]
public function getDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\Mailing;
use Basango\SharedKernel\Application\Mailing\EmailDefinition;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class AccountUnlockedEmail.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class AccountUnlockedEmail implements EmailDefinition
{
public function __construct(
private EmailAddress $recipient
) {
}
#[\Override]
public function recipient(): EmailAddress
{
return $this->recipient;
}
#[\Override]
public function subject(): string
{
return 'identity_and_access.emails.subjects.account_unlocked';
}
#[\Override]
public function subjectVariables(): array
{
return [];
}
#[\Override]
public function template(): string
{
return 'identity_and_access/account_unlocked';
}
#[\Override]
public function templateVariables(): array
{
return [];
}
#[\Override]
public function locale(): string
{
return 'fr';
}
#[\Override]
public function getDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\Mailing;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\SharedKernel\Application\Mailing\EmailDefinition;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class UserRegisteredEmail.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ConfirmationRequestedEmail implements EmailDefinition
{
public function __construct(
private EmailAddress $recipient,
private string $name,
private GeneratedToken $token
) {
}
#[\Override]
public function recipient(): EmailAddress
{
return $this->recipient;
}
#[\Override]
public function subject(): string
{
return 'identity_and_access.emails.subjects.user_registered';
}
#[\Override]
public function subjectVariables(): array
{
return [];
}
#[\Override]
public function template(): string
{
return 'identity_and_access/user_registered';
}
#[\Override]
public function templateVariables(): array
{
return [
'name' => $this->name,
'token' => $this->token,
];
}
#[\Override]
public function locale(): string
{
return 'fr';
}
#[\Override]
public function getDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\Mailing;
use Basango\SharedKernel\Application\Mailing\EmailDefinition;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
use Basango\SharedKernel\Domain\Model\ValueObject\Tracking\Device;
use Basango\SharedKernel\Domain\Model\ValueObject\Tracking\GeoLocation;
/**
* Class LoginProfileChangedEmail.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class LoginProfileChangedEmail implements EmailDefinition
{
public function __construct(
private EmailAddress $recipient,
private Device $device,
private GeoLocation $location
) {
}
#[\Override]
public function recipient(): EmailAddress
{
return $this->recipient;
}
#[\Override]
public function subject(): string
{
return 'identity_and_access.emails.subjects.login_profile_changed';
}
#[\Override]
public function subjectVariables(): array
{
return [];
}
#[\Override]
public function template(): string
{
return 'identity_and_access/login_profile_changed';
}
#[\Override]
public function templateVariables(): array
{
return [
'device' => $this->device,
'location' => $this->location,
];
}
#[\Override]
public function locale(): string
{
return 'fr';
}
#[\Override]
public function getDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\Mailing;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedCode;
use Basango\SharedKernel\Application\Mailing\EmailDefinition;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class PasswordCreatedEmail.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordCreatedEmail implements EmailDefinition
{
public function __construct(
private EmailAddress $recipient,
private GeneratedCode $code
) {
}
#[\Override]
public function recipient(): EmailAddress
{
return $this->recipient;
}
#[\Override]
public function subject(): string
{
return 'identity_and_access.emails.subjects.password_created';
}
#[\Override]
public function subjectVariables(): array
{
return [];
}
#[\Override]
public function template(): string
{
return 'identity_and_access/password_created';
}
#[\Override]
public function templateVariables(): array
{
return [
'code' => $this->code,
];
}
#[\Override]
public function locale(): string
{
return 'fr';
}
#[\Override]
public function getDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\Mailing;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\SharedKernel\Application\Mailing\EmailDefinition;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class PasswordForgottenEmail.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordForgottenEmail implements EmailDefinition
{
public function __construct(
private EmailAddress $recipient,
private GeneratedToken $token
) {
}
#[\Override]
public function recipient(): EmailAddress
{
return $this->recipient;
}
#[\Override]
public function subject(): string
{
return 'identity_and_access.emails.subjects.password_forgotten';
}
#[\Override]
public function subjectVariables(): array
{
return [];
}
#[\Override]
public function template(): string
{
return 'identity_and_access/password_forgotten';
}
#[\Override]
public function templateVariables(): array
{
return [
'token' => $this->token,
];
}
#[\Override]
public function locale(): string
{
return 'fr';
}
#[\Override]
public function getDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\Mailing;
use Basango\SharedKernel\Application\Mailing\EmailDefinition;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class PasswordResetEmail.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordResetEmail implements EmailDefinition
{
public function __construct(
private EmailAddress $recipient,
) {
}
#[\Override]
public function recipient(): EmailAddress
{
return $this->recipient;
}
#[\Override]
public function subject(): string
{
return 'identity_and_access.emails.subjects.password_reset';
}
#[\Override]
public function subjectVariables(): array
{
return [];
}
#[\Override]
public function template(): string
{
return 'identity_and_access/password_reset';
}
#[\Override]
public function templateVariables(): array
{
return [];
}
#[\Override]
public function locale(): string
{
return 'fr';
}
#[\Override]
public function getDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\Mailing;
use Basango\SharedKernel\Application\Mailing\EmailDefinition;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class PasswordUpdatedEmail.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordUpdatedEmail implements EmailDefinition
{
public function __construct(
private EmailAddress $recipient
) {
}
#[\Override]
public function recipient(): EmailAddress
{
return $this->recipient;
}
#[\Override]
public function subject(): string
{
return 'identity_and_access.emails.subjects.password_updated';
}
#[\Override]
public function subjectVariables(): array
{
return [];
}
#[\Override]
public function template(): string
{
return 'identity_and_access/password_updated';
}
#[\Override]
public function templateVariables(): array
{
return [];
}
#[\Override]
public function locale(): string
{
return 'fr';
}
#[\Override]
public function getDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\ReadModel;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\SharedKernel\Domain\DataTransfert\DataMapping;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class UserProfile.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class UserProfile
{
public function __construct(
public UserId $id,
public string $name,
public EmailAddress $email,
public \DateTimeImmutable $createdAt,
public ?\DateTimeImmutable $updatedAt = null,
) {
}
public static function create(array $item): self
{
return new self(
UserId::fromString(DataMapping::string($item, 'user_id')),
DataMapping::string($item, 'user_name'),
EmailAddress::from(DataMapping::string($item, 'user_email')),
DataMapping::dateTime($item, 'user_created_at'),
DataMapping::nullableDateTime($item, 'user_updated_at'),
);
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\Command;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
/**
* Class ConfirmRegistration.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ConfirmAccount
{
public function __construct(
public GeneratedToken $token
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\Command;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
/**
* Class LockAccount.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class LockAccount
{
public function __construct(
public UserId $userId
) {
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\Command;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Roles;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class Register.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class Register
{
public function __construct(
public string $name,
public EmailAddress $email,
public ?string $password,
public Roles $roles = new Roles()
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\Command;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
/**
* Class AddLoginAttempt.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class RegisterLoginAttempt
{
public function __construct(
public UserId $userId
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\Command;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\SharedKernel\Domain\Model\ValueObject\Tracking\ClientProfile;
/**
* Class RegisterLogin.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class RegisterLoginSuccess
{
public function __construct(
public UserId $userId,
public ClientProfile $profile
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\Command;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class RequestPassword.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class RequestPassword
{
public function __construct(
public EmailAddress $email
) {
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\Command;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
/**
* Class ResetPassword.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ResetPassword
{
public function __construct(
public GeneratedToken $token,
public string $password
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\Command;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
/**
* Class UnlockAccount.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class UnlockAccount
{
public function __construct(
public GeneratedToken $token,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\Command;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
/**
* Class UpdatePassword.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class UpdatePassword
{
public function __construct(
public UserId $userId,
public string $current,
public string $password,
) {
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\CommandHandler;
use Basango\IdentityAndAccess\Application\UseCase\Command\ConfirmAccount;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\IdentityAndAccess\Domain\Model\Repository\VerificationTokenRepository;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
use Basango\SharedKernel\Domain\EventDispatcher\EventDispatcher;
/**
* Class ConfirmRegistrationHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ConfirmAccountHandler implements CommandHandler
{
public function __construct(
private UserRepository $userRepository,
private VerificationTokenRepository $verificationTokenRepository,
private EventDispatcher $eventDispatcher
) {
}
public function __invoke(ConfirmAccount $command): void
{
$token = $this->verificationTokenRepository->getByToken(
$command->token,
TokenPurpose::CONFIRM_ACCOUNT
);
$user = $this->userRepository->getById($token->user->id);
$user->confirmAccount();
$this->verificationTokenRepository->remove($token);
$this->eventDispatcher->dispatch($user->releaseEvents());
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\CommandHandler;
use Basango\IdentityAndAccess\Application\UseCase\Command\LockAccount;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Model\Entity\VerificationToken;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\IdentityAndAccess\Domain\Model\Repository\VerificationTokenRepository;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
use Basango\IdentityAndAccess\Domain\Service\SecretGenerator;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
use Basango\SharedKernel\Domain\EventDispatcher\EventDispatcher;
/**
* Class ResetPasswordHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class LockAccountHandler implements CommandHandler
{
public function __construct(
private UserRepository $userRepository,
private VerificationTokenRepository $verificationTokenRepository,
private SecretGenerator $secretGenerator,
private EventDispatcher $eventDispatcher
) {
}
public function __invoke(LockAccount $command): void
{
$user = $this->userRepository->getById($command->userId);
$token = $this->createVerificationToken($user);
$user->lockAccount($token);
$this->userRepository->add($user);
$this->verificationTokenRepository->add($token);
$this->eventDispatcher->dispatch($user->releaseEvents());
}
private function createVerificationToken(User $user): VerificationToken
{
$secret = $this->secretGenerator->generateToken();
return VerificationToken::create($user, $secret, TokenPurpose::UNLOCK_ACCOUNT);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\CommandHandler;
use Basango\IdentityAndAccess\Application\UseCase\Command\Register;
use Basango\IdentityAndAccess\Domain\Exception\EmailAlreadyUsed;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Model\Entity\VerificationToken;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\IdentityAndAccess\Domain\Model\Repository\VerificationTokenRepository;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
use Basango\IdentityAndAccess\Domain\Service\PasswordHasher;
use Basango\IdentityAndAccess\Domain\Service\SecretGenerator;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
use Basango\SharedKernel\Domain\EventDispatcher\EventDispatcher;
/**
* Class RegisterHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class RegisterHandler implements CommandHandler
{
public function __construct(
private UserRepository $userRepository,
private VerificationTokenRepository $verificationTokenRepository,
private PasswordHasher $passwordHasher,
private SecretGenerator $secretGenerator,
private EventDispatcher $eventDispatcher
) {
}
public function __invoke(Register $command): void
{
$user = $this->userRepository->getByEmail($command->email);
if ($user instanceof User) {
throw EmailAlreadyUsed::with($command->email);
}
$user = User::register($command->name, $command->email, $command->roles);
$password = $command->password ?? $this->secretGenerator->generateCode();
$token = $this->createVerificationToken($user);
$user
->definePassword($password, $this->passwordHasher)
->requestAccountConfirmation($token);
$this->userRepository->add($user);
$this->verificationTokenRepository->add($token);
$this->eventDispatcher->dispatch($user->releaseEvents());
}
private function createVerificationToken(User $user): VerificationToken
{
$secret = $this->secretGenerator->generateToken();
return VerificationToken::create($user, $secret, TokenPurpose::CONFIRM_ACCOUNT);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\CommandHandler;
use Basango\IdentityAndAccess\Application\UseCase\Command\LockAccount;
use Basango\IdentityAndAccess\Application\UseCase\Command\RegisterLoginAttempt;
use Basango\IdentityAndAccess\Domain\Model\Entity\LoginAttempt;
use Basango\IdentityAndAccess\Domain\Model\Repository\LoginAttemptRepository;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Messaging\CommandBus;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
/**
* Class RegisterLoginAttemptHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class RegisterLoginAttemptHandler implements CommandHandler
{
private const int ATTEMPTS_LIMIT = 3;
public function __construct(
private UserRepository $userRepository,
private LoginAttemptRepository $loginAttemptRepository,
private CommandBus $commandBus
) {
}
public function __invoke(RegisterLoginAttempt $command): void
{
$user = $this->userRepository->getById($command->userId);
$attempts = $this->loginAttemptRepository->countBy($user);
if ($attempts < self::ATTEMPTS_LIMIT) {
$this->loginAttemptRepository->add(LoginAttempt::create($user));
} elseif ($user->isLocked === false) {
$this->commandBus->handle(new LockAccount($user->id));
}
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\CommandHandler;
use Basango\IdentityAndAccess\Application\UseCase\Command\RegisterLoginSuccess;
use Basango\IdentityAndAccess\Domain\Model\Entity\LoginHistory;
use Basango\IdentityAndAccess\Domain\Model\Repository\LoginAttemptRepository;
use Basango\IdentityAndAccess\Domain\Model\Repository\LoginHistoryRepository;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
use Basango\SharedKernel\Domain\EventDispatcher\EventDispatcher;
use Basango\SharedKernel\Domain\Tracking\ClientProfiler;
/**
* Class RegisterLoginSuccessHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class RegisterLoginSuccessHandler implements CommandHandler
{
public function __construct(
private UserRepository $userRepository,
private LoginHistoryRepository $loginHistoryRepository,
private LoginAttemptRepository $loginAttemptRepository,
private ClientProfiler $clientProfiler,
private EventDispatcher $eventDispatcher
) {
}
public function __invoke(RegisterLoginSuccess $command): void
{
$user = $this->userRepository->getById($command->userId);
$device = $this->clientProfiler->detect($command->profile);
$location = $this->clientProfiler->locate($command->profile);
$current = LoginHistory::create($user, $command->profile->userIp, $device, $location);
$previous = $this->loginHistoryRepository->getLastBy($user);
if ($previous instanceof LoginHistory) {
$current->matchPreviousProfile($previous);
}
$this->loginHistoryRepository->add($current);
$this->loginAttemptRepository->deleteBy($user);
$this->eventDispatcher->dispatch($current->releaseEvents());
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\CommandHandler;
use Basango\IdentityAndAccess\Application\UseCase\Command\RequestPassword;
use Basango\IdentityAndAccess\Domain\Exception\UserNotFound;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Model\Entity\VerificationToken;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\IdentityAndAccess\Domain\Model\Repository\VerificationTokenRepository;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
use Basango\IdentityAndAccess\Domain\Service\SecretGenerator;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
use Basango\SharedKernel\Domain\EventDispatcher\EventDispatcher;
/**
* Class RequestPasswordHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class RequestPasswordHandler implements CommandHandler
{
public function __construct(
private UserRepository $userRepository,
private VerificationTokenRepository $verificationTokenRepository,
private SecretGenerator $secretGenerator,
private EventDispatcher $eventDispatcher,
) {
}
public function __invoke(RequestPassword $command): void
{
$user = $this->userRepository->getByEmail($command->email);
if (! $user instanceof User) {
throw UserNotFound::withEmail($command->email);
}
$token = $this->createVerificationToken($user);
$user->requestPasswordReset($token);
$this->userRepository->add($user);
$this->verificationTokenRepository->add($token);
$this->eventDispatcher->dispatch($user->releaseEvents());
}
private function createVerificationToken(User $user): VerificationToken
{
$secret = $this->secretGenerator->generateToken();
return VerificationToken::create($user, $secret, TokenPurpose::PASSWORD_RESET);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\CommandHandler;
use Basango\IdentityAndAccess\Application\UseCase\Command\ResetPassword;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\IdentityAndAccess\Domain\Model\Repository\VerificationTokenRepository;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
use Basango\IdentityAndAccess\Domain\Service\PasswordHasher;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
use Basango\SharedKernel\Domain\EventDispatcher\EventDispatcher;
/**
* Class ResetPasswordHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ResetPasswordHandler implements CommandHandler
{
public function __construct(
private UserRepository $userRepository,
private VerificationTokenRepository $verificationTokenRepository,
private PasswordHasher $passwordHasher,
private EventDispatcher $eventDispatcher
) {
}
public function __invoke(ResetPassword $command): void
{
$token = $this->verificationTokenRepository->getByToken(
$command->token,
TokenPurpose::PASSWORD_RESET
);
$user = $this->userRepository->getById($token->user->id);
$user->resetPassword($command->password, $this->passwordHasher);
$this->userRepository->add($user);
$this->verificationTokenRepository->remove($token);
$this->eventDispatcher->dispatch($user->releaseEvents());
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\CommandHandler;
use Basango\IdentityAndAccess\Application\UseCase\Command\UnlockAccount;
use Basango\IdentityAndAccess\Domain\Model\Repository\LoginAttemptRepository;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\IdentityAndAccess\Domain\Model\Repository\VerificationTokenRepository;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
use Basango\SharedKernel\Domain\EventDispatcher\EventDispatcher;
/**
* Class ResetPasswordHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class UnlockAccountHandler implements CommandHandler
{
public function __construct(
private UserRepository $userRepository,
private VerificationTokenRepository $verificationTokenRepository,
private LoginAttemptRepository $loginAttemptRepository,
private EventDispatcher $eventDispatcher
) {
}
public function __invoke(UnlockAccount $command): void
{
$token = $this->verificationTokenRepository->getByToken(
$command->token,
TokenPurpose::UNLOCK_ACCOUNT
);
$user = $this->userRepository->getById($token->user->id);
$user->unlockAccount();
$this->userRepository->add($user);
$this->verificationTokenRepository->remove($token);
$this->loginAttemptRepository->deleteBy($user);
$this->eventDispatcher->dispatch($user->releaseEvents());
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\CommandHandler;
use Basango\IdentityAndAccess\Application\UseCase\Command\UpdatePassword;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\IdentityAndAccess\Domain\Service\PasswordHasher;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
use Basango\SharedKernel\Domain\EventDispatcher\EventDispatcher;
/**
* Class UpdatePasswordHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class UpdatePasswordHandler implements CommandHandler
{
public function __construct(
private UserRepository $userRepository,
private PasswordHasher $passwordHasher,
private EventDispatcher $eventDispatcher
) {
}
public function __invoke(UpdatePassword $command): void
{
$user = $this->userRepository->getById($command->userId);
$user->updatePassword($command->current, $command->password, $this->passwordHasher);
$this->userRepository->add($user);
$this->eventDispatcher->dispatch($user->releaseEvents());
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\Query;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
/**
* Class GetUserProfile.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GetUserProfile
{
public function __construct(
public UserId $userId
) {
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Application\UseCase\QueryHandler;
use Basango\IdentityAndAccess\Application\ReadModel\UserProfile;
use Basango\IdentityAndAccess\Application\UseCase\Query\GetUserProfile;
use Basango\SharedKernel\Application\Messaging\QueryHandler;
/**
* Interface GetUserProfileHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface GetUserProfileHandler extends QueryHandler
{
public function __invoke(GetUserProfile $query): UserProfile;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Event;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
/**
* Class UserConfirmed.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class AccountConfirmed
{
public function __construct(
public UserId $userId
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Event;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
/**
* Class AccountLocked.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class AccountLocked
{
public function __construct(
public UserId $userId,
public GeneratedToken $token
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Event;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
/**
* Class AccountUnlocked.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class AccountUnlocked
{
public function __construct(
public UserId $userId
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Event;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
/**
* Class AccountConfirmationRequested.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ConfirmationRequested
{
public function __construct(
public UserId $userId,
public GeneratedToken $token
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Event;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class EmailUpdated.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class EmailUpdated
{
public function __construct(
public UserId $userId,
public EmailAddress $previous,
public EmailAddress $current
) {
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Event;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\SharedKernel\Domain\Model\ValueObject\Tracking\Device;
use Basango\SharedKernel\Domain\Model\ValueObject\Tracking\GeoLocation;
/**
* Class LoginProfileChanged.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class LoginProfileChanged
{
public function __construct(
public UserId $userId,
public Device $device,
public GeoLocation $location
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Event;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedCode;
/**
* Class PasswordCreated.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordCreated
{
public function __construct(
public UserId $userId,
#[\SensitiveParameter] public GeneratedCode $password
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Event;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
/**
* Class PasswordForgotten.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordForgotten
{
public function __construct(
public UserId $userId,
public GeneratedToken $token,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Event;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
/**
* Class PasswordReset.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordReset
{
public function __construct(
public UserId $userId
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Event;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
/**
* Class PasswordUpdated.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PasswordUpdated
{
public function __construct(
public UserId $userId
) {
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Exception;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
/**
* Class TooManyLoginAttempts.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class AccountIsLocked extends \DomainException implements UserFacingError
{
#[\Override]
public function translationId(): string
{
return 'identity_and_access.exceptions.account_is_locked';
}
#[\Override]
public function translationParameters(): array
{
return [];
}
#[\Override]
public function translationDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Exception;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
/**
* Class UserNotConfirmed.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class AccountNotConfirmed extends \DomainException implements UserFacingError
{
#[\Override]
public function translationId(): string
{
return 'identity_and_access.exceptions.account_not_confirmed';
}
#[\Override]
public function translationParameters(): array
{
return [];
}
#[\Override]
public function translationDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Exception;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class EmailAlreadyUsed.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class EmailAlreadyUsed extends \DomainException implements UserFacingError
{
public static function with(EmailAddress $email): self
{
return new self(sprintf('the %s email is already used by another user', $email->value));
}
#[\Override]
public function translationId(): string
{
return 'identity_and_access.exceptions.email_already_used';
}
#[\Override]
public function translationParameters(): array
{
return [];
}
#[\Override]
public function translationDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Exception;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
/**
* Class InvalidCurrentPassword.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class InvalidCurrentPassword extends \DomainException implements UserFacingError
{
#[\Override]
public function translationId(): string
{
return 'identity_and_access.exceptions.invalid_current_password';
}
#[\Override]
public function translationParameters(): array
{
return [];
}
#[\Override]
public function translationDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Exception;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
/**
* Class InvalidToken.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class InvalidVerificationToken extends \DomainException implements UserFacingError
{
#[\Override]
public function translationId(): string
{
return 'identity_and_access.exceptions.invalid_verification_token';
}
#[\Override]
public function translationParameters(): array
{
return [];
}
#[\Override]
public function translationDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Exception;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
/**
* Class PasswordAlreadyDefined.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class PasswordAlreadyDefined extends \DomainException implements UserFacingError
{
#[\Override]
public function translationId(): string
{
return 'identity_and_access.exceptions.password_already_defined';
}
#[\Override]
public function translationParameters(): array
{
return [];
}
#[\Override]
public function translationDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Exception;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
/**
* Class PermissionNotGranted.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class PermissionNotGranted extends \DomainException implements UserFacingError
{
public static function withReason(string $message): self
{
return new self($message);
}
public function translationId(): string
{
return 'identity_and_access.exceptions.permission_not_granted';
}
public function translationParameters(): array
{
return [
'{reason}' => $this->message,
];
}
public function translationDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Exception;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class UserNotFound.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class UserNotFound extends \DomainException implements UserFacingError
{
public static function withEmail(EmailAddress $email): self
{
return new self(\sprintf('User with email %s was not found', $email->value));
}
public static function withId(UserId $userId): self
{
return new self(\sprintf('User with id %s was not found', $userId->toString()));
}
#[\Override]
public function translationId(): string
{
return 'identity_and_access.exceptions.user_not_found';
}
#[\Override]
public function translationParameters(): array
{
return [];
}
#[\Override]
public function translationDomain(): string
{
return 'identity_and_access';
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Entity;
use Basango\IdentityAndAccess\Domain\Model\Identity\LoginAttemptId;
/**
* Class LoginAttempt.
*
* @author bernard-ng <bernard@devscast.tech>
*/
readonly class LoginAttempt
{
public LoginAttemptId $id;
private function __construct(
public User $user,
public \DateTimeImmutable $createdAt = new \DateTimeImmutable()
) {
$this->id = new LoginAttemptId();
}
public static function create(User $user): self
{
return new self($user);
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Entity;
use Basango\IdentityAndAccess\Domain\Event\LoginProfileChanged;
use Basango\IdentityAndAccess\Domain\Model\Identity\LoginHistoryId;
use Basango\SharedKernel\Domain\EventDispatcher\EventEmitterTrait;
use Basango\SharedKernel\Domain\Model\ValueObject\Tracking\Device;
use Basango\SharedKernel\Domain\Model\ValueObject\Tracking\GeoLocation;
/**
* Class LoginHistory.
*
* @author bernard-ng <bernard@devscast.tech>
*/
class LoginHistory
{
use EventEmitterTrait;
public readonly LoginHistoryId $id;
private function __construct(
public readonly User $user,
public readonly ?string $ipAddress,
public readonly Device $device,
public readonly GeoLocation $location,
public readonly \DateTimeImmutable $createdAt = new \DateTimeImmutable()
) {
$this->id = new LoginHistoryId();
}
public static function create(User $user, ?string $userIp, Device $device, GeoLocation $location): self
{
return new self($user, $userIp, $device, $location);
}
public function matchPreviousProfile(self $previous): self
{
if (
$this->ipAddress !== $previous->ipAddress ||
$this->location->city !== $previous->location->city ||
$this->location->country !== $previous->location->country ||
$this->device->operatingSystem !== $previous->device->operatingSystem
) {
$this->emitEvent(new LoginProfileChanged($this->user->id, $this->device, $this->location));
}
return $this;
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Entity;
use Gesdinet\JWTRefreshTokenBundle\Model\AbstractRefreshToken;
class RefreshToken extends AbstractRefreshToken
{
}
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Entity;
use Basango\IdentityAndAccess\Domain\Event\AccountConfirmed;
use Basango\IdentityAndAccess\Domain\Event\AccountLocked;
use Basango\IdentityAndAccess\Domain\Event\AccountUnlocked;
use Basango\IdentityAndAccess\Domain\Event\ConfirmationRequested;
use Basango\IdentityAndAccess\Domain\Event\EmailUpdated;
use Basango\IdentityAndAccess\Domain\Event\PasswordCreated;
use Basango\IdentityAndAccess\Domain\Event\PasswordForgotten;
use Basango\IdentityAndAccess\Domain\Event\PasswordReset;
use Basango\IdentityAndAccess\Domain\Event\PasswordUpdated;
use Basango\IdentityAndAccess\Domain\Exception\InvalidCurrentPassword;
use Basango\IdentityAndAccess\Domain\Exception\PasswordAlreadyDefined;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Roles;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedCode;
use Basango\IdentityAndAccess\Domain\Service\PasswordHasher;
use Basango\SharedKernel\Domain\EventDispatcher\EventEmitterTrait;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class User.
*
* @author bernard-ng <bernard@devscast.tech>
*/
class User
{
use EventEmitterTrait;
public readonly UserId $id;
public function __construct(
private(set) string $name,
private(set) EmailAddress $email,
private(set) Roles $roles,
private(set) ?string $password = null,
private(set) bool $isLocked = false,
private(set) bool $isConfirmed = false,
private(set) ?\DateTimeImmutable $updatedAt = null,
public readonly ?\DateTimeImmutable $createdAt = new \DateTimeImmutable(),
) {
$this->id = new UserId();
}
public function lockAccount(VerificationToken $verificationToken): self
{
$this->isLocked = true;
$this->emitEvent(new AccountLocked($this->id, $verificationToken->token));
return $this;
}
public function unlockAccount(): self
{
$this->isLocked = false;
$this->emitEvent(new AccountUnlocked($this->id));
return $this;
}
public function confirmAccount(): self
{
$this->isConfirmed = true;
$this->emitEvent(new AccountConfirmed($this->id));
return $this;
}
public static function register(string $name, EmailAddress $email, ?Roles $roles): self
{
return new self($name, $email, $roles ?? Roles::user());
}
public function updateProfile(string $name, Roles $roles): static
{
$this->name = $name;
$this->roles = $roles;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function updateEmail(EmailAddress $email): self
{
$previous = $this->email;
$this->email = $email;
$this->emitEvent(new EmailUpdated($this->id, $previous, $email));
return $this;
}
public function resetPassword(string $password, PasswordHasher $passwordHasher): void
{
$this->password = $passwordHasher->hash($this, $password);
$this->emitEvent(new PasswordReset($this->id));
}
public function updatePassword(string $current, string $new, PasswordHasher $passwordHasher): self
{
if ($this->password === null || ! $passwordHasher->verify($this, $current)) {
throw new InvalidCurrentPassword();
}
$this->password = $passwordHasher->hash($this, $new);
$this->emitEvent(new PasswordUpdated($this->id));
return $this;
}
public function definePassword(GeneratedCode|string $password, PasswordHasher $passwordHasher): self
{
if ($this->password !== null) {
throw new PasswordAlreadyDefined();
}
$this->password = $passwordHasher->hash($this, (string) $password);
$this->updatedAt = new \DateTimeImmutable();
if ($password instanceof GeneratedCode) {
$this->emitEvent(new PasswordCreated($this->id, $password));
}
return $this;
}
public function requestPasswordReset(VerificationToken $verificationToken): self
{
$this->emitEvent(new PasswordForgotten($this->id, $verificationToken->token));
return $this;
}
public function requestAccountConfirmation(VerificationToken $verificationToken): self
{
$this->emitEvent(new ConfirmationRequested($this->id, $verificationToken->token));
return $this;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Entity;
use Basango\IdentityAndAccess\Domain\Exception\InvalidVerificationToken;
use Basango\IdentityAndAccess\Domain\Model\Identity\VerificationTokenId;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
/**
* Class VerificationToken.
*
* @author bernard-ng <bernard@devscast.tech>
*/
readonly class VerificationToken
{
public const string DEFAULT_VALIDITY = 'PT2H';
public VerificationTokenId $id;
public function __construct(
public User $user,
public GeneratedToken $token,
public TokenPurpose $purpose,
public \DateTimeImmutable $createdAt = new \DateTimeImmutable()
) {
$this->id = new VerificationTokenId();
}
public static function create(User $user, GeneratedToken $token, TokenPurpose $purpose): self
{
return new self($user, $token, $purpose);
}
public function isExpired(): bool
{
$now = new \DateTimeImmutable();
$validUntil = (\DateTime::createFromImmutable($this->createdAt))
->add(new \DateInterval(self::DEFAULT_VALIDITY));
return $now > $validUntil;
}
public function assertValid(): void
{
if ($this->isExpired()) {
throw new InvalidVerificationToken();
}
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class LoginAttemptId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class LoginAttemptId extends UuidV7
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class LoginHistoryId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class LoginHistoryId extends UuidV7
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class UserId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class UserId extends UuidV7
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class VerificationTokenId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class VerificationTokenId extends UuidV7
{
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Repository;
use Basango\IdentityAndAccess\Domain\Model\Entity\LoginAttempt;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
/**
* Interface LoginAttemptRepository.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface LoginAttemptRepository
{
public function add(LoginAttempt $loginAttempt): void;
public function remove(LoginAttempt $loginAttempt): void;
public function countBy(User $user): int;
public function deleteBy(User $user): void;
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Repository;
use Basango\IdentityAndAccess\Domain\Model\Entity\LoginHistory;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
/**
* Interface LoginHistoryRepository.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface LoginHistoryRepository
{
public function add(LoginHistory $loginHistory): void;
public function remove(LoginHistory $loginHistory): void;
public function getLastBy(User $user): ?LoginHistory;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Repository;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Interface UserRepository.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface UserRepository
{
public function add(User $user): void;
public function remove(User $user): void;
public function getById(UserId $userId): User;
public function getByEmail(EmailAddress $email): ?User;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Repository;
use Basango\IdentityAndAccess\Domain\Model\Entity\VerificationToken;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
/**
* Interface LoginAttemptRepository.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface VerificationTokenRepository
{
public function add(VerificationToken $verificationToken): void;
public function remove(VerificationToken $verificationToken): void;
public function getByToken(GeneratedToken $token, TokenPurpose $purpose): VerificationToken;
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\ValueObject;
/**
* Class Role.
*
* @author bernard-ng <bernard@devscast.tech>
*/
enum Role: string
{
case USER = 'ROLE_USER';
case ADMIN = 'ROLE_ADMIN';
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\ValueObject;
use Basango\SharedKernel\Domain\Assert;
/**
* @author bernard-ng <bernard@devscast.tech>
*/
readonly class Roles implements \Stringable
{
private array $roles;
public function __construct(array $roles = [Role::USER])
{
Assert::notEmpty($roles, 'identity_and_access.validations.empty_roles');
Assert::allIsInstanceOf($roles, Role::class);
$roles[] = Role::USER;
$this->roles = array_unique(\array_map(fn (Role $role) => $role->value, $roles));
}
#[\Override]
public function __toString(): string
{
return implode(',', $this->roles);
}
public static function admin(): self
{
return new self([Role::USER, Role::ADMIN]);
}
public static function user(): self
{
return new self();
}
public function toArray(): array
{
return $this->roles;
}
public static function fromArray(array $roles): self
{
return new self($roles);
}
public function contains(string $role): bool
{
return in_array($role, $this->roles, true);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret;
use Basango\SharedKernel\Domain\Assert;
/**
* Class GeneratedCode.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GeneratedCode implements \Stringable
{
public function __construct(
public string $code
) {
Assert::notEmpty($this->code);
}
#[\Override]
public function __toString(): string
{
return $this->code;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret;
use Basango\SharedKernel\Domain\Assert;
/**
* Class GeneratedToken.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GeneratedToken implements \Stringable
{
public function __construct(
public string $token,
) {
Assert::notEmpty($this->token);
}
#[\Override]
public function __toString(): string
{
return $this->token;
}
public function isEqualTo(self $token): bool
{
return $this->token === $token->token;
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\ValueObject;
/**
* Enum TokenPurpose.
*
* @author bernard-ng <bernard@devscast.tech>
*/
enum TokenPurpose: string
{
case CONFIRM_ACCOUNT = 'confirm_account';
case PASSWORD_RESET = 'password_reset';
case UNLOCK_ACCOUNT = 'unlock_account';
case DELETE_ACCOUNT = 'delete_account';
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Service;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
/**
* Interface PasswordHasher.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface PasswordHasher
{
public function hash(User $user, string $password): string;
public function verify(User $user, string $password): bool;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Service;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedCode;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
/**
* Class SecretGenerator.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface SecretGenerator
{
public function generateToken(int $length = 60): GeneratedToken;
public function generateCode(int $length = 6): GeneratedCode;
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Framework\Symfony\EventListener;
use Basango\IdentityAndAccess\Application\UseCase\Command\RegisterLoginAttempt;
use Basango\IdentityAndAccess\Infrastructure\Framework\Symfony\Security\SecurityUser;
use Basango\SharedKernel\Application\Messaging\CommandBus;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
/**
* Class LoginFailureListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
#[AsEventListener(LoginFailureEvent::class)]
final readonly class LoginFailureListener
{
public function __construct(
private CommandBus $commandBus
) {
}
public function __invoke(LoginFailureEvent $event): void
{
/** @var SecurityUser|null $user */
$user = $event->getPassport()?->getUser();
if ($user && $event->getException() instanceof BadCredentialsException) {
$this->commandBus->handle(new RegisterLoginAttempt($user->userId));
}
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Framework\Symfony\EventListener;
use Basango\IdentityAndAccess\Application\UseCase\Command\RegisterLoginSuccess;
use Basango\IdentityAndAccess\Infrastructure\Framework\Symfony\Security\SecurityUser;
use Basango\SharedKernel\Application\Messaging\CommandBus;
use Basango\SharedKernel\Domain\Model\ValueObject\Tracking\ClientProfile;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
/**
* Class LoginSuccessListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
#[AsEventListener(InteractiveLoginEvent::class)]
final readonly class LoginSuccessListener
{
public function __construct(
private CommandBus $commandBus,
) {
}
public function __invoke(InteractiveLoginEvent $event): void
{
/** @var SecurityUser|null $user */
$user = $event->getAuthenticationToken()->getUser();
if ($user !== null) {
$profile = new ClientProfile(
IpUtils::anonymize((string) $event->getRequest()->getClientIp(), 1),
$event->getRequest()->headers->get('User-Agent'),
$event->getRequest()->server->all()
);
$this->commandBus->handle(new RegisterLoginSuccess($user->userId, $profile));
}
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Framework\Symfony\Security;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
use Symfony\Component\Security\Core\User\EquatableInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Class SecurityUser.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class SecurityUser implements UserInterface, PasswordAuthenticatedUserInterface, EquatableInterface
{
public function __construct(
public UserId $userId,
public EmailAddress $email,
public ?string $password,
public array $roles,
public bool $isLocked,
public bool $isConfirmed
) {
}
public static function create(User $user): self
{
return new self(
$user->id,
$user->email,
(string) $user->password,
$user->roles->toArray(),
$user->isLocked,
$user->isConfirmed
);
}
#[\Override]
public function getPassword(): ?string
{
return $this->password;
}
#[\Override]
public function getRoles(): array
{
return $this->roles;
}
#[\Override]
public function eraseCredentials(): void
{
}
#[\Override]
public function getUserIdentifier(): string
{
/** @var non-empty-string $email */
$email = $this->email->value;
return $email;
}
#[\Override]
public function isEqualTo(UserInterface $user): bool
{
if (! $user instanceof self) {
return false;
}
return $this->userId->equals($user->userId);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Framework\Symfony\Security;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* Class SecurityUserProvider.
*
* @implements UserProviderInterface<SecurityUser>
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class SecurityUserProvider implements UserProviderInterface
{
public function __construct(
private UserRepository $userRepository,
) {
}
#[\Override]
public function refreshUser(UserInterface $user): UserInterface
{
return $this->loadUserByIdentifier($user->getUserIdentifier());
}
#[\Override]
public function loadUserByIdentifier(string $identifier): UserInterface
{
$user = $this->userRepository->getByEmail(EmailAddress::from($identifier));
if (! $user instanceof User) {
throw new UserNotFoundException();
}
return SecurityUser::create($user);
}
#[\Override]
public function supportsClass(string $class): bool
{
return $class === SecurityUser::class;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Framework\Symfony\Security;
use Basango\IdentityAndAccess\Domain\Exception\AccountIsLocked;
use Basango\IdentityAndAccess\Domain\Exception\AccountNotConfirmed;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Class UserChecker.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class UserChecker implements UserCheckerInterface
{
#[\Override]
public function checkPreAuth(UserInterface $user): void
{
if ($user instanceof SecurityUser && $user->isLocked) {
throw new AccountIsLocked();
}
}
#[\Override]
public function checkPostAuth(UserInterface $user): void
{
if ($user instanceof SecurityUser && $user->isConfirmed === false) {
throw new AccountNotConfirmed();
}
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Framework\Symfony\Security;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Service\PasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Class UserPasswordHasher.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class UserPasswordHasher implements PasswordHasher
{
public function __construct(
private UserPasswordHasherInterface $passwordHasher
) {
}
#[\Override]
public function hash(User $user, string $password): string
{
$securityUser = SecurityUser::create($user);
return $this->passwordHasher->hashPassword($securityUser, $password);
}
#[\Override]
public function verify(User $user, string $password): bool
{
$securityUser = SecurityUser::create($user);
return $this->passwordHasher->isPasswordValid($securityUser, $password);
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Persistence\Doctrine\DBAL;
use Basango\IdentityAndAccess\Application\ReadModel\UserProfile;
use Basango\IdentityAndAccess\Application\UseCase\Query\GetUserProfile;
use Basango\IdentityAndAccess\Application\UseCase\QueryHandler\GetUserProfileHandler;
use Basango\IdentityAndAccess\Domain\Exception\UserNotFound;
use Doctrine\DBAL\Connection;
/**
* Class GetUserProfileDbalHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GetUserProfileDbalHandler implements GetUserProfileHandler
{
public function __construct(
private Connection $connection
) {
}
public function __invoke(GetUserProfile $query): UserProfile
{
$qb = $this->connection->createQueryBuilder()
->select(
'u.id as user_id',
'u.name as user_name',
'u.email as user_email',
'u.created_at as user_created_at',
'u.updated_at as user_updated_at'
)
->from('user', 'u')
->where('u.id = :userId')
->setParameter('userId', $query->userId->toString());
/** @var array<string, mixed>|false $data */
$data = $qb->executeQuery()->fetchAssociative();
if ($data === false) {
throw UserNotFound::withId($query->userId);
}
return UserProfile::create($data);
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Persistence\Doctrine\DBAL\Types;
use Basango\IdentityAndAccess\Domain\Model\Identity\LoginAttemptId;
use Symfony\Bridge\Doctrine\Types\AbstractUidType;
/**
* Class LoginAttemptId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class LoginAttemptIdType extends AbstractUidType
{
#[\Override]
public function getName(): string
{
return 'login_attempt_id';
}
#[\Override]
protected function getUidClass(): string
{
return LoginAttemptId::class;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Persistence\Doctrine\DBAL\Types;
use Basango\IdentityAndAccess\Domain\Model\Identity\LoginHistoryId;
use Symfony\Bridge\Doctrine\Types\AbstractUidType;
/**
* Class LoginHistoryId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class LoginHistoryIdType extends AbstractUidType
{
#[\Override]
public function getName(): string
{
return 'login_history_id';
}
#[\Override]
protected function getUidClass(): string
{
return LoginHistoryId::class;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Persistence\Doctrine\DBAL\Types;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Symfony\Bridge\Doctrine\Types\AbstractUidType;
/**
* Class UserIdType.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class UserIdType extends AbstractUidType
{
#[\Override]
public function getName(): string
{
return 'user_id';
}
#[\Override]
protected function getUidClass(): string
{
return UserId::class;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Persistence\Doctrine\DBAL\Types;
use Basango\IdentityAndAccess\Domain\Model\Identity\VerificationTokenId;
use Symfony\Bridge\Doctrine\Types\AbstractUidType;
/**
* Class VerificationTokenId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class VerificationTokenIdType extends AbstractUidType
{
#[\Override]
public function getName(): string
{
return 'verification_token_id';
}
#[\Override]
protected function getUidClass(): string
{
return VerificationTokenId::class;
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Persistence\Doctrine\ORM;
use Basango\IdentityAndAccess\Domain\Model\Entity\LoginAttempt;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Model\Repository\LoginAttemptRepository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Class LoginAttemptOrmRepository.
*
* @extends ServiceEntityRepository<LoginAttempt>
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class LoginAttemptOrmRepository extends ServiceEntityRepository implements LoginAttemptRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, LoginAttempt::class);
}
#[\Override]
public function add(LoginAttempt $loginAttempt): void
{
$this->getEntityManager()->persist($loginAttempt);
$this->getEntityManager()->flush();
}
#[\Override]
public function remove(LoginAttempt $loginAttempt): void
{
$this->getEntityManager()->remove($loginAttempt);
$this->getEntityManager()->flush();
}
#[\Override]
public function countBy(User $user): int
{
return $this->count([
'user' => $user,
]);
}
#[\Override]
public function deleteBy(User $user): void
{
$this->createQueryBuilder('la')
->delete(LoginAttempt::class, 'la')
->where('la.user = :user')
->setParameter('user', $user)
->getQuery()
->execute();
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Persistence\Doctrine\ORM;
use Basango\IdentityAndAccess\Domain\Model\Entity\LoginHistory;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Model\Repository\LoginHistoryRepository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Class LoginHistoryOrmRepository.
*
* @extends ServiceEntityRepository<LoginHistory>
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class LoginHistoryOrmRepository extends ServiceEntityRepository implements LoginHistoryRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, LoginHistory::class);
}
#[\Override]
public function add(LoginHistory $loginHistory): void
{
$this->getEntityManager()->persist($loginHistory);
$this->getEntityManager()->flush();
}
#[\Override]
public function remove(LoginHistory $loginHistory): void
{
$this->getEntityManager()->remove($loginHistory);
$this->getEntityManager()->flush();
}
#[\Override]
public function getLastBy(User $user): ?LoginHistory
{
/** @var LoginHistory|null $loginHistory */
$loginHistory = $this->createQueryBuilder('lh')
->andWhere('lh.user = :user')
->setParameter('user', $user)
->orderBy('lh.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
return $loginHistory;
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Persistence\Doctrine\ORM;
use Basango\IdentityAndAccess\Domain\Exception\UserNotFound;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\IdentityAndAccess\Domain\Model\Repository\UserRepository;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Class UserOrmRepository.
*
* @extends ServiceEntityRepository<User>
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class UserOrmRepository extends ServiceEntityRepository implements UserRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
#[\Override]
public function add(User $user): void
{
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
#[\Override]
public function remove(User $user): void
{
$this->getEntityManager()->remove($user);
$this->getEntityManager()->flush();
}
#[\Override]
public function getById(UserId $userId): User
{
$user = $this->findOneBy([
'id' => $userId,
]);
if ($user === null) {
throw UserNotFound::withId($userId);
}
return $user;
}
#[\Override]
public function getByEmail(EmailAddress $email): ?User
{
return $this->findOneBy([
'email' => $email,
]);
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Persistence\Doctrine\ORM;
use Basango\IdentityAndAccess\Domain\Exception\InvalidVerificationToken;
use Basango\IdentityAndAccess\Domain\Model\Entity\VerificationToken;
use Basango\IdentityAndAccess\Domain\Model\Repository\VerificationTokenRepository;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Class VerificationTokenOrmRepository.
*
* @extends ServiceEntityRepository<VerificationToken>
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class VerificationTokenOrmRepository extends ServiceEntityRepository implements VerificationTokenRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, VerificationToken::class);
}
#[\Override]
public function add(VerificationToken $verificationToken): void
{
$this->getEntityManager()->persist($verificationToken);
$this->getEntityManager()->flush();
}
#[\Override]
public function remove(VerificationToken $verificationToken): void
{
$this->getEntityManager()->remove($verificationToken);
$this->getEntityManager()->flush();
}
#[\Override]
public function getByToken(GeneratedToken $token, TokenPurpose $purpose): VerificationToken
{
$token = $this->findOneBy([
'token.token' => $token->token,
'purpose' => $purpose->value,
]);
if ($token === null) {
throw new InvalidVerificationToken();
}
return $token;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Infrastructure\Secret;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedCode;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\IdentityAndAccess\Domain\Service\SecretGenerator;
use Random\Randomizer;
/**
* Class RandomizerSecretGenerator.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class RandomizerSecretGenerator implements SecretGenerator
{
private const string ALLOWED_CHARACTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
#[\Override]
public function generateToken(int $length = 60): GeneratedToken
{
$value = new Randomizer()
->getBytesFromString(self::ALLOWED_CHARACTERS, $length);
return new GeneratedToken($value);
}
#[\Override]
public function generateCode(int $length = 6): GeneratedCode
{
$min = 10 ** ($length - 1);
$max = 10 ** $length - 1;
$value = new Randomizer()
->getInt($min, $max);
return new GeneratedCode((string) $value);
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Presentation\Console;
use Basango\IdentityAndAccess\Application\UseCase\Command\Register;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Roles;
use Basango\SharedKernel\Application\Messaging\CommandBus;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
use Basango\SharedKernel\Presentation\Console\AskArgumentFeature;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Class RegisterConsole.
*
* @author bernard-ng <bernard@devscast.tech>
*/
#[AsCommand('app:user-register', 'register a new user')]
final class RegisterConsole extends Command
{
use AskArgumentFeature;
private SymfonyStyle $io;
public function __construct(
private readonly CommandBus $commandBus,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void
{
$this
->setDescription('Creates users and stores them in the database')
->addArgument('name', InputArgument::OPTIONAL, 'The name of the new user')
->addArgument('email', InputArgument::OPTIONAL, 'The email of the new user')
->addArgument('password', InputArgument::OPTIONAL, 'The plain password of the new user')
->addOption('admin', null, InputOption::VALUE_NONE, 'If set, the user is created as an administrator');
}
#[\Override]
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
}
#[\Override]
protected function interact(InputInterface $input, OutputInterface $output): void
{
if (
$input->getArgument('name') !== null &&
$input->getArgument('email') !== null &&
$input->getArgument('password') !== null
) {
return;
}
$this->askArgument($input, 'name');
$this->askArgument($input, 'email');
$this->askArgument($input, 'password', true);
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
/** @var string $name */
$name = $input->getArgument('name');
/** @var string $email */
$email = $input->getArgument('email');
/** @var string $password */
$password = $input->getArgument('password');
/** @var bool $admin */
$admin = $input->getOption('admin');
$command = new Register($name, EmailAddress::from($email), $password, $admin ? Roles::admin() : Roles::user());
$this->commandBus->handle($command);
$this->io->success(\sprintf('%s was created: %s', $admin ? 'ADMIN' : 'USER', $email));
return Command::SUCCESS;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Presentation\Web\Controller;
use Basango\IdentityAndAccess\Application\UseCase\Command\ConfirmAccount;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\SharedKernel\Presentation\Web\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
/**
* Class UnlockAccountController.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class ConfirmAccountController extends AbstractController
{
#[Route(
path: '/api/account/confirm/{token}',
name: 'identity_and_access_confirm_account',
requirements: [
'token' => Requirement::ASCII_SLUG,
],
methods: ['GET']
)]
public function __invoke(string $token): JsonResponse
{
$token = new GeneratedToken($token);
$this->handleCommand(new ConfirmAccount($token));
return new JsonResponse(status: 200);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Presentation\Web\Controller;
use Basango\IdentityAndAccess\Application\UseCase\Query\GetUserProfile;
use Basango\SharedKernel\Presentation\Web\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Class GetUserProfileController.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class GetUserProfileController extends AbstractController
{
#[Route(
path: '/api/me',
name: 'identity_and_access_me',
methods: ['GET']
)]
public function __invoke(): JsonResponse
{
$security = $this->getSecurityUser();
$data = $this->handleQuery(new GetUserProfile($security->userId));
return JsonResponse::fromJsonString($this->serialize($data));
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Presentation\Web\Controller;
use Basango\IdentityAndAccess\Application\UseCase\Command\Register;
use Basango\IdentityAndAccess\Presentation\WriteModel\RegisterModel;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
use Basango\SharedKernel\Presentation\Web\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
/**
* Class RegisterController.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class RegisterController extends AbstractController
{
#[Route(
path: '/api/register',
name: 'identity_and_access_register',
methods: ['POST']
)]
public function __invoke(#[MapRequestPayload] RegisterModel $model): JsonResponse
{
$this->handleCommand(new Register(
$model->name,
EmailAddress::from($model->email),
$model->password
));
return new JsonResponse(status: 201);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Presentation\Web\Controller;
use Basango\IdentityAndAccess\Application\UseCase\Command\RequestPassword;
use Basango\IdentityAndAccess\Presentation\WriteModel\RequestPasswordModel;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
use Basango\SharedKernel\Presentation\Web\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
/**
* Class RequestPasswordController.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class RequestPasswordController extends AbstractController
{
#[Route(
path: '/api/password/request',
name: 'identity_and_access_request_password',
methods: ['POST']
)]
public function __invoke(#[MapRequestPayload] RequestPasswordModel $model): JsonResponse
{
$email = EmailAddress::from($model->email);
$this->handleCommand(new RequestPassword($email));
return new JsonResponse(status: 200);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Presentation\Web\Controller;
use Basango\IdentityAndAccess\Application\UseCase\Command\ResetPassword;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\IdentityAndAccess\Presentation\WriteModel\ResetPasswordModel;
use Basango\SharedKernel\Presentation\Web\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
/**
* Class RequestPasswordController.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class ResetPasswordController extends AbstractController
{
#[Route(
path: '/api/password/reset/{token}',
name: 'identity_and_access_reset_password',
requirements: [
'token' => Requirement::ASCII_SLUG,
],
methods: ['POST']
)]
public function __invoke(#[MapRequestPayload] ResetPasswordModel $model, string $token): JsonResponse
{
$token = new GeneratedToken($token);
$this->handleCommand(new ResetPassword($token, $model->password));
return new JsonResponse(status: 200);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Presentation\Web\Controller;
use Basango\IdentityAndAccess\Application\UseCase\Command\UnlockAccount;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\SharedKernel\Presentation\Web\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
/**
* Class UnlockAccountController.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class UnlockAccountController extends AbstractController
{
#[Route(
path: '/api/account/unlock/{token}',
name: 'identity_and_access_unlock_account',
requirements: [
'token' => Requirement::ASCII_SLUG,
],
methods: ['GET']
)]
public function __invoke(string $token): JsonResponse
{
$token = new GeneratedToken($token);
$this->handleCommand(new UnlockAccount($token));
return new JsonResponse(status: 200);
}
}

Some files were not shown because too many files have changed in this diff Show More