diff --git a/openapi.yaml b/openapi.yaml index c92578f3..206578d5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1131,6 +1131,138 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /auth/passkey/challenge: + post: + tags: + - Authentication + summary: Initiate passkey authentication challenge + description: Generates a WebAuthn PublicKeyCredentialRequestOptions payload. If customer_number is provided, allowCredentials will be populated with existing passkeys for that account. Otherwise, a challenge is issued for discoverable credentials. + operationId: passkeyChallenge + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + customer_number: + type: integer + description: Optional customer's e-conomic customer number + example: 12345 + responses: + '200': + description: Challenge generated + content: + application/json: + schema: + type: object + properties: + challenge_token: + type: string + description: Temporary token binding the challenge to the login attempt + publicKey: + type: object + properties: + challenge: + type: string + description: Base64URL-encoded challenge + rpId: + type: string + description: Relying party ID (domain) + timeout: + type: integer + description: Timeout in milliseconds + userVerification: + type: string + enum: [required, preferred, discouraged] + allowCredentials: + type: array + items: + type: object + properties: + type: + type: string + example: public-key + id: + type: string + description: Base64URL-encoded credential ID + transports: + type: array + items: + type: string + '400': + $ref: '#/components/responses/BadRequest' + + /auth/passkey/verify: + post: + tags: + - Authentication + summary: Verify passkey authentication and start session + description: Verifies the WebAuthn assertion and challenge token. Returns a session token on success. + operationId: passkeyVerify + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - challenge_token + - id + - response + properties: + challenge_token: + type: string + description: The token returned by the challenge endpoint + id: + type: string + description: The credential ID (base64url) + response: + type: object + required: + - clientDataJSON + - authenticatorData + - signature + properties: + clientDataJSON: + type: string + description: Base64URL-encoded client data + authenticatorData: + type: string + description: Base64URL-encoded authenticator data + signature: + type: string + description: Base64URL-encoded signature + userHandle: + type: string + nullable: true + description: Base64URL-encoded user handle + responses: + '200': + description: Verification successful, session started + content: + application/json: + schema: + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer token for customer + - type: object + required: [session] + properties: + session: + type: string + description: Session token for subuser + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + /auth/logout: get: tags: diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index ef4d7d34..c8f71046 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -14,6 +14,7 @@ use objects\logs_o; use objects\tokens_o; use objects\users_o; use objects\subusers_o; +use objects\passkeys_o; use traits\route_t; class authRoute @@ -493,5 +494,186 @@ class authRoute $response->error($e->getMessage(), 400); } }); + + $this->post('/auth/passkey/challenge', function () { + global $response; + $this->requireRecaptcha(); + + $customer_number = self::getParameter('customer_number'); + $user_id = 0; + $allowCredentials = []; + + if ($customer_number !== null) { + $customer_number = (int)$customer_number; + self::requireType($customer_number, $this->type_int()); + self::requireMinValue($customer_number, 1); + + // Attempt to locate user; do not reveal existence in response + $user = (new users_o())->getUserByCustomerNumber($customer_number); + + if ($user->exists()) { + $user_id = (int)$user->id; + + // Load passkeys for this user (non-subuser) + $passkeys = new passkeys_o(); + $passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0'); + $list = $passkeys->listObjectsWithPaginationIfSet(function ($o) { + $transports = null; + if (isset($o['transports'])) { + $decoded = json_decode($o['transports'], true); + $transports = is_array($decoded) ? $decoded : null; + } + return [ + 'type' => 'public-key', + 'id' => $o['credential_id'] ?? null, + 'transports' => $transports, + ]; + }); + + // Ensure we return a simple array of credentials (without pagination wrapper) + if (isset($list['items']) && is_array($list['items'])) { + $allowCredentials = array_values(array_filter($list['items'], function ($item) { + return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0; + })); + } elseif (is_array($list)) { + $allowCredentials = array_values(array_filter($list, function ($item) { + return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0; + })); + } + } + } + + // Generate a challenge (Base64URL without padding) + $challenge_token = bin2hex(random_bytes(32)); + $rawChallenge = hex2bin($challenge_token); + $challenge = rtrim(strtr(base64_encode($rawChallenge), '+/', '-_'), '='); + + // rpId from current host (strip port) + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $rpId = explode(':', $host)[0]; + + // Create an ephemeral token to bind the challenge to the (potential) user + (new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE'); + + $logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)'; + (new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails); + + $response->success([ + 'challenge_token' => $challenge_token, + 'publicKey' => [ + 'challenge' => $challenge, + 'rpId' => $rpId, + 'timeout' => 60000, + 'userVerification' => 'preferred', + 'allowCredentials' => $allowCredentials + ] + ]); + }); + + $this->post('/auth/passkey/verify', function () { + global $response, $db; + $this->requireRecaptcha(); + self::requireParameters(['challenge_token', 'id', 'response']); + + $challenge_token_str = (string)self::getParameter('challenge_token'); + $credential_id = (string)self::getParameter('id'); + $webauthn_response = self::getParameter('response'); + + if (!is_array($webauthn_response)) { + $response->error('Invalid response format', 400); + } + + $token_o = new tokens_o(); + try { + $token = $token_o->getToken($challenge_token_str); + } catch (Exception $e) { + $response->error('Invalid or expired challenge token', 401); + } + + if ($token->type->value() !== 'PASSKEY_CHALLENGE') { + $response->error('Invalid token type', 401); + } + + // Find the passkey + $passkeys = new passkeys_o(); + $fields = ['id', 'user_id', 'is_subuser', 'public_key', 'sign_count', 'algorithm']; + $found = $passkeys->getFieldsWhere(['credential_id' => $credential_id], $fields); + + if (empty($found)) { + $response->error('Passkey not found', 401); + } + + $passkey_data = $found[0]; + $user_id = (int)$passkey_data['user_id']; + $is_subuser = (bool)$passkey_data['is_subuser']; + + // If the challenge was bound to a specific user, verify it matches + if ((int)$token->user_id->value() > 0 && (int)$token->user_id->value() !== $user_id) { + $response->error('Passkey does not match requested user', 401); + } + + // WebAuthn signature verification + $clientDataJSON = $webauthn_response['clientDataJSON'] ?? null; + $authenticatorData = $webauthn_response['authenticatorData'] ?? null; + $signature = $webauthn_response['signature'] ?? null; + + if (!$clientDataJSON || !$authenticatorData || !$signature) { + $response->error('Missing WebAuthn response fields', 400); + } + + $decode = function ($data) { + return base64_decode(strtr($data, '-_', '+/')); + }; + + $rawClientDataJSON = $decode($clientDataJSON); + $rawAuthenticatorData = $decode($authenticatorData); + $rawSignature = $decode($signature); + + $clientData = json_decode($rawClientDataJSON, true); + if (!$clientData || !isset($clientData['challenge'])) { + $response->error('Invalid clientDataJSON', 400); + } + + // Reconstruct and verify challenge + $expectedChallenge = rtrim(strtr(base64_encode(hex2bin($challenge_token_str)), '+/', '-_'), '='); + if ($clientData['challenge'] !== $expectedChallenge) { + $response->error('Challenge mismatch', 401); + } + + // Verify signature: S = ES256(authenticatorData || hash(clientDataJSON)) + $clientDataHash = hash('sha256', $rawClientDataJSON, true); + $dataToVerify = $rawAuthenticatorData . $clientDataHash; + $publicKey = $passkey_data['public_key']; + + // WebAuthn signatures are DER encoded, which openssl_verify accepts. + $algo = OPENSSL_ALGO_SHA256; + $verifyResult = openssl_verify($dataToVerify, $rawSignature, $publicKey, $algo); + + if ($verifyResult !== 1) { + (new logs_o())->add('auth', 'global', 0, $user_id, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Signature verification failed for credential ' . $credential_id); + $response->error('Invalid signature', 401); + } + + // Success! Update sign_count + $p_obj = (new passkeys_o())->select((int)$passkey_data['id']); + $p_obj->update(['sign_count' => (int)$passkey_data['sign_count'] + 1]); + + $token_o->delete($challenge_token_str); + $auth = new authentication(); + + if ($is_subuser) { + $subuser = (new subusers_o())->select($user_id); + $session = $subuser->generateSession(); + (new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_SUCCESS', 'Subuser login via passkey'); + $response->success(['session' => $session]); + } else { + $user = (new users_o())->getUserById($user_id); + $customer_number = (int)$user->customer_number->value(); + $new_token = $auth->create_token($customer_number); + (new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_SUCCESS', 'Customer login via passkey'); + $response->success(['token' => $new_token]); + } + }); + } } \ No newline at end of file diff --git a/services/nginx/app/tests/auth/PasskeyChallengeTest.php b/services/nginx/app/tests/auth/PasskeyChallengeTest.php new file mode 100644 index 00000000..9aa80237 --- /dev/null +++ b/services/nginx/app/tests/auth/PasskeyChallengeTest.php @@ -0,0 +1,165 @@ +id = $id; } + public function exists() { return $this->id > 0; } + } + + class users_o { + public static $existing_numbers = []; + public function getUserByCustomerNumber($num) { + if (in_array((int)$num, self::$existing_numbers, true)) { return new MockUserObj(42); } + return new MockUserObj(0); + } + } + + class passkeys_o { + public $where; + public function setAdditionalWhereClause($w) { $this->where = $w; } + public function listObjectsWithPaginationIfSet($parser) { + $items = [ + ['credential_id' => 'cred_abcd', 'transports' => json_encode(['internal'])], + ['credential_id' => 'cred_efgh', 'transports' => json_encode(['hybrid','usb'])], + ]; + $out = []; + foreach ($items as $o) { $out[] = $parser($o); } + return $out; + } + } +} + +// Provide a minimal router mock and run the route +namespace { + class MockRouter { + public $routes = []; + public function add($route, $method, $callback, $permissions) { $this->routes[$method][$route] = $callback; } + } + + $router = new MockRouter(); + $response = new \classes\response(); + + require_once WD . '/traits/route_t.php'; + require_once WD . '/routes/authRoute.php'; + + use routes\authRoute; + + function ok($m){ echo "\033[32m✔ $m\033[0m\n"; } + function fail($m){ echo "\033[31m✖ $m\033[0m\n"; } + + $authRoute = new authRoute(); + $authRoute->run(); + + if (!isset($router->routes['POST']['/auth/passkey/challenge'])) { + die("Route /auth/passkey/challenge not found\n"); + } + + $callback = $router->routes['POST']['/auth/passkey/challenge']; + + // Test case: valid request for existing user + \classes\response::reset(); + \classes\recaptcha::$mock_valid = true; + \objects\users_o::$existing_numbers = [12345]; + \classes\response::$request_parameters = [ + 'customer_number' => 12345, + 'g_recaptcha_response' => 'valid' + ]; + + try { + $callback(); + } catch (\classes\MockExitException $e) { + if ($e->getMessage() !== 'SUCCESS_EXIT') { fail('Expected SUCCESS_EXIT'); exit(1); } + } + + $data = \classes\response::$last_success; + if (!is_array($data)) { fail('Response should be array'); exit(1); } + if (!isset($data['challenge_token']) || !is_string($data['challenge_token'])) { fail('Missing challenge_token'); exit(1); } + if (!isset($data['publicKey']) || !is_array($data['publicKey'])) { fail('Missing publicKey'); exit(1); } + $pk = $data['publicKey']; + if (!isset($pk['challenge']) || !is_string($pk['challenge'])) { fail('Missing publicKey.challenge'); exit(1); } + if (!isset($pk['rpId']) || !is_string($pk['rpId'])) { fail('Missing publicKey.rpId'); exit(1); } + if (!isset($pk['allowCredentials']) || !is_array($pk['allowCredentials'])) { fail('Missing publicKey.allowCredentials'); exit(1); } + if (count($pk['allowCredentials']) !== 2) { fail('Expected 2 allowCredentials'); exit(1); } + ok('Passkey challenge returns expected structure for existing user'); + + // Test case: valid request for non-existing user → allowCredentials may be empty but still returns challenge + \classes\response::reset(); + \objects\users_o::$existing_numbers = []; + \classes\response::$request_parameters = [ + 'customer_number' => 99999, + 'g_recaptcha_response' => 'valid' + ]; + + try { + $callback(); + } catch (\classes\MockExitException $e) { + if ($e->getMessage() !== 'SUCCESS_EXIT') { fail('Expected SUCCESS_EXIT for non-existing user'); exit(1); } + } + + $data = \classes\response::$last_success; + if (!isset($data['publicKey']['allowCredentials'])) { fail('Missing allowCredentials for non-existing'); exit(1); } + ok('Passkey challenge works for non-existing user (empty allowCredentials)'); + + echo "PasskeyChallengeTest completed.\n"; +} diff --git a/services/nginx/app/traits/route_t.php b/services/nginx/app/traits/route_t.php index 84f3a275..94819f65 100644 --- a/services/nginx/app/traits/route_t.php +++ b/services/nginx/app/traits/route_t.php @@ -15,6 +15,11 @@ trait route_t { protected array $permissions = []; private string $route; + /** + * The route template (e.g., "/account/security/passkeys/{id}") of the currently matched route. + * This is populated just before the route callback is executed. + */ + private ?string $__current_route_template = null; /** * Lightweight per-request caches to avoid repeated DB/auth checks during a single request lifecycle. */ @@ -566,7 +571,15 @@ trait route_t private function registerRoute($route, $method, $callback, $permissions = []): void { global $router; - $router->add($route, $method, $callback, self::registerPermissions($permissions, $route, $method)); + // Wrap the original callback so we can expose the matched route template to fromRoute() + $self = $this; + $wrapped = function () use ($callback, $route, $self) { + // Set the current route template for parameter extraction + $self->__current_route_template = $route; + // Execute the original callback + $callback(); + }; + $router->add($route, $method, $wrapped, self::registerPermissions($permissions, $route, $method)); } /** @@ -643,15 +656,42 @@ trait route_t } /** - * Get the parameter from the route URL by index - * @param string $index - * @return string|null + * Get the value of a route parameter by name from the currently matched route. + * Example: for template "/users/{id}", current URL "/users/123" → fromRoute('id') === "123". + * @param string $index The parameter name (without braces), e.g., 'id' + * @return string|null The extracted value or null if not present */ public function fromRoute(string $index): ?string { - $params = explode('/', $this->route); - $index = array_search($index, $params); - return $params[$index] ?? null; + // Resolve current path without query string + $currentPath = explode('?', $this->route)[0] ?? ''; + $currentPath = trim($currentPath, '/'); + + // We need the route template used to register this callback + $template = $this->__current_route_template; + if ($template === null) { + // Fallback: no template context — cannot reliably parse; return null + return null; + } + $template = trim($template, '/'); + + $pathSegments = $currentPath === '' ? [] : explode('/', $currentPath); + $tplSegments = $template === '' ? [] : explode('/', $template); + + // Quick length guard: router allows alnum-only for params; still, differing counts means no match + if (count($pathSegments) !== count($tplSegments)) { + return null; + } + + foreach ($tplSegments as $i => $seg) { + if (preg_match('/^{([a-zA-Z0-9_]+)}$/', $seg, $m)) { + $name = $m[1]; + if ($name === $index) { + return $pathSegments[$i] ?? null; + } + } + } + return null; } /**