diff --git a/app/src/Application/Auth/AuthSettings.php b/app/src/Application/Auth/AuthSettings.php new file mode 100644 index 00000000..64878efa --- /dev/null +++ b/app/src/Application/Auth/AuthSettings.php @@ -0,0 +1,16 @@ +time = $time ?? static function (string $offset): \DateTimeImmutable { + return new \DateTimeImmutable($offset); + }; + } + + public function load(string $id): ?TokenInterface + { + try { + $token = (array) JWT::decode($id, new Key($this->secret, $this->algorithm)); + } catch (ExpiredException $exception) { + throw $exception; + } catch (\Throwable $exception) { + return null; + } + + if ( + false === isset($token['data']) + || false === isset($token['iat']) + || false === isset($token['exp']) + ) { + return null; + } + + return new Token( + $id, + $token, + (array) $token['data'], + (new \DateTimeImmutable())->setTimestamp($token['iat']), + (new \DateTimeImmutable())->setTimestamp($token['exp']) + ); + } + + public function create(array $payload, \DateTimeInterface $expiresAt = null): TokenInterface + { + $issuedAt = ($this->time)('now'); + $expiresAt = $expiresAt ?? ($this->time)($this->expiresAt); + $token = [ + 'iat' => $issuedAt->getTimestamp(), + 'exp' => $expiresAt->getTimestamp(), + 'data' => $payload, + ]; + + return new Token( + JWT::encode($token,$this->secret,$this->algorithm), + $token, + $payload, + $issuedAt, + $expiresAt + ); + } + + public function delete(TokenInterface $token): void + { + // We don't need to do anything here since JWT tokens are self-contained. + } +} diff --git a/app/src/Application/Auth/SuccessRedirect.php b/app/src/Application/Auth/SuccessRedirect.php new file mode 100644 index 00000000..44f09b25 --- /dev/null +++ b/app/src/Application/Auth/SuccessRedirect.php @@ -0,0 +1,23 @@ +response->redirect($this->redirectUrl . '?token=' . $token); + } +} diff --git a/app/src/Application/Auth/Token.php b/app/src/Application/Auth/Token.php new file mode 100644 index 00000000..c73f863b --- /dev/null +++ b/app/src/Application/Auth/Token.php @@ -0,0 +1,44 @@ +id; + } + + public function getToken(): array + { + return $this->token; + } + + public function getPayload(): array + { + return $this->payload; + } + + public function getIssuedAt(): \DateTimeImmutable + { + return $this->issuedAt; + } + + public function getExpiresAt(): \DateTimeInterface + { + return $this->expiresAt; + } +} diff --git a/app/src/Application/Bootloader/AuthBootloader.php b/app/src/Application/Bootloader/AuthBootloader.php new file mode 100644 index 00000000..cbdbe43f --- /dev/null +++ b/app/src/Application/Bootloader/AuthBootloader.php @@ -0,0 +1,76 @@ + static fn(EnvironmentInterface $env) => new SdkConfiguration( + strategy: $env->get('AUTH_STRATEGY', SdkConfiguration::STRATEGY_REGULAR), + domain: $env->get('AUTH_PROVIDER_URL'), + clientId: $env->get('AUTH_CLIENT_ID'), + redirectUri: $env->get('AUTH_CALLBACK_URL'), + clientSecret: $env->get('AUTH_CLIENT_SECRET'), + scope: \explode(',', $env->get('AUTH_SCOPES', 'openid,profile,email')), + cookieSecret: $env->get('AUTH_COOKIE_SECRET', $env->get('ENCRYPTER_KEY') ?? 'secret'), + ), + + Auth0::class => static fn(SdkConfiguration $config, SessionScope $session) => new Auth0( + $config->setTransientStorage(new SessionStore($session)), + ), + + AuthSettings::class => static fn( + EnvironmentInterface $env, + UriFactoryInterface $factory, + ) => new AuthSettings( + enabled: $env->get('AUTH_ENABLED', false), + loginUrl: $factory->createUri('/auth/sso/login'), + ), + + SuccessRedirect::class => static fn( + UriFactoryInterface $factory, + ResponseWrapper $response, + ) => new SuccessRedirect( + response: $response, + redirectUrl: $factory->createUri('/#/login'), + ), + ]; + } + + public function init( + HttpAuthBootloader $httpAuth, + EnvironmentInterface $env, + \Spiral\Bootloader\Auth\AuthBootloader $auth, + ): void { + $auth->addActorProvider(new Autowire(ActorProvider::class)); + $httpAuth->addTokenStorage( + 'jwt', + new Autowire( + JWTTokenStorage::class, + [ + 'secret' => $env->get('AUTH_JWT_SECRET', $env->get('ENCRYPTER_KEY')), + ], + ), + ); + } +} diff --git a/app/src/Application/Bootloader/RoutesBootloader.php b/app/src/Application/Bootloader/RoutesBootloader.php index 4cdd7264..595897f6 100644 --- a/app/src/Application/Bootloader/RoutesBootloader.php +++ b/app/src/Application/Bootloader/RoutesBootloader.php @@ -4,25 +4,40 @@ namespace App\Application\Bootloader; +use App\Application\Auth\AuthSettings; use App\Application\HTTP\Middleware\JsonPayloadMiddleware; use App\Interfaces\Http\EventHandlerAction; +use Spiral\Auth\Middleware\AuthMiddleware; +use Spiral\Auth\Middleware\Firewall\ExceptionFirewall; use Spiral\Bootloader\Http\RoutesBootloader as BaseRoutesBootloader; +use Spiral\Core\Container; use Spiral\Filter\ValidationHandlerMiddleware; +use Spiral\Http\Exception\ClientException\ForbiddenException; use Spiral\Http\Middleware\ErrorHandlerMiddleware; use Spiral\Router\Bootloader\AnnotatedRoutesBootloader; use Spiral\Router\GroupRegistry; use Spiral\Router\Loader\Configurator\RoutingConfigurator; use Spiral\OpenApi\Controller\DocumentationController; +use Spiral\Session\Middleware\SessionMiddleware; final class RoutesBootloader extends BaseRoutesBootloader { - protected const DEPENDENCIES = [ - AnnotatedRoutesBootloader::class, - ]; + public function __construct( + private readonly Container $container, + ) { + } + + public function defineDependencies(): array + { + return [ + AnnotatedRoutesBootloader::class, + ]; + } protected function globalMiddleware(): array { return [ + SessionMiddleware::class, JsonPayloadMiddleware::class, ErrorHandlerMiddleware::class, ValidationHandlerMiddleware::class, @@ -32,8 +47,21 @@ protected function globalMiddleware(): array protected function middlewareGroups(): array { return [ - 'web' => [], - 'api' => [], + 'auth' => [ + AuthMiddleware::class, + ], + 'guest' => [ + 'middleware:auth', + ], + 'api_guest' => [ + 'middleware:auth', + ], + 'web' => [ + 'middleware:auth', + ], + 'api' => [ + 'middleware:auth', + ], 'docs' => [], ]; } @@ -44,6 +72,16 @@ protected function middlewareGroups(): array protected function configureRouteGroups(GroupRegistry $groups): void { $groups->getGroup('api')->setPrefix('api/'); + $groups->getGroup('api_guest')->setPrefix('api/'); + + $settings = $this->container->get(AuthSettings::class); + + if ($settings->enabled) { + $groups->getGroup('api') + ->addMiddleware(new ExceptionFirewall(new ForbiddenException())); + $groups->getGroup('web') + ->addMiddleware(new ExceptionFirewall(new ForbiddenException())); + } } protected function defineRoutes(RoutingConfigurator $routes): void diff --git a/app/src/Application/HTTP/Response/UserResource.php b/app/src/Application/HTTP/Response/UserResource.php new file mode 100644 index 00000000..9a6f2c9a --- /dev/null +++ b/app/src/Application/HTTP/Response/UserResource.php @@ -0,0 +1,25 @@ + $this->user->getUsername(), + 'avatar' => $this->user->getAvatar(), + 'email' => $this->user->getEmail(), + ]; + } +} diff --git a/app/src/Application/Kernel.php b/app/src/Application/Kernel.php index 9d22625a..42727284 100644 --- a/app/src/Application/Kernel.php +++ b/app/src/Application/Kernel.php @@ -6,6 +6,7 @@ use App\Application\Bootloader\AppBootloader; use App\Application\Bootloader\AttributesBootloader; +use App\Application\Bootloader\AuthBootloader; use App\Application\Bootloader\HttpHandlerBootloader; use App\Application\Bootloader\MongoDBBootloader; use App\Application\Bootloader\PersistenceBootloader; @@ -84,6 +85,7 @@ protected function defineBootloaders(): array EventsBootloader::class, EventBootloader::class, CqrsBootloader::class, + Framework\Http\SessionBootloader::class, // Console commands Framework\CommandBootloader::class, @@ -106,6 +108,7 @@ protected function defineBootloaders(): array ProfilerBootloader::class, MongoDBBootloader::class, PersistenceBootloader::class, + AuthBootloader::class, ]; } } diff --git a/app/src/Application/OAuth/ActorProvider.php b/app/src/Application/OAuth/ActorProvider.php new file mode 100644 index 00000000..f0e63fd4 --- /dev/null +++ b/app/src/Application/OAuth/ActorProvider.php @@ -0,0 +1,36 @@ +getPayload(); + if (empty($payload)) { + $payload = self::getGuestPayload(); + } + + return new User($payload); + } + + public static function getGuestPayload(): array + { + return [ + 'nickname' => 'guest', + 'email' => '', + 'picture' => '', + ]; + } +} diff --git a/app/src/Application/OAuth/SessionStore.php b/app/src/Application/OAuth/SessionStore.php new file mode 100644 index 00000000..51b7f69d --- /dev/null +++ b/app/src/Application/OAuth/SessionStore.php @@ -0,0 +1,84 @@ +session->getSection($this->sessionPrefix)->delete($key); + } + + /** + * Gets persisted values identified by $key. + * If the value is not set, returns $default. + * + * @param string $key session key to set + * @param mixed $default default to return if nothing was found + * + * @return mixed + */ + public function get( + string $key, + $default = null, + ) { + return $this->session->getSection($this->sessionPrefix)->get($key, $default); + } + + /** + * Removes all persisted values. + */ + public function purge(): void + { + $this->session->getSection($this->sessionPrefix)->clear(); + } + + /** + * Persists $value on $_SESSION, identified by $key. + * + * @param string $key session key to set + * @param mixed $value value to use + */ + public function set( + string $key, + $value, + ): void { + $this->session->getSection($this->sessionPrefix)->set($key, $value); + } + + /** + * This basic implementation of BaseAuth0 SDK uses PHP Sessions to store volatile data. + */ + public function start(): void + { + } +} diff --git a/app/src/Application/OAuth/User.php b/app/src/Application/OAuth/User.php new file mode 100644 index 00000000..1c7ec33b --- /dev/null +++ b/app/src/Application/OAuth/User.php @@ -0,0 +1,28 @@ +data['nickname'] ?? 'guest'; + } + + public function getAvatar(): string + { + return $this->data['picture']; + } + + public function getEmail(): string + { + return $this->data['email']; + } +} diff --git a/app/src/Interfaces/Centrifugo/RPCService.php b/app/src/Interfaces/Centrifugo/RPCService.php index a4b58383..96bd1f32 100644 --- a/app/src/Interfaces/Centrifugo/RPCService.php +++ b/app/src/Interfaces/Centrifugo/RPCService.php @@ -17,7 +17,8 @@ final class RPCService implements ServiceInterface public function __construct( private readonly Http $http, private readonly ServerRequestFactoryInterface $requestFactory, - ) {} + ) { + } /** * @param Request\RPC $request @@ -26,7 +27,7 @@ public function handle(Request\RequestInterface $request): void { try { $response = $this->http->handle( - $this->createHttpRequest($request) + $this->createHttpRequest($request), ); $result = \json_decode((string)$response->getBody(), true); @@ -43,8 +44,8 @@ public function handle(Request\RequestInterface $request): void try { $request->respond( new RPCResponse( - data: $result - ) + data: $result, + ), ); } catch (\Throwable $e) { $request->error($e->getCode(), $e->getMessage()); @@ -59,9 +60,14 @@ public function createHttpRequest(Request\RPC $request): ServerRequestInterface $httpRequest = $this->requestFactory->createServerRequest(\strtoupper($method), \ltrim($uri, '/')) ->withHeader('Content-Type', 'application/json'); + $data = $request->getData(); + + $token = $data['token'] ?? null; + unset($data['token']); + return match ($method) { - 'GET', 'HEAD' => $httpRequest->withQueryParams($request->getData()), - 'POST', 'PUT', 'DELETE' => $httpRequest->withParsedBody($request->getData()), + 'GET', 'HEAD' => $httpRequest->withQueryParams($data)->withHeader('X-Auth-Token', $token), + 'POST', 'PUT', 'DELETE' => $httpRequest->withParsedBody($data)->withHeader('X-Auth-Token', $token), default => throw new \InvalidArgumentException('Unsupported method'), }; } diff --git a/app/src/Interfaces/Http/Controller/Auth/CallbackAction.php b/app/src/Interfaces/Http/Controller/Auth/CallbackAction.php new file mode 100644 index 00000000..8061ef21 --- /dev/null +++ b/app/src/Interfaces/Http/Controller/Auth/CallbackAction.php @@ -0,0 +1,36 @@ +exchange( + code: $input->query('code'), + state: $input->query('state'), + ); + + $authScope->start( + $token = $tokens->create($auth->getUser()), + ); + + return $successRedirect->makeResponse($token->getID()); + } +} diff --git a/app/src/Interfaces/Http/Controller/Auth/LoginAction.php b/app/src/Interfaces/Http/Controller/Auth/LoginAction.php new file mode 100644 index 00000000..45cdf229 --- /dev/null +++ b/app/src/Interfaces/Http/Controller/Auth/LoginAction.php @@ -0,0 +1,41 @@ +getCredentials(); + + if (null === $session || $session->accessTokenExpired) { + return $this->response->redirect($auth->login()); + } + + $authScope->start( + $token = $tokens->create($auth->getUser()), + ); + + return $successRedirect->makeResponse($token->getID()); + } +} diff --git a/app/src/Interfaces/Http/Controller/Auth/MeAction.php b/app/src/Interfaces/Http/Controller/Auth/MeAction.php new file mode 100644 index 00000000..a02f0f92 --- /dev/null +++ b/app/src/Interfaces/Http/Controller/Auth/MeAction.php @@ -0,0 +1,25 @@ +getActor() ?? new User(ActorProvider::getGuestPayload()); + + return new UserResource($actor); + } +} diff --git a/app/src/Interfaces/Http/Controller/GetVersionAction.php b/app/src/Interfaces/Http/Controller/GetVersionAction.php index 7d1642c3..fe58d44e 100644 --- a/app/src/Interfaces/Http/Controller/GetVersionAction.php +++ b/app/src/Interfaces/Http/Controller/GetVersionAction.php @@ -11,7 +11,7 @@ final class GetVersionAction { - #[Route(route: 'version', methods: ['GET'], group: 'api')] + #[Route(route: 'version', methods: ['GET'], group: 'api_guest')] public function __invoke(EnvironmentInterface $env): ResourceInterface { return new JsonResource([ diff --git a/app/src/Interfaces/Http/Controller/SettingsAction.php b/app/src/Interfaces/Http/Controller/SettingsAction.php new file mode 100644 index 00000000..7b93d5fa --- /dev/null +++ b/app/src/Interfaces/Http/Controller/SettingsAction.php @@ -0,0 +1,26 @@ + [ + 'enabled' => $settings->enabled, + 'login_url' => (string) $settings->loginUrl, + ], + 'version' => $env->get('APP_VERSION', 'dev'), + ]); + } +} diff --git a/composer.json b/composer.json index 55ef5f26..53221469 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,10 @@ "require": { "php": ">=8.1", "ext-mbstring": "*", + "auth0/auth0-php": "^8.11", "doctrine/collections": "^1.8", + "firebase/php-jwt": "^6.10", + "guzzlehttp/guzzle": "^7.8", "nesbot/carbon": "^2.64", "php-http/message": "^1.11", "spiral-packages/cqrs": "^2.0",