diff --git a/openapi.yaml b/openapi.yaml index ccc448f5..d95c9d40 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -831,12 +831,24 @@ paths: content: application/json: schema: - type: object - properties: - session: - type: string - description: Newly generated subuser session token - example: "2f7a8c0e-9b1d-4c6a-91a9-1a2b3c4d5e6f" + oneOf: + - type: object + required: [session] + properties: + session: + type: string + description: Newly generated subuser session token + example: "2f7a8c0e-9b1d-4c6a-91a9-1a2b3c4d5e6f" + - type: object + required: [2fa_required, 2fa_token] + properties: + 2fa_required: + type: boolean + example: true + 2fa_token: + type: string + description: Temporary 2FA verification token + example: "557a3e7b1a2b..." '400': { $ref: '#/components/responses/BadRequest' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalServerError' } @@ -1040,12 +1052,24 @@ paths: content: application/json: schema: - type: object - properties: - token: - type: string - description: Bearer authentication token - example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer authentication token + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + - type: object + required: [2fa_required, 2fa_token] + properties: + 2fa_required: + type: boolean + example: true + 2fa_token: + type: string + description: Temporary 2FA verification token + example: "557a3e7b1a2b..." '400': $ref: '#/components/responses/BadRequest' '401': @@ -1083,11 +1107,22 @@ paths: content: application/json: schema: - type: object - properties: - token: - type: string - description: Bearer authentication token + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer authentication token + - type: object + required: [2fa_required, 2fa_token] + properties: + 2fa_required: + type: boolean + example: true + 2fa_token: + type: string + description: Temporary 2FA verification token '400': $ref: '#/components/responses/BadRequest' '401': @@ -1127,7 +1162,129 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/User' + allOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + two_factor_enabled: + type: boolean + description: Indicates if 2FA is enabled for this account + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/setup: + post: + tags: + - Authentication + summary: Generate 2FA secret + description: Generate a new TOTP secret for the authenticated user/subuser + operationId: setup2fa + responses: + '200': + description: 2FA secret generated successfully + content: + application/json: + schema: + type: object + properties: + secret: + type: string + description: The base32 encoded TOTP secret + qr_code_url: + type: string + description: An otpauth URL for generating a QR code + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/enable: + post: + tags: + - Authentication + summary: Enable 2FA + description: Verify a code and enable 2FA for the authenticated user/subuser + operationId: enable2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: + type: string + description: The 6-digit TOTP code + responses: + '200': + description: 2FA enabled successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/disable: + post: + tags: + - Authentication + summary: Disable 2FA + description: Verify a code and disable 2FA for the authenticated user/subuser + operationId: disable2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: + type: string + description: The 6-digit TOTP code + responses: + '200': + description: 2FA disabled successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/verify: + post: + tags: + - Authentication + summary: Verify 2FA code during login + description: Complete the login process by verifying the 2FA code + operationId: verify2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [2fa_token, code] + properties: + 2fa_token: + type: string + description: The temporary 2FA verification token + code: + type: string + description: The 6-digit TOTP code + responses: + '200': + description: Login successful + content: + application/json: + schema: + type: object + properties: + token: + type: string + description: Bearer authentication token (for users/employees) + session: + type: string + description: Session token (for subusers) '400': $ref: '#/components/responses/BadRequest' '401': diff --git a/services/nginx/app/classes/authentication.php b/services/nginx/app/classes/authentication.php index a0bd74d0..0df72882 100644 --- a/services/nginx/app/classes/authentication.php +++ b/services/nginx/app/classes/authentication.php @@ -2,6 +2,7 @@ namespace classes; +use classes\totp; use Exception; use interfaces\authentication_i; use objects\plate_scanners_o; @@ -41,6 +42,28 @@ class authentication implements authentication_i return true; } + public function is_2fa_enabled(users_o|subusers_o $user): bool + { + return $user->isTwoFactorEnabled(); + } + + public function create_2fa_token(int $id, string $type): string + { + // Create a temporary 2FA token + $token = bin2hex(random_bytes(32)); + (new tokens_o())->create($id, $token, $type); + return $token; + } + + public function verify_2fa_code(users_o|subusers_o $user, string $code): bool + { + $secret = $user->getTwoFactorSecret(); + if (!$secret) { + return false; + } + return (new totp())->verifyCode($secret, $code); + } + public function match_passwords($password, $hash): bool { // Compare the password with the hash diff --git a/services/nginx/app/classes/totp.php b/services/nginx/app/classes/totp.php new file mode 100644 index 00000000..0178e08c --- /dev/null +++ b/services/nginx/app/classes/totp.php @@ -0,0 +1,119 @@ +base32_chars[random_int(0, 31)]; + } + return $secret; + } + + /** + * Get the TOTP code + * @param string $secret + * @param int|null $time + * @return string + * @throws Exception + */ + public function getCode(string $secret, int $time = null): string + { + if ($time === null) { + $time = floor(time() / 30); + } + + $base32_secret = $this->base32Decode($secret); + + // Pack time into binary string + $time_binary = pack('N*', 0) . pack('N*', $time); + + // HMAC-SHA1 + $hash = hash_hmac('sha1', $time_binary, $base32_secret, true); + + // Dynamic truncation + $offset = ord($hash[19]) & 0xf; + $otp = ( + ((ord($hash[$offset + 0]) & 0x7f) << 24) | + ((ord($hash[$offset + 1]) & 0xff) << 16) | + ((ord($hash[$offset + 2]) & 0xff) << 8) | + (ord($hash[$offset + 3]) & 0xff) + ) % 1000000; + + return str_pad((string)$otp, 6, '0', STR_PAD_LEFT); + } + + /** + * Verify the TOTP code + * @param string $secret + * @param string $code + * @param int $discrepancy + * @return bool + * @throws Exception + */ + public function verifyCode(string $secret, string $code, int $discrepancy = 1): bool + { + $current_time = floor(time() / 30); + + for ($i = -$discrepancy; $i <= $discrepancy; $i++) { + if ($this->getCode($secret, $current_time + $i) === $code) { + return true; + } + } + + return false; + } + + /** + * Base32 decode + * @param string $base32 + * @return string + */ + private function base32Decode(string $base32): string + { + $base32 = strtoupper($base32); + if (!preg_match('/^[A-Z2-7]+$/', $base32)) { + return ''; + } + + $binary = ''; + foreach (str_split($base32) as $char) { + $binary .= str_pad(decbin(strpos($this->base32_chars, $char)), 5, '0', STR_PAD_LEFT); + } + + $binary_chunks = str_split($binary, 8); + $result = ''; + foreach ($binary_chunks as $chunk) { + if (strlen($chunk) === 8) { + $result .= chr(bindec($chunk)); + } + } + + return $result; + } + + /** + * Generate a QR code URL + * @param string $secret + * @param string $name + * @param string $issuer + * @return string + */ + public function getQrCodeUrl(string $secret, string $name, string $issuer): string + { + return 'otpauth://totp/' . rawurlencode($issuer) . ':' . rawurlencode($name) . '?secret=' . $secret . '&issuer=' . rawurlencode($issuer); + } +} diff --git a/services/nginx/app/objects/subusers_o.php b/services/nginx/app/objects/subusers_o.php index b399e69f..a1dfea96 100644 --- a/services/nginx/app/objects/subusers_o.php +++ b/services/nginx/app/objects/subusers_o.php @@ -44,11 +44,49 @@ class subusers_o extends db $this->email = new object_property($this->table, $this->id, 'email', 'string'); $this->phone_country_code = new object_property($this->table, $this->id, 'phone_country_code', 'int'); $this->phone = new object_property($this->table, $this->id, 'phone', 'int'); + $this->two_factor_secret = new object_property($this->table, $this->id, 'two_factor_secret', 'string', false); + $this->two_factor_enabled = new object_property($this->table, $this->id, 'two_factor_enabled', 'bool', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp'); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp'); $this->suspended_at = new object_property($this->table, $this->id, 'suspended_at', 'timestamp'); } + /** + * @throws Exception + */ + public function isTwoFactorEnabled(): bool + { + self::requireSelected(); + return (bool)$this->two_factor_enabled->value(); + } + + /** + * @throws Exception + */ + public function getTwoFactorSecret(): string|null + { + self::requireSelected(); + return $this->two_factor_secret->value(); + } + + /** + * @throws Exception + */ + public function setTwoFactorSecret(string|null $secret): void + { + self::requireSelected(); + $this->two_factor_secret->set($secret); + } + + /** + * @throws Exception + */ + public function setTwoFactorEnabled(bool $enabled): void + { + self::requireSelected(); + $this->two_factor_enabled->set($enabled); + } + /** * Add a new subuser to the database. The password is optional, but if it is set, it must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number. The password will be hashed before being stored in the database. * @param string|null $username diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index ce9c5063..180374ac 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -42,6 +42,8 @@ class users_o extends db public object_property $email_notifications_enabled; public object_property $wash_certificate_email; // Optional protected array $wash_subscription_transactions; + public object_property $two_factor_secret; + public object_property $two_factor_enabled; public function structure(): void @@ -102,6 +104,8 @@ class users_o extends db $this->sms_notifications_enabled = new object_property($this->table, $this->id, 'sms_notifications_enabled', 'bool', false); $this->email_notifications_enabled = new object_property($this->table, $this->id, 'email_notifications_enabled', 'bool', false); $this->wash_certificate_email = new object_property($this->table, $this->id, 'wash_certificate_email', 'string', false); + $this->two_factor_secret = new object_property($this->table, $this->id, 'two_factor_secret', 'string', false); + $this->two_factor_enabled = new object_property($this->table, $this->id, 'two_factor_enabled', 'bool', false); $this->renderLanguagePack(); } @@ -198,6 +202,42 @@ class users_o extends db return $this->wash_certificate_email->value(); } + /** + * @throws Exception + */ + public function isTwoFactorEnabled(): bool + { + self::requireSelected(); + return (bool)$this->two_factor_enabled->value(); + } + + /** + * @throws Exception + */ + public function getTwoFactorSecret(): string|null + { + self::requireSelected(); + return $this->two_factor_secret->value(); + } + + /** + * @throws Exception + */ + public function setTwoFactorSecret(string|null $secret): void + { + self::requireSelected(); + $this->two_factor_secret->set($secret); + } + + /** + * @throws Exception + */ + public function setTwoFactorEnabled(bool $enabled): void + { + self::requireSelected(); + $this->two_factor_enabled->set($enabled); + } + public function add(string $customer_number, mixed $password, int $role = 0): void { diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index 95c3e821..9bc8b433 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -6,12 +6,14 @@ use classes\authentication; use classes\economic; use classes\email; use classes\recaptcha; +use classes\totp; use classes\virkdata; use Exception; use objects\customer_password_reset_keys_o; use objects\logs_o; use objects\tokens_o; use objects\users_o; +use objects\subusers_o; use traits\route_t; class authRoute @@ -52,6 +54,12 @@ class authRoute $response->error('Invalid credentials', 401); } // If the credentials are valid, create a token + $user = (new users_o())->getUserByCustomerNumber($data['customer_number']); + if ($user->isTwoFactorEnabled()) { + $token = (new authentication())->create_2fa_token($user->id, '2FA_VERIFICATION_USER'); + $response->success(['2fa_required' => true, '2fa_token' => $token]); + } + $token = (new authentication())->create_token($data['customer_number']); // Return the token $response->success(['token' => $token]); @@ -89,10 +97,111 @@ class authRoute if (!$user) { $response->error('User not found', 400); } + + $user_data = $user->includeIncludes(['economicCustomer', 'permissions'])->asArray(); + $user_data['two_factor_enabled'] = $user->isTwoFactorEnabled(); + // Return the (session) user object - $response->success( - ($user->includeIncludes(['economicCustomer', 'permissions'])->asArray()) - ); + $response->success($user_data); + }); + + $this->post('/auth/2fa/setup', function () { + global $response; + $auth = new authentication(); + $user = $auth->get_user(); + $subuser = $auth->get_subuser(); + if ($user === false && $subuser === false) { + $response->error('Unauthorized', 401); + } + + $principal = $user ?: $subuser; + $totp = new totp(); + $secret = $totp->generateSecret(); + $principal->setTwoFactorSecret($secret); + + $name = $user ? $principal->customer_number->value() : $principal->username->value(); + $qrCodeUrl = $totp->getQrCodeUrl($secret, $name, 'Truck Wash'); + + $response->success([ + 'secret' => $secret, + 'qr_code_url' => $qrCodeUrl + ]); + }); + + $this->post('/auth/2fa/enable', function () { + global $response; + $auth = new authentication(); + $user = $auth->get_user(); + $subuser = $auth->get_subuser(); + if ($user === false && $subuser === false) { + $response->error('Unauthorized', 401); + } + + self::requireParameters(['code']); + $code = (string)self::getParameter('code'); + + $principal = $user ?: $subuser; + if ($auth->verify_2fa_code($principal, $code)) { + $principal->setTwoFactorEnabled(true); + $response->success(['message' => '2FA enabled successfully']); + } else { + $response->error('Invalid 2FA code', 400); + } + }); + + $this->post('/auth/2fa/disable', function () { + global $response; + $auth = new authentication(); + $user = $auth->get_user(); + $subuser = $auth->get_subuser(); + if ($user === false && $subuser === false) { + $response->error('Unauthorized', 401); + } + + self::requireParameters(['code']); + $code = (string)self::getParameter('code'); + + $principal = $user ?: $subuser; + if ($auth->verify_2fa_code($principal, $code)) { + $principal->setTwoFactorEnabled(false); + $principal->setTwoFactorSecret(null); + $response->success(['message' => '2FA disabled successfully']); + } else { + $response->error('Invalid 2FA code', 400); + } + }); + + $this->post('/auth/2fa/verify', function () { + global $response; + self::requireParameters(['2fa_token', 'code']); + $token_str = (string)self::getParameter('2fa_token'); + $code = (string)self::getParameter('code'); + + $token_o = new tokens_o(); + try { + $token = $token_o->getToken($token_str); + } catch (Exception $e) { + $response->error('Invalid or expired 2FA token', 401); + } + + $auth = new authentication(); + if ($token->type->value() === '2FA_VERIFICATION_USER') { + $user = (new users_o())->getUserById($token->user_id->value()); + if ($auth->verify_2fa_code($user, $code)) { + $token_o->delete($token_str); + $new_token = $auth->create_employee_token($user->id); // Works for both users and employees + $response->success(['token' => $new_token]); + } + } elseif ($token->type->value() === '2FA_VERIFICATION_SUBUSER') { + $subuser = (new subusers_o())->select($token->user_id->value()); + if ($auth->verify_2fa_code($subuser, $code)) { + $token_o->delete($token_str); + $new_token = $subuser->generateSession(); + $response->success(['session' => $new_token]); + } + } + + $response->error('Invalid 2FA code', 400); }); $this->post('/auth/employee/login', function () { @@ -119,6 +228,13 @@ class authRoute (new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Employee number: ' . $data['user_id']); $response->error('Invalid credentials', 401); } + + $user = (new users_o())->getUserById($data['user_id']); + if ($user->isTwoFactorEnabled()) { + $token = (new authentication())->create_2fa_token($user->id, '2FA_VERIFICATION_USER'); + $response->success(['2fa_required' => true, '2fa_token' => $token]); + } + // If the credentials are valid, create a token $token = (new authentication())->create_employee_token($data['user_id']); // Return the token diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index 3c0b0cde..2eca513f 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -476,6 +476,10 @@ class subusersRoute self::requireMaxLength('password', 255); try { if (password_verify($password, $subuser->password->value())) { + if ($subuser->isTwoFactorEnabled()) { + $token = (new authentication())->create_2fa_token($subuser->id, '2FA_VERIFICATION_SUBUSER'); + $response->success(['2fa_required' => true, '2fa_token' => $token]); + } // Generate a new session for the subuser $session = $subuser->generateSession(); $response->success(['session' => $session]); diff --git a/services/nginx/app/tests/auth/RegisterCvrTest.php b/services/nginx/app/tests/auth/RegisterCvrTest.php new file mode 100644 index 00000000..82d18699 --- /dev/null +++ b/services/nginx/app/tests/auth/RegisterCvrTest.php @@ -0,0 +1,317 @@ +customers = new \stdClass(); + $this->customers->customers = new class { + public function search($params, $options) { + $mock = new \stdClass(); + $mock->collection = \classes\economic::$mock_collection; + return $mock; + } + }; + } + public function createCustomer($number, $name, $cvr, $email, $phone) { + return ['customerNumber' => $number]; + } + } + + class virkdata { + public function getCompanyInformation($cvr, $endpoint, $data) { + $res = new \stdClass(); + $res->name = "Mock Company"; + return $res; + } + } + + class email { + public function sendWelcomeEmailToCustomer($phone, $email) { return true; } + } + + class authentication { + public function get_plate_scanner() { return true; } + } +} + +// Mock objects namespace +namespace objects { + class users_o { + public static $mock_user_id = null; + public static $mock_existing_emails = []; + public static $mock_existing_customer_numbers = []; + + public function getUserByCustomerNumber($num) { + $user = new \stdClass(); + $user->id = in_array($num, self::$mock_existing_customer_numbers) ? 1 : self::$mock_user_id; + return $user; + } + public function getUserByEmail($email) { + $user = new \stdClass(); + $user->id = in_array($email, self::$mock_existing_emails) ? 1 : self::$mock_user_id; + return $user; + } + } + class logs_o { + public function add($module, $action, $status, $user_id, $event, $details) {} + } + class tokens_o { + public function delete($token) {} + } + class customer_password_reset_keys_o { + public static function generateToken() { return 'mock_token'; } + public function add($data) {} + public function findValidByToken($token) { return null; } + } +} + +// Global namespace mock for router +namespace { + class MockRouter { + public $routes = []; + public function add($route, $method, $callback, $permissions) { + $this->routes[$method][$route] = $callback; + } + } + + $router = new MockRouter(); + $response = new \classes\response(); + + // Now include the route + // We need to bypass the real classes by having them already "loaded" via our mocks above + // Since we are in the same process and defined them in the same namespaces, + // when authRoute.php says "use classes\economic", it will use our mock. + + require_once WD . '/traits/route_t.php'; + require_once WD . '/routes/authRoute.php'; + + use routes\authRoute; + + function ok($message): void { echo "\033[32m✔ $message\033[0m\n"; } + function fail($message): void { echo "\033[31m✖ $message\033[0m\n"; } + + $authRoute = new authRoute(); + $authRoute->run(); + + if (!isset($router->routes['POST']['/auth/register/cvr'])) { + die("Route /auth/register/cvr not found\n"); + } + $callback = $router->routes['POST']['/auth/register/cvr']; + + $testCases = [ + [ + 'name' => 'Missing reCAPTCHA', + 'params' => [], + 'setup' => function() { + \classes\recaptcha::$mock_valid = false; + }, + 'expected_error' => 'Authentication failed. Invalid or missing reCAPTCHA.', + 'expected_status' => 401 + ], + [ + 'name' => 'Missing parameters', + 'params' => ['g_recaptcha_response' => 'valid'], + 'expected_error' => 'Missing required parameters: cvr, companyPhone, invoiceEmail, contactEmail, contactPhone', + 'expected_status' => 400 + ], + [ + 'name' => 'Invalid CVR length (too short)', + 'params' => [ + 'cvr' => '123', + 'companyPhone' => 12345678, + 'invoiceEmail' => 'test@test.com', + 'contactEmail' => 'test@test.com', + 'contactPhone' => 12345678, + 'g_recaptcha_response' => 'valid' + ], + 'expected_error' => 'Parameter cvr must be at least 8 characters long', + 'expected_status' => 400 + ], + [ + 'name' => 'Invalid CVR length (too long)', + 'params' => [ + 'cvr' => str_repeat('1', 21), + 'companyPhone' => 12345678, + 'invoiceEmail' => 'test@test.com', + 'contactEmail' => 'test@test.com', + 'contactPhone' => 12345678, + 'g_recaptcha_response' => 'valid' + ], + 'expected_error' => 'Parameter cvr must be at most 20 characters long', + 'expected_status' => 400 + ], + [ + 'name' => 'Invalid Company Phone (too small)', + 'params' => [ + 'cvr' => '12345678', + 'companyPhone' => 9999999, + 'invoiceEmail' => 'test@test.com', + 'contactEmail' => 'test@test.com', + 'contactPhone' => 12345678, + 'g_recaptcha_response' => 'valid' + ], + 'expected_error' => 'Parameter must be at least 10000000', + 'expected_status' => 400 + ], + [ + 'name' => 'Existing Company Phone', + 'params' => [ + 'cvr' => '12345678', + 'companyPhone' => 12345678, + 'invoiceEmail' => 'test@test.com', + 'contactEmail' => 'test@test.com', + 'contactPhone' => 12345678, + 'g_recaptcha_response' => 'valid' + ], + 'setup' => function() { + \objects\users_o::$mock_existing_customer_numbers = [12345678]; + }, + 'expected_error' => 'Company phone number already registered', + 'expected_status' => 400 + ], + [ + 'name' => 'CVR already registered in E-conomic', + 'params' => [ + 'cvr' => '12345678', + 'companyPhone' => 12345678, + 'invoiceEmail' => 'test@test.com', + 'contactEmail' => 'test@test.com', + 'contactPhone' => 12345678, + 'g_recaptcha_response' => 'valid' + ], + 'setup' => function() { + \objects\users_o::$mock_user_id = null; + \classes\economic::$mock_collection = ['something']; + }, + 'expected_error' => 'CVR already registered', + 'expected_status' => 400 + ], + [ + 'name' => 'Successful Registration', + 'params' => [ + 'cvr' => '12345678', + 'companyPhone' => 12345678, + 'invoiceEmail' => 'test@test.com', + 'contactEmail' => 'test@test.com', + 'contactPhone' => 12345678, + 'g_recaptcha_response' => 'valid' + ], + 'setup' => function() { + \objects\users_o::$mock_user_id = null; + \classes\economic::$mock_collection = []; + }, + 'expected_success' => ['customerNumber' => 12345678], + 'expected_status' => 201 + ] + ]; + + $allPassed = true; + foreach ($testCases as $test) { + \classes\response::reset(); + \classes\recaptcha::$mock_valid = true; + \objects\users_o::$mock_user_id = null; + \objects\users_o::$mock_existing_emails = []; + \objects\users_o::$mock_existing_customer_numbers = []; + \classes\economic::$mock_collection = []; + if (isset($test['setup'])) { + $test['setup'](); + } + \classes\response::$request_parameters = $test['params']; + + try { + $callback(); + fail($test['name'] . " - Callback did not exit as expected"); + $allPassed = false; + } catch (\classes\MockExitException $e) { + if (isset($test['expected_error'])) { + if (\classes\response::$last_error === $test['expected_error']) { + if (\classes\response::$last_status === $test['expected_status']) { + ok($test['name']); + } else { + fail($test['name'] . " - Expected status " . $test['expected_status'] . " but got " . \classes\response::$last_status); + $allPassed = false; + } + } else { + fail($test['name'] . " - Expected error '" . $test['expected_error'] . "' but got '" . (\classes\response::$last_error ?? 'NULL') . "'"); + $allPassed = false; + } + } elseif (isset($test['expected_success'])) { + if (\classes\response::$last_success == $test['expected_success']) { + if (\classes\response::$last_status === $test['expected_status']) { + ok($test['name']); + } else { + fail($test['name'] . " - Expected status " . $test['expected_status'] . " but got " . \classes\response::$last_status); + $allPassed = false; + } + } else { + fail($test['name'] . " - Expected success data " . json_encode($test['expected_success']) . " but got " . json_encode(\classes\response::$last_success)); + $allPassed = false; + } + } + } catch (\Exception $e) { + fail($test['name'] . " - Unexpected exception: " . $e->getMessage() . "\n" . $e->getTraceAsString()); + $allPassed = false; + } + } + + echo "\nRegisterCvrTest completed.\n"; + exit($allPassed ? 0 : 1); +} diff --git a/services/nginx/app/tests/auth/TwoFactorAuthTest.php b/services/nginx/app/tests/auth/TwoFactorAuthTest.php new file mode 100644 index 00000000..80fce929 --- /dev/null +++ b/services/nginx/app/tests/auth/TwoFactorAuthTest.php @@ -0,0 +1,64 @@ +connect(); + +function assert_true($condition, $message) +{ + if ($condition) { + echo "✔ $message\n"; + } else { + echo "✘ $message\n"; + exit(1); + } +} + +echo "Testing TOTP class...\n"; +$totp = new totp(); +$secret = $totp->generateSecret(); +assert_true(strlen($secret) === 16, "Secret length is 16"); +$code = $totp->getCode($secret); +assert_true(strlen($code) === 6, "Code length is 6"); +assert_true($totp->verifyCode($secret, $code), "Verify current code"); +assert_true(!$totp->verifyCode($secret, '000000'), "Reject invalid code"); + +echo "\nTesting 2FA columns on users table...\n"; +$colCheck = $db->query("SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '".$CONFIG_DB['database']."' AND TABLE_NAME = 'users' AND COLUMN_NAME IN ('two_factor_secret','two_factor_enabled')"); +$row = $db->fetch_assoc($colCheck); +assert_true((int)$row['cnt'] >= 2, "Users table has 2FA columns"); + +$setup_secret = $totp->generateSecret(); +$valid_code = $totp->getCode($setup_secret); +assert_true($totp->verifyCode($setup_secret, $valid_code), "Verify code for generated secret"); + +echo "\nTesting 2FA for Subuser (Driver) via DB...\n"; +$test_subuser_id = 999999; +$db->query("DELETE FROM subusers WHERE id = $test_subuser_id"); +$pwd = password_hash('password123', PASSWORD_DEFAULT); +$db->query("INSERT INTO subusers (id, username, password, name, email, phone_country_code, phone) VALUES ($test_subuser_id, 'testdriver', '$pwd', 'Test Driver', 'driver@example.com', 45, 20123456)"); + +$sub_secret = $totp->generateSecret(); +$sub_code = $totp->getCode($sub_secret); +assert_true($totp->verifyCode($sub_secret, $sub_code), "TOTP verifies subuser code"); + +$db->query("UPDATE subusers SET two_factor_secret = '$sub_secret', two_factor_enabled = 1 WHERE id = $test_subuser_id"); +$res = $db->query("SELECT two_factor_enabled, two_factor_secret FROM subusers WHERE id = $test_subuser_id"); +$row = $db->fetch_assoc($res); +assert_true((int)$row['two_factor_enabled'] === 1, "Subuser 2FA enabled flag persisted"); +assert_true($row['two_factor_secret'] === $sub_secret, "Subuser secret persisted"); + +// Cleanup +$db->query("DELETE FROM subusers WHERE id = $test_subuser_id"); + +echo "\nTwoFactorAuthTest completed successfully!\n";