Basics of auth

This commit is contained in:
2026-01-01 10:32:17 -05:00
parent 23f2b6432b
commit 9f895bbb85
66 changed files with 5967 additions and 156 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ vendor/
.phpunit.cache/
tests/reports/
public/

View File

@@ -18,6 +18,11 @@ grpc:
- "protos/example.proto"
http:
middleware: [ "static" ]
static:
dir: "public/"
allow: [ ".css", ".js", ".ico" ]
pool:
allocate_timeout: 5s
reset_timeout: 5s

View File

@@ -28,7 +28,11 @@
"guzzlehttp/guzzle": "^7.10",
"zircote/swagger-php": "^5.7",
"spiral/roadrunner-grpc": "^3.5",
"league/tactician": "^1.1"
"league/tactician": "^1.1",
"league/oauth2-server": "^9.3",
"ext-sodium": "*",
"league/climate": "^3.10",
"hansott/psr7-cookies": "^4.0"
},
"require-dev": {
"phpunit/phpunit": "^12.4",

761
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": "d027bee8e875c5542f7ff9612bfac4e2",
"content-hash": "9344732ab599b7f8ea85a31743adcee1",
"packages": [
{
"name": "adhocore/cli",
@@ -208,6 +208,73 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "defuse/php-encryption",
"version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/defuse/php-encryption.git",
"reference": "f53396c2d34225064647a05ca76c1da9d99e5828"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828",
"reference": "f53396c2d34225064647a05ca76c1da9d99e5828",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"paragonie/random_compat": ">= 2",
"php": ">=5.6.0"
},
"require-dev": {
"phpunit/phpunit": "^5|^6|^7|^8|^9|^10",
"yoast/phpunit-polyfills": "^2.0.0"
},
"bin": [
"bin/generate-defuse-key"
],
"type": "library",
"autoload": {
"psr-4": {
"Defuse\\Crypto\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Hornby",
"email": "taylor@defuse.ca",
"homepage": "https://defuse.ca/"
},
{
"name": "Scott Arciszewski",
"email": "info@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "Secure PHP Encryption Library",
"keywords": [
"aes",
"authenticated encryption",
"cipher",
"crypto",
"cryptography",
"encrypt",
"encryption",
"openssl",
"security",
"symmetric key cryptography"
],
"support": {
"issues": "https://github.com/defuse/php-encryption/issues",
"source": "https://github.com/defuse/php-encryption/tree/v2.4.0"
},
"time": "2023-06-19T06:10:36+00:00"
},
{
"name": "doctrine/inflector",
"version": "2.1.0",
@@ -726,6 +793,73 @@
],
"time": "2025-08-23T21:21:41+00:00"
},
{
"name": "hansott/psr7-cookies",
"version": "4.0.0",
"source": {
"type": "git",
"url": "https://github.com/hansott/psr7-cookies.git",
"reference": "fdd4790274996a3d5a723fa32d1c82e2b319e8fc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/hansott/psr7-cookies/zipball/fdd4790274996a3d5a723fa32d1c82e2b319e8fc",
"reference": "fdd4790274996a3d5a723fa32d1c82e2b319e8fc",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^2.0"
},
"require-dev": {
"guzzlehttp/psr7": "^2.0",
"phpunit/phpunit": "^6.0 || ^7.0 || ^8.0 || ^9.0",
"scrutinizer/ocular": "~1.1"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"HansOtt\\PSR7Cookies\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Hans Ott",
"email": "hans@iott.consulting",
"role": "Developer"
}
],
"description": "🍪 bakes cookies for PSR-7 messages",
"homepage": "https://github.com/hansott/psr7-cookies",
"keywords": [
"cookies",
"fig",
"hansott",
"http-message",
"psr7-cookies",
"setcookie"
],
"support": {
"issues": "https://github.com/hansott/psr7-cookies/issues",
"source": "https://github.com/hansott/psr7-cookies/tree/4.0.0"
},
"funding": [
{
"url": "https://github.com/hansott",
"type": "github"
}
],
"time": "2024-01-08T11:06:34+00:00"
},
{
"name": "illuminate/collections",
"version": "v12.38.1",
@@ -1197,6 +1331,70 @@
},
"time": "2025-10-09T13:42:30+00:00"
},
{
"name": "lcobucci/clock",
"version": "3.5.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/clock.git",
"reference": "a3139d9e97d47826f27e6a17bb63f13621f86058"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/clock/zipball/a3139d9e97d47826f27e6a17bb63f13621f86058",
"reference": "a3139d9e97d47826f27e6a17bb63f13621f86058",
"shasum": ""
},
"require": {
"php": "~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"provide": {
"psr/clock-implementation": "1.0"
},
"require-dev": {
"infection/infection": "^0.31",
"lcobucci/coding-standard": "^11.2.0",
"phpstan/extension-installer": "^1.3.1",
"phpstan/phpstan": "^2.0.0",
"phpstan/phpstan-deprecation-rules": "^2.0.0",
"phpstan/phpstan-phpunit": "^2.0.0",
"phpstan/phpstan-strict-rules": "^2.0.0",
"phpunit/phpunit": "^12.0.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\Clock\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com"
}
],
"description": "Yet another clock abstraction",
"support": {
"issues": "https://github.com/lcobucci/clock/issues",
"source": "https://github.com/lcobucci/clock/tree/3.5.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-10-27T09:03:17+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
@@ -1270,6 +1468,227 @@
],
"time": "2025-10-17T11:30:53+00:00"
},
{
"name": "league/climate",
"version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/climate.git",
"reference": "237f70e1032b16d32ff3f65dcda68706911e1c74"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/climate/zipball/237f70e1032b16d32ff3f65dcda68706911e1c74",
"reference": "237f70e1032b16d32ff3f65dcda68706911e1c74",
"shasum": ""
},
"require": {
"php": "^7.3 || ^8.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"seld/cli-prompt": "^1.0"
},
"require-dev": {
"mikey179/vfsstream": "^1.6.12",
"mockery/mockery": "^1.6.12",
"phpunit/phpunit": "^9.5.10",
"squizlabs/php_codesniffer": "^3.10"
},
"suggest": {
"ext-mbstring": "If ext-mbstring is not available you MUST install symfony/polyfill-mbstring"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\CLImate\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Joe Tannenbaum",
"email": "hey@joe.codes",
"homepage": "http://joe.codes/",
"role": "Developer"
},
{
"name": "Craig Duncan",
"email": "git@duncanc.co.uk",
"homepage": "https://github.com/duncan3dc",
"role": "Developer"
}
],
"description": "PHP's best friend for the terminal. CLImate allows you to easily output colored text, special formats, and more.",
"keywords": [
"cli",
"colors",
"command",
"php",
"terminal"
],
"support": {
"issues": "https://github.com/thephpleague/climate/issues",
"source": "https://github.com/thephpleague/climate/tree/3.10.0"
},
"time": "2024-11-18T09:09:55+00:00"
},
{
"name": "league/event",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/event.git",
"reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210",
"reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210",
"shasum": ""
},
"require": {
"php": ">=7.2.0",
"psr/event-dispatcher": "^1.0"
},
"provide": {
"psr/event-dispatcher-implementation": "1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.16",
"phpstan/phpstan": "^0.12.45",
"phpunit/phpunit": "^8.5"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"League\\Event\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frenky.net"
}
],
"description": "Event package",
"keywords": [
"emitter",
"event",
"listener"
],
"support": {
"issues": "https://github.com/thephpleague/event/issues",
"source": "https://github.com/thephpleague/event/tree/3.0.3"
},
"time": "2024-09-04T16:06:53+00:00"
},
{
"name": "league/oauth2-server",
"version": "9.3.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth2-server.git",
"reference": "d8e2f39f645a82b207bbac441694d6e6079357cb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/d8e2f39f645a82b207bbac441694d6e6079357cb",
"reference": "d8e2f39f645a82b207bbac441694d6e6079357cb",
"shasum": ""
},
"require": {
"defuse/php-encryption": "^2.4",
"ext-json": "*",
"ext-openssl": "*",
"lcobucci/clock": "^2.3 || ^3.0",
"lcobucci/jwt": "^5.0",
"league/event": "^3.0",
"league/uri": "^7.0",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/http-message": "^2.0",
"psr/http-server-middleware": "^1.0"
},
"replace": {
"league/oauth2server": "*",
"lncd/oauth2": "*"
},
"require-dev": {
"laminas/laminas-diactoros": "^3.5",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpstan/extension-installer": "^1.3.1",
"phpstan/phpstan": "^1.12|^2.0",
"phpstan/phpstan-deprecation-rules": "^1.1.4|^2.0",
"phpstan/phpstan-phpunit": "^1.3.15|^2.0",
"phpstan/phpstan-strict-rules": "^1.5.2|^2.0",
"phpunit/phpunit": "^10.5|^11.5|^12.0",
"roave/security-advisories": "dev-master",
"slevomat/coding-standard": "^8.14.1",
"squizlabs/php_codesniffer": "^3.8"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\OAuth2\\Server\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Bilbie",
"email": "hello@alexbilbie.com",
"homepage": "http://www.alexbilbie.com",
"role": "Developer"
},
{
"name": "Andy Millington",
"email": "andrew@noexceptions.io",
"homepage": "https://www.noexceptions.io",
"role": "Developer"
}
],
"description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.",
"homepage": "https://oauth2.thephpleague.com/",
"keywords": [
"Authentication",
"api",
"auth",
"authorisation",
"authorization",
"oauth",
"oauth 2",
"oauth 2.0",
"oauth2",
"protect",
"resource",
"secure",
"server"
],
"support": {
"issues": "https://github.com/thephpleague/oauth2-server/issues",
"source": "https://github.com/thephpleague/oauth2-server/tree/9.3.0"
},
"funding": [
{
"url": "https://github.com/sephster",
"type": "github"
}
],
"time": "2025-11-25T22:51:15+00:00"
},
{
"name": "league/route",
"version": "6.2.0",
@@ -1415,6 +1834,188 @@
},
"time": "2021-02-14T15:29:04+00:00"
},
{
"name": "league/uri",
"version": "7.7.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri.git",
"reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807",
"reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807",
"shasum": ""
},
"require": {
"league/uri-interfaces": "^7.7",
"php": "^8.1",
"psr/http-factory": "^1"
},
"conflict": {
"league/uri-schemes": "^1.0"
},
"suggest": {
"ext-bcmath": "to improve IPV4 host parsing",
"ext-dom": "to convert the URI into an HTML anchor tag",
"ext-fileinfo": "to create Data URI from file contennts",
"ext-gmp": "to improve IPV4 host parsing",
"ext-intl": "to handle IDN host with the best performance",
"ext-uri": "to use the PHP native URI class",
"jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain",
"league/uri-components": "Needed to easily manipulate URI objects components",
"league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP",
"php-64bit": "to improve IPV4 host parsing",
"rowbot/url": "to handle WHATWG URL",
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
}
},
"autoload": {
"psr-4": {
"League\\Uri\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ignace Nyamagana Butera",
"email": "nyamsprod@gmail.com",
"homepage": "https://nyamsprod.com"
}
],
"description": "URI manipulation library",
"homepage": "https://uri.thephpleague.com",
"keywords": [
"URN",
"data-uri",
"file-uri",
"ftp",
"hostname",
"http",
"https",
"middleware",
"parse_str",
"parse_url",
"psr-7",
"query-string",
"querystring",
"rfc2141",
"rfc3986",
"rfc3987",
"rfc6570",
"rfc8141",
"uri",
"uri-template",
"url",
"ws"
],
"support": {
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
"source": "https://github.com/thephpleague/uri/tree/7.7.0"
},
"funding": [
{
"url": "https://github.com/sponsors/nyamsprod",
"type": "github"
}
],
"time": "2025-12-07T16:02:06+00:00"
},
{
"name": "league/uri-interfaces",
"version": "7.7.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri-interfaces.git",
"reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c",
"reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c",
"shasum": ""
},
"require": {
"ext-filter": "*",
"php": "^8.1",
"psr/http-message": "^1.1 || ^2.0"
},
"suggest": {
"ext-bcmath": "to improve IPV4 host parsing",
"ext-gmp": "to improve IPV4 host parsing",
"ext-intl": "to handle IDN host with the best performance",
"php-64bit": "to improve IPV4 host parsing",
"rowbot/url": "to handle WHATWG URL",
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
}
},
"autoload": {
"psr-4": {
"League\\Uri\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ignace Nyamagana Butera",
"email": "nyamsprod@gmail.com",
"homepage": "https://nyamsprod.com"
}
],
"description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
"homepage": "https://uri.thephpleague.com",
"keywords": [
"data-uri",
"file-uri",
"ftp",
"hostname",
"http",
"https",
"parse_str",
"parse_url",
"psr-7",
"query-string",
"querystring",
"rfc3986",
"rfc3987",
"rfc6570",
"uri",
"url",
"ws"
],
"support": {
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
"source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0"
},
"funding": [
{
"url": "https://github.com/sponsors/nyamsprod",
"type": "github"
}
],
"time": "2025-12-07T16:03:21+00:00"
},
{
"name": "monolog/monolog",
"version": "3.9.0",
@@ -1809,6 +2410,56 @@
],
"time": "2024-09-09T07:06:30+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.0",
@@ -2020,6 +2671,56 @@
},
"time": "2021-11-05T16:47:00+00:00"
},
{
"name": "psr/event-dispatcher",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/event-dispatcher.git",
"reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
"reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
"shasum": ""
},
"require": {
"php": ">=7.2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\EventDispatcher\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Standard interfaces for event handling.",
"keywords": [
"events",
"psr",
"psr-14"
],
"support": {
"issues": "https://github.com/php-fig/event-dispatcher/issues",
"source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
},
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
@@ -2828,6 +3529,61 @@
},
"time": "2020-12-06T19:13:21+00:00"
},
{
"name": "seld/cli-prompt",
"version": "1.0.4",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/cli-prompt.git",
"reference": "b8dfcf02094b8c03b40322c229493bb2884423c5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/cli-prompt/zipball/b8dfcf02094b8c03b40322c229493bb2884423c5",
"reference": "b8dfcf02094b8c03b40322c229493bb2884423c5",
"shasum": ""
},
"require": {
"php": ">=5.3"
},
"require-dev": {
"phpstan/phpstan": "^0.12.63"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Seld\\CliPrompt\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be"
}
],
"description": "Allows you to prompt for user input on the command line, and optionally hide the characters they type",
"keywords": [
"cli",
"console",
"hidden",
"input",
"prompt"
],
"support": {
"issues": "https://github.com/Seldaek/cli-prompt/issues",
"source": "https://github.com/Seldaek/cli-prompt/tree/1.0.4"
},
"time": "2020-12-15T21:32:01+00:00"
},
{
"name": "siteworxpro/config",
"version": "1.1.1",
@@ -6844,7 +7600,8 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.5"
"php": "^8.5",
"ext-sodium": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"

View File

@@ -1 +1,6 @@
drop table if exists client_users;
drop table if exists client_scopes;
drop table if exists scopes;
drop table if exists client_redirect_uris;
drop table if exists clients;
drop table if exists users;

View File

@@ -1,3 +1,63 @@
create table clients
(
id uuid default gen_random_uuid()
constraint client_pk
primary key,
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()
);
create table client_redirect_uris
(
id uuid default gen_random_uuid()
constraint client_redirect_uris_pk
primary key,
client_id uuid not null
constraint client_redirect_uris_client_id_fk
references clients
on delete cascade,
redirect_uri varchar not null
);
create table scopes
(
id uuid default gen_random_uuid()
constraint scopes_pk
primary key,
name varchar not null
constraint scopes_name_key
unique,
description varchar
);
create table client_scopes
(
id uuid default gen_random_uuid()
constraint client_scopes_pk
primary key,
client_id uuid not null
constraint client_scopes_client_id_fk
references clients
on delete cascade,
scope_id uuid not null
constraint client_scopes_scope_id_fk
references scopes
on delete cascade,
constraint client_scopes_client_id_scope_id_key
unique (client_id, scope_id)
);
create table users
(
id uuid default gen_random_uuid()
@@ -9,5 +69,23 @@ create table users
constraint users_email_key
unique,
password varchar not null,
created_at timestamp default now()
created_at timestamp default now(),
updated_at timestamp default now()
);
create table client_users
(
id uuid default gen_random_uuid()
constraint client_users_pk
primary key,
client_id uuid not null
constraint client_users_client_id_fk
references clients
on delete cascade,
user_id uuid not null
constraint client_users_user_id_fk
references users
on delete cascade,
constraint client_users_client_id_user_id_key
unique (client_id, user_id)
);

View File

@@ -0,0 +1 @@
VITE_AUTH_URL=https://auth.dev.int

View File

@@ -0,0 +1 @@
VITE_AUTH_URL=https://auth.careeruprising.com

1
front-end/.env.sandbox Normal file
View File

@@ -0,0 +1 @@
VITE_AUTH_URL=https://auth.sandbox.careeruprising.com

26
front-end/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

1
front-end/.nvmrc Normal file
View File

@@ -0,0 +1 @@
v22.14.0

BIN
front-end/assets/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

15
front-end/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link rel="icon" href="./assets/favicon.ico"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Career UpRising - Login</title>
<script src="https://kit.fontawesome.com/a0fd516b66.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="/src/style.css"/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2990
front-end/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
front-end/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "front-end",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build --emptyOutDir",
"preview": "vite preview",
"clean": "rm -Rf node_modules && rm -Rf ../public"
},
"dependencies": {
"@primevue/themes": "^4.0.5",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/vite": "^4.1.17",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.13.2",
"lodash": "^4.17.21",
"primevue": "^4.4.1",
"vue": "^3.5.24",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"sass": "^1.94.2",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

14
front-end/src/App.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<Toast/>
<router-view />
</template>
<script lang="ts">
import {defineComponent} from "vue";
export default defineComponent({})
</script>
<style lang="scss">
a {
text-decoration: none;
}
</style>

29
front-end/src/main.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createApp } from 'vue'
import App from './App.vue'
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import theme from '@primevue/themes/nora'
import router from './router'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
const app = createApp(App)
app.use(PrimeVue, {
theme: {
preset: theme,
options: {
prefix: 'p-',
darkModeSelector: 'off',
cssLayer: false
}
}
});
app.use(ToastService);
app.use(router)
app.component('InputText', InputText)
app.component('Button', Button)
app.component('Toast', Toast)
app.mount('#app')

View File

@@ -0,0 +1,56 @@
<template>
<div class="flex flex-row justify-center md:p-10">
<Card class="p-10 w-full md:w-2/3 lg:w-1/2 xl:w-5/12">
<template #header>
<h1 class="text-3xl font-bold text-center mb-4">
Oh No, An Error!
</h1>
</template>
<template #content>
<div class="text-center">
<i class="fa-solid fa-triangle-exclamation text-6xl text-yellow-500 mb-8"></i>
<p class="mb-8">Error: {{ getErrorMessage() }}</p>
<p>
Please check your configuration or contact support if the issue persists.
</p>
</div>
</template>
<template #footer>
<p class="text-xs text-center text-gray-500 mt-10">
Authentication Portal ::
<i class="text-xs fa-sharp fa-thin fa-copyright"></i> {{ date() }} :: {{ version() }}
</p>
<p class="text-center text-gray-500 mt-10">
<i class="fa-regular text-2xl fa-shield-keyhole"></i>
</p>
</template>
</Card>
</div>
</template>
<script lang="ts" setup>
import {defineAsyncComponent} from "vue";
const Card = defineAsyncComponent(() => import("primevue/card"));
function date() {
return new Date().getFullYear()
}
function version (): string {
return import.meta.env.VITE_VERSION || 'dev-master'
}
function getErrorMessage(): string {
const status = new URLSearchParams(window.location.search).get('e') || '';
switch (status) {
case 'invalid_client':
return 'Unknown client. Please check the client ID and try again.';
case 'unsupported_grant_type':
return 'The authentication method is not supported. Please contact support.';
default:
return 'Sorry, something went wrong on our end. Please try again later.';
}
}
</script>

View File

@@ -0,0 +1,290 @@
<template>
<div class="flex flex-row justify-center md:p-10">
<Card class="p-10 w-full md:w-2/3 lg:w-1/2 xl:w-5/12">
<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>
<span v-else>Login Portal</span>
</div>
</div>
</template>
<template #content>
<div v-if="capabilities.userPass" @keydown.enter="login">
<div>
<InputText
v-model="form.email"
:invalid="v$.$dirty && v$.form.email.$invalid"
class="w-full"
placeholder="Email"
/>
<label v-if="v$.$dirty && v$.form.email.required.$invalid" class="text-xs text-red-600">Please enter an
email address</label>
<label v-if="v$.$dirty && v$.form.email.email.$invalid" class="text-xs text-red-600">Email address is
invalid</label>
</div>
<div v-if="!magicLink" class="mt-5">
<InputText
v-model="form.password"
:invalid="v$.$dirty && v$.form.password.$invalid"
class="w-full"
placeholder="Password"
type="password"
/>
<label v-if="v$.$dirty && v$.form.password.required.$invalid" class="text-xs text-red-600">Please enter a
password</label>
</div>
<div v-if="capabilities.magicLogin" class="mt-5">
<div class="flex items-center">
<Checkbox id="magic-link" v-model="magicLink" binary class="mr-3" />
<label for="magic-link">
Use Magic Login (Password-less)
</label>
</div>
</div>
</div>
<div v-if="!magicLink && capabilities.userPass">
<Button
class="w-full mt-5"
:loading="loading"
raised
@click="login"
icon="fa-regular fa-sharp fa-right-to-bracket"
label="Login"
/>
<p class="text-center mt-5">
<router-link
to="/password-reset"
class="p-button p-button-raised p-button-secondary w-full"
>
<i class="fa-light fa-sharp fa-lock"></i>
Reset Password
</router-link>
</p>
</div>
<div v-if="magicLink">
<Button
class="w-full mt-5"
:loading="loading"
raised
label="Send Magic Link"
@click="sendMagicLink"
icon="fa-light fa-wand-magic-sparkles"
/>
</div>
<div v-if="capabilities.socials && Object.keys(capabilities.socials).length > 0" class="mt-5">
<div class="text-center mt-5 mb-5">
<div class="mb-5 w-1/4 ml-auto mr-auto" style="border-bottom: 1px solid rgba(156,134,134,0.27)" />
Social Logins
</div>
<div class="flex justify-around mt-5">
<Button style="display: none" />
<a v-if="capabilities.socials.google" :href="capabilities.socials.google.redirectUrl" class="p-button"
style="background-color: #de5246">
<i class="fa-brands fa-google mr-2"></i> Google
</a>
<a v-if="capabilities.socials.linkedIn" :href="capabilities.socials.linkedIn.redirectUrl" class="p-button"
style="background-color: #55ACEE">
<i class="fa-brands fa-linkedin mr-2"></i> LinkedIn
</a>
</div>
</div>
</template>
<template #footer>
<p class="text-xs text-center text-gray-500 mt-10">
Career Uprising, Inc :: Authentication Portal ::
<i class="text-xs fa-sharp fa-thin fa-copyright"></i> {{ date() }} :: {{ version() }}
</p>
<p class="text-center text-gray-500 mt-10">
<i class="fa-regular text-2xl fa-shield-keyhole"></i>
</p>
</template>
</Card>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import axios from 'axios'
import { ToastMessageOptions } from 'primevue/toast'
import Image from 'primevue/image'
import Card from 'primevue/card'
import Checkbox from 'primevue/checkbox'
import useVuelidate from '@vuelidate/core'
import { email, required } from '@vuelidate/validators'
interface Data {
form: {
email: string
password: string
}
magicLink: boolean
loading: boolean
capabilities: {
client_name: string
userPass: boolean
magicLogin: boolean
socials: {
linkedIn?: socialProvider
google?: socialProvider
}
}
}
interface socialProvider {
provider: string
clientId: string
redirectUrl: string
}
export default defineComponent({
components: {
Image,
Card,
Checkbox,
},
setup () {
return { v$: useVuelidate() }
},
data: (): Data => ({
form: {
email: '',
password: '',
},
magicLink: false,
loading: false,
capabilities: {
client_name: '',
userPass: false,
magicLogin: false,
socials: {},
},
}),
validations: {
form: {
email: {
required,
email,
},
password: {
required: function (value: string) {
// @ts-ignore
if (!this.magicLink) {
return value !== ''
}
return true
},
},
},
},
mounted () {
this.getCapabilities()
const urlParams = new URLSearchParams(window.location.search)
const error = urlParams.get('error')
// response type === nil means no auth needed. send the user
// back through the flow
const responseType = urlParams.get('response_type')
const redirectUri = urlParams.get('redirect_url')
if (responseType === 'nil') {
window.location.href = redirectUri || '/'
return
}
if (error) {
this.$toast.add({
severity: 'error',
summary: 'Failed',
detail: error,
life: 5000,
closable: false,
} as ToastMessageOptions)
}
},
methods: {
date() {
return new Date().getFullYear()
},
version (): string {
return import.meta.env.VITE_VERSION || 'dev-master'
},
getCapabilities () {
const urlParams = new URLSearchParams(window.location.search)
axios.get(`/client/capabilities?client_id=${urlParams.get('client_id')}`).then((response) => {
this.capabilities = response.data
})
},
sendMagicLink () {
this.v$.$touch()
if (this.v$.$error) {
return
}
this.loading = true
axios.post('/magic-link', {
email: this.form.email,
}).finally(() => {
this.loading = false
this.v$.$reset()
}).then(() => {
this.$toast.add({
severity: 'success',
summary: 'Magic Link Sent',
detail: 'A Link sent to your email if it exists.',
life: 3000,
})
}).catch(() => {
this.$toast.add({
severity: 'error',
summary: 'Error',
detail: 'An error occurred. Please try again later.',
life: 3000,
})
})
},
login () {
if (this.magicLink) {
return this.sendMagicLink()
}
this.v$.$touch()
if (this.v$.$error) {
return
}
this.loading = true
axios.post('/login', this.form).then(r => {
window.location.href = r.data.location
}).catch(() => {
this.$toast.add({
severity: 'error',
summary: 'Failed',
detail: 'The Login Has Failed',
life: 5000,
closable: false,
} as ToastMessageOptions)
}).finally(() => {
this.loading = false
this.v$.$reset()
})
},
},
})
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div class="flex flex-row justify-center p-10">
<Card class="p-10 w-full md:w-1/2">
<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-1xl mt-5">
Password Reset
</div>
</div>
</template>
<template #content>
<InputText class="w-full" placeholder="Email" v-model="form.email" />
</template>
<template #footer>
<div>
<Button raised :loading="loading" class="w-full" @click="sendReset">
<template #default>
<span class="p-button-label">
<i class="fa-regular fa-paper-plane"></i>
Send Reset
</span>
</template>
</Button>
</div>
<router-link class="w-full p-button p-button-raised p-component p-button-secondary mt-5" to="/">
<span class="p-button-label">
<i class="fa-sharp fa-regular fa-hand-point-left"></i>
Back
</span>
</router-link>
</template>
</Card>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import Image from 'primevue/image'
import Card from 'primevue/card'
import axios from 'axios'
interface Data {
loading: boolean
form: {
email: string
}
}
export default defineComponent({
components: {
Image,
Card,
},
data: (): Data => ({
loading: false,
form: {
email: '',
},
}),
methods: {
sendReset () {
if (this.form.email === '') {
this.$toast.add({
severity: 'error',
summary: 'Error',
detail: 'Please complete all fields.',
life: 3000,
})
return
}
this.loading = true
axios.post('/password-reset', this.form).then(() => {
this.$toast.add({
severity: 'success',
summary: 'Password Reset',
detail: 'A link was sent to your email if it exists.',
life: 3000,
})
this.$router.push('/')
}).catch(() => {
this.$toast.add({
severity: 'error',
summary: 'Error',
detail: 'An error occurred. Please try again later.',
life: 3000,
})
this.loading = false
})
},
},
})
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div class="flex flex-row justify-center p-10">
<Card class="p-10 w-full md:w-1/2">
<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-1xl mt-5">
Password Reset
</div>
</div>
</template>
<template #content>
<div class="w-full">
<Password
toggleMask
required
inputClass="w-full"
v-model="password"
placeholder="Password"
class="w-full"
ref="passwordField"
/>
</div>
<div class="mt-4">
<InputText class="w-full" type="password" v-model="passwordAgain" placeholder="Password Again"/>
</div>
<div v-if="toStrong" class="mt-3">
<Message :value="true" severity="error">Your Password is To Strong</Message>
</div>
</template>
<template #footer>
<Button
:loading="loading"
:disabled="!formValid"
class="w-full"
label="Update Password"
@click="updatePassword"
/>
</template>
</Card>
</div>
</template>
<script lang="ts">
import {defineComponent} from "vue";
import Image from 'primevue/image'
import Card from 'primevue/card'
import Password from 'primevue/password'
import Message from 'primevue/message'
import axios from "axios";
interface Data {
password: string
passwordAgain: string
loading: boolean
toStrong: boolean
}
export default defineComponent({
components: {
Image,
Card,
Password,
Message
},
data: (): Data => ({
password: '',
passwordAgain: '',
loading: false,
toStrong: false,
}),
created() {
const urlParams = new URLSearchParams(window.location.search);
axios.get('/password-reset?k=' + urlParams.get('k'))
.catch(() => {
this.$toast.add({
severity: 'error',
summary: 'Error',
detail: 'Key is no longer valid. Please make your request again.',
life: 5000,
})
this.$router.push('/')
})
},
computed: {
formValid(): boolean {
this.toStrong = false
if (this.password === '' || this.passwordAgain === '') {
return false
}
if (this.password !== this.passwordAgain) {
return false
}
if (this.password.length < 8) {
return false
}
if (this.password.toLowerCase.toString() === 'chucknorris' || this.password.toLowerCase.toString()=== 'chuck norris') {
this.toStrong = true
return false
}
// @ts-ignore
return this.$refs.passwordField.$data.meter?.strength !== null && this.$refs.passwordField.$data.meter?.strength !== 'weak'
}
},
methods: {
updatePassword() {
this.loading = true
const urlParams = new URLSearchParams(window.location.search);
axios.put('/password-reset', {
password: this.password,
k: urlParams.get('k')
}).then(() => {
this.$toast.add({
severity: 'success',
summary: 'Password Reset',
detail: 'Your password has been updated.',
life: 5000,
})
this.$router.push('/')
})
.catch(() => {
this.$toast.add({
severity: 'error',
summary: 'Error',
detail: 'An error occurred. Please try again later.',
life: 5000,
})
this.loading = false
})
}
}
})
</script>

View File

@@ -0,0 +1,15 @@
import { createWebHashHistory, createRouter } from 'vue-router'
const routes = [
{ path: '/', component: () => import('../pages/login.vue') },
{ path: '/error', component: () => import('../pages/error.vue') },
{ path: '/password-reset', component: () => import('../pages/password-reset.vue') },
{ path: '/update-password', component: () => import('../pages/update-password.vue') },
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
export default router

64
front-end/src/style.css Normal file
View File

@@ -0,0 +1,64 @@
@import "tailwindcss";
body {
background-color: #474747;
}
:root {
color: #444;
font-family: "Poppins", sans-serif;
font-weight: 400;
font-style: normal;
font-size: 13px;
--color-primary: #304d63;
--color-primary-500: #426f8a;
--color-secondary: #b3e7e8;
--color-success: #8fb9aa;
--color-success-900: #479881;
--color-error: #dc5e5e;
--color-warning: #f2d196;
--p--inputtext-border-radius: 0 !important;
--p--card-border-radius: 0 !important;
--p--select-border-radius: 0 !important;
--p--button-primary-background: var(--color-primary) !important;
--p--button-primary-hover-background: var(--color-primary-500) !important;
--p--button-primary-active-background: var(--color-primary) !important;
--p--button-secondary-background: var(--color-secondary) !important;
--p--button-info-background: #ed8975 !important;
--p--button-info-hover-background: #ea9b8c !important;
--p--button-info-border-color: #ed8975 !important;
--p--button-info-hover-border-color: #ed8975 !important;
--p--button-danger-background: var(--color-error) !important;
--p--button-danger-border-color: #ffffff !important;
--p--button-label-font-weight: 100 !important;
--p--message-error-background: var(--color-error) !important;
--p--message-error-border-color: #ffffff !important;
--p--button-help-background: #4d95df !important;
--p--button-help-hover-background: #2880d6 !important;
--p--button-help-border-color: #84b0d8 !important;
--p--button-help-hover-border-color: #4f799f !important;
--p--toast-success-background: var(--color-success-900) !important;
--p--toast-info-background: var(--color-secondary) !important;
--p--toast-info-color: #4a2525 !important;
--p--toast-info-detail-color: #4a3131 !important;
--p--toast-info-border-color: #3d5875 !important;
--p--toast-info-close-button-hover-background: #ed8975 !important;
--p--toast-warn-background: var(--color-warning) !important;
--p--toast-warn-color: #712828 !important;
--p--toast-warn-detail-color: #4a3131 !important;
--p--toast-error-background: var(--color-error) !important;
}

1
front-end/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

25
front-end/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
front-end/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [vue(), tailwindcss()],
build: {
outDir: '../public'
}
})

View File

@@ -9,6 +9,8 @@ use League\Route\Http\Exception\NotFoundException;
use League\Route\RouteGroup;
use League\Route\Router;
use Nyholm\Psr7\Factory\Psr17Factory;
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;
@@ -71,8 +73,6 @@ class Api
);
$this->router = new Router();
$this->router->get('/', IndexController::class . '::get');
$this->router->post('/', IndexController::class . '::post');
$this->router->get('/healthz', HealthcheckController::class . '::get');
$this->router->group('/.well-known', function (RouteGroup $router) {
@@ -80,6 +80,12 @@ class Api
$router->get('/swagger.json', OpenApiController::class . '::get');
});
$this->router->group('/client', function (RouteGroup $group) {
$group->get('/capabilities', CapabilitiesController::class . '::get');
});
$this->router->get('/authorize', AuthorizeController::class . '::get');
$this->router->middleware(new CorsMiddleware());
$this->router->middleware(new JwtMiddleware());
$this->router->middleware(new ScopeMiddleware());

View File

@@ -5,12 +5,11 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli;
use Ahc\Cli\Application;
use Siteworxpro\App\Cli\Commands\DemoCommand;
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\Queue\TestJob;
use Siteworxpro\App\Helpers\Version;
use Siteworxpro\App\Kernel;
use Siteworxpro\App\Services\Facades\Config;
class App
{
@@ -22,11 +21,11 @@ class App
public function __construct()
{
Kernel::boot();
$this->app = new Application('Php-Template', Version::VERSION);
$this->app = new Application('Php-Auth', Version::VERSION);
$this->app->add(new DemoCommand());
$this->app->add(new CreateClient());
$this->app->add(new AddRedirectUri());
$this->app->add(new Start());
$this->app->add(new TestJob());
}
public function run(): int

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
use Ahc\Cli\Application as App;
use League\CLImate\CLImate;
abstract class Command extends \Ahc\Cli\Input\Command
{
protected Climate $climate;
public function __construct(string $_name, string $_desc = '', bool $_allowUnknown = false, ?App $_app = null)
{
parent::__construct($_name, $_desc, $_allowUnknown, $_app);
$this->climate = new CLImate();
}
}

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\CommandBus;
class DemoCommand extends Command implements CommandInterface
{
public function __construct()
{
parent::__construct('api:demo', 'A demo command to showcase the CLI functionality.');
$this->argument('[name]', 'Your name')
->option('-g, --greet', 'Include a greeting message');
}
public function execute(): int
{
$pb = $this->progress(100);
for ($i = 0; $i < 100; $i += 10) {
usleep(100000); // Simulate work
$pb->advance(10);
}
$pb->finish();
$this->writer()->boldBlue("Demo Command Executed!\n");
$name = $this->values()['name'];
$greet = $this->values()['greet'] ?? false;
if ($greet) {
$this->writer()->green("Hello, $name! Welcome to the CLI demo.\n");
} else {
$exampleCommand = new ExampleCommand($name);
$this->writer()->yellow(CommandBus::handle($exampleCommand));
}
return 0;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use League\CLImate\TerminalObject\Dynamic\Input;
use Siteworxpro\App\Models\ClientRedirectUri;
use Siteworxpro\App\OAuth\Entities\Client;
class AddRedirectUri extends \Siteworxpro\App\Cli\Commands\Command
{
public function __construct()
{
parent::__construct('oauth:redirect-uri:add', 'Add a redirect URI to an existing OAuth client.');
}
public function execute(): int
{
$clients = Client::all('id', 'name');
/** @var Input $input */
$input = $this->climate->input(
'Select the OAuth client to add a redirect URI to' . PHP_EOL .
$clients->map(fn(Client $client) => "[$client->id $client->name]")->implode(PHP_EOL) .
PHP_EOL .
'Enter the client ID: '
);
$input->accept(
$clients->pluck('id')->toArray()
);
$id = $input->prompt();
$client = Client::find($id);
if (!$client) {
$this->climate->error('Client not found.');
return 1;
}
/** @var Input $uriInput */
$uriInput = $this->climate->input('Enter the redirect URI to add: ');
$uriInput->accept(function (string $value) {
return filter_var($value, FILTER_VALIDATE_URL) !== false;
}, 'Please enter a valid URL.');
$redirectUri = $uriInput->prompt();
$redirectUris = $client->clientRedirectUris;
if (in_array($redirectUri, $redirectUris->toArray(), true)) {
$this->climate->error('The redirect URI already exists for this client.');
return 1;
}
$clientRedirectUri = new ClientRedirectUri();
$clientRedirectUri->client_id = $client->id;
$clientRedirectUri->redirect_uri = $redirectUri;
$clientRedirectUri->save();
return 0;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use Ahc\Cli\IO\Interactor;
use Siteworxpro\App\CommandBus\Commands\CreateClient as CreateClientCommand;
use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\CommandBus;
class CreateClient extends \Siteworxpro\App\Cli\Commands\Command
{
public function __construct()
{
parent::__construct('oauth:client:create', 'Create a new OAuth client.');
}
public function execute(): int
{
$interactor = new Interactor();
$clientName = $interactor->prompt('Enter client name');
$clientDescription = $interactor->prompt('Enter client description (optional)', '');
$clientGrantsString = $interactor->prompt(
'Enter client grants (comma separated, e.g. "authorization_code,refresh_token")',
false
);
$grants = explode(',', $clientGrantsString);
$command = new CreateClientCommand($clientName, $grants, $clientDescription);
try {
/** @var Client $client */
$client = CommandBus::handle($command);
$this->climate->green('OAuth client created successfully');
$this->climate->info('Client ID: ' . $client->client_id);
$this->climate->info('Client Secret: ' . $client->client_secret)->br(2);
} catch (CommandHandlerException $exception) {
$this->climate->error($exception->getMessage());
return 1;
}
return 0;
}
}

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Queue;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Cli\Commands\CommandInterface;
/**
* Class TestJob
*
* A CLI command to schedule a demo job that dispatches a SayHelloMessage.
*/
class TestJob extends Command implements CommandInterface
{
public function __construct()
{
parent::__construct('queue:demo', 'Schedule a demo job.');
}
/**
* Execute the command to dispatch a SayHelloMessage.
*
* @return int Exit code
*/
public function execute(): int
{
SayHelloMessage::dispatch('World from TestJob Command!');
return 0;
}
}

View File

@@ -17,11 +17,50 @@ class AttributeLocator implements HandlerLocator
public function __construct()
{
$directory = __DIR__ . '/Handlers';
$this->scanDir($directory);
}
public function getHandlerForCommand($commandName)
{
if (isset($this->handlers[$commandName])) {
$handlerClass = $this->handlers[$commandName];
return new $handlerClass();
}
throw new CanNotInvokeHandlerException("No handler found for command: " . $commandName);
}
/**
* @param string $directory
* @return void
*/
public function scanDir(string $directory): void
{
$files = scandir($directory);
foreach ($files as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$fullPath = $directory . DIRECTORY_SEPARATOR . $file;
if (is_dir($fullPath)) {
$this->scanDir($fullPath);
continue;
}
if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
$className = pathinfo($file, PATHINFO_FILENAME);
$fullClassName = self::HANDLER_NAMESPACE . $className;
$relativePath = str_replace(__DIR__ . '/Handlers/', '', $fullPath);
$namespacePath = str_replace(DIRECTORY_SEPARATOR, '\\', dirname($relativePath));
if ($namespacePath === '.') {
$namespacePath = '';
} else {
$namespacePath .= '\\';
}
$fullClassName = self::HANDLER_NAMESPACE . $namespacePath . $className;
if (class_exists($fullClassName)) {
$reflectionClass = new \ReflectionClass($fullClassName);
@@ -36,14 +75,4 @@ class AttributeLocator implements HandlerLocator
}
}
}
public function getHandlerForCommand($commandName)
{
if (isset($this->handlers[$commandName])) {
$handlerClass = $this->handlers[$commandName];
return new $handlerClass();
}
throw new CanNotInvokeHandlerException("No handler found for command: " . $commandName);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Siteworxpro\App\CommandBus\Commands;
readonly class CreateClient extends Command
{
private const array VALID_GRANTS = [
'authorization_code',
'password',
'client_credentials',
'refresh_token',
'implicit',
];
public function __construct(
private string $clientName,
private array $clientGrants = [],
private string $clientDescription = ''
) {
foreach ($this->clientGrants as $grant) {
if (!in_array($grant, self::VALID_GRANTS, true)) {
throw new \InvalidArgumentException("Invalid grant type: $grant");
}
}
}
/**
* @return string
*/
public function getClientName(): string
{
return $this->clientName;
}
/**
* @return string
*/
public function getClientDescription(): string
{
return $this->clientDescription;
}
/**
* @return array
*/
public function getClientGrants(): array
{
return $this->clientGrants;
}
}

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
readonly class ExampleCommand extends Command
{
public function __construct(
private string $name
) {
}
public function getName(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Exceptions;
class CommandHandlerException extends \InvalidArgumentException
{
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\Logger;
#[HandlesCommand(ExampleCommand::class)]
class ExampleHandler extends CommandHandler
{
/**
* @param Command|ExampleCommand $command
* @return string
*/
public function __invoke(Command|ExampleCommand $command): string
{
if (!method_exists($command, 'getName')) {
throw new \TypeError('Invalid command type provided to ExampleHandler.');
}
$name = $command->getName();
Logger::info('Handling ExampleCommand for name: ' . $name);
return 'Hello, ' . $name . '!';
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers\OAuth;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException;
use Siteworxpro\App\CommandBus\Handlers\CommandHandler;
use Siteworxpro\App\OAuth\Entities\Client;
#[HandlesCommand(\Siteworxpro\App\CommandBus\Commands\CreateClient::class)]
class CreateClient extends CommandHandler
{
public function __invoke(Command $command): Client
{
if (!$command instanceof \Siteworxpro\App\CommandBus\Commands\CreateClient) {
throw new CommandHandlerException('Invalid command type');
}
$client = new Client();
$client->name = $command->getClientName();
$client->description = $command->getClientDescription();
$client->grant_types = $command->getClientGrants();
$client->save();
return $client;
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use HansOtt\PSR7Cookies\SetCookie;
use League\OAuth2\Server\Exception\OAuthServerException;
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;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\Logger;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
final class AuthorizeController extends Controller
{
/**
* @throws InvalidArgumentException
*/
// #[\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]
// ]
// );
// }
/**
* @throws \Exception
*/
public function get(ServerRequest $request): Response
{
try {
if (!file_exists('public/index.html')) {
throw new \RuntimeException('Frontend not built. Please run `npm run build`.');
}
$contents = file_get_contents('public/index.html');
if ($request->getQueryParams()['e']) {
return new Response(
200,
['content-type' => 'text/html'],
Stream::create($contents)
);
}
if (
isset($request->getCookieParams()['s']) &&
Redis::exists('session:' . $request->getCookieParams()['s'] ?? '')
) {
$s = $request->getCookieParams()['s'];
} else {
$s = Rand::string();
}
$clientId = $request->getQueryParams()['client_id'] ?? '';
Logger::info('Authorization request', ['client_id' => $clientId]);
$client = Client::byClientId($clientId);
if ($client === null) {
Logger::warning('Invalid client in authorization request', ['client_id' => $clientId]);
throw OAuthServerException::invalidClient($request);
}
$authRequest = $client->getAuthorizationServer()->validateAuthorizationRequest($request);
Redis::set('session:' . $s, serialize($authRequest), 'EX', 60 * 60 * 24);
$response = new Response(
200,
['content-type' => 'text/html'],
Stream::create($contents)
);
$cookie = new SetCookie('s', $s, time() + 3600, '/', secure: true);
/** @var Response $response */
$response = $cookie->addToResponse($response);
return $response;
} catch (OAuthServerException $e) {
return new Response(
CodesEnum::TEMPORARY_REDIRECT->value,
[
'Location' => sprintf(
'/authorize?e=%s&client_id=%s&response_type=%s&redirect_uri=%s#/error',
$e->getMessage(),
$request->getQueryParams()['client_id'] ?? '',
$request->getQueryParams()['response_type'] ?? '',
$request->getQueryParams()['redirect_uri'] ?? ''
)
]
);
} catch (\Exception $e) {
Logger::error($e->getMessage(), ['exception' => $e]);
return JsonResponseFactory::createJsonResponse(new ServerErrorResponse($e));
}
}
}

View File

@@ -0,0 +1,30 @@
<?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\Http\Responses\NotFoundResponse;
use Siteworxpro\App\OAuth\Entities\Client;
final class CapabilitiesController extends Controller
{
/**
* @throws \JsonException
*/
public function get(ServerRequest $request): ResponseInterface
{
$clientId = $request->getQueryParams()['client_id'] ?? '0';
$client = Client::byClientId($clientId);
if (!$client) {
return JsonResponseFactory::createJsonResponse(new NotFoundResponse($request->getUri()->getPath()));
}
return JsonResponseFactory::createJsonResponse($client->capabilities->toArray());
}
}

View File

@@ -23,7 +23,7 @@ use OpenApi\Attributes as OA;
*
* @package Siteworxpro\App\Controllers
*/
class HealthcheckController extends Controller
final class HealthcheckController extends Controller
{
/**
* Handles the GET request for health check.

View File

@@ -20,7 +20,7 @@ use Siteworxpro\App\Services\Facades\CommandBus;
*
* This class handles the index route of the application.
*/
class IndexController extends Controller
final class IndexController extends Controller
{
/**
* Handles the GET request for the index route.

View File

@@ -9,7 +9,7 @@ use Nyholm\Psr7\ServerRequest;
use OpenApi\Generator;
use Psr\Http\Message\ResponseInterface;
class OpenApiController extends Controller
final class OpenApiController extends Controller
{
/**
* Handles the GET request to generate and return the OpenAPI specification.

View File

@@ -28,8 +28,6 @@ class Connected extends Listener
throw new \TypeError("Invalid event type passed to listener " . static::class);
}
Logger::info("Database connection event", [get_class($event), $event->connectionName]);
return null;
}
}

24
src/Helpers/Rand.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
use Random\RandomException;
class Rand
{
/**
* @throws RandomException
*/
public static function string(int $length = 16): string
{
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$randomString .= $characters[random_int(0, $charactersLength - 1)];
}
return $randomString;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Siteworxpro\App\OAuth\Entities\Client;
/**
* Class ClientRedirectUrl
* @package Siteworxpro\App\Models
*
* @property string $id
* @property string $client_id
* @property string $redirect_uri
*/
class ClientRedirectUri extends Model
{
public $timestamps = false;
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
/**
* Class ClientScope
* @package Siteworxpro\App\Models
*
* @property string $id
* @property string $client_id
* @property string $scope_id
*/
class ClientScope extends Model
{
}

18
src/Models/ClientUser.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
/**
* Class ClientUser
* @package Siteworxpro\App\Models
*
* @property string $id
* @property string $client_id
* @property string $user_id
*/
class ClientUser extends Model
{
}

View File

@@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Model as ORM;
* Class Model
*
* @package Siteworxpro\App\Models
* @method static static|null find(string $id, array $columns = ['*'])
* @method static where(string $column, string $operator = null, string $value = null, string $boolean = 'and')
*/
abstract class Model extends ORM
{

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Carbon\Carbon;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Helpers\Ulid;
@@ -40,6 +41,8 @@ use Siteworxpro\App\Helpers\Ulid;
)]
class User extends Model
{
use EntityTrait;
protected $casts = [
'created_at' => 'datetime',
];

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
use Siteworxpro\App\OAuth\Entities\AccessToken;
use Siteworxpro\App\OAuth\Entities\Client;
class AccessTokenRepository implements AccessTokenRepositoryInterface
{
public function getNewToken(
ClientEntityInterface | Client $clientEntity,
array $scopes,
?string $userIdentifier = null
): AccessTokenEntityInterface | AccessToken {
$accessToken = new AccessToken();
$accessToken->setClient($clientEntity);
foreach ($scopes as $scope) {
$accessToken->addScope($scope);
}
$accessToken->setUserIdentifier($userIdentifier);
return $accessToken;
}
public function persistNewAccessToken(AccessTokenEntityInterface | AccessToken $accessTokenEntity): void
{
$accessTokenEntity->save();
}
public function revokeAccessToken(string $tokenId): void
{
$accessToken = AccessToken::find($tokenId);
if ($accessToken) {
$accessToken->delete();
}
}
public function isAccessTokenRevoked(string $tokenId): bool
{
$accessToken = AccessToken::find($tokenId);
return $accessToken === null;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
class AuthCodeRepository implements AuthCodeRepositoryInterface
{
public function getNewAuthCode(): AuthCodeEntityInterface
{
// TODO: Implement getNewAuthCode() method.
}
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void
{
// TODO: Implement persistNewAuthCode() method.
}
public function revokeAuthCode(string $codeId): void
{
// TODO: Implement revokeAuthCode() method.
}
public function isAuthCodeRevoked(string $codeId): bool
{
// TODO: Implement isAuthCodeRevoked() method.
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
use Siteworxpro\App\OAuth\Entities\Client;
readonly class ClientRepository implements ClientRepositoryInterface
{
public function __construct(private Client $client)
{
}
/**
* get a client entity.
*
* @param string $clientIdentifier
* @return ClientEntityInterface|null
*/
public function getClientEntity(string $clientIdentifier): ?ClientEntityInterface
{
if ($this->client->client_id === $clientIdentifier) {
return $this->client;
}
return null;
}
/**
* validate a client with given data.
*
* @param string $clientIdentifier
* @param string|null $clientSecret
* @param string|null $grantType
* @return bool
*/
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool
{
$client = Client::find($clientIdentifier);
if ($client === null) {
return false;
}
if ($clientSecret && $client->client_secret != $clientSecret) {
return false;
}
if ($grantType && !in_array($grantType, $client->grant_types)) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\AccessTokenTrait;
class AccessToken extends RedisModel implements AccessTokenEntityInterface
{
use AccessTokenTrait;
public function getClient(): ClientEntityInterface
{
// TODO: Implement getClient() method.
}
public function getExpiryDateTime(): DateTimeImmutable
{
// TODO: Implement getExpiryDateTime() method.
}
public function getUserIdentifier(): string|null
{
// TODO: Implement getUserIdentifier() method.
}
public function getScopes(): array
{
// TODO: Implement getScopes() method.
}
protected static function getRedisPrefix(): string
{
return 'oauth_access_token';
}
public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{
// TODO: Implement setExpiryDateTime() method.
}
public function setUserIdentifier(string $identifier): void
{
// TODO: Implement setUserIdentifier() method.
}
public function setClient(ClientEntityInterface $client): void
{
// TODO: Implement setClient() method.
}
public function addScope(ScopeEntityInterface $scope): void
{
// TODO: Implement addScope() method.
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
class AuthorizationCode extends RedisModel implements AuthCodeEntityInterface
{
use AuthCodeTrait;
protected static function getRedisPrefix(): string
{
return 'oauth_auth_code';
}
public function getExpiryDateTime(): DateTimeImmutable
{
// TODO: Implement getExpiryDateTime() method.
}
public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{
// TODO: Implement setExpiryDateTime() method.
}
public function setUserIdentifier(string $identifier): void
{
// TODO: Implement setUserIdentifier() method.
}
public function getUserIdentifier(): string|null
{
// TODO: Implement getUserIdentifier() method.
}
public function getClient(): ClientEntityInterface
{
// TODO: Implement getClient() method.
}
public function setClient(ClientEntityInterface $client): void
{
// TODO: Implement setClient() method.
}
public function addScope(ScopeEntityInterface $scope): void
{
// TODO: Implement addScope() method.
}
public function getScopes(): array
{
// TODO: Implement getScopes() method.
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use Defuse\Crypto\Exception\BadFormatException;
use Defuse\Crypto\Exception\EnvironmentIsBrokenException;
use Defuse\Crypto\Key;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
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 Random\RandomException;
use Siteworxpro\App\Helpers\Rand;
use Siteworxpro\App\Models\ClientRedirectUri;
use Siteworxpro\App\Models\ClientScope;
use Siteworxpro\App\Models\ClientUser;
use Siteworxpro\App\Models\Model;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\AccessTokenRepository;
use Siteworxpro\App\OAuth\ClientRepository;
use Siteworxpro\App\OAuth\ScopeRepository;
/**
* Class Client
* @package Siteworxpro\App\Models
*
* @property string $id
* @property string $client_id
* @property string $client_secret
* @property string $name
* @property string $description
* @property string $private_key
* @property string $encryption_key
* @property string[] $grant_types
* @property bool $confidential
*
* @property-read ClientCapabilities $capabilities
* @property-read Collection<ClientRedirectUri> $clientRedirectUris
* @property-read Scope[]|Collection $scopes
*/
class Client extends Model implements ClientEntityInterface
{
use EntityTrait;
protected $casts = [
'id' => 'string',
'grant_types' => 'collection',
'confidential' => 'boolean',
];
/**
* @throws RandomException|EnvironmentIsBrokenException
*/
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->client_id = Rand::string(32);
$this->client_secret = Rand::string(64);
$this->generatePrivateKey();
}
public static function byClientId(string $clientId): ?Client
{
return self::where('client_id', $clientId)->first();
}
/**
* @return void
* @throws EnvironmentIsBrokenException
*/
private function generatePrivateKey(): void
{
// generate rsa private and public key pair
$config = [
"digest_alg" => "sha256",
"private_key_bits" => 4096,
"private_key_type" => OPENSSL_KEYTYPE_RSA,
];
$res = openssl_pkey_new($config);
openssl_pkey_export($res, $privateKey);
$this->private_key = $privateKey;
$this->encryption_key = Key::createNewRandomKey()->saveToAsciiSafeString();
}
/**
* @return HasMany
*/
public function clientRedirectUris(): HasMany
{
return $this->hasMany(ClientRedirectUri::class);
}
/**
* @return HasManyThrough
*/
public function scopes(): HasManyThrough
{
return $this->hasManyThrough(Scope::class, ClientScope::class);
}
/**
* @return HasManyThrough
*/
public function users(): HasManyThrough
{
return $this->hasManyThrough(User::class, ClientUser::class);
}
/**
* @return string
*/
public function getIdentifier(): string
{
return $this->id;
}
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @return string|array
*/
public function getRedirectUri(): string|array
{
return $this->clientRedirectUris->pluck('redirect_uri')->toArray();
}
/**
* @return bool
*/
public function isConfidential(): bool
{
return $this->confidential;
}
public function getCapabilitiesAttribute(string $capabilities): ClientCapabilities
{
return ClientCapabilities::fromJson($capabilities);
}
/**
* @throws \JsonException
*/
public function setCapabilitiesAttribute(ClientCapabilities $capabilities): void
{
$this->attributes->capabilities = $capabilities->toJson();
}
/**
* @throws BadFormatException
* @throws EnvironmentIsBrokenException
* @throws \Exception
*/
public function getAuthorizationServer(): AuthorizationServer
{
$authorizationServer = new AuthorizationServer(
new ClientRepository($this),
new AccessTokenRepository(),
new ScopeRepository(),
$this->private_key,
Key::loadFromAsciiSafeString($this->encryption_key)
);
if (!empty($this->grant_types)) {
foreach ($this->grant_types as $grantType) {
switch ($grantType) {
case 'authorization_code':
$grant = new \League\OAuth2\Server\Grant\AuthCodeGrant(
new \Siteworxpro\App\OAuth\AuthCodeRepository(),
new \Siteworxpro\App\OAuth\RefreshTokenRepository(),
new \DateInterval('PT10M')
);
$grant->setRefreshTokenTTL(new \DateInterval('P1M'));
break;
case 'client_credentials':
$grant = new \League\OAuth2\Server\Grant\ClientCredentialsGrant();
break;
case 'refresh_token':
$grant = new \League\OAuth2\Server\Grant\RefreshTokenGrant(
new \Siteworxpro\App\OAuth\RefreshTokenRepository()
);
$grant->setRefreshTokenTTL(new \DateInterval('P1M'));
break;
default:
continue 2;
}
$authorizationServer->enableGrantType($grant);
}
}
return $authorizationServer;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use Illuminate\Contracts\Support\Arrayable;
class ClientCapabilities implements Arrayable
{
private bool $userPass = false;
private bool $magicLink = false;
private bool $passkey = false;
private array $socials = [];
private array $theme = [
'primaryColor' => '#000000',
'secondaryColor' => '#FFFFFF',
'logoUrl' => null,
];
public function __construct(array $capabilities)
{
if (isset($capabilities['userPass'])) {
$this->userPass = (bool)$capabilities['userPass'];
}
if (isset($capabilities['magicLink'])) {
$this->magicLink = (bool)$capabilities['magicLink'];
}
if (isset($capabilities['passkey'])) {
$this->passkey = (bool)$capabilities['passkey'];
}
if (isset($capabilities['socials']) && is_array($capabilities['socials'])) {
$this->socials = $capabilities['socials'];
}
if (isset($capabilities['theme']) && is_array($capabilities['theme'])) {
$this->theme = array_merge($this->theme, $capabilities['theme']);
}
}
public static function fromJson(string $data): self
{
try {
$arrayData = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
return new self($arrayData);
} catch (\JsonException $e) {
return new self([]);
}
}
public function toArray(): array
{
return [
'userPass' => $this->userPass,
'magicLink' => $this->magicLink,
'passkey' => $this->passkey,
'socials' => $this->socials,
'theme' => $this->theme,
];
}
/**
* @throws \JsonException
*/
public function toJson(): string
{
return json_encode($this->toArray(), JSON_THROW_ON_ERROR);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use Carbon\Carbon;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use Psr\SimpleCache\InvalidArgumentException;
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();
}
abstract protected static function getRedisPrefix(): string;
public static function find(string $identifier): ?self
{
$instance = Redis::get(static::getRedisPrefix() . ':' . $identifier);
if ($instance !== null) {
return unserialize($instance);
}
return null;
}
public function save(): void
{
$diff = 0;
if ($this->expireTime) {
$diff = $this->expireTime->getTimestamp() - Carbon::now()->timestamp;
}
$this->redis->set(
static::getRedisPrefix() . ':' . $this->getIdentifier(),
serialize($this),
$diff
);
}
/**
* @throws InvalidArgumentException
*/
public function delete(): void
{
$this->redis->delete(static::getRedisPrefix() . ':' . $this->getIdentifier());
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
class RefreshToken extends RedisModel implements RefreshTokenEntityInterface
{
use RefreshTokenTrait;
protected static function getRedisPrefix(): string
{
return 'oauth_refresh_token';
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\ScopeTrait;
use Siteworxpro\App\Models\Model;
/**
* Class Scope
* @package Siteworxpro\App\Models
*
* @property string $id
* @property string $name
* @property string $description
*/
class Scope extends Model implements ScopeEntityInterface
{
use ScopeTrait;
public function getIdentifier(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
class RefreshTokenRepository implements RefreshTokenRepositoryInterface
{
public function getNewRefreshToken(): ?RefreshTokenEntityInterface
{
// TODO: Implement getNewRefreshToken() method.
}
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void
{
// TODO: Implement persistNewRefreshToken() method.
}
public function revokeRefreshToken(string $tokenId): void
{
// TODO: Implement revokeRefreshToken() method.
}
public function isRefreshTokenRevoked(string $tokenId): bool
{
// TODO: Implement isRefreshTokenRevoked() method.
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
use Siteworxpro\App\OAuth\Entities\Scope;
class ScopeRepository implements ScopeRepositoryInterface
{
public function getScopeEntityByIdentifier(string $identifier): ?ScopeEntityInterface
{
return Scope::where('name', $identifier)->first();
}
public function finalizeScopes(
array $scopes,
string $grantType,
ClientEntityInterface $clientEntity,
?string $userIdentifier = null,
?string $authCodeId = null
): array {
return $scopes;
}
}

View File

@@ -18,6 +18,7 @@ use Siteworxpro\App\Services\Facade;
* @method static Status|null set(string $key, $value, $expireResolution = null, $expireTTL = null, $flag = null)
* @method static array keys(string $pattern)
* @method static int del(string $key)
* @method static bool exists(string $key)
* @method static Status ping()
*/
class Redis extends Facade