Enhance user and audit logging by adding client ID to user scopes and login events
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in -21s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in -22s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in -12s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in -20s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in -14s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in -36s

This commit is contained in:
2026-01-29 23:45:23 -05:00
parent eaff081e44
commit 5ec683890e
11 changed files with 118 additions and 71 deletions

View File

@@ -105,6 +105,10 @@ create table user_scopes
constraint user_scopes_scope_id_fk constraint user_scopes_scope_id_fk
references scopes references scopes
on delete cascade, on delete cascade,
client_id VARCHAR(26) not null
constraint user_scopes_client_id_fk
references clients
on delete cascade,
constraint user_scopes_user_id_scope_id_key constraint user_scopes_user_id_scope_id_key
unique (user_id, scope_id) unique (user_id, scope_id, client_id)
); );

View File

@@ -3,7 +3,7 @@ create table audit_logs
id VARCHAR(26) not null id VARCHAR(26) not null
constraint audit_logs_pkey constraint audit_logs_pkey
primary key, primary key,
user_id integer, user_id varchar(26) default null,
action varchar(255) not null, action varchar(255) not null,
timestamp timestamptz default current_timestamp, timestamp timestamptz default current_timestamp,
details jsonb details jsonb

View File

@@ -45,7 +45,7 @@ final class AccessTokenController extends Controller
JsonResponseFactory::createJsonResponse([]) JsonResponseFactory::createJsonResponse([])
); );
Dispatcher::push(new Issued($response)); Dispatcher::push(new Issued($client, $response));
return $response; return $response;
} catch (OAuthServerException $e) { } catch (OAuthServerException $e) {

View File

@@ -12,7 +12,6 @@ use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use Nyholm\Psr7\Response; use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use Nyholm\Psr7\Stream; use Nyholm\Psr7\Stream;
use Siteworxpro\App\Events\Login\LoginAttempt;
use Siteworxpro\App\Events\Login\LoginFailed; use Siteworxpro\App\Events\Login\LoginFailed;
use Siteworxpro\App\Events\Login\LoginSuccess; use Siteworxpro\App\Events\Login\LoginSuccess;
use Siteworxpro\App\Helpers\Rand; use Siteworxpro\App\Helpers\Rand;
@@ -35,8 +34,6 @@ final class AuthorizeController extends Controller
*/ */
public function post(ServerRequest $request): Response public function post(ServerRequest $request): Response
{ {
Dispatcher::push(new LoginAttempt($request));
$s = $request->getCookieParams()['s'] ?? ''; $s = $request->getCookieParams()['s'] ?? '';
$password = $request->getParsedBody()['password'] ?? ''; $password = $request->getParsedBody()['password'] ?? '';
@@ -69,7 +66,7 @@ final class AuthorizeController extends Controller
$user = $client->loginUser($email, $password); $user = $client->loginUser($email, $password);
if (!$user) { if (!$user) {
Dispatcher::push(new LoginFailed($request)); Dispatcher::push(new LoginFailed($request, $client));
return JsonResponseFactory::createJsonResponse([ return JsonResponseFactory::createJsonResponse([
'success' => false, 'success' => false,
@@ -84,7 +81,7 @@ final class AuthorizeController extends Controller
Redis::del('session:' . $s); Redis::del('session:' . $s);
Dispatcher::push(new LoginSuccess($request, $user)); Dispatcher::push(new LoginSuccess($request, $client, $user));
return JsonResponseFactory::createJsonResponse([ return JsonResponseFactory::createJsonResponse([
'success' => true, 'success' => true,

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\EventListeners;
use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Models\AuditLog;
use Siteworxpro\App\Models\Enums\AuditLogAction;
#[ListensFor(\Siteworxpro\App\Events\AccessToken\Issued::class)]
class AccessTokenIssuedListener extends Listener
{
/**
* Handle the event.
*
* @param string | \Siteworxpro\App\Events\AccessToken\Issued $event
* @param array $payload
* @return AuditLog|null
*/
public function __invoke(mixed $event, array $payload = []): ?AuditLog
{
if (is_string($event)) {
$event = $payload[0] ?? null;
}
if (!$event instanceof \Siteworxpro\App\Events\AccessToken\Issued) {
return null;
}
return AuditLog::create([
'user_id' => null,
'action' => AuditLogAction::TOKEN_ISSUED,
'details' => [
'response_status' => $event->getResponse()->getStatusCode(),
'client_id' => $event->getClient()->getIdentifier(),
'client_name' => $event->getClient()->getName(),
],
]);
}
}

View File

@@ -9,7 +9,8 @@ use Siteworxpro\App\Models\AuditLog;
use Siteworxpro\App\Models\Enums\AuditLogAction; use Siteworxpro\App\Models\Enums\AuditLogAction;
#[ListensFor(\Siteworxpro\App\Events\Login\LoginFailed::class)] #[ListensFor(\Siteworxpro\App\Events\Login\LoginFailed::class)]
class LoginFailed extends Listener #[ListensFor(\Siteworxpro\App\Events\Login\LoginSuccess::class)]
class LoginListener extends Listener
{ {
/** /**
* Handle the event. * Handle the event.
@@ -24,15 +25,24 @@ class LoginFailed extends Listener
$event = $payload[0] ?? null; $event = $payload[0] ?? null;
} }
if (!$event instanceof \Siteworxpro\App\Events\Login\LoginFailed) { if (
!$event instanceof \Siteworxpro\App\Events\Login\LoginFailed
&& !$event instanceof \Siteworxpro\App\Events\Login\LoginSuccess
) {
return null; return null;
} }
$action = $event instanceof \Siteworxpro\App\Events\Login\LoginSuccess
? AuditLogAction::LOGIN_SUCCESS
: AuditLogAction::LOGIN_FAIL;
return AuditLog::create([ return AuditLog::create([
'user_id' => $event->getUser()?->id, 'user_id' => $event->getUser()?->id,
'action' => AuditLogAction::LOGIN_FAIL, 'action' => $action,
'details' => [ 'details' => [
'username_attempted' => $event->getUsernameAttempted(), 'client_id' => $event->getClient()->client_id,
'client_name' => $event->getClient()->name,
'username' => $event->getUsernameAttempted(),
'ip_address' => $event->getRequestIp(), 'ip_address' => $event->getRequestIp(),
], ],
]); ]);

View File

@@ -5,10 +5,11 @@ declare(strict_types=1);
namespace Siteworxpro\App\Events\AccessToken; namespace Siteworxpro\App\Events\AccessToken;
use Nyholm\Psr7\Response; use Nyholm\Psr7\Response;
use Siteworxpro\App\OAuth\Entities\Client;
readonly class Issued readonly class Issued
{ {
public function __construct(private Response $response) public function __construct(private Client $client, private Response $response)
{ {
} }
@@ -16,4 +17,9 @@ readonly class Issued
{ {
return $this->response; return $this->response;
} }
public function getClient(): Client
{
return $this->client;
}
} }

View File

@@ -5,16 +5,56 @@ declare(strict_types=1);
namespace Siteworxpro\App\Events\Login; namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client;
readonly class LoginAttempt abstract class LoginAttempt
{ {
public function __construct( public function __construct(
private ServerRequest $request, private readonly ServerRequest $request,
private readonly Client $client,
private readonly ?User $user = null,
) { ) {
} }
public function getEmail(): string public function getRequestIp(): string
{
if ($this->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'] ?? ''; return $this->request->getParsedBody()['email'] ?? '';
} }
public function getUser(): ?User
{
return $this->user;
}
/**
* @return Client
*/
public function getClient(): Client
{
return $this->client;
}
} }

View File

@@ -7,44 +7,6 @@ namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User; use Siteworxpro\App\Models\User;
readonly class LoginFailed class LoginFailed extends LoginAttempt
{ {
public function __construct(
private ServerRequest $request,
private ?User $user = null,
) {
}
public function getRequestIp(): string
{
if ($this->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;
}
} }

View File

@@ -7,21 +7,6 @@ namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User; use Siteworxpro\App\Models\User;
readonly class LoginSuccess class LoginSuccess extends LoginAttempt
{ {
public function __construct(
private ServerRequest $serverRequest,
private User $user
) {
}
public function getRequest(): ServerRequest
{
return $this->serverRequest;
}
public function getUser(): User
{
return $this->user;
}
} }

View File

@@ -9,4 +9,6 @@ enum AuditLogAction: string
case LOGIN_SUCCESS = 'login_success'; case LOGIN_SUCCESS = 'login_success';
case LOGIN_FAIL = 'login_fail'; case LOGIN_FAIL = 'login_fail';
case LOGOUT = 'logout'; case LOGOUT = 'logout';
case TOKEN_ISSUED = 'token_issued';
} }