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
references scopes
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
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
constraint audit_logs_pkey
primary key,
user_id integer,
user_id varchar(26) default null,
action varchar(255) not null,
timestamp timestamptz default current_timestamp,
details jsonb

View File

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

View File

@@ -12,7 +12,6 @@ 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;
@@ -35,8 +34,6 @@ final class AuthorizeController extends Controller
*/
public function post(ServerRequest $request): Response
{
Dispatcher::push(new LoginAttempt($request));
$s = $request->getCookieParams()['s'] ?? '';
$password = $request->getParsedBody()['password'] ?? '';
@@ -69,7 +66,7 @@ final class AuthorizeController extends Controller
$user = $client->loginUser($email, $password);
if (!$user) {
Dispatcher::push(new LoginFailed($request));
Dispatcher::push(new LoginFailed($request, $client));
return JsonResponseFactory::createJsonResponse([
'success' => false,
@@ -84,7 +81,7 @@ final class AuthorizeController extends Controller
Redis::del('session:' . $s);
Dispatcher::push(new LoginSuccess($request, $user));
Dispatcher::push(new LoginSuccess($request, $client, $user));
return JsonResponseFactory::createJsonResponse([
'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;
#[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.
@@ -24,15 +25,24 @@ class LoginFailed extends Listener
$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;
}
$action = $event instanceof \Siteworxpro\App\Events\Login\LoginSuccess
? AuditLogAction::LOGIN_SUCCESS
: AuditLogAction::LOGIN_FAIL;
return AuditLog::create([
'user_id' => $event->getUser()?->id,
'action' => AuditLogAction::LOGIN_FAIL,
'action' => $action,
'details' => [
'username_attempted' => $event->getUsernameAttempted(),
'client_id' => $event->getClient()->client_id,
'client_name' => $event->getClient()->name,
'username' => $event->getUsernameAttempted(),
'ip_address' => $event->getRequestIp(),
],
]);

View File

@@ -5,10 +5,11 @@ declare(strict_types=1);
namespace Siteworxpro\App\Events\AccessToken;
use Nyholm\Psr7\Response;
use Siteworxpro\App\OAuth\Entities\Client;
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;
}
public function getClient(): Client
{
return $this->client;
}
}

View File

@@ -5,16 +5,56 @@ declare(strict_types=1);
namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client;
readonly class LoginAttempt
abstract class LoginAttempt
{
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'] ?? '';
}
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 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 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_FAIL = 'login_fail';
case LOGOUT = 'logout';
case TOKEN_ISSUED = 'token_issued';
}