initial code to support login via username/password

This commit is contained in:
arabcoders
2025-05-14 17:45:58 +03:00
parent 1cbd558bd2
commit 1af94641c4
9 changed files with 481 additions and 125 deletions

View File

@@ -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(),

View File

@@ -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;
})();

View File

@@ -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
View 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]);
}
}

View File

@@ -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))));
}
}

View 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
View 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;
}
}

View File

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

View File

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