initial code to support login via username/password
This commit is contained in:
@@ -4,13 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
use App\Libs\Middlewares\{AddCorsMiddleware,
|
||||
AddTimingMiddleware,
|
||||
APIKeyRequiredMiddleware,
|
||||
AuthorizationMiddleware,
|
||||
NoAccessLogMiddleware,
|
||||
ParseJsonBodyMiddleware};
|
||||
|
||||
return static fn(): array => [
|
||||
fn() => new AddTimingMiddleware(),
|
||||
fn() => new APIKeyRequiredMiddleware(),
|
||||
fn() => new AuthorizationMiddleware(),
|
||||
fn() => new ParseJsonBodyMiddleware(),
|
||||
fn() => new NoAccessLogMiddleware(),
|
||||
fn() => new AddCorsMiddleware(),
|
||||
|
||||
@@ -18,13 +18,13 @@ use Monolog\Level;
|
||||
|
||||
return (function () {
|
||||
$inContainer = inContainer();
|
||||
$progressTimeCheck = fn(int $v, int $d): int => 0 === $v || $v >= 180 ? $v : $d;
|
||||
$progressTimeCheck = fn (int $v, int $d): int => 0 === $v || $v >= 180 ? $v : $d;
|
||||
|
||||
$config = [
|
||||
'name' => 'WatchState',
|
||||
'version' => '$(version_via_ci)',
|
||||
'tz' => env('WS_TZ', env('TZ', 'UTC')),
|
||||
'path' => fixPath(env('WS_DATA_PATH', fn() => $inContainer ? '/config' : __DIR__ . '/../var')),
|
||||
'path' => fixPath(env('WS_DATA_PATH', fn () => $inContainer ? '/config' : __DIR__ . '/../var')),
|
||||
'logs' => [
|
||||
'context' => (bool)env('WS_LOGS_CONTEXT', false),
|
||||
'prune' => [
|
||||
@@ -45,7 +45,7 @@ return (function () {
|
||||
'encode' => JSON_INVALID_UTF8_IGNORE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Application-Version' => fn() => getAppVersion(),
|
||||
'X-Application-Version' => fn () => getAppVersion(),
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
],
|
||||
],
|
||||
@@ -155,14 +155,14 @@ return (function () {
|
||||
|
||||
$config['profiler'] = [
|
||||
'save' => (bool)env('WS_PROFILER_SAVE', true),
|
||||
'path' => env('WS_PROFILER_PATH', fn() => ag($config, 'tmpDir') . '/profiler'),
|
||||
'path' => env('WS_PROFILER_PATH', fn () => ag($config, 'tmpDir') . '/profiler'),
|
||||
'collector' => env('WS_PROFILER_COLLECTOR', null),
|
||||
];
|
||||
|
||||
$config['cache'] = [
|
||||
'prefix' => env('WS_CACHE_PREFIX', null),
|
||||
'url' => env('WS_CACHE_URL', 'redis://127.0.0.1:6379'),
|
||||
'path' => env('WS_CACHE_PATH', fn() => ag($config, 'tmpDir') . '/cache'),
|
||||
'path' => env('WS_CACHE_PATH', fn () => ag($config, 'tmpDir') . '/cache'),
|
||||
];
|
||||
|
||||
$config['logger'] = [
|
||||
@@ -343,5 +343,16 @@ return (function () {
|
||||
],
|
||||
];
|
||||
|
||||
$config['password'] = [
|
||||
'prefix' => 'ws_hash@:',
|
||||
'algo' => PASSWORD_BCRYPT,
|
||||
'options' => ['cost' => 10],
|
||||
];
|
||||
|
||||
$config['system'] = [
|
||||
'user' => env('WS_SYSTEM_USER', null),
|
||||
'password' => env('WS_SYSTEM_PASSWORD', null),
|
||||
];
|
||||
|
||||
return $config;
|
||||
})();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Last update: 2024-05-10
|
||||
*
|
||||
@@ -7,6 +8,7 @@
|
||||
* Avoid using complex datatypes, the value should be a simple scalar value.
|
||||
*/
|
||||
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Exceptions\ValidationException;
|
||||
use Cron\CronExpression;
|
||||
|
||||
@@ -228,6 +230,47 @@ return (function () {
|
||||
'description' => 'Whether to send backend requests in parallel or sequentially.',
|
||||
'type' => 'bool',
|
||||
],
|
||||
[
|
||||
'key' => 'WS_SYSTEM_USER',
|
||||
'description' => '(NOT IMPLEMENTED YET) The login user name',
|
||||
'type' => 'string',
|
||||
'validate' => function (mixed $value): string {
|
||||
if (!is_numeric($value) && empty($value)) {
|
||||
throw new ValidationException('Invalid username. Empty value.');
|
||||
}
|
||||
|
||||
if (false === isValidName($value)) {
|
||||
throw new ValidationException('Invalid username. Username can only contains [lower case a-z, 0-9 and _].');
|
||||
}
|
||||
return $value;
|
||||
},
|
||||
'mask' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'WS_SYSTEM_PASSWORD',
|
||||
'description' => '(NOT IMPLEMENTED YET) The login password. The given plaintext password will be converted to hash.',
|
||||
'type' => 'string',
|
||||
'validate' => function (mixed $value): string {
|
||||
if (empty($value)) {
|
||||
throw new ValidationException('Invalid password. Empty value.');
|
||||
}
|
||||
|
||||
$prefix = Config::get('password.prefix', 'ws_hash@:');
|
||||
|
||||
if (true === str_starts_with($value, $prefix)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$hash = password_hash($value, Config::get('password.algo'), Config::get('password.options', []));
|
||||
|
||||
if (false === $hash) {
|
||||
throw new ValidationException('Invalid password. Password hashing failed.');
|
||||
}
|
||||
|
||||
return $prefix . $hash;
|
||||
},
|
||||
'mask' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$validateCronExpression = function (string $value): string {
|
||||
|
||||
119
src/API/System/Auth.php
Normal file
119
src/API/System/Auth.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\System;
|
||||
|
||||
use App\Libs\Attributes\Route\Get;
|
||||
use App\Libs\Attributes\Route\Post;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Enums\Http\Method;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\TokenUtil;
|
||||
use App\Libs\Traits\APITraits;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
|
||||
final class Auth
|
||||
{
|
||||
use APITraits;
|
||||
|
||||
public const string URL = '%{api.prefix}/system/auth';
|
||||
|
||||
#[Get(self::URL . '/has_user[/]', name: 'system.auth.has_user')]
|
||||
public function has_user(): iResponse
|
||||
{
|
||||
$user = Config::get('system.user');
|
||||
$password = Config::get('system.password');
|
||||
|
||||
return api_response(empty($user) || empty($password) ? Status::NO_CONTENT : Status::OK);
|
||||
}
|
||||
|
||||
#[Post(self::URL . '/signup[/]', name: 'system.auth.signup')]
|
||||
public function do_signup(iRequest $request): iResponse
|
||||
{
|
||||
$user = Config::get('system.user');
|
||||
$pass = Config::get('system.password');
|
||||
|
||||
if (!empty($user) && !empty($pass)) {
|
||||
return api_error('System user and password is already configured.', Status::FORBIDDEN);
|
||||
}
|
||||
|
||||
$data = DataUtil::fromRequest($request);
|
||||
|
||||
$username = $data->get('username');
|
||||
$password = $data->get('password');
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
return api_error('Username and password are required.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$response = APIRequest(Method::POST, '/system/env/WS_SYSTEM_PASSWORD', ['value' => $password]);
|
||||
if (Status::OK !== $response->status) {
|
||||
$message = r("Failed to set system password. {status}: {message}", [
|
||||
'status' => $response->status->value,
|
||||
'message' => ag($response->body, 'error.message', 'Unknown error.')
|
||||
]);
|
||||
return api_error($message, $response->status);
|
||||
}
|
||||
|
||||
$response = APIRequest(Method::POST, '/system/env/WS_SYSTEM_USER', ['value' => $username]);
|
||||
|
||||
if (Status::OK !== $response->status) {
|
||||
$message = r("Failed to set system user. {status}: {message}", [
|
||||
'status' => $response->status->value,
|
||||
'message' => ag($response->body, 'error.message', 'Unknown error.')
|
||||
]);
|
||||
return api_error($message, $response->status);
|
||||
}
|
||||
|
||||
return api_response(Status::CREATED);
|
||||
}
|
||||
|
||||
#[Post(self::URL . '/login[/]', name: 'system.auth.login')]
|
||||
public function do_login(iRequest $request): iResponse
|
||||
{
|
||||
$data = DataUtil::fromRequest($request);
|
||||
|
||||
$username = $data->get('username');
|
||||
$password = $data->get('password');
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
return api_error('Username and password are required.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$system_user = Config::get('system.user');
|
||||
$system_pass = Config::get('system.password');
|
||||
|
||||
if (empty($system_user) || empty($system_pass)) {
|
||||
return api_error('System user or password is not configured.', Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$validUser = true === hash_equals($username, $system_user);
|
||||
$validPass = password_verify(
|
||||
$password,
|
||||
after($system_pass, Config::get('password.prefix', 'ws_hash@:'))
|
||||
);
|
||||
|
||||
if (false === $validUser || false === $validPass) {
|
||||
return api_error('Invalid username or password.', Status::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'username' => $system_user,
|
||||
'iat' => time(),
|
||||
'version' => getAppVersion(),
|
||||
];
|
||||
|
||||
if (false === ($token = json_encode($payload))) {
|
||||
return api_error('Failed to encode token.', Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
if (false === ($token = TokenUtil::encode(TokenUtil::sign($token) . '.' . $token))) {
|
||||
return api_error('Failed to sign token.', Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return api_response(Status::OK, ['token' => $token]);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libs\Middlewares;
|
||||
|
||||
use App\API\Backends\AccessToken;
|
||||
use App\API\System\AutoConfig;
|
||||
use App\API\System\HealthCheck;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Enums\Http\Method;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as iHandler;
|
||||
|
||||
final class APIKeyRequiredMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public const string KEY_NAME = 'apikey';
|
||||
|
||||
/**
|
||||
* Public routes that are accessible without an API key. and must remain open.
|
||||
*/
|
||||
private const array PUBLIC_ROUTES = [
|
||||
HealthCheck::URL,
|
||||
AutoConfig::URL,
|
||||
];
|
||||
|
||||
/**
|
||||
* Routes that follow the open route policy. However, those routes are subject to user configuration.
|
||||
*/
|
||||
private const array OPEN_ROUTES = [
|
||||
'/webhook',
|
||||
'%{api.prefix}/player/'
|
||||
];
|
||||
|
||||
public function process(iRequest $request, iHandler $handler): iResponse
|
||||
{
|
||||
if (true === (bool)$request->getAttribute('INTERNAL_REQUEST')) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
if (Method::OPTIONS === Method::from($request->getMethod())) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$requestPath = rtrim($request->getUri()->getPath(), '/');
|
||||
|
||||
$openRoutes = self::PUBLIC_ROUTES;
|
||||
if (false === (bool)Config::get('api.secure', false)) {
|
||||
$openRoutes = array_merge($openRoutes, self::OPEN_ROUTES);
|
||||
}
|
||||
|
||||
foreach ($openRoutes as $route) {
|
||||
$route = rtrim(parseConfigValue($route), '/');
|
||||
if (true === str_starts_with($requestPath, $route) || true === str_ends_with($requestPath, $route)) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
|
||||
$tokens = $this->parseTokens($request);
|
||||
|
||||
if (count($tokens) < 1) {
|
||||
return api_error('API key is required to access the API.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (array_any($tokens, fn ($token) => true === $this->validate($token))) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
return api_error('incorrect API key.', Status::FORBIDDEN);
|
||||
}
|
||||
|
||||
private function validate(?string $token): bool
|
||||
{
|
||||
if (empty($token) || !($storedKey = Config::get('api.key')) || empty($storedKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($storedKey, $token);
|
||||
}
|
||||
|
||||
private function parseTokens(iRequest $request): array
|
||||
{
|
||||
$tokens = [];
|
||||
|
||||
if (true === $request->hasHeader('x-' . self::KEY_NAME)) {
|
||||
$tokens['header'] = $request->getHeaderLine('x-' . self::KEY_NAME);
|
||||
}
|
||||
|
||||
if (true === ag_exists($request->getQueryParams(), self::KEY_NAME)) {
|
||||
$tokens['param'] = ag($request->getQueryParams(), self::KEY_NAME);
|
||||
}
|
||||
|
||||
$auth = $request->getHeaderLine('Authorization');
|
||||
if (!empty($auth)) {
|
||||
[$type, $key] = explode(' ', $auth, 2);
|
||||
if (true === in_array(strtolower($type), ['bearer', 'token'])) {
|
||||
$tokens['auth'] = trim($key);
|
||||
}
|
||||
}
|
||||
|
||||
return array_map(fn ($val) => rawurldecode($val), array_values(array_unique(array_filter($tokens))));
|
||||
}
|
||||
}
|
||||
176
src/Libs/Middlewares/AuthorizationMiddleware.php
Normal file
176
src/Libs/Middlewares/AuthorizationMiddleware.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libs\Middlewares;
|
||||
|
||||
use App\API\System\Auth;
|
||||
use App\API\System\AutoConfig;
|
||||
use App\API\System\HealthCheck;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Enums\Http\Method;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\TokenUtil;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as iHandler;
|
||||
use Throwable;
|
||||
|
||||
final class AuthorizationMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public const string KEY_NAME = 'apikey';
|
||||
|
||||
/**
|
||||
* Public routes that are accessible without an API key. and must remain open.
|
||||
*/
|
||||
private const array PUBLIC_ROUTES = [
|
||||
HealthCheck::URL,
|
||||
AutoConfig::URL,
|
||||
Auth::URL,
|
||||
];
|
||||
|
||||
/**
|
||||
* Routes that follow the open route policy. However, those routes are subject to user configuration.
|
||||
*/
|
||||
private const array OPEN_ROUTES = [
|
||||
'/webhook',
|
||||
'%{api.prefix}/player/'
|
||||
];
|
||||
|
||||
public function process(iRequest $request, iHandler $handler): iResponse
|
||||
{
|
||||
if (true === (bool)$request->getAttribute('INTERNAL_REQUEST')) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
if (Method::OPTIONS === Method::from($request->getMethod())) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$requestPath = rtrim($request->getUri()->getPath(), '/');
|
||||
|
||||
$openRoutes = self::PUBLIC_ROUTES;
|
||||
if (false === (bool)Config::get('api.secure', false)) {
|
||||
$openRoutes = array_merge($openRoutes, self::OPEN_ROUTES);
|
||||
}
|
||||
|
||||
foreach ($openRoutes as $route) {
|
||||
$route = rtrim(parseConfigValue($route), '/');
|
||||
if (true === str_starts_with($requestPath, $route) || true === str_ends_with($requestPath, $route)) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
|
||||
$tokens = $this->parseTokens($request);
|
||||
|
||||
if (count($tokens) < 1) {
|
||||
return api_error('Authorization is required to access the API.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (array_any($tokens, fn ($token, $type) => true === $this->validate($type, $token))) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
return api_error('Incorrect authorization credentials.', Status::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
private function validate(string $type, ?string $token): bool
|
||||
{
|
||||
if (empty($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('token' === $type) {
|
||||
return $this->validateToken($token);
|
||||
}
|
||||
|
||||
if (!($storedKey = Config::get('api.key')) || empty($storedKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($storedKey, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user token.
|
||||
*
|
||||
* @param string|null $token The token to validate.
|
||||
*
|
||||
* @return bool True if the tken is valid. False otherwise.
|
||||
*/
|
||||
private function validateToken(?string $token): bool
|
||||
{
|
||||
if (empty($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = TokenUtil::decode($token);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (false === $decoded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = explode('.', $decoded, 2);
|
||||
if (2 !== count($parts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$signature, $payload] = $parts;
|
||||
|
||||
if (false === TokenUtil::verify($payload, $signature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
|
||||
$rand = fn () => TokenUtil::generateSecret();
|
||||
$systemUser = (string)Config::get('system.user', $rand);
|
||||
$payloadUser = (string)ag($payload, 'username', $rand);
|
||||
|
||||
if (false === hash_equals($systemUser, $payloadUser)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// $version = (string)ag($payload, 'version', '');
|
||||
// $currentVersion = getAppVersion();
|
||||
// if (false === hash_equals($currentVersion, $version)) {
|
||||
// return false;
|
||||
// }
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function parseTokens(iRequest $request): array
|
||||
{
|
||||
$tokens = [];
|
||||
|
||||
if (true === $request->hasHeader('x-' . self::KEY_NAME)) {
|
||||
$tokens['header'] = $request->getHeaderLine('x-' . self::KEY_NAME);
|
||||
}
|
||||
|
||||
if (true === ag_exists($request->getQueryParams(), self::KEY_NAME)) {
|
||||
$tokens['param'] = ag($request->getQueryParams(), self::KEY_NAME);
|
||||
}
|
||||
|
||||
foreach ($request->getHeader('Authorization') as $auth) {
|
||||
[$type, $value] = explode(' ', $auth, 2);
|
||||
$type = strtolower(trim($type));
|
||||
|
||||
if (false === in_array($type, ['bearer', 'token'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tokens[$type] = trim($value);
|
||||
}
|
||||
|
||||
return array_unique(array_map(fn ($val) => rawurldecode($val), $tokens));
|
||||
}
|
||||
}
|
||||
114
src/Libs/TokenUtil.php
Normal file
114
src/Libs/TokenUtil.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libs;
|
||||
|
||||
use App\Libs\Exceptions\RuntimeException;
|
||||
|
||||
final class TokenUtil
|
||||
{
|
||||
/**
|
||||
* Sign the given data using HMAC.
|
||||
*
|
||||
* @param string $data The data to sign.
|
||||
* @param string $algo The hashing algorithm to use (default: 'sha256').
|
||||
*
|
||||
* @return string The HMAC signature.
|
||||
*/
|
||||
public static function sign(string $data, string $algo = 'sha256'): string
|
||||
{
|
||||
return hash_hmac($algo, $data, static::getSecret());
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the given signature against the data.
|
||||
*
|
||||
* @param string $data The data to verify.
|
||||
* @param string $signature The signature to verify against.
|
||||
* @param string $algo The hashing algorithm to use (default: 'sha256').
|
||||
*
|
||||
* @return bool True if the signature is valid, false otherwise.
|
||||
*/
|
||||
public static function verify(string $data, string $signature, $algo = 'sha256'): bool
|
||||
{
|
||||
return hash_equals(static::sign($data, $algo), $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-safe Base64 Encode.
|
||||
*
|
||||
* @param string $data The data to encode.
|
||||
*
|
||||
* @return string The encoded data.
|
||||
*/
|
||||
public static function encode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-safe Base64 Decode.
|
||||
*
|
||||
* @param string $input The URL-safe Base64 string to decode.
|
||||
*
|
||||
* @return string|false The decoded data, or false on failure.
|
||||
*/
|
||||
public static function decode(string $input): string|false
|
||||
{
|
||||
$base64 = strtr($input, '-_', '+/');
|
||||
|
||||
$pad = strlen($base64) % 4;
|
||||
|
||||
if ($pad > 0) {
|
||||
$base64 .= str_repeat('=', 4 - $pad);
|
||||
}
|
||||
|
||||
return base64_decode($base64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string.
|
||||
*
|
||||
* @param int $length The length. (default: 16).
|
||||
*
|
||||
*
|
||||
* @return string The generated random string.
|
||||
*/
|
||||
public static function generateSecret(int $length = 16): string
|
||||
{
|
||||
return bin2hex(random_bytes($length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the secret key from the config file or generate a new one if it doesn't exist.
|
||||
*
|
||||
* @return string The secret key.
|
||||
*/
|
||||
private static function getSecret(): string
|
||||
{
|
||||
static $_secretKey = null;
|
||||
|
||||
if (null !== $_secretKey) {
|
||||
return $_secretKey;
|
||||
}
|
||||
|
||||
$secretFile = fixPath(Config::get('path') . '/config/.secret.key');
|
||||
|
||||
if (false === file_exists($secretFile) || filesize($secretFile) < 32) {
|
||||
$_secretKey = static::generateSecret();
|
||||
$stream = Stream::make($secretFile, 'w');
|
||||
$stream->write($_secretKey);
|
||||
$stream->close();
|
||||
return $_secretKey;
|
||||
}
|
||||
|
||||
$_secretKey = Stream::make($secretFile, 'r')->getContents();
|
||||
|
||||
if (empty($_secretKey)) {
|
||||
throw new RuntimeException('Failed to read secret key from file.');
|
||||
}
|
||||
|
||||
return $_secretKey;
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Libs\Middlewares;
|
||||
|
||||
use App\API\Backends\AccessToken;
|
||||
use App\API\System\AutoConfig;
|
||||
use App\API\System\HealthCheck;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Enums\Http\Method;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Middlewares\APIKeyRequiredMiddleware;
|
||||
use App\Libs\Middlewares\AuthorizationMiddleware;
|
||||
use App\Libs\TestCase;
|
||||
use Tests\Support\RequestResponseTrait;
|
||||
|
||||
class APIKeyRequiredMiddlewareTest extends TestCase
|
||||
class AuthorizationMiddlewareTest extends TestCase
|
||||
{
|
||||
use RequestResponseTrait;
|
||||
|
||||
@@ -25,7 +24,7 @@ class APIKeyRequiredMiddlewareTest extends TestCase
|
||||
|
||||
public function test_internal_request()
|
||||
{
|
||||
$result = new APIKeyRequiredMiddleware()->process(
|
||||
$result = new AuthorizationMiddleware()->process(
|
||||
request: $this->getRequest()->withAttribute('INTERNAL_REQUEST', true),
|
||||
handler: $this->getHandler()
|
||||
);
|
||||
@@ -34,7 +33,7 @@ class APIKeyRequiredMiddlewareTest extends TestCase
|
||||
|
||||
public function test_options_request()
|
||||
{
|
||||
$result = new APIKeyRequiredMiddleware()->process(
|
||||
$result = new AuthorizationMiddleware()->process(
|
||||
request: $this->getRequest(method: Method::OPTIONS),
|
||||
handler: $this->getHandler()
|
||||
);
|
||||
@@ -56,7 +55,7 @@ class APIKeyRequiredMiddlewareTest extends TestCase
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$uri = parseConfigValue($route);
|
||||
$result = new APIKeyRequiredMiddleware()->process(
|
||||
$result = new AuthorizationMiddleware()->process(
|
||||
request: $this->getRequest(uri: $uri),
|
||||
handler: $this->getHandler()
|
||||
);
|
||||
@@ -65,7 +64,7 @@ class APIKeyRequiredMiddlewareTest extends TestCase
|
||||
|
||||
foreach ($routesSemiOpen as $route) {
|
||||
$uri = parseConfigValue($route);
|
||||
$result = new APIKeyRequiredMiddleware()->process(
|
||||
$result = new AuthorizationMiddleware()->process(
|
||||
request: $this->getRequest(uri: $uri),
|
||||
handler: $this->getHandler()
|
||||
);
|
||||
@@ -76,7 +75,7 @@ class APIKeyRequiredMiddlewareTest extends TestCase
|
||||
|
||||
foreach ($routesSemiOpen as $route) {
|
||||
$uri = parseConfigValue($route);
|
||||
$result = new APIKeyRequiredMiddleware()->process(
|
||||
$result = new AuthorizationMiddleware()->process(
|
||||
request: $this->getRequest(uri: $uri)->withoutHeader('Authorization'),
|
||||
handler: $this->getHandler()
|
||||
);
|
||||
@@ -89,12 +88,12 @@ class APIKeyRequiredMiddlewareTest extends TestCase
|
||||
|
||||
foreach ($routesSemiOpen as $route) {
|
||||
$uri = parseConfigValue($route);
|
||||
$result = new APIKeyRequiredMiddleware()->process(
|
||||
$result = new AuthorizationMiddleware()->process(
|
||||
request: $this->getRequest(uri: $uri)->withHeader('Authorization', 'Bearer api'),
|
||||
handler: $this->getHandler()
|
||||
);
|
||||
$this->assertSame(
|
||||
Status::FORBIDDEN->value,
|
||||
Status::UNAUTHORIZED->value,
|
||||
$result->getStatusCode(),
|
||||
"Route '{$route}' should fail without correct API key"
|
||||
);
|
||||
@@ -103,7 +102,7 @@ class APIKeyRequiredMiddlewareTest extends TestCase
|
||||
Config::save('api.key', 'api_test_token');
|
||||
foreach ($routesSemiOpen as $route) {
|
||||
$uri = parseConfigValue($route);
|
||||
$result = new APIKeyRequiredMiddleware()->process(
|
||||
$result = new AuthorizationMiddleware()->process(
|
||||
request: $this->getRequest(uri: $uri, query: ['apikey' => 'api_test_token'])->withHeader(
|
||||
'X-apikey',
|
||||
'api_test_token'
|
||||
@@ -18,7 +18,7 @@ trait RequestResponseTrait
|
||||
{
|
||||
$response ??= new Response(Status::OK);
|
||||
|
||||
return new readonly class($response) implements iHandler {
|
||||
return new class($response) implements iHandler {
|
||||
private mixed $response;
|
||||
|
||||
public function __construct(iResponse|callable $response)
|
||||
|
||||
Reference in New Issue
Block a user