diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d79ed50..095a273 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: testsuite: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -26,7 +26,7 @@ jobs: if: matrix.db-type == 'pgsql' run: docker run --rm --name=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=cakephp -p 5432:5432 -d postgres - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -45,7 +45,7 @@ jobs: run: echo "::set-output name=date::$(date +'%Y-%m')" - name: Cache composer dependencies - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} @@ -75,14 +75,14 @@ jobs: - name: Submit code coverage if: matrix.php-version == '8.1' - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v5 cs-stan: name: Coding Standard & Static Analysis runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -100,7 +100,7 @@ jobs: run: echo "::set-output name=date::$(date +'%Y-%m')" - name: Cache composer dependencies - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} diff --git a/.gitignore b/.gitignore index bb48951..10a3bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ tmp pmip webroot/coverage .php_cs.cache +.phpunit.cache/ diff --git a/composer.json b/composer.json index 983c007..129a303 100644 --- a/composer.json +++ b/composer.json @@ -35,10 +35,15 @@ "firebase/php-jwt": "^6.3" }, "require-dev": { - "cakephp/cakephp-codesniffer": "~4.4.0", + "cakephp/cakephp-codesniffer": "^4.5", + "google/recaptcha": "@stable", "league/flysystem-vfs": "^1.0", + "laminas/laminas-diactoros": "^3.0", "phpunit/phpunit": "^10.0", - "vlucas/phpdotenv": "^3.3" + "phpstan/phpstan": "^1.8", + "robthree/twofactorauth": "^1.6", + "vlucas/phpdotenv": "^3.3", + "web-auth/webauthn-lib": "^5.0" }, "autoload": { "psr-4": { diff --git a/config/Migrations/20231003201241_auth_store.php b/config/Migrations/20231003201241_auth_store.php new file mode 100644 index 0000000..fcc98ec --- /dev/null +++ b/config/Migrations/20231003201241_auth_store.php @@ -0,0 +1,34 @@ +table('auth_store', ['id' => false, 'primary_key' => ['id']]) + ->addColumn('id', 'string', [ + 'limit' => 500, + 'null' => false, + ]) + ->addColumn('store', 'text', [ + 'null' => true, + ]) + ->addColumn('created', 'datetime', [ + 'null' => false, + ]) + ->addColumn('modified', 'datetime', [ + 'null' => false, + ]) + ->create(); + } +} diff --git a/config/api.php b/config/api.php index 10f7544..8c48302 100644 --- a/config/api.php +++ b/config/api.php @@ -35,6 +35,23 @@ 'serviceLookupPlugins' => null, 'lookupMode' => 'underscore', + '2fa' => [ + 'enabled' => false, + ], + + 'OneTimePasswordAuthenticator' => [ + 'login' => false, + 'checker' => \CakeDC\Api\Service\Auth\TwoFactorAuthentication\DefaultOneTimePasswordAuthenticationChecker::class, + ], + 'Webauthn2fa' => [ + 'checker' => \CakeDC\Api\Service\Auth\TwoFactorAuthentication\DefaultWebauthn2fAuthenticationChecker::class, + 'localhost' => [ + 'enabled' => false, + 'appName' => 'apilocal', + 'id' => 'localhost', + ], + ], + // auth permission uses require auth strategy 'Auth' => [ 'Crud' => [ diff --git a/config/routes.php b/config/routes.php index 40e96a0..dd00494 100644 --- a/config/routes.php +++ b/config/routes.php @@ -19,7 +19,7 @@ $routes->plugin('CakeDC/Api', ['path' => '/api'], function ($routes) { $useVersioning = Configure::read('Api.useVersioning'); $versionPrefix = Configure::read('Api.versionPrefix'); - $middlewares = Configure::read('Api.Middleware'); + $middlewares = Configure::read('Api.Middleware', []); $middlewareNames = array_keys($middlewares); $routes->applyMiddleware(...$middlewareNames); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8c069f1..685dbdd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -17,11 +17,6 @@ parameters: count: 1 path: src\Service\Action\Auth\ResetPasswordAction.php - - - message: "#^Call to an undefined method Cake\\\\ORM\\\\Table\\:\\:validationPasswordConfirm\\(\\)\\.$#" - count: 1 - path: src\Service\Action\Auth\ResetPasswordAction.php - - message: "#^Call to an undefined method Cake\\\\ORM\\\\Table\\:\\:changePassword\\(\\)\\.$#" count: 1 @@ -46,5 +41,3 @@ parameters: message: "#^Call to an undefined method Cake\\\\ORM\\\\Table\\:\\:resetToken\\(\\)\\.$#" count: 1 path: src\Service\Action\Auth\ValidateAccountRequestAction.php - - diff --git a/phpstan.neon b/phpstan.neon index 634e6ee..73c259c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,7 +2,7 @@ includes: - phpstan-baseline.neon parameters: - level: 4 + level: 2 bootstrapFiles: - tests/bootstrap.php ignoreErrors: diff --git a/src/Command/ServiceRoutesCommand.php b/src/Command/ServiceRoutesCommand.php new file mode 100644 index 0000000..a52932d --- /dev/null +++ b/src/Command/ServiceRoutesCommand.php @@ -0,0 +1,153 @@ +setDescription(__('Display all routes in a service')); + $parser->addArgument('service', [ + 'help' => __('The name of the service to display routes for.'), + 'required' => true, + ]); + + return $parser; + } + + /** + * Display all routes in an application + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $serviceName = $args->getArgument('service'); + $header = ['Route name', 'Method(s)', 'URI template', 'Service', 'Action', 'Plugin']; + if ($args->getOption('verbose')) { + $header[] = 'Defaults'; + } + + $service = ServiceRegistry::getServiceLocator()->get($serviceName); + if ($service === null) { + $io->error(__('Service "{0}" not found', $serviceName)); + + return Command::CODE_ERROR; + } + + $availableRoutes = $service->routes(); + + $output = $duplicateRoutesCounter = []; + + foreach ($availableRoutes as $route) { + $methods = isset($route->defaults['_method']) ? (array)$route->defaults['_method'] : ['']; + + $item = [ + $route->options['_name'] ?? $route->getName(), + implode(', ', $methods), + $route->template, + $route->defaults['controller'] ?? '', + $route->defaults['action'] ?? '', + $route->defaults['plugin'] ?? '', + ]; + + if ($args->getOption('verbose')) { + ksort($route->defaults); + $item[] = json_encode($route->defaults); + } + + $output[] = $item; + + foreach ($methods as $method) { + if (!isset($duplicateRoutesCounter[$route->template][$method])) { + $duplicateRoutesCounter[$route->template][$method] = 0; + } + + $duplicateRoutesCounter[$route->template][$method]++; + } + } + + if ($args->getOption('sort')) { + usort($output, function ($a, $b) { + return strcasecmp($a[0], $b[0]); + }); + } + + array_unshift($output, $header); + + $io->helper('table')->output($output); + $io->out(); + + $duplicateRoutes = []; + + foreach ($availableRoutes as $route) { + $methods = isset($route->defaults['_method']) ? (array)$route->defaults['_method'] : ['']; + + foreach ($methods as $method) { + if ( + $duplicateRoutesCounter[$route->template][$method] > 1 || + ($method === '' && count($duplicateRoutesCounter[$route->template]) > 1) || + ($method !== '' && isset($duplicateRoutesCounter[$route->template][''])) + ) { + $duplicateRoutes[] = [ + $route->options['_name'] ?? $route->getName(), + $route->template, + $route->defaults['plugin'] ?? '', + $route->defaults['prefix'] ?? '', + $route->defaults['controller'] ?? '', + $route->defaults['action'] ?? '', + implode(', ', $methods), + ]; + + break; + } + } + } + + if ($duplicateRoutes) { + array_unshift($duplicateRoutes, $header); + $io->warning('The following possible route collisions were detected.'); + $io->helper('table')->output($duplicateRoutes); + $io->out(); + } + + return static::CODE_SUCCESS; + } +} diff --git a/src/Model/Entity/AuthStore.php b/src/Model/Entity/AuthStore.php new file mode 100644 index 0000000..2006d5f --- /dev/null +++ b/src/Model/Entity/AuthStore.php @@ -0,0 +1,33 @@ + + */ + protected array $_accessible = [ + 'id' => true, + 'store' => true, + 'created' => true, + 'modified' => true, + ]; +} diff --git a/src/Model/Table/AuthStoreTable.php b/src/Model/Table/AuthStoreTable.php new file mode 100644 index 0000000..14bfbab --- /dev/null +++ b/src/Model/Table/AuthStoreTable.php @@ -0,0 +1,74 @@ +setTable('auth_store'); + $this->setDisplayField('id'); + $this->setPrimaryKey('id'); + + $this->addBehavior('Timestamp'); + } + + /** + * Default validation rules. + * + * @param \Cake\Validation\Validator $validator Validator instance. + * @return \Cake\Validation\Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->scalar('store') + ->allowEmptyString('store'); + + return $validator; + } + + /** + * Initialize schema + * + * @return \Cake\Database\Schema\TableSchemaInterface + */ + public function getSchema(): TableSchemaInterface + { + $schema = parent::getSchema(); + $schema->setColumnType('store', 'json'); + + return $schema; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 0e33b05..802ac1f 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -13,9 +13,11 @@ namespace CakeDC\Api; +use Cake\Console\CommandCollection; use Cake\Core\BasePlugin; use Cake\Core\Configure; use Cake\Core\ContainerInterface; +use CakeDC\Api\Command\ServiceRoutesCommand; /** * Api plugin @@ -29,7 +31,7 @@ class Plugin extends BasePlugin */ public function routes($routes): void { - $middlewares = Configure::read('Api.Middleware'); + $middlewares = Configure::read('Api.Middleware', []); foreach ($middlewares as $alias => $middleware) { $class = $middleware['class']; if (array_key_exists('request', $middleware)) { @@ -83,4 +85,15 @@ public function services(ContainerInterface $container): void $this->middlewares['apiParser']->setContainer($container); } } + + /** + * Add console commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + return $commands->add('service routes', ServiceRoutesCommand::class); + } } diff --git a/src/Rbac/Rules/TwoFactorPassedScope.php b/src/Rbac/Rules/TwoFactorPassedScope.php new file mode 100644 index 0000000..af4e733 --- /dev/null +++ b/src/Rbac/Rules/TwoFactorPassedScope.php @@ -0,0 +1,46 @@ +getAttribute('authentication'); + if ($authentication === null) { + return false; + } + $provider = $authentication->getAuthenticationProvider(); + if ($provider === null || !($provider instanceof JwtAuthenticator)) { + return false; + } + $payload = $provider->getPayload(); + $aud = Router::url('/', true); + + return $payload->aud == $aud; + } +} diff --git a/src/Rbac/Rules/TwoFactorScope.php b/src/Rbac/Rules/TwoFactorScope.php new file mode 100644 index 0000000..7b96fac --- /dev/null +++ b/src/Rbac/Rules/TwoFactorScope.php @@ -0,0 +1,46 @@ +getAttribute('authentication'); + if ($authentication === null) { + return false; + } + $provider = $authentication->getAuthenticationProvider(); + if ($provider === null || !($provider instanceof JwtAuthenticator)) { + return false; + } + $payload = $provider->getPayload(); + $aud = Router::url('/2fa', true); + + return $payload->aud == $aud; + } +} diff --git a/src/Routing/ApiRouter.php b/src/Routing/ApiRouter.php index 2b5f17f..b33d6d1 100644 --- a/src/Routing/ApiRouter.php +++ b/src/Routing/ApiRouter.php @@ -134,7 +134,7 @@ class ApiRouter extends Router * The stack of URL filters to apply against routing URLs before passing the * parameters to the route collection. * - * @var array<\Closure> + * @var array */ protected static array $_urlFilters = []; diff --git a/src/Service/Action/Auth/JwtLoginAction.php b/src/Service/Action/Auth/JwtLoginAction.php index 0627714..1d79b96 100644 --- a/src/Service/Action/Auth/JwtLoginAction.php +++ b/src/Service/Action/Auth/JwtLoginAction.php @@ -39,6 +39,6 @@ protected function _afterIdentifyUser($user, $socialLogin = false) return false; } - return $this->generateTokenResponse($user); + return $this->generateTokenResponse($user, 'login'); } } diff --git a/src/Service/Action/Auth/JwtRefreshAction.php b/src/Service/Action/Auth/JwtRefreshAction.php index 3b1ef27..252c337 100644 --- a/src/Service/Action/Auth/JwtRefreshAction.php +++ b/src/Service/Action/Auth/JwtRefreshAction.php @@ -35,6 +35,8 @@ class JwtRefreshAction extends Action */ protected $user; + protected $payload; + /** * Initialize an action instance * @@ -94,7 +96,7 @@ public function validates(): bool throw new ValidationException('Invalid token provided', 401); } - $payload = $auth->getPayload(); + $this->payload = $auth->getPayload(); return true; } @@ -110,6 +112,6 @@ public function execute() return false; } - return $this->generateTokenResponse($this->user); + return $this->generateRefreshTokenResponse($this->user, $this->payload); } } diff --git a/src/Service/Action/Auth/JwtSocialLoginAction.php b/src/Service/Action/Auth/JwtSocialLoginAction.php index 122004b..500a770 100644 --- a/src/Service/Action/Auth/JwtSocialLoginAction.php +++ b/src/Service/Action/Auth/JwtSocialLoginAction.php @@ -28,6 +28,7 @@ class JwtSocialLoginAction extends Action * Execute action. * * @return mixed + * @throws \Exception */ public function execute() { @@ -37,6 +38,6 @@ public function execute() return false; } - return $this->generateTokenResponse($user); + return $this->generateTokenResponse($user, 'login'); } } diff --git a/src/Service/Action/Auth/JwtTokenTrait.php b/src/Service/Action/Auth/JwtTokenTrait.php index 837192a..181a09c 100644 --- a/src/Service/Action/Auth/JwtTokenTrait.php +++ b/src/Service/Action/Auth/JwtTokenTrait.php @@ -17,28 +17,55 @@ use Cake\ORM\TableRegistry; use Cake\Routing\Router; use Cake\Utility\Hash; +use CakeDC\Api\Service\Auth\TwoFactorAuthentication\OneTimePasswordAuthenticationCheckerFactory; +use CakeDC\Api\Service\Auth\TwoFactorAuthentication\Webauthn2fAuthenticationCheckerFactory; use DateInterval; use DateTimeImmutable; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Hmac\Sha512; use Lcobucci\JWT\Signer\Key\InMemory; +/** + * JwtTokenTrait + */ trait JwtTokenTrait { /** * Generates token response. * * @param \Cake\Datasource\EntityInterface|array $user User info. + * @param string|null $type The type of token being generated. + * @return array + */ + public function generateTokenResponse($user, $type) + { + $timestamp = new DateTimeImmutable('-1 second'); + unset($user['additional_data'], $user['secret'], $user['secret_verified']); + + return Hash::merge($user, [ + 'access_token' => $this->generateAccessToken($user, $timestamp, $type), + 'refresh_token' => $this->generateRefreshToken($user, $timestamp, $type), + 'expired' => $this->accessTokenLifeTime($timestamp), + 'enabled2FA' => $this->is2FAEnabled($user), + 'enabledWebauthn' => $this->isEnabledWebauthn2faAuthentication($user), + 'enabledOtp' => $this->isEnabledOneTimePasswordAuthentication($user), + ]); + } + + /** + * Generates refresh token response. + * + * @param \Cake\Datasource\EntityInterface|array $user User info. + * @param array $payload Additional payload data. * @return array */ - public function generateTokenResponse($user) + public function generateRefreshTokenResponse($user, $payload) { - //$timestamp = time(); $timestamp = new DateTimeImmutable(); return Hash::merge($user, [ - 'access_token' => $this->generateAccessToken($user, $timestamp), - 'refresh_token' => $this->generateRefreshToken($user, $timestamp), + 'access_token' => $this->generateAccessToken($user, $timestamp, null, $payload), + 'refresh_token' => $this->generateRefreshToken($user, $timestamp, null, $payload), 'expired' => $this->accessTokenLifeTime($timestamp), ]); } @@ -48,16 +75,18 @@ public function generateTokenResponse($user) * * @param \Cake\Datasource\EntityInterface|array $user User info. * @param \DateTimeImmutable $timestamp Timestamp. + * @param string|null $type The type of token being generated. + * @param array|null $payload Additional payload data. * @return bool|string */ - public function generateAccessToken($user, $timestamp) + public function generateAccessToken($user, $timestamp, $type, $payload = null) { if (empty($user)) { return false; } $subject = $user['id']; - $audience = Router::url('/', true); + $audience = $this->getAudience($user, $type, $payload); $issuer = Router::url('/', true); $signer = new Sha512(); $secret = Configure::read('Api.Jwt.AccessToken.secret'); @@ -75,21 +104,110 @@ public function generateAccessToken($user, $timestamp) return $token->toString(); } + /** + * Get the audience for the token. + * + * @param \Cake\Datasource\EntityInterface|array $user User info. + * @param string|null $type The type of token being generated. + * @param array|null $payload Additional payload data. + * @return string + */ + public function getAudience($user, $type, $payload) + { + if ($type === null && is_array($payload) && isset($payload['aud'])) { + return $payload['aud']; + } + if ($type == 'login' && $this->is2FAEnabled($user)) { + $audience = Router::url('/2fa', true); + } else { + $audience = Router::url('/', true); + } + + return $audience; + } + + /** + * Check if 2FA is enabled for the user. + * + * @param \Cake\Datasource\EntityInterface|array $user User info. + * @return bool + */ + protected function is2FAEnabled($user) + { + return $this->isEnabledWebauthn2faAuthentication($user) || $this->isEnabledOneTimePasswordAuthentication($user); + } + + /** + * Check if Webauthn 2FA authentication is enabled for the user. + * + * @param \Cake\Datasource\EntityInterface|array $user User info. + * @return bool + */ + public function isEnabledWebauthn2faAuthentication($user) + { + $enabledTwoFactorVerify = Configure::read('Api.2fa.enabled'); + $webauthn2faChecker = $this->getWebauthn2fAuthenticationChecker(); + if ($enabledTwoFactorVerify && $webauthn2faChecker->isRequired((array)$user)) { + return true; + } + + return false; + } + + /** + * Check if One-Time Password authentication is enabled for the user. + * + * @param \Cake\Datasource\EntityInterface|array $user User info. + * @return bool + */ + public function isEnabledOneTimePasswordAuthentication($user) + { + $enabledTwoFactorVerify = Configure::read('Api.2fa.enabled'); + $otpChecker = $this->getOneTimePasswordAuthenticationChecker(); + if ($enabledTwoFactorVerify && $otpChecker->isRequired((array)$user)) { + return true; + } + + return false; + } + + /** + * Get the One-Time Password Authentication Checker. + * + * @return \CakeDC\Auth\Authentication\OneTimePasswordAuthenticationCheckerInterface + */ + protected function getOneTimePasswordAuthenticationChecker() + { + return (new OneTimePasswordAuthenticationCheckerFactory())->build(); + } + + /** + * Get the configured u2f authentication checker + * + * @return \CakeDC\Auth\Authentication\Webauthn2fAuthenticationCheckerInterface + */ + protected function getWebauthn2fAuthenticationChecker() + { + return (new Webauthn2fAuthenticationCheckerFactory())->build(); + } + /** * Generates refresh token. * * @param \Cake\Datasource\EntityInterface|array $user User info. * @param \DateTimeImmutable $timestamp Timestamp. + * @param string|null $type The type of token being generated. + * @param array|null $payload Additional payload data. * @return bool|string */ - public function generateRefreshToken($user, $timestamp) + public function generateRefreshToken($user, $timestamp, $type, $payload = null) { if (empty($user)) { return false; } $subject = $user['id']; - $audience = Router::url('/', true); + $audience = $this->getAudience($user, $type, $payload); $issuer = Router::url('/', true); $signer = new Sha512(); $secret = Configure::read('Api.Jwt.RefreshToken.secret'); @@ -111,6 +229,7 @@ public function generateRefreshToken($user, $timestamp) $model = $UsersTable->getAlias(); $table = TableRegistry::getTableLocator()->get('CakeDC/Api.JwtRefreshTokens'); + /** @var \CakeDC\Api\Model\Entity\JwtRefreshToken $entity */ $entity = $table->find()->where([ 'model' => $model, 'foreign_key' => $subject, diff --git a/src/Service/Action/Auth/OtpVerifyAction.php b/src/Service/Action/Auth/OtpVerifyAction.php new file mode 100644 index 0000000..24bc23f --- /dev/null +++ b/src/Service/Action/Auth/OtpVerifyAction.php @@ -0,0 +1,87 @@ +tfa = new TwoFactorAuth( + Configure::read('OneTimePasswordAuthenticator.issuer'), + Configure::read('OneTimePasswordAuthenticator.digits'), + Configure::read('OneTimePasswordAuthenticator.period'), + Configure::read('OneTimePasswordAuthenticator.algorithm'), + Configure::read('OneTimePasswordAuthenticator.qrcodeprovider'), + Configure::read('OneTimePasswordAuthenticator.rngprovider') + ); + } + + /** + * createSecret + * + * @return string base32 shared secret stored in users table + */ + public function createSecret() + { + return $this->tfa->createSecret(); + } + + /** + * verifyCode + * Verifying tfa code with shared secret + * + * @param string $secret of the user + * @param string $code from verification form + * @return bool + */ + public function verifyCode($secret, $code) + { + return $this->tfa->verifyCode($secret, $code); + } + + /** + * getQRCodeImageAsDataUri + * + * @param string $issuer issuer + * @param string $secret secret + * @return string base64 string containing QR code for shared secret + */ + public function getQRCodeImageAsDataUri($issuer, $secret) + { + return $this->tfa->getQRCodeImageAsDataUri($issuer, $secret); + } +} diff --git a/src/Service/Action/Auth/OtpVerifyCheckAction.php b/src/Service/Action/Auth/OtpVerifyCheckAction.php new file mode 100644 index 0000000..b22136a --- /dev/null +++ b/src/Service/Action/Auth/OtpVerifyCheckAction.php @@ -0,0 +1,59 @@ +getData('code'); + $user = $this->getIdentity(); + $entity = $this->getUsersTable()->get($user['id']); + + if (!empty($entity['secret'])) { + $codeVerified = $this->verifyCode($entity['secret'], $verificationCode); + } + + if (!$codeVerified) { + throw new \Exception(__d('cake_d_c/api', 'Verification code is invalid. Try again')); + } + + unset($user['secret']); + + if (!$user['secret_verified']) { + $this->getUsersTable()->updateQuery() + ->set(['secret_verified' => true]) + ->where(['id' => $user['id']]) + ->execute(); + } + + return $this->generateTokenResponse($user->toArray(), '2fa'); + } +} diff --git a/src/Service/Action/Auth/OtpVerifyGetAction.php b/src/Service/Action/Auth/OtpVerifyGetAction.php new file mode 100644 index 0000000..4cbd6be --- /dev/null +++ b/src/Service/Action/Auth/OtpVerifyGetAction.php @@ -0,0 +1,83 @@ +getIdentity(); + $secretVerified = $user['secret_verified'] ?? null; + // showing QR-code until shared secret is verified + if (!$secretVerified) { + $secret = $this->onVerifyGetSecret($user); + if (empty($secret)) { + throw new \Exception('Secret generation issue, please try again'); + } else { + $secretDataUri = $this->getQRCodeImageAsDataUri($user['email'], $secret); + $result = [ + 'secretDataUri' => $secretDataUri, + 'secret' => $secret, + 'verified' => false, + ]; + } + } else { + $result = ['verified' => true]; + } + + return $result; + } + + /** + * onVerifyGetSecret + * + * @param array $user User. + * @return string + */ + protected function onVerifyGetSecret($user) + { + if (isset($user['secret']) && $user['secret']) { + return $user['secret']; + } + + $secret = $this->createSecret(); + try { + $query = $this->getUsersTable()->updateQuery(); + $query + ->set(['secret' => $secret]) + ->where(['id' => $user['id']]); + $query->execute(); + } catch (\Exception $e) { + $message = __d('cake_d_c/api', 'Could not verify, please try again'); + + throw new \Exception($message); + } + + return $secret; + } +} diff --git a/src/Service/Action/Auth/ResetPasswordAction.php b/src/Service/Action/Auth/ResetPasswordAction.php index 2417659..71714a0 100644 --- a/src/Service/Action/Auth/ResetPasswordAction.php +++ b/src/Service/Action/Auth/ResetPasswordAction.php @@ -56,6 +56,24 @@ public function validates(): bool $validator ->requirePresence('token', 'create') ->notBlank('token'); + + $validator + ->requirePresence('password_confirm', 'create') + ->notBlank('password_confirm'); + + $validator + ->requirePresence('password', 'create') + ->notBlank('password') + ->add('password', [ + 'password_confirm_check' => [ + 'rule' => ['compareWith', 'password_confirm'], + 'message' => __d( + 'cake_d_c/users', + 'Your password does not match your confirm password. Please try again' + ), + 'allowEmpty' => false, + ]]); + $errors = $validator->validate($this->getData()); if (!empty($errors)) { throw new ValidationException(__('Validation failed'), 0, null, $errors); @@ -100,22 +118,17 @@ public function execute() */ protected function _changePassword($userId) { + /** @var \CakeDC\Users\Model\Entity\User $user */ $user = $this->getUsersTable()->newEntity([], ['validate' => false]); $user->id = $userId; try { - $validator = $this->getUsersTable()->validationPasswordConfirm(new Validator()); - $this->getUsersTable()->setValidator('changePassV', $validator); - $user = $this->getUsersTable()->patchEntity($user, $this->getData(), ['validate' => 'changePassV']); - if ($user->getErrors()) { - $message = __d('CakeDC/Api', 'Password could not be changed'); - throw new ValidationException($message, 0, null, $user->getErrors()); + $data = $this->getData(); + $user->password = $data['password']; + $user = $this->getUsersTable()->changePassword($user); + if ($user) { + return __d('CakeDC/Api', 'Password has been changed successfully'); } else { - $user = $this->getUsersTable()->changePassword($user); - if ($user) { - return __d('CakeDC/Api', 'Password has been changed successfully'); - } else { - throw new Exception(__d('CakeDC/Api', 'Password could not be changed'), 500); - } + throw new Exception(__d('CakeDC/Api', 'Password could not be changed'), 500); } } catch (UserNotFoundException $exception) { throw new Exception(__d('CakeDC/Api', 'User was not found'), 404, $exception); diff --git a/src/Service/Action/Auth/ResetPasswordRequestAction.php b/src/Service/Action/Auth/ResetPasswordRequestAction.php index 47d18ab..953fa80 100644 --- a/src/Service/Action/Auth/ResetPasswordRequestAction.php +++ b/src/Service/Action/Auth/ResetPasswordRequestAction.php @@ -73,14 +73,22 @@ public function execute() { $data = $this->getData(); $reference = $data['reference']; + $baseUrl = $data['base_url'] ?? null; try { - $resetUser = $this->getUsersTable()->resetToken($reference, [ + $options = [ 'expiration' => Configure::read('Users.Token.expiration'), 'checkActive' => false, 'sendEmail' => true, 'type' => 'password', 'ensureActive' => Configure::read('Users.Registration.ensureActive'), - ]); + ]; + if (!empty($baseUrl)) { + $options['linkGenerator'] = function ($token) use ($baseUrl) { + return $baseUrl . '?token=' . $token; + }; + } + + $resetUser = $this->getUsersTable()->resetToken($reference, $options); if ($resetUser) { return __d('CakeDC/Api', 'Please check your email to continue with password reset process'); } else { diff --git a/src/Service/Action/Auth/TwoFactorAuthAction.php b/src/Service/Action/Auth/TwoFactorAuthAction.php new file mode 100644 index 0000000..3eb200b --- /dev/null +++ b/src/Service/Action/Auth/TwoFactorAuthAction.php @@ -0,0 +1,52 @@ +getIdentity(); + $adapter = new RegisterAdapter($this->getService()->getRequest(), $this->getUsersTable(), $user); + $hasWebauthn = $adapter->hasCredential(); + $user = $user->toArray(); + + $hasOtp = $user['secret'] && $user['secret_verified']; + + return [ + 'hasRegistered2fa' => $hasWebauthn && $hasOtp, + 'hasOtp' => $hasOtp, + 'hasWebauthn' => $hasWebauthn, + 'enabledWebauthn' => $this->isEnabledWebauthn2faAuthentication((array)$user), + 'enabledOtp' => $this->isEnabledOneTimePasswordAuthentication((array)$user), + ]; + } +} diff --git a/src/Service/Action/Auth/Webauthn2faAction.php b/src/Service/Action/Auth/Webauthn2faAction.php new file mode 100644 index 0000000..b3196fd --- /dev/null +++ b/src/Service/Action/Auth/Webauthn2faAction.php @@ -0,0 +1,45 @@ +getIdentity(); + $request = $this->getService()->getRequest(); + $adapter = new RegisterAdapter($request, $this->getUsersTable(), $user); + + return [ + 'isRegister' => !$adapter->hasCredential(), + 'username' => $user->webauthn_username ?? $user->username, + ]; + } +} diff --git a/src/Service/Action/Auth/Webauthn2faAuthAction.php b/src/Service/Action/Auth/Webauthn2faAuthAction.php new file mode 100644 index 0000000..d1c8ac0 --- /dev/null +++ b/src/Service/Action/Auth/Webauthn2faAuthAction.php @@ -0,0 +1,53 @@ +getIdentity(); + $adapter = new AuthenticateAdapter($this->getService()->getRequest(), $this->getUsersTable(), $user); + $adapter->verifyResponse(); + $adapter->deleteStore(); + + return $this->generateTokenResponse($user->toArray(), '2fa'); + } catch (\Throwable $e) { + $user = $this->getIdentity(); + $message = __d('cake_d_c/api', 'Register error with webauthn for user id: {0}', $user['id'] ?? 'empty'); + Log::debug($message); + throw $e; + } + } +} diff --git a/src/Service/Action/Auth/Webauthn2faAuthOptionsAction.php b/src/Service/Action/Auth/Webauthn2faAuthOptionsAction.php new file mode 100644 index 0000000..89f666b --- /dev/null +++ b/src/Service/Action/Auth/Webauthn2faAuthOptionsAction.php @@ -0,0 +1,41 @@ +getService()->getRequest(); + $adapter = new AuthenticateAdapter($request, $this->getUsersTable(), $this->getIdentity()); + + return $adapter->getOptions(); + } +} diff --git a/src/Service/Action/Auth/Webauthn2faRegisterAction.php b/src/Service/Action/Auth/Webauthn2faRegisterAction.php new file mode 100644 index 0000000..1567fde --- /dev/null +++ b/src/Service/Action/Auth/Webauthn2faRegisterAction.php @@ -0,0 +1,50 @@ +getIdentity(); + $adapter = new RegisterAdapter($this->getService()->getRequest(), $this->getUsersTable(), $user); + + if (!$adapter->hasCredential()) { + $adapter->verifyResponse(); + + return ['success' => true]; + } + + throw new BadRequestException( + __d('cake_d_c/api', 'User already has configured webauthn2fa') + ); + } +} diff --git a/src/Service/Action/Auth/Webauthn2faRegisterOptionsAction.php b/src/Service/Action/Auth/Webauthn2faRegisterOptionsAction.php new file mode 100644 index 0000000..8dc3f36 --- /dev/null +++ b/src/Service/Action/Auth/Webauthn2faRegisterOptionsAction.php @@ -0,0 +1,47 @@ +getIdentity(); + $adapter = new RegisterAdapter($this->getService()->getRequest(), $this->getUsersTable(), $user); + + if (!$adapter->hasCredential()) { + return $adapter->getOptions(); + } + throw new BadRequestException( + __d('cake_d_c/api', 'User already has configured webauthn2fa') + ); + } +} diff --git a/src/Service/Action/Extension/AuthorizationExtension.php_ b/src/Service/Action/Extension/AuthorizationExtension.php_ index 0ff3b82..a540c39 100644 --- a/src/Service/Action/Extension/AuthorizationExtension.php_ +++ b/src/Service/Action/Extension/AuthorizationExtension.php_ @@ -37,7 +37,7 @@ class AuthorizationExtension extends Extension implements EventListenerInterface * * @var array */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'identityAttribute' => 'identity', 'serviceAttribute' => 'authorization', 'authorizationEvent' => 'Action.Auth.onAuthorization', diff --git a/src/Service/Action/Recaptcha/ValidateAction.php b/src/Service/Action/Recaptcha/ValidateAction.php new file mode 100644 index 0000000..6608308 --- /dev/null +++ b/src/Service/Action/Recaptcha/ValidateAction.php @@ -0,0 +1,37 @@ +validateReCaptcha(); + } +} diff --git a/src/Service/Action/Traits/ReCaptchaTrait.php b/src/Service/Action/Traits/ReCaptchaTrait.php new file mode 100644 index 0000000..3aff708 --- /dev/null +++ b/src/Service/Action/Traits/ReCaptchaTrait.php @@ -0,0 +1,64 @@ +getService()->getRequest()); + $reCaptcha = Configure::read('Api.reCaptcha.' . $domain); + if ($reCaptcha['disabled'] ?? false) { + return true; + } + + $recaptcha = $this->_getReCaptchaInstance(); + if ($recaptchaResponse === null) { + $recaptchaResponse = $this->getService()->getRequest()->getData('g-recaptcha-response'); + } + $resp = $recaptcha->verify($recaptchaResponse, $this->getService()->getRequest()->clientIp()); + + return $resp->isSuccess(); + } + + /** + * Create reCaptcha instance if enabled in configuration + * + * @return \ReCaptcha\ReCaptcha|null + */ + protected function _getReCaptchaInstance() + { + $domain = RequestParser::getDomain($this->getService()->getRequest()); + $reCaptchaSecret = Configure::read('Api.reCaptcha.' . $domain . '.secret'); + if (!empty($reCaptchaSecret)) { + return new \ReCaptcha\ReCaptcha($reCaptchaSecret); + } + + return null; + } +} diff --git a/src/Service/Auth/TwoFactorAuthentication/DefaultOneTimePasswordAuthenticationChecker.php b/src/Service/Auth/TwoFactorAuthentication/DefaultOneTimePasswordAuthenticationChecker.php new file mode 100644 index 0000000..394c9bf --- /dev/null +++ b/src/Service/Auth/TwoFactorAuthentication/DefaultOneTimePasswordAuthenticationChecker.php @@ -0,0 +1,61 @@ +enabledKey = $enableKey; + } + } + + /** + * Check if two factor authentication is enabled + * + * @return bool + */ + public function isEnabled() + { + return Configure::read($this->enabledKey) !== false; + } + + /** + * Check if two factor authentication is required for a user + * + * @param array $user user data + * @return bool + */ + public function isRequired(?array $user = null) + { + return !empty($user) && $this->isEnabled(); + } +} diff --git a/src/Service/Auth/TwoFactorAuthentication/DefaultWebauthn2fAuthenticationChecker.php b/src/Service/Auth/TwoFactorAuthentication/DefaultWebauthn2fAuthenticationChecker.php new file mode 100644 index 0000000..2e52f67 --- /dev/null +++ b/src/Service/Auth/TwoFactorAuthentication/DefaultWebauthn2fAuthenticationChecker.php @@ -0,0 +1,44 @@ +isEnabled(); + } +} diff --git a/src/Service/Auth/TwoFactorAuthentication/OneTimePasswordAuthenticationCheckerFactory.php b/src/Service/Auth/TwoFactorAuthentication/OneTimePasswordAuthenticationCheckerFactory.php new file mode 100644 index 0000000..edeaac4 --- /dev/null +++ b/src/Service/Auth/TwoFactorAuthentication/OneTimePasswordAuthenticationCheckerFactory.php @@ -0,0 +1,42 @@ + ['GET'], 'mapCors' => true]; $methods = ['method' => ['POST'], 'mapCors' => true]; + + $this->mapAction('webauthn2fa', Webauthn2faAction::class, $getMethods); + $this->mapAction('two_factor_auth', TwoFactorAuthAction::class, $getMethods); + $this->mapAction('webauthn2fa_register_options', Webauthn2faRegisterOptionsAction::class, $getMethods); + $this->mapAction('webauthn2fa_register', Webauthn2faRegisterAction::class, $methods); + $this->mapAction('webauthn2fa_auth_options', Webauthn2faAuthOptionsAction::class, $getMethods); + $this->mapAction('webauthn2fa_auth', Webauthn2faAuthAction::class, $methods); + + $this->mapAction('otp_verify', OtpVerifyGetAction::class, $getMethods); + $this->mapAction('otp_verify_check', OtpVerifyCheckAction::class, $methods); + $this->mapAction('jwt_login', JwtLoginAction::class, $methods); $this->mapAction('jwt_refresh', JwtRefreshAction::class, $methods); $this->mapAction('jwt_social_login', JwtSocialLoginAction::class, $methods); + $this->mapAction('login', LoginAction::class, $methods); $this->mapAction('register', RegisterAction::class, $methods); + $this->mapAction('reset_password_request', ResetPasswordRequestAction::class, $methods); $this->mapAction('reset_password', ResetPasswordAction::class, $methods); + $this->mapAction('validate_account_request', ValidateAccountRequestAction::class, $methods); $this->mapAction('validate_account', ValidateAccountAction::class, $methods); $this->mapAction('social_login', SocialLoginAction::class, $methods); diff --git a/src/Service/ConfigReader.php b/src/Service/ConfigReader.php index a09af6e..1ee01a5 100644 --- a/src/Service/ConfigReader.php +++ b/src/Service/ConfigReader.php @@ -22,7 +22,7 @@ class ConfigReader * Builds service options. * * @param string $serviceName Service name. - * @param ?string $version Version number. + * @param string|null $version Version number. * @return array */ public function serviceOptions(string $serviceName, ?string $version = null): array @@ -44,7 +44,7 @@ public function serviceOptions(string $serviceName, ?string $version = null): ar * * @param string $serviceName A Service name. * @param string $actionName An Action name. - * @param ?string $version Version number. + * @param string|null $version Version number. * @return array */ public function actionOptions(string $serviceName, string $actionName, ?string $version = null): array diff --git a/src/Service/RecaptchaService.php b/src/Service/RecaptchaService.php new file mode 100644 index 0000000..e82b0fb --- /dev/null +++ b/src/Service/RecaptchaService.php @@ -0,0 +1,35 @@ + ['POST'], 'mapCors' => true]; + + $this->mapAction('validate', ValidateAction::class, $methods); + } +} diff --git a/src/Service/Service.php b/src/Service/Service.php index 62c28af..532fef7 100644 --- a/src/Service/Service.php +++ b/src/Service/Service.php @@ -601,8 +601,7 @@ public function buildAction(): Action } if ($serviceName === $this->getName()) { $service = $this; - } - if (in_array($serviceName, $this->_innerServices)) { + } elseif (in_array($serviceName, $this->_innerServices)) { $options = [ 'version' => $this->getVersion(), 'request' => $this->getRequest(), diff --git a/src/Utility/RequestParser.php b/src/Utility/RequestParser.php new file mode 100644 index 0000000..3483f5f --- /dev/null +++ b/src/Utility/RequestParser.php @@ -0,0 +1,48 @@ +domain(); + } + + if ($replace) { + return str_replace('.', '$', $host); + } else { + return $host; + } + } +} diff --git a/src/Webauthn/AuthenticateAdapter.php b/src/Webauthn/AuthenticateAdapter.php new file mode 100644 index 0000000..f55f4c1 --- /dev/null +++ b/src/Webauthn/AuthenticateAdapter.php @@ -0,0 +1,79 @@ +getUserEntity(); + $allowed = array_map(function (PublicKeyCredentialSource $credential) { + return $credential->getPublicKeyCredentialDescriptor(); + }, $this->repository->findAllForUserEntity($userEntity)); + Log::error(print_r($allowed, true)); + + $options = $this->server->generatePublicKeyCredentialRequestOptions( + PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED, + $allowed + ); + $storeEntity = $this->readStore(); + Log::error(print_r($storeEntity, true)); + $storeEntity['store'] = []; + $storeEntity = $this->patchStore($storeEntity, 'authenticateOptions', base64_encode(serialize($options))); + $res = $this->store->save($storeEntity); + Log::error(print_r($storeEntity, true)); + Log::error(print_r($res, true)); + + return $options; + } + + /** + * Verify the registration response + * + * @return \Webauthn\PublicKeyCredentialSource + */ + public function verifyResponse(): \Webauthn\PublicKeyCredentialSource + { + $storeEntity = $this->readStore(); + Log::error(print_r($storeEntity, true)); + $options = $this->getStore($storeEntity, 'authenticateOptions'); + if ($options) { + $options = unserialize(base64_decode($options)); + } + Log::error(print_r($options, true)); + + return $this->loadAndCheckAssertionResponse($options); + } + + /** + * @param \Webauthn\PublicKeyCredentialRequestOptions $options request options + * @return \Webauthn\PublicKeyCredentialSource + */ + protected function loadAndCheckAssertionResponse($options): PublicKeyCredentialSource + { + return $this->server->loadAndCheckAssertionResponse( + json_encode($this->request->getData()), + $options, + $this->getUserEntity(), + $this->request + ); + } +} diff --git a/src/Webauthn/BaseAdapter.php b/src/Webauthn/BaseAdapter.php new file mode 100644 index 0000000..34f6f20 --- /dev/null +++ b/src/Webauthn/BaseAdapter.php @@ -0,0 +1,232 @@ +request = $request; + /** @var \CakeDC\Api\Model\Table\AuthStoreTable $store */ + $store = TableRegistry::getTableLocator()->get('CakeDC/Api.AuthStore'); + $this->store = $store; + $session = $this->readStore(); + $rpEntity = new PublicKeyCredentialRpEntity( + Configure::read('Api.Webauthn2fa.' . $this->getDomain() . '.appName'), // The application name + Configure::read('Api.Webauthn2fa.' . $this->getDomain() . '.id') + ); + /** + * @var \Cake\ORM\Entity $userSession + */ + $userSession = $userData; + $this->user = $usersTable->get($userSession->id); + $this->repository = new UserCredentialSourceRepository( + $request, + $this->user, + $usersTable + ); + + $this->server = new Server( + $rpEntity, + $this->repository + ); + } + + /** + * Get the user entity. + * + * @return \Webauthn\PublicKeyCredentialUserEntity + */ + protected function getUserEntity(): PublicKeyCredentialUserEntity + { + $user = $this->getUser(); + + return new PublicKeyCredentialUserEntity( + $user->webauthn_username ?? $user->username, + (string)$user->id, + (string)$user->first_name + ); + } + + /** + * Get the user. + * + * @return array|mixed|null + */ + public function getUser() + { + return $this->user; + } + + /** + * Check if the user has a credential. + * + * @return bool + */ + public function hasCredential(): bool + { + return (bool)$this->repository->findAllForUserEntity( + $this->getUserEntity() + ); + } + + /** + * Read the store data. + * + * @return \CakeDC\Api\Model\Entity\AuthStore + */ + public function readStore() + { + /** @var \CakeDC\Api\Model\Entity\AuthStore|null $entity */ + $entity = $this->store->find()->where(['id' => $this->getStoreKey()])->first(); + if ($entity === null) { + $entity = $this->store->newEmptyEntity(); + $entity->id = $this->getStoreKey(); + } + if (empty($entity->store)) { + $entity->store = []; + } + + return $entity; + } + + /** + * Save the store data. + * + * @param array $data The data to save. + * @return \CakeDC\Api\Model\Entity\AuthStore|false + */ + public function saveStore($data) + { + $entity = $this->readStore(); + $entity->store = $data; + + return $this->store->save($entity); + } + + /** + * Delete the store data. + * + * @return bool + */ + public function deleteStore() + { + $entity = $this->readStore(); + + return $this->store->delete($entity); + } + + /** + * Get the store key. + * + * @return string + */ + public function getStoreKey() + { + $authHeader = $this->request->getHeader('Authorization'); + if (is_array($authHeader)) { + $authHeader = array_pop($authHeader); + } + $options = [ + 'tokenPrefix' => 'bearer', + ]; + + return str_ireplace($options['tokenPrefix'] . ' ', '', $authHeader); + } + + /** + * Patch the store data. + * + * @param \CakeDC\Api\Model\Entity\AuthStore $entity The entity to patch. + * @param string $name The name of the data. + * @param array $options The options to patch. + * @return \CakeDC\Api\Model\Entity\AuthStore + */ + public function patchStore($entity, $name, $options) + { + $entity['store']['api']['Webauthn2fa'][$this->getDomain()][$name] = $options; + + return $entity; + } + + /** + * Get the store data. + * + * @param array $entity The entity to get data from. + * @param string $name The name of the data to get. + * @return mixed|null + */ + public function getStore($entity, $name) + { + $path = self::STORE_PREFIX . '.' . $this->getDomain() . '.' . $name; + + return Hash::get($entity['store'], $path, null); + } + + /** + * Get the current domain. + * + * @param bool $replace Whether to replace the domain. + * @return string + */ + public function getDomain($replace = true) + { + return RequestParser::getDomain($this->request, $replace); + } +} diff --git a/src/Webauthn/RegisterAdapter.php b/src/Webauthn/RegisterAdapter.php new file mode 100644 index 0000000..7332377 --- /dev/null +++ b/src/Webauthn/RegisterAdapter.php @@ -0,0 +1,71 @@ +getUserEntity(); + $options = $this->server->generatePublicKeyCredentialCreationOptions( + $userEntity, + PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, + [] + ); + $storeEntity = $this->readStore(); + $storeEntity = $this->patchStore($storeEntity, 'registerOptions', base64_encode(serialize($options))); + $this->store->save($storeEntity); + + return $options; + } + + /** + * Verify the registration response + * + * @return \Webauthn\PublicKeyCredentialSource + */ + public function verifyResponse(): \Webauthn\PublicKeyCredentialSource + { + $storeEntity = $this->readStore(); + $options = $this->getStore($storeEntity, 'registerOptions'); + if ($options) { + $options = unserialize(base64_decode($options)); + } + + $credential = $this->loadAndCheckAttestationResponse($options); + $this->repository->saveCredentialSource($credential); + + return $credential; + } + + /** + * @param \Webauthn\PublicKeyCredentialCreationOptions $options creation options + * @return \Webauthn\PublicKeyCredentialSource + */ + protected function loadAndCheckAttestationResponse($options): \Webauthn\PublicKeyCredentialSource + { + $credential = $this->server->loadAndCheckAttestationResponse( + json_encode($this->request->getData()), + $options, + $this->request + ); + + return $credential; + } +} diff --git a/src/Webauthn/Repository/UserCredentialSourceRepository.php b/src/Webauthn/Repository/UserCredentialSourceRepository.php new file mode 100644 index 0000000..69d44ca --- /dev/null +++ b/src/Webauthn/Repository/UserCredentialSourceRepository.php @@ -0,0 +1,131 @@ +request = $request; + $this->user = $user; + $this->usersTable = $usersTable; + } + + /** + * @param string $publicKeyCredentialId Public key credential id + * @return \Webauthn\PublicKeyCredentialSource|null + */ + public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource + { + $encodedId = Base64Url::encode($publicKeyCredentialId); + $credentials = $this->getUserData($this->user); + $credential = $credentials[$encodedId] ?? null; + + return $credential + ? PublicKeyCredentialSource::createFromArray($credential) + : null; + } + + /** + * @inheritDoc + */ + public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array + { + if ($publicKeyCredentialUserEntity->getId() != $this->user->id) { + return []; + } + \Cake\Log\Log::error(print_r($this->user, true)); + $credentials = $this->getUserData($this->user); + \Cake\Log\Log::error(print_r($credentials, true)); + + $list = []; + foreach ($credentials as $credential) { + $list[] = PublicKeyCredentialSource::createFromArray($credential); + } + + return $list; + } + + /** + * @param \Webauthn\PublicKeyCredentialSource $publicKeyCredentialSource Public key credential source + * @return void + */ + public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void + { + $credentials = $this->getUserData($this->user); + $id = Base64Url::encode($publicKeyCredentialSource->getPublicKeyCredentialId()); + $credentials[$id] = json_decode(json_encode($publicKeyCredentialSource), true); + $this->patchUserData($this->user, $credentials); + $res = $this->usersTable->saveOrFail($this->user); + } + + /** + * Patch user data with webauthn credentials. + * + * @param \Cake\Datasource\EntityInterface $entity The user entity + * @param array $options The webauthn credentials + * @return \Cake\Datasource\EntityInterface + */ + public function patchUserData($entity, $options) + { + $entity['additional_data'] = $entity['additional_data'] ?? []; + $apiData = $entity['additional_data']['api'] ?? []; + $apiData[$this->getDomain()] = $apiData[$this->getDomain()] ?? []; + $apiData[$this->getDomain()]['webauthn_credentials'] = $options; + $entity['additional_data']['api'] = $apiData; + + return $entity; + } + + /** + * Get user data for webauthn credentials. + * + * @param \Cake\Datasource\EntityInterface $entity The user entity + * @return array + */ + public function getUserData($entity) + { + $path = 'additional_data.api.' . $this->getDomain() . '.webauthn_credentials'; + + return Hash::get($entity, $path, []); + } + + /** + * Get the current domain. + * + * @return string + */ + public function getDomain() + { + return RequestParser::getDomain($this->request); + } +} diff --git a/tests/Config/api.php b/tests/Config/api.php index 2a95f49..9eb84d2 100644 --- a/tests/Config/api.php +++ b/tests/Config/api.php @@ -29,6 +29,23 @@ 'parser' => 'CakeDC/Api.Form', 'ServiceFallback' => \CakeDC\Api\Service\FallbackService::class, + '2fa' => [ + 'enabled' => false, + ], + + 'OneTimePasswordAuthenticator' => [ + 'login' => false, + 'checker' => \CakeDC\Api\Service\Auth\TwoFactorAuthentication\DefaultOneTimePasswordAuthenticationChecker::class, + ], + 'Webauthn2fa' => [ + 'checker' => \CakeDC\Api\Service\Auth\TwoFactorAuthentication\DefaultWebauthn2fAuthenticationChecker::class, + 'localhost' => [ + 'enabled' => false, + 'appName' => 'apilocal', + 'id' => 'localhost', + ], + ], + 'Jwt' => [ 'AccessToken' => [ 'lifetime' => 600, diff --git a/tests/Fixture/UsersFixture.php b/tests/Fixture/UsersFixture.php index b0e541d..c20db91 100644 --- a/tests/Fixture/UsersFixture.php +++ b/tests/Fixture/UsersFixture.php @@ -15,14 +15,12 @@ use Authentication\PasswordHasher\DefaultPasswordHasher; use Authentication\PasswordHasher\PasswordHasherFactory; use Cake\TestSuite\Fixture\TestFixture; -use CakeDC\Users\Webauthn\Base64Utility; /** * UsersFixture */ class UsersFixture extends TestFixture { - /** * records property * diff --git a/tests/TestCase/Command/ServiceRoutesCommandTest.php b/tests/TestCase/Command/ServiceRoutesCommandTest.php new file mode 100644 index 0000000..dd754b8 --- /dev/null +++ b/tests/TestCase/Command/ServiceRoutesCommandTest.php @@ -0,0 +1,152 @@ +_publicAccess(); + parent::setUp(); + } + + /** + * tearDown method + */ + public function tearDown(): void + { + parent::tearDown(); + ServiceRegistry::getServiceLocator()->clear(); + } + + /** + * Test help output + */ + public function testServiceRoutesHelp(): void + { + $this->exec('service routes -h'); + $this->assertExitCode(Command::CODE_SUCCESS); + $this->assertOutputContains('Display all routes in a service'); + $this->assertErrorEmpty(); + } + + /** + * Test basic route listing + */ + public function testServiceRoutesList(): void + { + $this->exec('service routes Articles'); + $this->assertExitCode(Command::CODE_SUCCESS); + + // Assert table header + $this->assertOutputContainsRow($this->getHeaderRow()); + + // Assert all routes + foreach ($this->getArticleRoutes() as $route) { + $this->assertOutputContainsRow($route); + } + } + + private function getHeaderRow(): array + { + return [ + 'Route name', + 'Method(s)', + 'URI template', + 'Service', + 'Action', + 'Plugin', + ]; + } + + private function getArticleRoutes(): array + { + return [ + [ + 'articles:untag', + 'PUT, POST', + '/articles/untag/{id}', + 'articles', + 'untag', + '', + ], + [ + 'articles:tag', + 'PUT, POST', + '/articles/tag/{id}', + 'articles', + 'tag', + '', + ], + [ + 'articles:view', + 'GET', + '/articles/{id}', + 'articles', + 'view', + '', + ], + [ + 'articles:edit', + 'PUT, PATCH', + '/articles/{id}', + 'articles', + 'edit', + '', + ], + [ + 'articles:delete', + 'DELETE', + '/articles/{id}', + 'articles', + 'delete', + '', + ], + [ + 'articles:describe', + 'OPTIONS', + '/articles/{id}', + 'articles', + 'describe', + '', + ], + [ + 'articles:index', + 'GET', + '/articles', + 'articles', + 'index', + '', + ], + [ + 'articles:add', + 'POST', + '/articles', + 'articles', + 'add', + '', + ], + [ + 'articles:describe', + 'OPTIONS', + '/articles', + 'articles', + 'describe', + '', + ], + ]; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index eb81423..494e55d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -83,15 +83,21 @@ function def($name, $value) // Cake\Core\Configure::write('App.encoding', 'UTF-8'); Cake\Core\Configure::write('debug', true); -@mkdir(TMP . 'cache/models', 0777); -@mkdir(TMP . 'cache/persistent', 0777); -@mkdir(TMP . 'cache/views', 0777); +if (!file_exists(TMP . 'cache/models')) { + mkdir(TMP . 'cache/models', 0777); +} +if (!file_exists(TMP . 'cache/persistent')) { + mkdir(TMP . 'cache/persistent', 0777); +} +if (!file_exists(TMP . 'cache/views')) { + mkdir(TMP . 'cache/views', 0777); +} $cache = [ 'default' => [ 'engine' => 'File', ], - '_cake_core_' => [ + '_cake_translations_' => [ 'className' => 'File', 'prefix' => 'api_app_cake_core_', 'path' => CACHE . 'persistent/',