diff --git a/.dev/docker-compose.yml b/.dev/docker-compose.yml index 80bc08a..f7380f0 100644 --- a/.dev/docker-compose.yml +++ b/.dev/docker-compose.yml @@ -83,9 +83,9 @@ services: - "traefik.http.routers.api.rule=Host(`localhost`) || Host(`127.0.0.1`)" - "traefik.http.routers.api.tls=true" - "traefik.http.routers.api.service=api" - - "traefik.http.services.api.loadbalancer.healthcheck.path=/healthz" - - "traefik.http.services.api.loadbalancer.healthcheck.interval=5s" - - "traefik.http.services.api.loadbalancer.healthcheck.timeout=60s" +# - "traefik.http.services.api.loadbalancer.healthcheck.path=/healthz" +# - "traefik.http.services.api.loadbalancer.healthcheck.interval=5s" +# - "traefik.http.services.api.loadbalancer.healthcheck.timeout=60s" - "traefik.tcp.services.api.loadbalancer.server.port=9001" - "traefik.http.services.api.loadbalancer.server.port=9501" - "traefik.tcp.routers.grpc.entrypoints=grpc" diff --git a/db/migrations/000002_create_audit_table.down.sql b/db/migrations/000002_create_audit_table.down.sql new file mode 100644 index 0000000..151cf68 --- /dev/null +++ b/db/migrations/000002_create_audit_table.down.sql @@ -0,0 +1 @@ +drop table if exists audit_logs; \ No newline at end of file diff --git a/db/migrations/000002_create_audit_table.up.sql b/db/migrations/000002_create_audit_table.up.sql new file mode 100644 index 0000000..d75ea81 --- /dev/null +++ b/db/migrations/000002_create_audit_table.up.sql @@ -0,0 +1,14 @@ +create table audit_logs +( + id VARCHAR(26) not null + constraint audit_logs_pkey + primary key, + user_id integer, + action varchar(255) not null, + timestamp timestamptz default current_timestamp, + details jsonb +); + +create index idx_audit_logs_action on audit_logs (action); +create index idx_audit_logs_user_id on audit_logs (user_id); +create index idx_audit_logs_timestamp on audit_logs (timestamp); \ No newline at end of file diff --git a/src/Cli/Commands/OAuth/CreateClient.php b/src/Cli/Commands/OAuth/CreateClient.php index 57ed955..aec4616 100644 --- a/src/Cli/Commands/OAuth/CreateClient.php +++ b/src/Cli/Commands/OAuth/CreateClient.php @@ -7,6 +7,7 @@ namespace Siteworxpro\App\Cli\Commands\OAuth; use Siteworxpro\App\Cli\ClimateOutput; use Siteworxpro\App\CommandBus\Commands\CreateClient as CreateClientCommand; use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException; +use Siteworxpro\App\Models\Enums\ClientGrant as ClientGrantAlias; use Siteworxpro\App\OAuth\Entities\Client; use Siteworxpro\App\Services\Facades\CommandBus; use Symfony\Component\Console\Attribute\AsCommand; @@ -37,8 +38,13 @@ class CreateClient extends \Siteworxpro\App\Cli\Commands\Command $question->setMultiselect(true); $grants = $this->helper->ask($input, $output, $question); + $grantsEnum = []; - $command = new CreateClientCommand($clientName, $grants, $clientDescription); + foreach ($grants as $grant) { + $grantsEnum[] = ClientGrantAlias::from($grant); + } + + $command = new CreateClientCommand($clientName, $grantsEnum, $clientDescription); try { /** @var Client $client */ $client = CommandBus::handle($command); diff --git a/src/Cli/Commands/User/Add.php b/src/Cli/Commands/User/Add.php index 3e521e0..dad8466 100644 --- a/src/Cli/Commands/User/Add.php +++ b/src/Cli/Commands/User/Add.php @@ -6,9 +6,11 @@ namespace Siteworxpro\App\Cli\Commands\User; use Illuminate\Database\Eloquent\Collection; use Siteworxpro\App\Cli\Commands\Command; +use Siteworxpro\App\CommandBus\Commands\CreateUser; use Siteworxpro\App\Models\ClientUser; use Siteworxpro\App\Models\User; use Siteworxpro\App\OAuth\Entities\Client; +use Siteworxpro\App\Services\Facades\CommandBus; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command as SCommand; use Symfony\Component\Console\Helper\QuestionHelper; @@ -76,19 +78,16 @@ class Add extends Command $lastNameQuestion = new QuestionInput('Enter the user\'s last name: '); $lastName = $helper->ask($input, $output, $lastNameQuestion); - $user = new User(); - $user->email = $email; - $user->password = $password; - $user->first_name = $firstName; - $user->last_name = $lastName; - $user->save(); + $createUserCommand = new CreateUser($client, $email, $password, $firstName, $lastName); - $clientUser = new ClientUser(); - $clientUser->client_id = $client->id; - $clientUser->user_id = $user->id; - $clientUser->save(); + /** @var User $user */ + $user = CommandBus::handle($createUserCommand); $output->green('User added and associated with the client successfully.'); + $output->info('User Details:'); + $output->out("ID: $user->id"); + $output->out("Email: $user->email"); + $output->out("Name: $user->first_name $user->last_name"); return SCommand::SUCCESS; } diff --git a/src/CommandBus/Commands/CreateClient.php b/src/CommandBus/Commands/CreateClient.php index f233a63..ab7a5b1 100644 --- a/src/CommandBus/Commands/CreateClient.php +++ b/src/CommandBus/Commands/CreateClient.php @@ -2,24 +2,32 @@ namespace Siteworxpro\App\CommandBus\Commands; +use Siteworxpro\App\Models\Enums\ClientGrant; + readonly class CreateClient extends Command { - private const array VALID_GRANTS = [ - 'authorization_code', - 'password', - 'client_credentials', - 'refresh_token', - 'implicit', - ]; - + /** + * @param string $clientName + * @param array $clientGrants + * @param string $clientDescription + */ public function __construct( private string $clientName, private array $clientGrants = [], private string $clientDescription = '' ) { foreach ($this->clientGrants as $grant) { - if (!in_array($grant, self::VALID_GRANTS, true)) { - throw new \InvalidArgumentException("Invalid grant type: $grant"); + if ($grant instanceof ClientGrant === false) { + throw new \InvalidArgumentException( + sprintf( + 'Invalid client grant provided: %s. Valid grants are: %s', + is_string($grant) ? $grant : gettype($grant), + implode(', ', array_map( + fn(ClientGrant $validGrant) => $validGrant->value, + ClientGrant::getValidGrants() + )) + ) + ); } } } diff --git a/src/CommandBus/Commands/CreateUser.php b/src/CommandBus/Commands/CreateUser.php new file mode 100644 index 0000000..7785546 --- /dev/null +++ b/src/CommandBus/Commands/CreateUser.php @@ -0,0 +1,51 @@ +email = $email; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getPassword(): string + { + return $this->password; + } + + public function getFirstName(): string + { + return $this->firstName; + } + + public function getLastName(): string + { + return $this->lastName; + } + + public function getClient(): Client + { + return $this->client; + } +} diff --git a/src/CommandBus/Handlers/CreateUserHandler.php b/src/CommandBus/Handlers/CreateUserHandler.php new file mode 100644 index 0000000..10a49e3 --- /dev/null +++ b/src/CommandBus/Handlers/CreateUserHandler.php @@ -0,0 +1,31 @@ + $command->getEmail(), + 'password' => password_hash($command->getPassword(), PASSWORD_BCRYPT), + 'first_name' => $command->getFirstName(), + 'last_name' => $command->getLastName(), + ]); + + $clientUser = new ClientUser(); + $clientUser->client_id = $command->getClient()->id; + $clientUser->user_id = $user->id; + $clientUser->save(); + + return $user; + } +} diff --git a/src/CommandBus/Handlers/OAuth/CreateClient.php b/src/CommandBus/Handlers/OAuth/CreateClient.php index 8381d9b..9051d6c 100644 --- a/src/CommandBus/Handlers/OAuth/CreateClient.php +++ b/src/CommandBus/Handlers/OAuth/CreateClient.php @@ -10,6 +10,7 @@ use Siteworxpro\App\CommandBus\Commands\Command; use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException; use Siteworxpro\App\CommandBus\Handlers\CommandHandler; use Siteworxpro\App\OAuth\Entities\Client; +use Siteworxpro\App\OAuth\Entities\ClientCapabilities; #[HandlesCommand(\Siteworxpro\App\CommandBus\Commands\CreateClient::class)] class CreateClient extends CommandHandler @@ -24,6 +25,7 @@ class CreateClient extends CommandHandler $client->name = $command->getClientName(); $client->description = $command->getClientDescription(); $client->grant_types = new Collection($command->getClientGrants()); // @phpstan-ignore-line assign.propertyType + $client->capabilities = new ClientCapabilities(); $client->save(); diff --git a/src/Controllers/AccessTokenController.php b/src/Controllers/AccessTokenController.php index fdab052..2c3a70d 100644 --- a/src/Controllers/AccessTokenController.php +++ b/src/Controllers/AccessTokenController.php @@ -7,11 +7,14 @@ namespace Siteworxpro\App\Controllers; use Defuse\Crypto\Exception\BadFormatException; use Defuse\Crypto\Exception\EnvironmentIsBrokenException; use League\OAuth2\Server\Exception\OAuthServerException; +use Nyholm\Psr7\Response; use Nyholm\Psr7\ServerRequest; use Psr\Http\Message\ResponseInterface; +use Siteworxpro\App\Events\AccessToken\Issued; use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\Responses\GenericResponse; use Siteworxpro\App\OAuth\Entities\Client; +use Siteworxpro\App\Services\Facades\Dispatcher; use Siteworxpro\HttpStatus\CodesEnum; final class AccessTokenController extends Controller @@ -34,9 +37,14 @@ final class AccessTokenController extends Controller ); } - return $client + /** @var Response $response */ + $response = $client ->getAuthorizationServer() ->respondToAccessTokenRequest($request, JsonResponseFactory::createJsonResponse([])); + + Dispatcher::push(new Issued($response)); + + return $response; } catch (OAuthServerException $e) { return JsonResponseFactory::createJsonResponse( $e->getPayload(), diff --git a/src/Controllers/AuthorizeController.php b/src/Controllers/AuthorizeController.php index a10cda8..696c8e5 100644 --- a/src/Controllers/AuthorizeController.php +++ b/src/Controllers/AuthorizeController.php @@ -12,10 +12,14 @@ use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use Nyholm\Psr7\Response; use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\Stream; +use Siteworxpro\App\Events\Login\LoginAttempt; +use Siteworxpro\App\Events\Login\LoginFailed; +use Siteworxpro\App\Events\Login\LoginSuccess; use Siteworxpro\App\Helpers\Rand; use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\Responses\ServerErrorResponse; use Siteworxpro\App\OAuth\Entities\Client; +use Siteworxpro\App\Services\Facades\Dispatcher; use Siteworxpro\App\Services\Facades\Logger; use Siteworxpro\App\Services\Facades\Redis; use Siteworxpro\HttpStatus\CodesEnum; @@ -31,6 +35,8 @@ final class AuthorizeController extends Controller */ public function post(ServerRequest $request): Response { + Dispatcher::push(new LoginAttempt($request)); + $s = $request->getCookieParams()['s'] ?? ''; $password = $request->getParsedBody()['password'] ?? ''; @@ -63,6 +69,8 @@ final class AuthorizeController extends Controller $user = $client->loginUser($email, $password); if (!$user) { + Dispatcher::push(new LoginFailed($request)); + return JsonResponseFactory::createJsonResponse([ 'success' => false, 'reason' => 'login failed' @@ -76,6 +84,8 @@ final class AuthorizeController extends Controller Redis::del('session:' . $s); + Dispatcher::push(new LoginSuccess($request, $user)); + return JsonResponseFactory::createJsonResponse([ 'success' => true, 'location' => $response->getHeader('Location')[0] diff --git a/src/Events/Listeners/Listener.php b/src/EventListeners/Listener.php similarity index 78% rename from src/Events/Listeners/Listener.php rename to src/EventListeners/Listener.php index cbf29ea..b744371 100644 --- a/src/Events/Listeners/Listener.php +++ b/src/EventListeners/Listener.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Siteworxpro\App\Events\Listeners; +namespace Siteworxpro\App\EventListeners; /** * Class Listener diff --git a/src/Events/Listeners/ListenerInterface.php b/src/EventListeners/ListenerInterface.php similarity index 87% rename from src/Events/Listeners/ListenerInterface.php rename to src/EventListeners/ListenerInterface.php index 7c0a6db..a889627 100644 --- a/src/Events/Listeners/ListenerInterface.php +++ b/src/EventListeners/ListenerInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Siteworxpro\App\Events\Listeners; +namespace Siteworxpro\App\EventListeners; /** * Interface ListenerInterface diff --git a/src/EventListeners/LoginFailed.php b/src/EventListeners/LoginFailed.php new file mode 100644 index 0000000..c0afa4f --- /dev/null +++ b/src/EventListeners/LoginFailed.php @@ -0,0 +1,40 @@ + $event->getUser()?->id, + 'action' => AuditLogAction::LOGIN_FAIL, + 'details' => [ + 'username_attempted' => $event->getUsernameAttempted(), + 'ip_address' => $event->getRequestIp(), + ], + ]); + } +} diff --git a/src/Events/AccessToken/Issued.php b/src/Events/AccessToken/Issued.php new file mode 100644 index 0000000..57bc6bc --- /dev/null +++ b/src/Events/AccessToken/Issued.php @@ -0,0 +1,19 @@ +response; + } +} diff --git a/src/Events/Dispatcher.php b/src/Events/Dispatcher.php index 7b817a2..e8c576c 100644 --- a/src/Events/Dispatcher.php +++ b/src/Events/Dispatcher.php @@ -37,7 +37,7 @@ class Dispatcher implements DispatcherContract, Arrayable /** * @var string LISTENERS_NAMESPACE The namespace where listeners are located */ - private const string LISTENERS_NAMESPACE = 'Siteworxpro\\App\\Events\\Listeners\\'; + private const string LISTENERS_NAMESPACE = 'Siteworxpro\\App\\EventListeners\\'; public function __construct() { @@ -63,7 +63,7 @@ class Dispatcher implements DispatcherContract, Arrayable private function registerListeners(): void { // traverse the Listeners directory and register all listeners - $listenersPath = __DIR__ . '/Listeners'; + $listenersPath = __DIR__ . '/../EventListeners'; $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($listenersPath)); foreach ($iterator as $file) { @@ -199,6 +199,11 @@ class Dispatcher implements DispatcherContract, Arrayable */ public function push($event, $payload = []): void { + if (!is_string($event)) { + $payload = [$event]; + $event = get_class($event); + } + $this->pushed->put($event, $payload); } diff --git a/src/Events/Listeners/Database/Connected.php b/src/Events/Listeners/Database/Connected.php deleted file mode 100644 index c578668..0000000 --- a/src/Events/Listeners/Database/Connected.php +++ /dev/null @@ -1,33 +0,0 @@ -request->getParsedBody()['email'] ?? ''; + } +} diff --git a/src/Events/Login/LoginFailed.php b/src/Events/Login/LoginFailed.php new file mode 100644 index 0000000..83eb4b6 --- /dev/null +++ b/src/Events/Login/LoginFailed.php @@ -0,0 +1,50 @@ +request->getHeader('X-Forwarded-For')) { + $ipAddresses = explode(',', $this->request->getHeaderLine('X-Forwarded-For')); + return trim($ipAddresses[0]); + } + + if ($this->request->getHeader('X-Real-IP')) { + return $this->request->getHeaderLine('X-Real-IP'); + } + + if ($this->request->getServerParams()['HTTP_CLIENT_IP'] ?? false) { + return $this->request->getServerParams()['HTTP_CLIENT_IP']; + } + + if ($this->request->getServerParams()['HTTP_X_FORWARDED_FOR'] ?? false) { + $ipAddresses = explode(',', $this->request->getServerParams()['HTTP_X_FORWARDED_FOR']); + return trim($ipAddresses[0]); + } + + return $this->request->getServerParams()['REMOTE_ADDR'] ?? 'unknown'; + } + + public function getUsernameAttempted(): string + { + return $this->request->getParsedBody()['email'] ?? ''; + } + + public function getUser(): ?User + { + return $this->user; + } +} diff --git a/src/Events/Login/LoginSuccess.php b/src/Events/Login/LoginSuccess.php new file mode 100644 index 0000000..a8821b2 --- /dev/null +++ b/src/Events/Login/LoginSuccess.php @@ -0,0 +1,27 @@ +serverRequest; + } + + public function getUser(): User + { + return $this->user; + } +} diff --git a/src/Models/AuditLog.php b/src/Models/AuditLog.php new file mode 100644 index 0000000..898cd56 --- /dev/null +++ b/src/Models/AuditLog.php @@ -0,0 +1,55 @@ + 'array', + 'timestamp' => 'datetime', + ]; + + protected $fillable = [ + 'user_id', + 'action', + 'details', + ]; + + public function getActionAttribute(string $value): AuditLogAction + { + return AuditLogAction::from($value); + } + + public function setActionAttribute(AuditLogAction $value): void + { + $this->attributes['action'] = $value->value; + } + + public function setDetailsAttribute(array $value): void + { + $this->attributes['details'] = json_encode($value); + } + + public function getDetailsAttribute($value): array + { + return json_decode($value, true) ?? []; + } +} diff --git a/src/Models/Enums/AuditLogAction.php b/src/Models/Enums/AuditLogAction.php new file mode 100644 index 0000000..0f93b4f --- /dev/null +++ b/src/Models/Enums/AuditLogAction.php @@ -0,0 +1,12 @@ + self::AUTHORIZATION_CODE, + 'password' => self::PASSWORD, + 'client_credentials' => self::CLIENT_CREDENTIALS, + 'refresh_token' => self::REFRESH_TOKEN, + 'implicit' => self::IMPLICIT, + default => null, + }; + } + + public static function getValidGrants(): array + { + return [ + self::AUTHORIZATION_CODE, + self::PASSWORD, + self::CLIENT_CREDENTIALS, + self::REFRESH_TOKEN, + self::IMPLICIT, + ]; + } +} diff --git a/src/Models/Model.php b/src/Models/Model.php index 8b7514c..91a842a 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -15,6 +15,7 @@ use Siteworxpro\App\Helpers\Ulid; * @method static static|null find(string $id, array $columns = ['*']) * @method static Builder where(string $column, string $operator = null, string $value = null, string $boolean = 'and') * @method static Builder whereJsonContains(string $column, mixed $value, string $boolean = 'and', bool $not = false) + * @method static static create(array $attributes = []) */ abstract class Model extends ORM { diff --git a/src/OAuth/Entities/Client.php b/src/OAuth/Entities/Client.php index 6646321..addc3ec 100644 --- a/src/OAuth/Entities/Client.php +++ b/src/OAuth/Entities/Client.php @@ -38,7 +38,7 @@ use Siteworxpro\App\OAuth\ScopeRepository; * @property Collection $grant_types * @property bool $confidential * - * @property-read ClientCapabilities $capabilities + * @property ClientCapabilities $capabilities * @property-read Collection $clientRedirectUris * @property-read Scope[]|Collection $scopes */ diff --git a/src/OAuth/Entities/ClientCapabilities.php b/src/OAuth/Entities/ClientCapabilities.php index c68d9ac..3b8f57b 100644 --- a/src/OAuth/Entities/ClientCapabilities.php +++ b/src/OAuth/Entities/ClientCapabilities.php @@ -19,7 +19,7 @@ class ClientCapabilities implements Arrayable 'logoUrl' => null, ]; - public function __construct(array $capabilities) + public function __construct(array $capabilities = []) { if (isset($capabilities['userPass'])) { $this->userPass = (bool)$capabilities['userPass']; diff --git a/tests/Events/Listeners/ConnectedTest.php b/tests/Events/Listeners/ConnectedTest.php index 7c5a3a2..af3945e 100644 --- a/tests/Events/Listeners/ConnectedTest.php +++ b/tests/Events/Listeners/ConnectedTest.php @@ -8,7 +8,7 @@ use Illuminate\Database\Events\ConnectionEstablished; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LogLevel; -use Siteworxpro\App\Events\Listeners\Database\Connected; +use Siteworxpro\App\Events\EventListeners\Database\Connected; use Siteworxpro\App\Log\Logger; use Siteworxpro\Tests\Unit;