Basics of auth
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m31s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m24s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m57s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m14s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Failing after 2m58s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 1m24s

This commit is contained in:
2026-01-01 15:38:19 -05:00
parent 9f895bbb85
commit d0cee7b48f
35 changed files with 664 additions and 202 deletions

16
.dev/.env Normal file
View File

@@ -0,0 +1,16 @@
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
QUEUE_BROKER: redis
PHP_IDE_CONFIG: serverName=localhost
WORKERS: 1
GRPC_WORKERS: 1
DEBUG: 1
REDIS_HOST: redis
DB_HOST: postgres
DEV_MODE: 1
APP_ENCRYPTION_KEY: base64:40U+IWaPTpp5o23quMfxcZJ0lOzkNy07SQ1rH6AV13o=
DB_USERNAME: siteworxpro
DB_PASSWORD: password
DB_DATABASE: siteworxpro
DB_PORT: 5432

View File

@@ -73,12 +73,8 @@ services:
depends_on:
postgres:
condition: service_healthy
environment:
DB_USERNAME: ${DB_USERNAME:-siteworxpro}
DB_PASSWORD: ${DB_PASSWORD:-password}
DB_DATABASE: ${DB_DATABASE:-siteworxpro}
DB_HOST: ${DB_HOST-postgres}
DB_PORT: ${DB_PORT-5432}
env_file:
- .env
dev-runtime:
labels:
@@ -116,18 +112,8 @@ services:
condition: service_healthy
postgres:
condition: service_healthy
environment:
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
QUEUE_BROKER: redis
PHP_IDE_CONFIG: serverName=localhost
WORKERS: 1
GRPC_WORKERS: 1
DEBUG: 1
REDIS_HOST: redis
DB_HOST: postgres
DEV_MODE: 1
env_file:
- .env
## Kafka and Zookeeper for local development
kafka-ui:

View File

@@ -7,6 +7,8 @@ return [
'app' => [
'log_level' => Env::get('LOG_LEVEL', 'debug'),
'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'),
],
/**

View File

@@ -1,41 +1,41 @@
create table clients
(
id uuid default gen_random_uuid()
id VARCHAR(26) not null
constraint client_pk
primary key,
client_id varchar not null
client_id varchar not null
constraint client_client_id_key
unique,
client_secret varchar not null,
name varchar not null,
description varchar default '',
private_key text not null,
encryption_key text not null,
grant_types jsonb not null default '[]'::jsonb,
capabilities jsonb not null default '[]'::jsonb,
confidential boolean not null default true,
created_at timestamp default now(),
updated_at timestamp default now()
client_secret varchar not null,
name varchar not null,
description varchar default '',
private_key text not null,
encryption_key text not null,
grant_types jsonb not null default '[]'::jsonb,
capabilities jsonb not null default '[]'::jsonb,
confidential boolean not null default true,
created_at timestamp default now(),
updated_at timestamp default now()
);
create table client_redirect_uris
(
id uuid default gen_random_uuid()
id VARCHAR(26) not null
constraint client_redirect_uris_pk
primary key,
client_id uuid not null
client_id VARCHAR(26) not null
constraint client_redirect_uris_client_id_fk
references clients
on delete cascade,
redirect_uri varchar not null
redirect_uri varchar not null
);
create table scopes
(
id uuid default gen_random_uuid()
id VARCHAR(26) not null
constraint scopes_pk
primary key,
name varchar not null
name varchar not null
constraint scopes_name_key
unique,
description varchar
@@ -43,14 +43,14 @@ create table scopes
create table client_scopes
(
id uuid default gen_random_uuid()
id VARCHAR(26) not null
constraint client_scopes_pk
primary key,
client_id uuid not null
client_id VARCHAR(26) not null
constraint client_scopes_client_id_fk
references clients
on delete cascade,
scope_id uuid not null
scope_id VARCHAR(26) not null
constraint client_scopes_scope_id_fk
references scopes
on delete cascade,
@@ -60,29 +60,29 @@ create table client_scopes
create table users
(
id uuid default gen_random_uuid()
id VARCHAR(26) not null
constraint users_pk
primary key,
first_name varchar not null,
last_name varchar not null,
email varchar not null
first_name varchar not null,
last_name varchar not null,
email varchar not null
constraint users_email_key
unique,
password varchar not null,
password varchar not null,
created_at timestamp default now(),
updated_at timestamp default now()
);
create table client_users
(
id uuid default gen_random_uuid()
id VARCHAR(26) not null
constraint client_users_pk
primary key,
client_id uuid not null
client_id VARCHAR(26) not null
constraint client_users_client_id_fk
references clients
on delete cascade,
user_id uuid not null
user_id VARCHAR(26) not null
constraint client_users_user_id_fk
references users
on delete cascade,

View File

@@ -4,9 +4,7 @@
<template #header>
<div class="flex flex-col items-center justify-center">
<div>
<Image width="300"
src="https://i.careeruprising.com/_Pa5TnsUJ5v-EHQQZy3BHnbaiCjMGxusd7qNcvhd8jA/pr:sm/sm:1/enc/Ec8S-CxpyLc2M5XdibEf85vGU5KNfdR0Dx8Qf6DI2nbZG85hSSFtDV7TuynR5djSw5jhdTIyjd5xDX5z-Dgemw"
/>
</div>
<div class="text-2xl mt-5">
<span v-if="capabilities.client_name !== ''">{{ capabilities.client_name }}</span>
@@ -270,7 +268,7 @@ export default defineComponent({
this.loading = true
axios.post('/login', this.form).then(r => {
axios.post('/authorize', this.form).then(r => {
window.location.href = r.data.location
}).catch(() => {
this.$toast.add({

View File

@@ -9,11 +9,13 @@ use League\Route\Http\Exception\NotFoundException;
use League\Route\RouteGroup;
use League\Route\Router;
use Nyholm\Psr7\Factory\Psr17Factory;
use Siteworxpro\App\Controllers\AccessTokenController;
use Siteworxpro\App\Controllers\AuthorizeController;
use Siteworxpro\App\Controllers\CapabilitiesController;
use Siteworxpro\App\Controllers\HealthcheckController;
use Siteworxpro\App\Controllers\IndexController;
use Siteworxpro\App\Controllers\OpenApiController;
use Siteworxpro\App\Controllers\OpenIdController;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Http\Middleware\JwtMiddleware;
@@ -22,6 +24,7 @@ use Siteworxpro\App\Http\Responses\NotFoundResponse;
use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Logger;
use Siteworxpro\HttpStatus\CodesEnum;
use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\Worker;
@@ -84,7 +87,14 @@ class Api
$group->get('/capabilities', CapabilitiesController::class . '::get');
});
// Authorize URL
$this->router->get('/authorize', AuthorizeController::class . '::get');
$this->router->post('/authorize', AuthorizeController::class . '::post');
$this->router->group('/client/{client_id}', function (RouteGroup $group) {
$group->get('/.well-known/openid-configuration', OpenIdController::class . '::get');
$group->post('/access_token', AccessTokenController::class . '::post');
});
$this->router->middleware(new CorsMiddleware());
$this->router->middleware(new JwtMiddleware());
@@ -125,14 +135,17 @@ class Api
}
$this->worker->respond(
JsonResponseFactory::createJsonResponse(new NotFoundResponse($uri))
JsonResponseFactory::createJsonResponse(new NotFoundResponse($uri), CodesEnum::NOT_FOUND)
);
} catch (\Throwable $e) {
Logger::error($e->getMessage());
Logger::error($e->getTraceAsString());
$this->worker->respond(
JsonResponseFactory::createJsonResponse(new ServerErrorResponse($e))
JsonResponseFactory::createJsonResponse(
new ServerErrorResponse($e),
CodesEnum::INTERNAL_SERVER_ERROR
)
);
}
}

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli;
use Ahc\Cli\Application;
use Siteworxpro\App\Cli\Commands\Crypt\GenerateKey;
use Siteworxpro\App\Cli\Commands\OAuth\AddRedirectUri;
use Siteworxpro\App\Cli\Commands\OAuth\CreateClient;
use Siteworxpro\App\Cli\Commands\Queue\Start;
use Siteworxpro\App\Cli\Commands\User\Add;
use Siteworxpro\App\Helpers\Version;
use Siteworxpro\App\Kernel;
@@ -25,7 +27,9 @@ class App
$this->app->add(new CreateClient());
$this->app->add(new AddRedirectUri());
$this->app->add(new Add());
$this->app->add(new Start());
$this->app->add(new GenerateKey());
}
public function run(): int

View File

@@ -7,7 +7,7 @@ namespace Siteworxpro\App\Cli\Commands;
use Ahc\Cli\Application as App;
use League\CLImate\CLImate;
abstract class Command extends \Ahc\Cli\Input\Command
abstract class Command extends \Ahc\Cli\Input\Command implements CommandInterface
{
protected Climate $climate;

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Crypt;
use Random\RandomException;
use Siteworxpro\App\Cli\Commands\Command;
class GenerateKey extends Command
{
public function __construct()
{
parent::__construct('crypt:generate-key', 'Generate a new encryption key for the application');
}
/**
* @throws RandomException
*/
public function execute(): int
{
$key = base64_encode(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES));
$this->climate->info('Generated Encryption Key:');
$this->climate->out('base64:' . $key);
return 0;
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\User;
use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\Models\ClientUser;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client;
class Add extends Command
{
public function __construct()
{
parent::__construct('user:add', 'Add a new user to the system');
}
public function execute(): int
{
$clients = Client::all(['id', 'name']);
$this->climate->info('Available OAuth Clients:');
foreach ($clients as $client) {
$this->climate->out("[$client->id] $client->name");
}
$input = $this->climate->input('Enter the client ID to associate the new user with: ');
$input->accept(
$clients->pluck('id')->toArray()
);
$id = $input->prompt();
$client = Client::find($id);
if (!$client) {
$this->climate->error('Client not found.');
return 1;
}
$this->climate->info("Adding a new user for client: $client->name");
$input = $this->climate->input('Enter the user\'s email:');
$email = $input->prompt();
$user = User::where('email', $email)->first();
if ($user) {
$this->climate->error('A user with this email already exists. Associating the user with the client.');
$clientUser = new ClientUser();
$clientUser->client_id = $client->id;
$clientUser->user_id = $user->id;
$clientUser->save();
return 0;
}
$passwordInput = $this->climate->password('Enter the user\'s password: 🔐');
$password = $passwordInput->prompt();
$firstNameInput = $this->climate->input('Enter the user\'s first name: ');
$firstName = $firstNameInput->prompt();
$lastNameInput = $this->climate->input('Enter the user\'s last name: ');
$lastName = $lastNameInput->prompt();
$user = new User();
$user->email = $email;
$user->password = $password;
$user->first_name = $firstName;
$user->last_name = $lastName;
$user->save();
$clientUser = new ClientUser();
$clientUser->client_id = $client->id;
$clientUser->user_id = $user->id;
$clientUser->save();
return 0;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Defuse\Crypto\Exception\BadFormatException;
use Defuse\Crypto\Exception\EnvironmentIsBrokenException;
use League\OAuth2\Server\Exception\OAuthServerException;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\GenericResponse;
use Siteworxpro\App\OAuth\Entities\AuthorizationCode;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\HttpStatus\CodesEnum;
final class AccessTokenController extends Controller
{
/**
* @param ServerRequest $request
* @return ResponseInterface
* @throws BadFormatException
* @throws EnvironmentIsBrokenException
* @throws \JsonException
* @throws OAuthServerException
*/
public function post(ServerRequest $request): ResponseInterface
{
try {
$grantType = $request->getParsedBody()['grant_type'] ?? null;
$client = Client::find($request->getAttribute('client_id'));
if ($client === null) {
return JsonResponseFactory::createJsonResponse(
new GenericResponse('Invalid client'),
CodesEnum::BAD_REQUEST,
);
}
switch ($grantType) {
case 'authorization_code':
return $client
->getAuthorizationServer()
->respondToAccessTokenRequest($request, JsonResponseFactory::createJsonResponse([]));
case 'refresh_token':
break;
default:
return JsonResponseFactory::createJsonResponse(
new GenericResponse('Unsupported grant type'),
CodesEnum::BAD,
);
}
$response = $this->authorizationServer->respondToAccessTokenRequest(
$request,
new Response(),
);
return $response;
} catch (OAuthServerException $e) {
return $e->generateHttpResponse(new Response());
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers\Admin;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\OAuth\Entities\Client;
#[Jwt]
final class ClientsController extends Controller
{
/**
* @throws \JsonException
*/
#[Scope(['admin:clients:view'])]
public function get(ServerRequest $request): ResponseInterface
{
$clients = Client::all();
return JsonResponseFactory::createJsonResponse($clients->toArray());
}
}

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Defuse\Crypto\Exception\BadFormatException;
use Defuse\Crypto\Exception\EnvironmentIsBrokenException;
use HansOtt\PSR7Cookies\SetCookie;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Nyholm\Psr7\Stream;
use Psr\SimpleCache\InvalidArgumentException;
use Siteworxpro\App\Helpers\Rand;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\ServerErrorResponse;
@@ -21,75 +23,64 @@ use Siteworxpro\HttpStatus\CodesEnum;
final class AuthorizeController extends Controller
{
/**
* @throws InvalidArgumentException
* @param ServerRequest $request
* @return Response
* @throws BadFormatException
* @throws EnvironmentIsBrokenException
* @throws \JsonException
*/
// #[\Override] public function post(ServerRequest $request): Response
// {
// $s = $request->getCookieParams()['s'] ?? '';
//
// $password = $request->getParsedBody()['password'] ?? '';
// $email = $request->getParsedBody()['email'] ?? '';
//
// if (!$this->redis->get('session:' . $s)) {
// $this->log->error('Session Timed out', ['session' => $s]);
//
// return $this->sendJsonResponse(
// [
// 'error' => "your login session has timed out. please try again."
// ],
// 400
// );
// }
//
// /** @var AuthorizationRequest $authRequest */
// $authRequest = unserialize($this->redis->get('session:' . $s));
//
// if ($authRequest->isAuthorizationApproved()) {
// $response = $this
// ->authorizationServer
// ->completeAuthorizationRequest($authRequest, $this->sendJsonResponse());
//
// return $this->sendJsonResponse(
// [
// 'success' => true,
// 'location' => $response->getHeader('Location')[0]
// ]
// );
// }
//
// /** @var Client $client */
// $client = $authRequest->getClient();
//
// /** @var LoginInterface $entitiesModel */
// $entitiesModel = $client->entities_model;
//
// /** @var User | null $entity */
// $entity = $entitiesModel::performLogin($email, $password);
// if (!$entity) {
// return $this->sendJsonResponse(
// [
// 'success' => false,
// 'reason' => 'login failed'
// ],
// 401
// );
// }
//
// $authRequest->setUser($entity);
// $authRequest->setAuthorizationApproved(true);
// $response = $this
// ->authorizationServer
// ->completeAuthorizationRequest($authRequest, $this->sendJsonResponse());
//
// $this->redis->delete('session:' . $s);
//
// return $this->sendJsonResponse(
// [
// 'success' => true,
// 'location' => $response->getHeader('Location')[0]
// ]
// );
// }
public function post(ServerRequest $request): Response
{
$s = $request->getCookieParams()['s'] ?? '';
$password = $request->getParsedBody()['password'] ?? '';
$email = $request->getParsedBody()['email'] ?? '';
if (!Redis::get('session:' . $s)) {
Logger::warning('Session Timed out', ['session' => $s]);
return JsonResponseFactory::createJsonResponse([]);
}
/** @var AuthorizationRequest $authRequest */
$authRequest = unserialize(Redis::get('session:' . $s));
/** @var Client $client */
$client = $authRequest->getClient();
$authorizationServer = $client->getAuthorizationServer();
if ($authRequest->isAuthorizationApproved()) {
$response = $authorizationServer
->completeAuthorizationRequest($authRequest, JsonResponseFactory::createJsonResponse([]));
return JsonResponseFactory::createJsonResponse([
'success' => true,
'location' => $response->getHeader('Location')[0]
]);
}
$user = $client->loginUser($email, $password);
if (!$user) {
return JsonResponseFactory::createJsonResponse([
'success' => false,
'reason' => 'login failed'
], CodesEnum::UNAUTHORIZED);
}
$authRequest->setUser($user);
$authRequest->setAuthorizationApproved(true);
$response = $authorizationServer
->completeAuthorizationRequest($authRequest, JsonResponseFactory::createJsonResponse([]));
Redis::del('session:' . $s);
return JsonResponseFactory::createJsonResponse([
'success' => true,
'location' => $response->getHeader('Location')[0]
]);
}
/**
* @throws \Exception

View File

@@ -25,6 +25,8 @@ final class CapabilitiesController extends Controller
return JsonResponseFactory::createJsonResponse(new NotFoundResponse($request->getUri()->getPath()));
}
return JsonResponseFactory::createJsonResponse($client->capabilities->toArray());
return JsonResponseFactory::createJsonResponse(array_merge($client->capabilities->toArray(), [
'client_name' => $client->name,
]));
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\HttpStatus\CodesEnum;
final class OpenIdController extends Controller
{
/**
* @throws \JsonException
*/
public function get(ServerRequest $request): ResponseInterface
{
$clientId = $request->getAttribute('client_id');
$client = Client::find($clientId);
if (!$client) {
return JsonResponseFactory::createJsonResponse(
['error' => 'invalid_client'],
CodesEnum::BAD_REQUEST
);
}
$data = [
'issuer' => Config::get('app.url') . '/' . $clientId,
'authorization_endpoint' => Config::get('app.url') . '/authorize',
'token_endpoint' => Config::get('app.url') . '/client/' . $clientId . '/access_token',
'userinfo_endpoint' => Config::get('app.url') . '/user_info',
'end_session_endpoint' => Config::get('app.url') . '/end-session',
'introspection_endpoint' => Config::get('app.url') . '/introspect',
'revocation_endpoint' => Config::get('app.url') . '/revoke',
'device_authorization_endpoint' => Config::get('app.url') . '/device',
'jwks_uri' => Config::get('app.url') . '/client/' . $clientId . '/jwks',
'grant_types_supported' => $client->grant_types,
'scopes_supported' => $client->scopes->pluck('name')->toArray()
];
return JsonResponseFactory::createJsonResponse($data);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
use Random\RandomException;
readonly class Encryption
{
public function __construct(private string $key)
{
}
/**
* Encrypt the given data with AES256-GCM
* @param string $data
* @return string
* @throws RandomException
* @throws \SodiumException
*/
public function encrypt(string $data): string
{
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$enc = sodium_crypto_secretbox(
$data,
$nonce,
$this->key
);
return $nonce . $enc;
}
/**
* Decrypt the given data with AES256-GCM
* @param string $encryptedData
* @return string
* @throws \SodiumException
*/
public function decrypt(string $encryptedData): string
{
$nonce = substr($encryptedData, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = substr($encryptedData, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
return sodium_crypto_secretbox_open(
$ciphertext,
$nonce,
$this->key
);
}
}

View File

@@ -12,12 +12,15 @@ abstract class Env
{
/**
* @param string $key
* @param null $default
* @param mixed $default
* @param string $castTo
* @return float|bool|int|string
*/
public static function get(string $key, $default = null, string $castTo = 'string'): float | bool | int | string
{
public static function get(
string $key,
mixed $default = null,
string $castTo = 'string'
): float | bool | int | string {
$env = getenv($key) !== false ? getenv($key) : $default;
return match ($castTo) {

View File

@@ -17,6 +17,6 @@ class Ulid
*/
public static function generate(): string
{
return \Ulid\Ulid::generate()->getRandomness();
return (string) \Ulid\Ulid::generate();
}
}

View File

@@ -12,6 +12,7 @@ use Siteworxpro\App\Services\Facades\Dispatcher;
use Siteworxpro\App\Services\ServiceProviders\BrokerServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\CommandBusProvider;
use Siteworxpro\App\Services\ServiceProviders\DispatcherServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\EncryptionServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\LoggerServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\RedisServiceProvider;
@@ -36,6 +37,7 @@ class Kernel
DispatcherServiceProvider::class,
BrokerServiceProvider::class,
CommandBusProvider::class,
EncryptionServiceProvider::class,
];
/**

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Illuminate\Database\Eloquent\Model as ORM;
use Siteworxpro\App\Helpers\Ulid;
/**
* Class Model
@@ -16,4 +17,10 @@ use Illuminate\Database\Eloquent\Model as ORM;
abstract class Model extends ORM
{
protected $dateFormat = 'Y-m-d H:i:s';
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->attributes['id'] = $this->attributes['id'] ?? Ulid::generate();
}
}

View File

@@ -6,6 +6,7 @@ namespace Siteworxpro\App\Models;
use Carbon\Carbon;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\UserEntityInterface;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Helpers\Ulid;
@@ -39,11 +40,10 @@ use Siteworxpro\App\Helpers\Ulid;
new OA\Property(property: "created_at", type: "string", format: "date-time"),
]
)]
class User extends Model
class User extends Model implements UserEntityInterface
{
use EntityTrait;
protected $casts = [
'id' => 'string',
'created_at' => 'datetime',
];
@@ -58,12 +58,6 @@ class User extends Model
'password',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->attributes['id'] = $this->attributes['id'] ?? Ulid::generate();
}
public function getFullNameAttribute(): string
{
return "$this->first_name $this->last_name";
@@ -77,4 +71,20 @@ class User extends Model
strtolower($this->email)
);
}
public function verifyPassword(string $password): bool
{
// Verify the provided password against the stored hashed password
return password_verify($password, $this->password);
}
public function setPasswordAttribute(string $password): void
{
$this->attributes['password'] = password_hash($password, PASSWORD_ARGON2ID);
}
public function getIdentifier(): string
{
return $this->id;
}
}

View File

@@ -6,26 +6,35 @@ namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
use Psr\SimpleCache\InvalidArgumentException;
use Siteworxpro\App\OAuth\Entities\AuthorizationCode;
class AuthCodeRepository implements AuthCodeRepositoryInterface
{
public function getNewAuthCode(): AuthCodeEntityInterface
{
// TODO: Implement getNewAuthCode() method.
return new AuthorizationCode();
}
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void
public function persistNewAuthCode(AuthCodeEntityInterface | AuthorizationCode $authCodeEntity): void
{
// TODO: Implement persistNewAuthCode() method.
$authCodeEntity->save();
}
/**
* @throws InvalidArgumentException
*/
public function revokeAuthCode(string $codeId): void
{
// TODO: Implement revokeAuthCode() method.
$authCode = AuthorizationCode::find($codeId);
$authCode?->delete();
}
public function isAuthCodeRevoked(string $codeId): bool
{
// TODO: Implement isAuthCodeRevoked() method.
$authCode = AuthorizationCode::find($codeId);
return $authCode === null;
}
}

View File

@@ -40,17 +40,15 @@ readonly class ClientRepository implements ClientRepositoryInterface
*/
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool
{
$client = Client::find($clientIdentifier);
if ($client === null) {
if ($this->client->client_id != $clientIdentifier) {
return false;
}
if ($clientSecret && $client->client_secret != $clientSecret) {
if ($clientSecret && $this->client->client_secret != $clientSecret) {
return false;
}
if ($grantType && !in_array($grantType, $client->grant_types)) {
if ($grantType && !in_array($grantType, $this->client->grant_types->toArray())) {
return false;
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use Carbon\Carbon;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
@@ -14,24 +15,31 @@ class AccessToken extends RedisModel implements AccessTokenEntityInterface
{
use AccessTokenTrait;
private Client |null $client = null;
private string | null $userIdentifier = null;
/** @var ScopeEntityInterface|Scope[] */
private array $scopes = [];
public function getClient(): ClientEntityInterface
{
// TODO: Implement getClient() method.
return $this->client;
}
public function getExpiryDateTime(): DateTimeImmutable
{
// TODO: Implement getExpiryDateTime() method.
return $this->expiryDateTime->toDateTimeImmutable();
}
public function getUserIdentifier(): string|null
{
// TODO: Implement getUserIdentifier() method.
return $this->userIdentifier;
}
public function getScopes(): array
{
// TODO: Implement getScopes() method.
return $this->scopes;
}
protected static function getRedisPrefix(): string
@@ -41,21 +49,21 @@ class AccessToken extends RedisModel implements AccessTokenEntityInterface
public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{
// TODO: Implement setExpiryDateTime() method.
$this->expiryDateTime = Carbon::instance($dateTime);
}
public function setUserIdentifier(string $identifier): void
{
// TODO: Implement setUserIdentifier() method.
$this->userIdentifier = $identifier;
}
public function setClient(ClientEntityInterface $client): void
{
// TODO: Implement setClient() method.
$this->client = $client;
}
public function addScope(ScopeEntityInterface $scope): void
{
// TODO: Implement addScope() method.
$this->scopes[] = $scope;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use Carbon\Carbon;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
@@ -14,6 +15,15 @@ class AuthorizationCode extends RedisModel implements AuthCodeEntityInterface
{
use AuthCodeTrait;
private Client | null $client = null;
private string | null $userIdentifier = null;
/**
* @var array<ScopeEntityInterface | Scope> $scopes
*/
private array $scopes = [];
protected static function getRedisPrefix(): string
{
return 'oauth_auth_code';
@@ -21,41 +31,41 @@ class AuthorizationCode extends RedisModel implements AuthCodeEntityInterface
public function getExpiryDateTime(): DateTimeImmutable
{
// TODO: Implement getExpiryDateTime() method.
return $this->expiryDateTime->toDateTimeImmutable();
}
public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{
// TODO: Implement setExpiryDateTime() method.
$this->expiryDateTime = Carbon::instance($dateTime);
}
public function setUserIdentifier(string $identifier): void
{
// TODO: Implement setUserIdentifier() method.
$this->userIdentifier = $identifier;
}
public function getUserIdentifier(): string|null
{
// TODO: Implement getUserIdentifier() method.
return $this->userIdentifier;
}
public function getClient(): ClientEntityInterface
public function getClient(): ClientEntityInterface | Client
{
// TODO: Implement getClient() method.
return $this->client;
}
public function setClient(ClientEntityInterface $client): void
{
// TODO: Implement setClient() method.
$this->client = $client;
}
public function addScope(ScopeEntityInterface $scope): void
{
// TODO: Implement addScope() method.
$this->scopes[] = $scope;
}
public function getScopes(): array
{
// TODO: Implement getScopes() method.
return $this->scopes;
}
}

View File

@@ -35,7 +35,7 @@ use Siteworxpro\App\OAuth\ScopeRepository;
* @property string $description
* @property string $private_key
* @property string $encryption_key
* @property string[] $grant_types
* @property Collection<string> $grant_types
* @property bool $confidential
*
* @property-read ClientCapabilities $capabilities
@@ -101,7 +101,14 @@ class Client extends Model implements ClientEntityInterface
*/
public function scopes(): HasManyThrough
{
return $this->hasManyThrough(Scope::class, ClientScope::class);
return $this->hasManyThrough(
Scope::class,
ClientScope::class,
'client_id',
'id',
'id',
'scope_id'
);
}
/**
@@ -109,7 +116,14 @@ class Client extends Model implements ClientEntityInterface
*/
public function users(): HasManyThrough
{
return $this->hasManyThrough(User::class, ClientUser::class);
return $this->hasManyThrough(
User::class,
ClientUser::class,
'client_id',
'id',
'id',
'user_id'
);
}
/**
@@ -154,7 +168,7 @@ class Client extends Model implements ClientEntityInterface
*/
public function setCapabilitiesAttribute(ClientCapabilities $capabilities): void
{
$this->attributes->capabilities = $capabilities->toJson();
$this->attributes['capabilities'] = $capabilities->toJson();
}
/**
@@ -203,4 +217,16 @@ class Client extends Model implements ClientEntityInterface
return $authorizationServer;
}
public function loginUser(string $username, string $password): ?User
{
/** @var User|null $user */
$user = $this->users()->where('email', $username)->first();
if (!$user) {
return null;
}
return $user->verifyPassword($password) ? $user : null;
}
}

View File

@@ -13,7 +13,7 @@ class ClientCapabilities implements Arrayable
private bool $passkey = false;
private array $socials = [];
private array $theme = [
private array $branding = [
'primaryColor' => '#000000',
'secondaryColor' => '#FFFFFF',
'logoUrl' => null,
@@ -37,8 +37,8 @@ class ClientCapabilities implements Arrayable
$this->socials = $capabilities['socials'];
}
if (isset($capabilities['theme']) && is_array($capabilities['theme'])) {
$this->theme = array_merge($this->theme, $capabilities['theme']);
if (isset($capabilities['branding']) && is_array($capabilities['branding'])) {
$this->branding = array_merge($this->branding, $capabilities['branding']);
}
}
@@ -60,7 +60,7 @@ class ClientCapabilities implements Arrayable
'magicLink' => $this->magicLink,
'passkey' => $this->passkey,
'socials' => $this->socials,
'theme' => $this->theme,
'branding' => $this->branding,
];
}

View File

@@ -8,28 +8,24 @@ use Carbon\Carbon;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use Psr\SimpleCache\InvalidArgumentException;
use Siteworxpro\App\Services\Facades\Encryption;
use Siteworxpro\App\Services\Facades\Redis;
abstract class RedisModel
{
use EntityTrait;
private \Predis\Client $redis;
protected ?DateTimeImmutable $expireTime;
public function __construct()
{
$this->redis = Redis::getFacadeRoot();
}
protected ?Carbon $expiryDateTime;
abstract protected static function getRedisPrefix(): string;
public static function find(string $identifier): ?self
public static function find(string $identifier): ?static
{
$instance = Redis::get(static::getRedisPrefix() . ':' . $identifier);
if ($instance !== null) {
$instance = Encryption::decrypt($instance);
return unserialize($instance);
}
@@ -39,22 +35,20 @@ abstract class RedisModel
public function save(): void
{
$diff = 0;
if ($this->expireTime) {
$diff = $this->expireTime->getTimestamp() - Carbon::now()->timestamp;
if ($this->expiryDateTime) {
$diff = $this->expiryDateTime->getTimestamp() - Carbon::now()->timestamp;
}
$this->redis->set(
Redis::set(
static::getRedisPrefix() . ':' . $this->getIdentifier(),
serialize($this),
Encryption::encrypt(serialize($this)),
'EX',
$diff
);
}
/**
* @throws InvalidArgumentException
*/
public function delete(): void
{
$this->redis->delete(static::getRedisPrefix() . ':' . $this->getIdentifier());
Redis::del(static::getRedisPrefix() . ':' . $this->getIdentifier());
}
}

View File

@@ -4,15 +4,37 @@ declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use Carbon\Carbon;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
class RefreshToken extends RedisModel implements RefreshTokenEntityInterface
{
use RefreshTokenTrait;
private AccessToken | null $accessToken = null;
protected static function getRedisPrefix(): string
{
return 'oauth_refresh_token';
}
public function getExpiryDateTime(): DateTimeImmutable
{
return $this->expiryDateTime->toDateTimeImmutable();
}
public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{
$this->expiryDateTime = Carbon::instance($dateTime);
}
public function setAccessToken(AccessTokenEntityInterface $accessToken): void
{
$this->accessToken = $accessToken;
}
public function getAccessToken(): AccessTokenEntityInterface
{
return $this->accessToken;
}
}

View File

@@ -6,27 +6,33 @@ namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use Psr\SimpleCache\InvalidArgumentException;
use Siteworxpro\App\OAuth\Entities\RefreshToken;
class RefreshTokenRepository implements RefreshTokenRepositoryInterface
{
public function getNewRefreshToken(): ?RefreshTokenEntityInterface
{
// TODO: Implement getNewRefreshToken() method.
return new RefreshToken();
}
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void
public function persistNewRefreshToken(RefreshTokenEntityInterface | RefreshToken $refreshTokenEntity): void
{
// TODO: Implement persistNewRefreshToken() method.
$refreshTokenEntity->save();
}
/**
* @throws InvalidArgumentException
*/
public function revokeRefreshToken(string $tokenId): void
{
// TODO: Implement revokeRefreshToken() method.
$token = RefreshToken::find($tokenId);
$token?->delete();
}
public function isRefreshTokenRevoked(string $tokenId): bool
{
// TODO: Implement isRefreshTokenRevoked() method.
$token = RefreshToken::find($tokenId);
return $token === null;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworxpro\App\Services\Facade;
/**
* @method static string encrypt(string $data)
* @method static string decrypt(string $data)
*/
class Encryption extends Facade
{
protected static function getFacadeAccessor(): string
{
return \Siteworxpro\App\Helpers\Encryption::class;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use Siteworxpro\App\Helpers\Encryption;
use Siteworxpro\App\Services\Facades\Config;
class EncryptionServiceProvider extends ServiceProvider
{
public function provides(): array
{
return [Encryption::class];
}
public function register(): void
{
$this->app->singleton(Encryption::class, function () {
$key = Config::get('app.encryption_key');
if (empty($key)) {
throw new \RuntimeException('Encryption key is not set in configuration.');
}
if (str_contains($key, 'base64:')) {
$key = base64_decode(substr($key, 7));
}
return new Encryption($key);
});
}
}