13 Commits

Author SHA1 Message Date
f0e191b2cb Add UserInfo endpoint and enhance client management
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 5m21s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 5m43s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 5m33s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 5m28s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 5m52s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 3m10s
- Introduced a new UserInfo controller to handle user information retrieval based on JWT authentication and scopes.
- Added a publicKey method in the Client model to convert private keys to public RSA PEM keys.
- Updated the JwtMiddleware to validate tokens against client IDs and improved error handling.
- Modified the CreateUserHandler to save the user's password directly instead of hashing it.
- Adjusted the .env file to include a new APP_URL variable.
2026-02-11 21:29:18 -05:00
75757f1403 Enhance client management by adding PKCE option and refactoring client retrieval
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Has been cancelled
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Has been cancelled
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Has been cancelled
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Has been cancelled
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Has been cancelled
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Has been cancelled
- Introduced a boolean parameter to `askForClient` method to allow retrieval of all clients.
- Updated client creation process to include an option for requiring PKCE for Authorization Code grants.
- Refactored related code for improved clarity and functionality.
2026-02-08 00:01:08 -05:00
f28d8f2ec8 fixed linting
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in -24s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in -29s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in -30s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in -19s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in -14s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in -47s
2026-01-30 15:26:54 -05:00
b4c892c104 Add scope management functionality for clients and enhance client creation process
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m5s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m25s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m24s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m14s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Failing after 1m20s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in -19s
2026-01-30 12:43:26 -05:00
5ec683890e 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
2026-01-29 23:45:23 -05:00
eaff081e44 Refactor password reset handler to use a variable for client redirect URI
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in -34s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in -9s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in -18s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in -22s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in -11s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in -32s
2026-01-29 23:01:53 -05:00
8f5f57f5f6 Add DeleteClient command and enhance token settings management
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after -33s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in -36s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Has been cancelled
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Has been cancelled
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Has been cancelled
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Has been cancelled
2026-01-29 23:00:01 -05:00
96409973bf password reset 2026-01-29 22:34:15 -05:00
8dbf3c22b6 password reset
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 17s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 22s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 52s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in -2s
2026-01-29 19:41:07 -05:00
4ee830327e tests and linting
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in -7s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in -16s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after -3s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in -4s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 6s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in -41s
2026-01-29 19:28:57 -05:00
e9cb49d942 Password reset and email
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Failing after 1m19s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m9s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Failing after 1m19s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m34s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Failing after 1m20s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 12s
2026-01-29 18:52:49 -05:00
b2b85b5261 initial
Some checks failed
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m50s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m59s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m11s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m33s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Failing after 3m18s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 1m14s
2026-01-15 14:33:51 -05:00
a1d7512ebc Add audit logging functionality with database schema and event handling
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m23s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m35s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m25s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m39s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Failing after 2m26s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 1m5s
2026-01-10 09:51:35 -05:00
69 changed files with 1711 additions and 404 deletions

View File

@@ -10,5 +10,6 @@ return new LicenseConfigurationBuilder()
'BSD-2-Clause',
'BSD-3-Clause',
'Apache-2.0',
'LGPL-2.1-only'
)
->build();

View File

@@ -1,3 +1,4 @@
APP_URL: https://localhost
JWT_ISSUER: https://auth.siteworxpro.com/application/o/postman/
JWT_AUDIENCE: 1RWyqJFlyA4hmsDzq6kSxs0LXvk7UgEAfgmBCpQ9
JWT_SIGNING_KEY: https://auth.siteworxpro.com/application/o/postman/.well-known/openid-configuration

View File

@@ -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"

View File

@@ -1,6 +1,16 @@
{
"name": "siteworxpro/app",
"type": "project",
"config": {
"optimize-autoloader": true,
"sort-packages": true,
"audit": {
"ignore": {
"PKSA-rkkf-636k-qjb3": "Dev only package",
"PKSA-z3gr-8qht-p93v": "Dev only package"
}
}
},
"autoload": {
"psr-4": {
"Siteworxpro\\App\\": "src/",
@@ -32,15 +42,18 @@
"ext-sodium": "*",
"league/climate": "^3.10",
"hansott/psr7-cookies": "^4.0",
"symfony/console": "^v7.4.3"
"symfony/console": "^v7.4.3",
"twig/twig": "^3.22",
"phpmailer/phpmailer": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^12.4",
"mockery/mockery": "^1.6",
"squizlabs/php_codesniffer": "^4.0",
"jetbrains/phpstorm-attributes": "^1.2",
"kwn/php-rdkafka-stubs": "^2.2",
"lendable/composer-license-checker": "^1.3.0",
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.1.31",
"kwn/php-rdkafka-stubs": "^2.2"
"phpunit/phpunit": "^12.4",
"squizlabs/php_codesniffer": "^4.0"
},
"scripts": {
"tests:all": [

205
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7aee32dcb1c3e870610dda4d399383f0",
"content-hash": "3063e521174c3d0c51a4b8143aee7e85",
"packages": [
{
"name": "brick/math",
@@ -2442,6 +2442,88 @@
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phpmailer/phpmailer",
"version": "v7.0.2",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088",
"reference": "ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-filter": "*",
"ext-hash": "*",
"php": ">=5.5.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^10.0.0@dev",
"squizlabs/php_codesniffer": "^3.13.5",
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example",
"ext-imap": "Needed to support advanced email address parsing according to RFC822",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
"psr/log": "For optional PSR-3 debug logging",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
},
"type": "library",
"autoload": {
"psr-4": {
"PHPMailer\\PHPMailer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-only"
],
"authors": [
{
"name": "Marcus Bointon",
"email": "phpmailer@synchromedia.co.uk"
},
{
"name": "Jim Jagielski",
"email": "jimjag@gmail.com"
},
{
"name": "Andy Prevost",
"email": "codeworxtech@users.sourceforge.net"
},
{
"name": "Brent R. Matzelle"
}
],
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
"source": "https://github.com/PHPMailer/PHPMailer/tree/v7.0.2"
},
"funding": [
{
"url": "https://github.com/Synchro",
"type": "github"
}
],
"time": "2026-01-09T18:02:33+00:00"
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.0",
@@ -5363,6 +5445,85 @@
],
"time": "2025-12-04T18:17:06+00:00"
},
{
"name": "twig/twig",
"version": "v3.22.2",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2",
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2",
"shasum": ""
},
"require": {
"php": ">=8.1.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.3"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
"psr/container": "^1.0|^2.0",
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
},
"type": "library",
"autoload": {
"files": [
"src/Resources/core.php",
"src/Resources/debug.php",
"src/Resources/escaper.php",
"src/Resources/string_loader.php"
],
"psr-4": {
"Twig\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
},
{
"name": "Twig Team",
"role": "Contributors"
},
{
"name": "Armin Ronacher",
"email": "armin.ronacher@active-4.com",
"role": "Project Founder"
}
],
"description": "Twig, the flexible, fast, and secure template language for PHP",
"homepage": "https://twig.symfony.com",
"keywords": [
"templating"
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.22.2"
},
"funding": [
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
"type": "tidelift"
}
],
"time": "2025-12-14T11:28:47+00:00"
},
{
"name": "voku/portable-ascii",
"version": "2.0.3",
@@ -5578,6 +5739,48 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "jetbrains/phpstorm-attributes",
"version": "1.2",
"source": {
"type": "git",
"url": "https://github.com/JetBrains/phpstorm-attributes.git",
"reference": "64de815a4509c29e00d5e3474087fd24c171afc2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/JetBrains/phpstorm-attributes/zipball/64de815a4509c29e00d5e3474087fd24c171afc2",
"reference": "64de815a4509c29e00d5e3474087fd24c171afc2",
"shasum": ""
},
"type": "library",
"autoload": {
"psr-4": {
"JetBrains\\PhpStorm\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "JetBrains",
"homepage": "https://www.jetbrains.com"
}
],
"description": "PhpStorm specific attributes",
"keywords": [
"attributes",
"jetbrains",
"phpstorm"
],
"support": {
"issues": "https://youtrack.jetbrains.com/newIssue?project=WI",
"source": "https://github.com/JetBrains/phpstorm-attributes/tree/1.2"
},
"time": "2024-10-11T10:46:19+00:00"
},
{
"name": "kwn/php-rdkafka-stubs",
"version": "v2.2.1",

View File

@@ -9,6 +9,7 @@ return [
'dev_mode' => Env::get('DEV_MODE', false, 'bool'),
'url' => Env::get('APP_URL', 'https://localhost'),
'encryption_key' => Env::get('APP_ENCRYPTION_KEY', 'base64:change_me'),
'default_support_email' => Env::get('DEFAULT_SUPPORT_EMAIL', 'no@email.com'),
],
/**
@@ -36,6 +37,22 @@ return [
],
],
'mailer' => [
'driver' => Env::get('MAILER_DRIVER', 'log'), // Options: 'log', 'smtp'
'log' => [
'log_file' => Env::get('MAILER_LOG_FILE', 'php://stdout'),
],
'smtp' => [
'host' => Env::get('SMTP_HOST', 'localhost'),
'port' => Env::get('SMTP_PORT', 25, 'int'),
'username' => Env::get('SMTP_USERNAME', ''),
'password' => Env::get('SMTP_PASSWORD', ''),
'encryption' => Env::get('SMTP_ENCRYPTION', 'none'), // Options: 'ssl', 'starttls', 'none'
],
],
'cors' => [
'allowed_origins' => Env::get('CORS_ALLOWED_ORIGINS', 'localhost:3000'),
'allow_credentials' => Env::get('CORS_ALLOW_CREDENTIALS', true, 'bool'),

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

@@ -0,0 +1 @@
drop table if exists audit_logs;

View File

@@ -0,0 +1,14 @@
create table audit_logs
(
id VARCHAR(26) not null
constraint audit_logs_pkey
primary key,
user_id varchar(26) default null,
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);

View File

@@ -14,6 +14,7 @@ use Siteworxpro\App\Controllers\AuthorizeController;
use Siteworxpro\App\Controllers\CapabilitiesController;
use Siteworxpro\App\Controllers\HealthcheckController;
use Siteworxpro\App\Controllers\OpenIdController;
use Siteworxpro\App\Controllers\UserInfo;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Http\Middleware\JwtMiddleware;
@@ -89,6 +90,8 @@ class Api
$group->get('/.well-known/openid-configuration', OpenIdController::class . '::get');
});
$this->router->get('/user_info', UserInfo::class . '::get');
$this->router->middleware(new CorsMiddleware());
$this->router->middleware(new JwtMiddleware());
$this->router->middleware(new ScopeMiddleware());

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Handlers;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Services\Facades\Logger;
#[HandlesMessage(SayHelloMessage::class)]
class SayHelloHandler implements HandlerInterface
{
public function __invoke(Message | SayHelloMessage $message): void
{
$name = $message->getPayload()['name'] ?? 'Guest';
Logger::info(sprintf("Hello, %s!", $name));
}
}

View File

@@ -7,6 +7,7 @@ namespace Siteworxpro\App\Async\Messages;
use Siteworxpro\App\Async\Queues\DefaultQueue;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Helpers\Ulid;
use Siteworxpro\App\Services\Facades\Broker;
abstract class Message implements \Serializable
{
@@ -22,10 +23,6 @@ abstract class Message implements \Serializable
protected const string DEFAULT_QUEUE = DefaultQueue::class;
abstract public static function dispatch(...$args): void;
abstract public static function dispatchLater(int $delay, ...$args): void;
public function __construct()
{
$this->uniqueId = Ulid::generate();

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Messages;
use Siteworxpro\App\Services\Facades\Broker;
class SayHelloMessage extends Message
{
public static function dispatch(...$args): void
{
$name = $args[0] ?? 'World';
$message = new self($name);
Broker::publish(
$message->getQueue(),
$message
);
}
public static function dispatchLater(int $delay, ...$args): void
{
$name = $args[0] ?? 'World';
$message = new self($name);
Broker::publishLater(
$message->getQueue(),
$message,
$delay
);
}
private function __construct(
private readonly string $name
) {
parent::__construct();
$this->payload = [
'name' => $this->name,
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Messages;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client;
class SendUserReset extends Message
{
public function __construct(User $user, Client $client, string $token)
{
parent::__construct();
$this->payload = [
'user' => $user,
'client' => $client,
'token' => $token,
];
}
}

View File

@@ -16,7 +16,7 @@ readonly class Scope
*/
public function __construct(
private array $scopes = [],
private string $claim = 'scope',
private string $claim = 'scopes',
private string $separator = ' '
) {
}

View File

@@ -7,10 +7,13 @@ namespace Siteworxpro\App\Cli;
use Siteworxpro\App\Cli\Commands\Crypt\GenerateKey;
use Siteworxpro\App\Cli\Commands\OAuth\AddRedirectUri;
use Siteworxpro\App\Cli\Commands\OAuth\AddScope;
use Siteworxpro\App\Cli\Commands\OAuth\ClientCapabilities;
use Siteworxpro\App\Cli\Commands\OAuth\CreateClient;
use Siteworxpro\App\Cli\Commands\OAuth\DeleteClient;
use Siteworxpro\App\Cli\Commands\OAuth\ListClients;
use Siteworxpro\App\Cli\Commands\Queue\Start;
use Siteworxpro\App\Cli\Commands\User\Add;
use Siteworxpro\App\Cli\Commands\User\ResetPassword;
use Siteworxpro\App\Helpers\Version;
use Siteworxpro\App\Kernel;
use Symfony\Component\Console\Application;
@@ -38,6 +41,9 @@ class App
$this->app->addCommand(new Start());
$this->app->addCommand(new GenerateKey());
$this->app->addCommand(new AddScope());
$this->app->addCommand(new ResetPassword());
$this->app->addCommand(new ClientCapabilities());
$this->app->addCommand(new DeleteClient());
}
public function run(): int

View File

@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
use Illuminate\Database\Eloquent\Collection;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client;
use Symfony\Component\Console\Helper\HelperSet;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Question\Question as QuestionInput;
abstract class Command extends \Symfony\Component\Console\Command\Command implements CommandInterface
{
@@ -18,4 +24,74 @@ abstract class Command extends \Symfony\Component\Console\Command\Command implem
$this->helper = new QuestionHelper();
$this->setHelperSet(new HelperSet([$this->helper]));
}
/**
* @param ClimateOutput $output
* @param ClimateOutput|ArgvInput $input
* @param bool $allClients
* @return Client | null
*/
protected function askForClient(
ClimateOutput $output,
ClimateOutput|ArgvInput $input,
bool $allClients = false
): ?Client {
if ($allClients) {
/** @var Collection<Client> $clients */
$clients = Client::all(['id', 'name']);
} else {
/** @var Collection<Client> $clients */
$clients = Client::whereJsonContains('grant_types', 'authorization_code')
->get(['id', 'name']);
}
if ($clients->isEmpty()) {
$output->error('No OAuth clients available.');
return null;
}
$output->info('Available OAuth Clients:');
foreach ($clients as $client) {
$output->out("[$client->id] $client->name");
}
$question = new QuestionInput('Enter the client ID to associate the new user with: ');
$question->setAutocompleterValues($clients->pluck('id')->toArray());
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$id = $helper->ask($input, $output, $question);
return Client::find($id);
}
protected function askForUser(
Client $client,
ClimateOutput $output,
ClimateOutput|ArgvInput $input
): ?User {
$users = $client->users;
if ($users->isEmpty()) {
$output->error('No users found for the specified client.');
return null;
}
$output->info('Available Users for Client ' . $client->name . ':');
/** @var User $user */
foreach ($users as $user) {
$output->out("[$user->id] $user->email");
}
$question = new QuestionInput('Enter the user ID: ');
$question->setAutocompleterValues($users->pluck('id')->toArray());
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$id = $helper->ask($input, $output, $question);
return User::find($id);
}
}

View File

@@ -14,7 +14,12 @@ interface CommandInterface
/**
* Execute the command.
*
* @param ArgvInput|ClimateOutput $input
* @param ClimateOutput $output
* @return int
*/
public function __invoke(InputInterface | ArgvInput $input, OutputInterface | ClimateOutput $output): int;
public function __invoke(
ClimateOutput|ArgvInput $input,
ClimateOutput $output
): int;
}

View File

@@ -9,6 +9,7 @@ use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Cli\Commands\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
#[AsCommand('crypt:generate-key', 'Generate a new encryption key for the application')]
class GenerateKey extends Command

View File

@@ -11,6 +11,7 @@ use Siteworxpro\App\OAuth\Entities\Client;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Question\Question;
#[AsCommand(name: 'oauth:redirect-uri:create', description: 'Add a redirect URI to an existing OAuth client.')]

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\OAuth\Entities\Scope;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -22,7 +23,7 @@ class AddScope extends Command
->addArgument('description', InputArgument::OPTIONAL, 'The description of the scope');
}
public function __invoke(ArgvInput | InputInterface $input, $output): int
public function __invoke($input, $output): int
{
$name = $input->getArgument('name');
$description = $input->getArgument('description') ?? '';

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\OAuth\Entities\Scope;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
#[AsCommand(name: 'oauth:client:edit', description: 'Edit an OAuth client.')]
class ClientCapabilities extends Command
{
public function __invoke($input, ClimateOutput|OutputInterface $output): int
{
$client = $this->askForClient($output, $input);
if ($client === null) {
$output->error('Client not found.');
return \Symfony\Component\Console\Command\Command::FAILURE;
}
while (true) {
$output->info('Choose a capability to modify:');
$capabilities = $client->capabilities->toArray();
$output->info('[0] Exit');
$output->info('[1] Username Password: ' . ($capabilities['userPass'] ? 'Enabled' : 'Disabled'));
$output->info('[2] Magic Link: ' . ($capabilities['magicLink'] ? 'Enabled' : 'Disabled'));
$output->info('[3] Social Logins: ' . ($capabilities['socials'] ? 'Enabled' : 'Disabled'));
$output->info('[4] External Client (require pkce): ' . (!$client->confidential ? 'Enabled' : 'Disabled'));
$output->info('[5] Manage Scopes');
$question = new Question('What do you want to edit: ', '');
$selection = $this->helper->ask($input, $output, $question);
if ($selection === '0') {
break;
}
switch ($selection) {
case '1':
$capabilities['userPass'] = !$capabilities['userPass'];
break;
case '2':
$capabilities['magicLink'] = !$capabilities['magicLink'];
break;
case '3':
$output->info('Social Logins cannot be modified via CLI at this time.');
break;
case '4':
$client->confidential = !$client->confidential;
break;
case '5':
$this->manageClientScopes($input, $output, $client);
break;
default:
$output->error('Invalid selection. Please try again.');
continue 2;
}
$client->capabilities = $capabilities;
$client->save();
}
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
private function manageClientScopes($input, ClimateOutput $output, Client $client): void
{
$allScopes = Scope::all();
$output->info('These are scope that are available for this client.');
$output->info('Type "exit" to finish managing scopes.');
while (true) {
$clientScopes = $client->scopes()->get();
$output->info('Available Scopes:');
/** @var Scope $scope */
foreach ($allScopes as $scope) {
$status = $clientScopes->contains($scope) ? 'Enabled' : 'Disabled';
$output->info("$scope->id - $scope->name $status");
}
$question = new Question('Enter scope ID to toggle (or type "exit" to finish): ', '');
$question->setAutocompleterValues($allScopes->pluck('id')->toArray());
$scopeId = $this->helper->ask($input, $output, $question);
if (strtolower($scopeId) === 'exit') {
break;
}
$scope = $allScopes->where('id', $scopeId)->first();
if ($scope === null) {
$output->error('Scope not found. Please try again.');
continue;
}
if ($clientScopes->contains($scope)) {
$client->disableScope($scope);
$output->info("Scope '$scope->name' disabled for client.");
} else {
$client->enableScope($scope);
$output->info("Scope '$scope->name' enabled for client.");
}
}
}
}

View File

@@ -7,12 +7,11 @@ 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;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
@@ -20,7 +19,7 @@ use Symfony\Component\Console\Question\Question;
#[AsCommand(name: 'oauth:client:create', description: 'Create a new OAuth client.')]
class CreateClient extends \Siteworxpro\App\Cli\Commands\Command
{
public function __invoke($input, $output): int
public function __invoke($input, ClimateOutput | OutputInterface $output): int
{
$question = new Question('Enter client name: ');
$clientName = $this->helper->ask($input, $output, $question);
@@ -30,15 +29,35 @@ class CreateClient extends \Siteworxpro\App\Cli\Commands\Command
$question = new ChoiceQuestion('Enter client grants', [
'authorization_code',
'client_credentials',
'refresh_token',
'client_credentials',
'password',
], 0);
$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);
}
$isExternal = false;
if (in_array('authorization_code', $grants)) {
$question = $this->helper->ask(
$input,
$output,
new \Symfony\Component\Console\Question\ConfirmationQuestion(
'Require PKCE for Authorization Code grant? (y/N): ',
false,
'/^(y|yes)/i'
)
);
$isExternal = $question === 'y' || $question === true;
}
$command = new CreateClientCommand($clientName, $grantsEnum, $clientDescription, $isExternal);
try {
/** @var Client $client */
$client = CommandBus::handle($command);

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Cli\Commands\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\ArgvInput;
#[AsCommand('oauth:client:delete', 'Delete an OAuth client')]
class DeleteClient extends Command
{
public function __invoke(ClimateOutput|ArgvInput $input, $output): int
{
$client = $this->askForClient($output, $input, true);
if ($client === null) {
$output->red('No client selected, aborting.');
return \Symfony\Component\Console\Command\Command::FAILURE;
}
$output->red()->bold('You are about to delete the following OAuth client:');
$output->red("ID: $client->id");
$output->red("Name: $client->name");
$output->red("Description: $client->description");
$output
->br()
->backgroundRed()
->yellow()
->bold('This action is irreversible and will remove all associated data.');
$question = $this->helper->ask(
$input,
$output,
new \Symfony\Component\Console\Question\ConfirmationQuestion(
'Are you sure you want to proceed? (y/N): ',
false,
'/^(y|yes)/i'
)
);
if (!$question) {
$output->info('Operation cancelled by user.');
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
$client->delete();
$output->green('OAuth client deleted successfully.');
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
}

View File

@@ -22,11 +22,11 @@ class ListClients extends Command
}
/**
* @param ArgvInput|InputInterface $input
* @param ArgvInput|ClimateOutput|InputInterface $input
* @param ClimateOutput $output
* @return int
*/
public function __invoke(ArgvInput|InputInterface $input, $output): int
public function __invoke(ClimateOutput|ArgvInput|InputInterface $input, $output): int
{
if ($input->getArgument('client-id')) {
$client = Client::find($input->getArgument('client-id'));
@@ -46,7 +46,8 @@ class ListClients extends Command
'Access Token Url' => Config::get('app.url') . '/client/access_token',
'OAuth Config Url' => Config::get('app.url') .
'/client/' . $client->id . '/.well-known/openid-configuration',
'Scopes' => $client->scopes->toArray()
'Scopes' => $client->scopes->pluck('name')->toArray(),
'Capabilities' => $client->capabilities->toArray(),
]);
return self::SUCCESS;

View File

@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Queue;
use Siteworxpro\App\Async\Consumer;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\Cli\Commands\CommandInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
#[AsCommand('queue:start', 'Start the queue consumer')]
class Start extends Command implements CommandInterface
@@ -23,7 +26,7 @@ class Start extends Command implements CommandInterface
);
}
public function __invoke($input, $output): int
public function __invoke(ClimateOutput|ArgvInput|InputInterface $input, $output): int
{
$queues = $input->getArgument('queues');

View File

@@ -4,38 +4,25 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\User;
use Illuminate\Database\Eloquent\Collection;
use Siteworxpro\App\Cli\ClimateOutput;
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;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Question\Question as QuestionInput;
#[AsCommand('user:add', 'Add a new user associated with an OAuth client')]
class Add extends Command
{
public function __invoke($input, $output): int
public function __invoke(ClimateOutput|ArgvInput|InputInterface $input, $output): int
{
/** @var Collection<Client> $clients */
$clients = Client::whereJsonContains('grant_types', 'authorization_code')
->get(['id', 'name']);
$output->info('Available OAuth Clients:');
foreach ($clients as $client) {
$output->out("[$client->id] $client->name");
}
$question = new QuestionInput('Enter the client ID to associate the new user with: ');
$question->setAutocompleterValues($clients->pluck('id')->toArray());
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$id = $helper->ask($input, $output, $question);
$client = Client::find($id);
$client = $this->askForClient($output, $input);
if (!$client) {
$output->error('Client not found.');
return SCommand::FAILURE;
@@ -51,6 +38,9 @@ class Add extends Command
return $input;
});
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$email = $helper->ask($input, $output, $emailQuestion);
/** @var User| null $user */
@@ -76,19 +66,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;
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\User;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\SendPasswordReset;
use Siteworxpro\App\Mailer\Message;
use Siteworxpro\App\Services\Facades\CommandBus;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\Question as QuestionInput;
#[AsCommand('user:reset-password', 'Reset a user\'s password')]
class ResetPassword extends Command
{
protected function configure(): void
{
$this->addOption(
'send-email',
's',
InputOption::VALUE_NONE,
description: 'Send password reset email to the user'
);
}
public function __invoke($input, $output): int
{
$client = $this->askForClient($output, $input);
if (!$client) {
$output->error('Client not found.');
return \Symfony\Component\Console\Command\Command::FAILURE;
}
$user = $this->askForUser($client, $output, $input);
if (!$user) {
$output->error('User not found for the specified client.');
return \Symfony\Component\Console\Command\Command::FAILURE;
}
if ($input->getOption('send-email')) {
/** @var Message $message */
$message = CommandBus::handle(new SendPasswordReset($user, $client));
$output->info('Password reset email sent to the user.');
$output->info('Email Subject: ' . $message->getSubject());
$output->info('Email Body: ' . $message->getBody());
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new QuestionInput('Enter the new password: ');
$question->setValidator(function ($input): string {
if (empty($input) || strlen($input) < 8) {
throw new \InvalidArgumentException('Password must be at least 8 characters long.');
}
return $input;
});
$question->setHidden(true);
$question->setHiddenFallback(false);
$newPassword = $helper->ask($input, $output, $question);
$user->password = $newPassword;
$user->save();
$output->info('Password has been reset successfully.');
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
}

View File

@@ -2,28 +2,46 @@
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<ClientGrant | mixed> $clientGrants
* @param string $clientDescription
* @param bool $isExternal
*/
public function __construct(
private string $clientName,
private array $clientGrants = [],
private string $clientDescription = ''
private string $clientDescription = '',
private bool $isExternal = false
) {
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()
))
)
);
}
}
}
/**
* @return bool
*/
public function isExternal(): bool
{
return $this->isExternal;
}
/**
* @return string
*/

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
use Siteworxpro\App\OAuth\Entities\Client;
readonly class CreateUser extends Command
{
private string $email;
public function __construct(
private Client $client,
string $email,
private string $password,
private string $firstName,
private string $lastName,
) {
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email address.');
}
$this->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;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client;
readonly class SendPasswordReset extends Command
{
public function __construct(
private User $user,
private Client $client
) {
}
public function getUser(): User
{
return $this->user;
}
/**
* @return Client
*/
public function getClient(): Client
{
return $this->client;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
use Siteworxpro\App\CommandBus\Commands\CreateUser;
use Siteworxpro\App\Models\ClientUser;
use Siteworxpro\App\Models\User;
#[HandlesCommand(CreateUser::class)]
class CreateUserHandler
{
public function __invoke(CreateUser $command): User
{
$user = User::create([
'email' => $command->getEmail(),
'first_name' => $command->getFirstName(),
'last_name' => $command->getLastName(),
]);
$user->password = $command->getPassword();
$user->save();
$clientUser = new ClientUser();
$clientUser->client_id = $command->getClient()->id;
$clientUser->user_id = $user->id;
$clientUser->save();
return $user;
}
}

View File

@@ -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,8 @@ 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->confidential = !$command->isExternal();
$client->save();

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Random\RandomException;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\SendPasswordReset;
use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException;
use Siteworxpro\App\Helpers\Rand;
use Siteworxpro\App\Mailer\Message;
use Siteworxpro\App\Models\ClientRedirectUri;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Mailer;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\App\Services\Facades\Twig;
#[HandlesCommand(SendPasswordReset::class)]
class SendPasswordResetHandler extends CommandHandler
{
/**
* @throws RandomException
*/
public function __invoke(Command|SendPasswordReset $command): Message
{
if (!$command instanceof SendPasswordReset) {
throw new CommandHandlerException('Invalid command type provided to handler.');
}
$token = Rand::string(64);
/** @var ClientRedirectUri $redirectUri */
$redirectUri = $command->getClient()->clientRedirectUris->first();
$content = Twig::render('password-reset.twig', [
'user' => $command->getUser(),
'resetLink' => sprintf(
'%s/authorize?token=%s&client_id=%s&response_type=code&redirect_uri=%s#/password-reset',
Config::get('app.url'),
$token,
$command->getClient()->client_id,
urlencode($redirectUri->redirect_uri)
),
'client' => $command->getClient()
]);
$from = $command->getClient()->capabilities->toArray()['support_email'] != ''
? $command->getClient()->capabilities->toArray()['support_email']
: Config::get('app.default_support_email');
$message = new Message(
$command->getUser()->email,
$from,
'Password Reset Request',
$content
);
if (Mailer::send($message) === false) {
throw new CommandHandlerException('Failed to send password reset email.');
}
Redis::set('password_reset:' . $command->getUser()->id, $token, 'EX', Redis::MINUTE * 15);
return $message;
}
}

View File

@@ -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,17 @@ final class AccessTokenController extends Controller
);
}
return $client
/** @var Response $response */
$response = $client
->getAuthorizationServer()
->respondToAccessTokenRequest($request, JsonResponseFactory::createJsonResponse([]));
->respondToAccessTokenRequest(
$request,
JsonResponseFactory::createJsonResponse([])
);
Dispatcher::push(new Issued($client, $response));
return $response;
} catch (OAuthServerException $e) {
return JsonResponseFactory::createJsonResponse(
$e->getPayload(),

View File

@@ -12,10 +12,13 @@ use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Nyholm\Psr7\Stream;
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;
@@ -63,6 +66,8 @@ final class AuthorizeController extends Controller
$user = $client->loginUser($email, $password);
if (!$user) {
Dispatcher::push(new LoginFailed($request, $client));
return JsonResponseFactory::createJsonResponse([
'success' => false,
'reason' => 'login failed'
@@ -76,6 +81,8 @@ final class AuthorizeController extends Controller
Redis::del('session:' . $s);
Dispatcher::push(new LoginSuccess($request, $client, $user));
return JsonResponseFactory::createJsonResponse([
'success' => true,
'location' => $response->getHeader('Location')[0]

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\NotFoundResponse;
use Siteworxpro\App\Models\User;
class UserInfo extends Controller
{
/**
* @throws \JsonException
*/
#[Scope(['profile', 'email', 'openid'])]
#[Jwt]
public function get(ServerRequest $request): ResponseInterface
{
$userId = $request->getAttribute('sub');
/** @var User | null $user */
$user = User::find($userId);
if (!$user) {
return JsonResponseFactory::createJsonResponse(
new NotFoundResponse($request->getUri()->getPath())
);
}
$responseData = [];
$scopes = $request->getAttribute('scopes', []);
$responseData['id'] = $user->id;
if (in_array('profile', $scopes, true)) {
$responseData['name'] = $user->first_name . ' ' . $user->last_name;
$responseData['given_name'] = $user->first_name;
$responseData['middle_name'] = ''; // todo
$responseData['family_name'] = $user->last_name;
$responseData['nickname'] = ''; // todo
$responseData['preferred_username'] = substr($user->first_name, 0, 1) . $user->last_name;
$responseData['picture'] = ''; // todo
$responseData['birthdate'] = ''; // todo
$responseData['phone_number'] = ''; // todo
$responseData['phone_number_verified'] = false; // todo
}
if (in_array('email', $scopes, true)) {
$responseData['email'] = $user->email;
$responseData['email_verified'] = false; // todo
}
return JsonResponseFactory::createJsonResponse($responseData);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\EventListeners;
use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Events\AccessToken\Issued as IssuedEvent;
use Siteworxpro\App\Models\AuditLog;
use Siteworxpro\App\Models\Enums\AuditLogAction;
#[ListensFor(IssuedEvent::class)]
class AccessTokenIssuedListener extends Listener
{
/**
* Handle the event.
*
* @param string | IssuedEvent $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 IssuedEvent) {
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

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners;
namespace Siteworxpro\App\EventListeners;
/**
* Class Listener

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners;
namespace Siteworxpro\App\EventListeners;
/**
* Interface ListenerInterface

View File

@@ -0,0 +1,50 @@
<?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\Login\LoginFailed::class)]
#[ListensFor(\Siteworxpro\App\Events\Login\LoginSuccess::class)]
class LoginListener extends Listener
{
/**
* Handle the event.
*
* @param string | \Siteworxpro\App\Events\Login\LoginFailed $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\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' => $action,
'details' => [
'client_id' => $event->getClient()->client_id,
'client_name' => $event->getClient()->name,
'username' => $event->getUsernameAttempted(),
'ip_address' => $event->getRequestIp(),
],
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
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 Client $client, private Response $response)
{
}
public function getResponse(): Response
{
return $this->response;
}
public function getClient(): Client
{
return $this->client;
}
}

View File

@@ -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) {
@@ -193,12 +193,17 @@ class Dispatcher implements DispatcherContract, Arrayable
/**
* Push an event to be dispatched later.
*
* @param $event
* @param mixed $event
* @param array $payload
* @return void
*/
public function push($event, $payload = []): void
{
if (!is_string($event)) {
$payload = [$event];
$event = get_class($event);
}
$this->pushed->put($event, $payload);
}

View File

@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners\Database;
use Illuminate\Database\Events\ConnectionEstablished;
use Illuminate\Database\Events\ConnectionEvent;
use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Events\Listeners\Listener;
use Siteworxpro\App\Services\Facades\Logger;
/**
* Class Connected
* @package Siteworxpro\App\Events\Listeners\Database
*/
#[ListensFor(ConnectionEstablished::class)]
class Connected extends Listener
{
/**
* @param mixed $event
* @param array $payload
* @return null
*/
public function __invoke(mixed $event, array $payload = []): null
{
if (!($event instanceof ConnectionEvent)) {
throw new \TypeError("Invalid event type passed to listener " . static::class);
}
return null;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client;
abstract class LoginAttempt
{
public function __construct(
private readonly ServerRequest $request,
private readonly Client $client,
private readonly ?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;
}
/**
* @return Client
*/
public function getClient(): Client
{
return $this->client;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User;
class LoginFailed extends LoginAttempt
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User;
class LoginSuccess extends LoginAttempt
{
}

View File

@@ -8,8 +8,6 @@ use Carbon\Carbon;
use Carbon\WrapperClock;
use GuzzleHttp\Exception\GuzzleException;
use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Hmac\Sha256 as Hmac256;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\InvalidTokenStructure;
@@ -26,9 +24,8 @@ use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Guzzle;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
/**
@@ -91,7 +88,6 @@ class JwtMiddleware extends Middleware
// Extract Bearer token from Authorization header.
$token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization'));
if (empty($token)) {
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
@@ -99,31 +95,39 @@ class JwtMiddleware extends Middleware
], CodesEnum::UNAUTHORIZED);
}
// Aggregate required issuers and audience from attributes.
$requiredIssuers = [];
$requiredAudience = '';
foreach ($attributes as $attribute) {
/** @var Jwt $jwtInstance */
$jwtInstance = $attribute->newInstance();
if ($jwtInstance->getAudience() !== '') {
$requiredAudience = $jwtInstance->getAudience();
$jwt = explode('.', $token);
if (count($jwt) !== 3) {
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'message' => 'Unauthorized: Invalid token format',
], CodesEnum::UNAUTHORIZED);
}
$requiredIssuers[] = $jwtInstance->getIssuer();
$payload = json_decode(base64_decode($jwt[1]), true, 512, JSON_THROW_ON_ERROR);
$clientId = str_replace(Config::get('app.url') . '/', '', $payload['iss'] ?? '');
$client = Client::find($clientId);
if (!$client) {
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'message' => 'Unauthorized: Invalid token issuer',
], CodesEnum::UNAUTHORIZED);
}
$iss = Config::get('app.url') . '/' . $client->id;
$aud = $clientId;
try {
// Parse and validate the token with signature, time, issuer and audience constraints.
$key = InMemory::plainText($client->publicKey());
$jwt = new JwtFacade()->parse(
$token,
$this->getSignedWith($token),
new SignedWith(new Sha256(), $key),
Config::get('jwt.strict_validation') ?
new StrictValidAt(new WrapperClock(Carbon::now())) :
new LooseValidAt(new WrapperClock(Carbon::now())),
new IssuedBy(...$requiredIssuers),
new PermittedFor($requiredAudience)
new IssuedBy($iss),
new PermittedFor($aud)
);
} catch (RequiredConstraintsViolated $exception) {
// Collect human-readable violations to return to the client.
@@ -159,164 +163,4 @@ class JwtMiddleware extends Middleware
return $handler->handle($request);
}
/**
* Build the signature validation constraint from configured key.
*
* - If the configured key content includes the string `PUBLIC KEY`, use RSA SHA-256.
* - Otherwise assume an HMAC SHA-256 shared secret.
* - Supports raw key strings or `file://` paths.
*
* @return SignedWith Signature constraint used during JWT parsing.
*
* @throws \RuntimeException When no signing key is configured.
* @throws \JsonException
*/
private function getSignedWith(string $token): SignedWith
{
$keyConfig = Config::get('jwt.signing_key');
if ($keyConfig === null) {
throw new \RuntimeException('JWT signing key is not configured.');
}
// file:// path to key
if (str_starts_with($keyConfig, 'file://')) {
$key = InMemory::file(substr($keyConfig, 7));
// openid jwks url
} elseif (str_contains($keyConfig, '.well-known/')) {
$jwt = explode('.', $token);
if (count($jwt) !== 3) {
throw new InvalidTokenStructure('Invalid JWT structure for JWKS key retrieval.');
}
$header = json_decode(base64_decode($jwt[0]), true, 512, JSON_THROW_ON_ERROR);
$keyId = $header['kid'] ?? '0'; // Default to '0' if no kid present
$key = $this->getJwksKey($keyConfig, $keyId);
} else {
$key = InMemory::plainText($keyConfig);
}
// Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC.
if (str_contains($key->contents(), 'PUBLIC KEY')) {
return new SignedWith(new Sha256(), $key);
}
return new SignedWith(new Hmac256(), $key);
}
private function getJwksKey(string $url, string $keyId): Key
{
$cached = Redis::get('jwks_key_' . $keyId);
if ($cached !== null) {
return InMemory::plainText($cached);
}
$openIdConfig = Guzzle::get($url);
$body = json_decode($openIdConfig->getBody()->getContents(), true, JSON_THROW_ON_ERROR);
$jwksUri = $body['jwks_uri'] ?? '';
if (empty($jwksUri)) {
throw new \RuntimeException('JWKS URI not found in OpenID configuration.');
}
$jwksResponse = Guzzle::get($jwksUri);
$jwksBody = json_decode(
$jwksResponse->getBody()->getContents(),
true,
JSON_THROW_ON_ERROR
);
// For simplicity, we take the first key in the JWKS.
$firstKey = array_filter(
$jwksBody['keys'],
fn($key) => $key['kid'] === $keyId
)[0] ?? $jwksBody['keys'][0] ?? null;
if (empty($firstKey)) {
throw new \RuntimeException('No matching key found in JWKS for key ID: ' . $keyId);
}
$n = $firstKey['n'];
$e = $firstKey['e'];
$publicKeyPem = "-----BEGIN PUBLIC KEY-----\n" .
chunk_split(base64_encode($this->convertJwkToPem($n, $e)), 64) .
"-----END PUBLIC KEY-----\n";
Redis::set('jwks_key_' . $keyId, $publicKeyPem, 'EX', 3600);
return InMemory::plainText($publicKeyPem);
}
/**
* Build a DER-encoded SubjectPublicKeyInfo from JWK 'n' and 'e'.
* Returns raw DER bytes; caller base64-encodes and wraps with PEM headers.
*/
private function convertJwkToPem(string $n, string $e): string
{
$modulus = $this->base64UrlDecode($n);
$exponent = $this->base64UrlDecode($e);
$derN = $this->derEncodeInteger($modulus);
$derE = $this->derEncodeInteger($exponent);
// RSAPublicKey (PKCS#1): SEQUENCE { n INTEGER, e INTEGER }
$rsaPublicKey = $this->derEncodeSequence($derN . $derE);
// AlgorithmIdentifier for rsaEncryption: 1.2.840.113549.1.1.1 with NULL
$algId = hex2bin('300d06092a864886f70d0101010500');
// SubjectPublicKey (SPKI) BIT STRING, 0 unused bits + RSAPublicKey
$subjectPublicKey = $this->derEncodeBitString($rsaPublicKey);
// SubjectPublicKeyInfo: SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }
return $this->derEncodeSequence($algId . $subjectPublicKey);
}
private function base64UrlDecode(string $data): string
{
$data = strtr($data, '-_', '+/');
$pad = strlen($data) % 4;
if ($pad) {
$data .= str_repeat('=', 4 - $pad);
}
return base64_decode($data);
}
private function derEncodeLength(int $len): string
{
if ($len < 0x80) {
return chr($len);
}
$bytes = '';
while ($len > 0) {
$bytes = chr($len & 0xFF) . $bytes;
$len >>= 8;
}
return chr(0x80 | strlen($bytes)) . $bytes;
}
private function derEncodeInteger(string $bytes): string
{
// Remove leading zeroes
$bytes = ltrim($bytes, "\x00");
if ($bytes === '') {
$bytes = "\x00";
}
// Ensure positive INTEGER (prepend 0x00 if MSB set)
if ((ord($bytes[0]) & 0x80) !== 0) {
$bytes = "\x00" . $bytes;
}
return "\x02" . $this->derEncodeLength(strlen($bytes)) . $bytes;
}
private function derEncodeSequence(string $bytes): string
{
return "\x30" . $this->derEncodeLength(strlen($bytes)) . $bytes;
}
private function derEncodeBitString(string $bytes): string
{
// 0 unused bits + data
$payload = "\x00" . $bytes;
return "\x03" . $this->derEncodeLength(strlen($payload)) . $payload;
}
}

View File

@@ -15,6 +15,7 @@ use Siteworxpro\App\Services\ServiceProviders\DispatcherServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\EncryptionServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\LoggerServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\RedisServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\TwigProvider;
/**
* Class Kernel
@@ -38,6 +39,7 @@ class Kernel
BrokerServiceProvider::class,
CommandBusProvider::class,
EncryptionServiceProvider::class,
TwigProvider::class,
];
/**

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Mailer\Drivers;
use Siteworxpro\App\Mailer\Message;
interface DriverInterface
{
public function setFrom(string $address, string $name): void;
public function send(Message $message): bool;
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Mailer\Drivers;
use Siteworxpro\App\Mailer\Message;
use Siteworxpro\App\Services\Facades\Logger;
class Log implements DriverInterface
{
private string $fromAddress = '';
public function send(Message $message): bool
{
$logMessage = sprintf(
"=================================" . PHP_EOL .
"Email sent via Log Driver" . PHP_EOL .
"From: %s" . PHP_EOL .
"To: %s" . PHP_EOL .
"Subject: %s" . PHP_EOL .
"Body: " . PHP_EOL . "%s" . PHP_EOL .
"=================================",
$this->fromAddress,
$message->getTo(),
$message->getSubject(),
$this->formatBodyForLog($message->getBody())
);
Logger::info($logMessage);
return true;
}
public function setFrom(string $address, string $name): void
{
$this->fromAddress = sprintf('%s <%s>', $name, $address);
}
private function formatBodyForLog(string $body): string
{
$body = str_replace('<br>', "\n", $body);
$body = str_replace('<br/>', "\n", $body);
$body = strip_tags($body);
return wordwrap($body, 80);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Mailer\Drivers;
use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\PHPMailer;
use Siteworxpro\App\Mailer\Message;
use Siteworxpro\App\Services\Facades\Config;
class Smtp implements DriverInterface
{
private PHPMailer $mailer;
public function __construct()
{
$config = Config::get('mailer.smtp');
if (empty($config['host']) || empty($config['port'])) {
throw new \InvalidArgumentException(
'SMTP host and port must be specified in the configuration mailer.smtp.'
);
}
if ($config['encryption'] && !in_array($config['encryption'], ['ssl', 'starttls', 'none'], true)) {
throw new \InvalidArgumentException(
'Invalid SMTP encryption type specified. Valid options are: ssl, starttls, or none.'
);
}
$this->mailer = new PHPMailer(true);
$this->mailer->isSMTP();
$this->mailer->Host = $config['host'];
$this->mailer->Port = $config['port'];
$this->mailer->SMTPAuth = !empty($config['username']) && !empty($config['password']);
$this->mailer->Username = $config['username'] ?? '';
$this->mailer->Password = $config['password'] ?? '';
}
public function send(Message $message): bool
{
return true;
}
/**
* @throws Exception
*/
public function setFrom(string $address, string $name): void
{
$this->mailer->setFrom($address, $name, false);
}
}

42
src/Mailer/Message.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Mailer;
readonly class Message
{
public function __construct(
private string $to,
private string $from,
private string $subject,
private string $body
) {
if ($to != filter_var($to, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email address provided.');
}
}
public function getTo(): string
{
return $this->to;
}
/**
* @return string
*/
public function getFrom(): string
{
return $this->from;
}
public function getSubject(): string
{
return $this->subject;
}
public function getBody(): string
{
return $this->body;
}
}

26
src/Mailer/Sendmail.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Mailer;
use Siteworxpro\App\Services\Facades\Config;
class Sendmail
{
private Drivers\DriverInterface $driver;
public function __construct()
{
$this->driver = match (Config::get('mailer.driver')) {
'log' => new Drivers\Log(),
'smtp' => new Drivers\Smtp(),
default => throw new \InvalidArgumentException('Invalid mailer driver specified.'),
};
}
public function send(Message $message): bool
{
return $this->driver->send($message);
}
}

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ subject }}</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f9f9f9;
margin: 0;
padding: 20px;
}
.container {
background-color: #ffffff;
padding: 20px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
</style>
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,17 @@
{% extends '_base.twig' %}
{% block subject %}
Password Reset Request
{% endblock %}
{% block body %}
<p>{{ user.first_name }},</p>
<p>We received a request to reset your password. Click the link below to set a new password:</p>
<p><a href="{{ resetLink }}">Reset Your Password</a></p>
<p>If you did not request a password reset, please ignore this email.</p>
<p>Best regards,<br>{{ client.name }}</p>
{% endblock %}

55
src/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Carbon\Carbon;
use Siteworxpro\App\Models\Enums\AuditLogAction;
/**
* Class AuditLog
*
* @package Siteworxpro\App\Models
*
* @property int $id
* @property int|null $user_id
* @property AuditLogAction $action
* @property-read Carbon $timestamp
* @property array $details
*/
class AuditLog extends Model
{
public $timestamps = false;
protected $casts = [
'details' => '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) ?? [];
}
}

View File

@@ -14,4 +14,5 @@ namespace Siteworxpro\App\Models;
*/
class ClientScope extends Model
{
public $timestamps = false;
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models\Enums;
enum AuditLogAction: string
{
case LOGIN_SUCCESS = 'login_success';
case LOGIN_FAIL = 'login_fail';
case LOGOUT = 'logout';
case TOKEN_ISSUED = 'token_issued';
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models\Enums;
enum ClientGrant: string
{
case AUTHORIZATION_CODE = 'authorization_code';
case PASSWORD = 'password';
case CLIENT_CREDENTIALS = 'client_credentials';
case REFRESH_TOKEN = 'refresh_token';
case IMPLICIT = 'implicit';
public static function fromString(string $grant): ?ClientGrant
{
return match ($grant) {
'authorization_code' => 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,
];
}
}

View File

@@ -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
{

View File

@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Grant\ClientCredentialsGrant as ClientCredentialsGrant;
use Random\RandomException;
use Siteworxpro\App\Helpers\Rand;
use Siteworxpro\App\Models\ClientRedirectUri;
@@ -38,9 +39,10 @@ use Siteworxpro\App\OAuth\ScopeRepository;
* @property Collection $grant_types
* @property bool $confidential
*
* @property-read ClientCapabilities $capabilities
* @property ClientCapabilities | array $capabilities
* @property-read Collection<ClientRedirectUri> $clientRedirectUris
* @property-read Scope[]|Collection $scopes
* @property-read Collection<User> $users
*/
class Client extends Model implements ClientEntityInterface
{
@@ -169,8 +171,12 @@ class Client extends Model implements ClientEntityInterface
/**
* @throws \JsonException
*/
public function setCapabilitiesAttribute(ClientCapabilities $capabilities): void
public function setCapabilitiesAttribute(ClientCapabilities | array $capabilities): void
{
if (is_array($capabilities)) {
$capabilities = new ClientCapabilities($capabilities);
}
$this->attributes['capabilities'] = $capabilities->toJson();
}
@@ -190,6 +196,9 @@ class Client extends Model implements ClientEntityInterface
Key::loadFromAsciiSafeString($this->encryption_key)
);
$accessTokenTtl = $this->capabilities->toArray()['tokenSettings']['accessTokenTTL'] ?? 'PT1H';
$refreshTokenTtl = $this->capabilities->toArray()['tokenSettings']['refreshTokenTTL'] ?? 'P1M';
if (!empty($this->grant_types)) {
foreach ($this->grant_types as $grantType) {
switch ($grantType) {
@@ -199,22 +208,22 @@ class Client extends Model implements ClientEntityInterface
new \Siteworxpro\App\OAuth\RefreshTokenRepository(),
new \DateInterval('PT10M')
);
$grant->setRefreshTokenTTL(new \DateInterval('P1M'));
$grant->setRefreshTokenTTL(new \DateInterval($refreshTokenTtl));
break;
case 'client_credentials':
$grant = new \League\OAuth2\Server\Grant\ClientCredentialsGrant();
$grant = new ClientCredentialsGrant();
break;
case 'refresh_token':
$grant = new \League\OAuth2\Server\Grant\RefreshTokenGrant(
new \Siteworxpro\App\OAuth\RefreshTokenRepository()
);
$grant->setRefreshTokenTTL(new \DateInterval('P1M'));
$grant->setRefreshTokenTTL(new \DateInterval($refreshTokenTtl));
break;
default:
continue 2;
}
$authorizationServer->enableGrantType($grant);
$authorizationServer->enableGrantType($grant, new \DateInterval($accessTokenTtl));
}
}
@@ -232,4 +241,32 @@ class Client extends Model implements ClientEntityInterface
return $user->verifyPassword($password) ? $user : null;
}
public function disableScope(Scope $scope): void
{
/** @var ClientScope | null $clientScope */
$clientScope = ClientScope::where('client_id', $this->id)
->where('scope_id', $scope->id)
->first();
$clientScope?->delete();
}
public function enableScope(Scope $scope): void
{
$clientScope = new ClientScope();
$clientScope->client_id = $this->id;
$clientScope->scope_id = $scope->id;
$clientScope->save();
}
// convert private key to public rsa pem key and return it
public function publicKey(): string
{
$res = openssl_pkey_get_private($this->private_key);
$details = openssl_pkey_get_details($res);
return $details['key'];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use Illuminate\Contracts\Support\Arrayable;
use JetBrains\PhpStorm\ArrayShape;
class ClientCapabilities implements Arrayable
{
@@ -13,13 +14,20 @@ class ClientCapabilities implements Arrayable
private bool $passkey = false;
private array $socials = [];
private string $support_email = '';
private array $branding = [
'primaryColor' => '#000000',
'secondaryColor' => '#FFFFFF',
'logoUrl' => null,
];
public function __construct(array $capabilities)
private array $tokenSettings = [
'accessTokenTTL' => 'PT1H',
'refreshTokenTTL' => 'P1M',
];
public function __construct(array $capabilities = [])
{
if (isset($capabilities['userPass'])) {
$this->userPass = (bool)$capabilities['userPass'];
@@ -40,6 +48,14 @@ class ClientCapabilities implements Arrayable
if (isset($capabilities['branding']) && is_array($capabilities['branding'])) {
$this->branding = array_merge($this->branding, $capabilities['branding']);
}
if (isset($capabilities['support_email'])) {
$this->support_email = (string)$capabilities['support_email'];
}
if (isset($capabilities['tokenSettings']) && is_array($capabilities['tokenSettings'])) {
$this->tokenSettings = array_merge($this->tokenSettings, $capabilities['tokenSettings']);
}
}
public static function fromJson(string $data): self
@@ -53,6 +69,15 @@ class ClientCapabilities implements Arrayable
}
}
#[ArrayShape([
'userPass' => "bool",
'magicLink' => "bool",
'passkey' => "bool",
'socials' => "array",
'branding' => "array",
'support_email' => "string",
'tokenSettings' => "array",
])]
public function toArray(): array
{
return [
@@ -61,6 +86,8 @@ class ClientCapabilities implements Arrayable
'passkey' => $this->passkey,
'socials' => $this->socials,
'branding' => $this->branding,
'support_email' => $this->support_email,
'tokenSettings' => $this->tokenSettings,
];
}

View File

@@ -20,6 +20,10 @@ class Scope extends Model implements ScopeEntityInterface
{
use ScopeTrait;
protected $casts = [
'id' => 'string',
];
public function getIdentifier(): string
{
return $this->name;

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworxpro\App\Mailer\Message;
use Siteworxpro\App\Mailer\Sendmail;
use Siteworxpro\App\Services\Facade;
/**
* @method static bool send(Message $message)
*/
class Mailer extends Facade
{
public static function getFacadeRoot(): Sendmail
{
if (static::resolveFacadeInstance(self::getFacadeAccessor())) {
static::resolveFacadeInstance(self::getFacadeAccessor());
}
$sendMail = new Sendmail();
static::getFacadeContainer()->bind(self::getFacadeAccessor(), fn() => $sendMail);
return $sendMail;
}
protected static function getFacadeAccessor(): string
{
return Sendmail::class;
}
}

View File

@@ -23,6 +23,8 @@ use Siteworxpro\App\Services\Facade;
*/
class Redis extends Facade
{
public const MINUTE = 60;
/**
* Get the registered name of the component.
*

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworxpro\App\Services\Facade;
use Twig\Environment;
/**
* Class Twig
*
* Facade for accessing the Twig templating engine.
*
* @package Siteworxpro\App\Services\Facades
* @method static render(string $template, array $context = []): string
*/
class Twig extends Facade
{
protected static function getFacadeAccessor(): string
{
return Environment::class;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use Twig\Cache\NullCache;
use Twig\Environment;
class TwigProvider extends ServiceProvider
{
public function provides(): array
{
return [ Environment::class ];
}
public function register(): void
{
$this->app->singleton(Environment::class, function () {
$loader = new \Twig\Loader\FilesystemLoader(__DIR__ . '/../../Mailer/Templates');
return new Environment($loader, [
'cache' => new NullCache()
]);
});
}
}

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Events\Listeners;
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\Log\Logger;
use Siteworxpro\Tests\Unit;
class ConnectedTest extends Unit
{
/**
* @throws ContainerExceptionInterface
* @throws \ReflectionException
* @throws NotFoundExceptionInterface
*/
protected function setUp(): void
{
parent::setUp();
$inputBuffer = fopen('php://memory', 'r+');
$logger = new Logger(LogLevel::DEBUG, $inputBuffer);
\Siteworxpro\App\Services\Facades\Logger::getFacadeContainer()->bind(Logger::class, fn() => $logger);
}
public function testHandlesEvent()
{
$this->expectNotToPerformAssertions();
$connectedEvent = $this->createMock(ConnectionEstablished::class);
$listener = new Connected();
$listener->__invoke($connectedEvent);
}
public function testThrowsException()
{
$this->expectException(\TypeError::class);
$listener = new Connected();
$listener->__invoke(new \stdClass());
}
}