From 15cb0b8430b28c11f3e655f22b6bc1e6abaf10d7 Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Thu, 26 Feb 2026 15:29:10 +0530 Subject: [PATCH 1/2] HR token issue --- app/Controllers/RestAuthenticationController.php | 2 +- app/Filters/AuthJWT.php | 16 +++++++++++++--- app/Helpers/JWTToken.php | 8 ++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/Controllers/RestAuthenticationController.php b/app/Controllers/RestAuthenticationController.php index 78ee61e..4573bbf 100755 --- a/app/Controllers/RestAuthenticationController.php +++ b/app/Controllers/RestAuthenticationController.php @@ -1977,7 +1977,7 @@ class RestAuthenticationController extends AdminController } $decoded = $result['decoded']; - $userId = $decoded['id'] ?? null; + $userId = $decoded['pre_hr_id'] ?? null; if (!$userId) { return $this->respond([ diff --git a/app/Filters/AuthJWT.php b/app/Filters/AuthJWT.php index 4eac9e5..00d985d 100755 --- a/app/Filters/AuthJWT.php +++ b/app/Filters/AuthJWT.php @@ -115,9 +115,19 @@ class AuthJWT implements FilterInterface } // Refresh sliding expiration - $model->update($id, [ - 'token_time_out' => time() + getenv('TOKENTIMEOUT') - ]); + $newExpiry = time() + getenv('TOKENTIMEOUT'); + + if (!isset($decoded['emp_code'])) { + // HR user: refresh token_time_out across all branches (same mobile/email) + // so switching branches doesn't cause an unexpected expiry + $field = !empty($user['mobile']) ? 'mobile' : 'email'; + $model->where($field, $user[$field]) + ->where('contact_type', 'client') + ->where('is_active', 1) + ->update(null, ['token_time_out' => $newExpiry]); + } else { + $model->update($id, ['token_time_out' => $newExpiry]); + } return true; } diff --git a/app/Helpers/JWTToken.php b/app/Helpers/JWTToken.php index f7b32ba..1715d54 100755 --- a/app/Helpers/JWTToken.php +++ b/app/Helpers/JWTToken.php @@ -137,7 +137,7 @@ class JWTToken $token = $jwtParts[0]; try { - $decoded = JWT::decode($token, new Key("secret", 'HS512')); + $decoded = JWT::decode($token, new Key(env('JWT_SECRET'), 'HS512')); return json_encode(['status' => true, 'message' => 'Token is valid', 'data' =>$decoded->data->id ]); } catch (ExpiredException $e) { return json_encode(['status' => false, 'message' => 'Token has expired']); @@ -159,7 +159,7 @@ class JWTToken $jwtParts = explode(' ', $jwt); $token = $jwtParts[1]; - $decoded = JWT::decode($token, new Key("secret", 'HS512')); + $decoded = JWT::decode($token, new Key(env('JWT_SECRET'), 'HS512')); return $decoded->id; } @@ -170,7 +170,7 @@ class JWTToken $jwtParts = explode(' ', $jwt); $token = $jwtParts[1]; - $decoded = JWT::decode($token, new Key("secret", 'HS512')); + $decoded = JWT::decode($token, new Key(env('JWT_SECRET'), 'HS512')); return $decoded->role; } @@ -182,7 +182,7 @@ class JWTToken $jwtParts = explode(' ', $jwt); $token = $jwtParts[1]; - $decoded = JWT::decode($token, new Key("secret", 'HS512')); + $decoded = JWT::decode($token, new Key(env('JWT_SECRET'), 'HS512')); return $decoded; } From 7d1842eda679356b486482982dc11b45cb003d5b Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Thu, 26 Feb 2026 15:42:31 +0530 Subject: [PATCH 2/2] GWM : HR Auth unit test --- tests/unit/HRAuthenticationTest.php | 607 ++++++++++++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 tests/unit/HRAuthenticationTest.php diff --git a/tests/unit/HRAuthenticationTest.php b/tests/unit/HRAuthenticationTest.php new file mode 100644 index 0000000..2c1db65 --- /dev/null +++ b/tests/unit/HRAuthenticationTest.php @@ -0,0 +1,607 @@ +jwtSecret}"); + putenv("TOKENTIMEOUT={$this->tokenTimeout}"); + + $_ENV['JWT_SECRET'] = $this->jwtSecret; + $_SERVER['JWT_SECRET'] = $this->jwtSecret; + } + + protected function tearDown(): void + { + putenv('JWT_SECRET'); + putenv('TOKENTIMEOUT'); + unset($_ENV['JWT_SECRET'], $_SERVER['JWT_SECRET']); + + parent::tearDown(); + } + + // ── Helpers ── + + private function createToken(array $payload, string $algo = 'HS512'): string + { + return JWT::encode($payload, $this->jwtSecret, $algo); + } + + private function bearerHeader(array $payload): string + { + return 'Bearer ' . $this->createToken($payload); + } + + private function createMockRequest(string $authHeader): IncomingRequest + { + $request = $this->createMock(IncomingRequest::class); + $request->method('getHeaderLine') + ->with('Authorization') + ->willReturn($authHeader); + + return $request; + } + + // ═══════════════════════════════════════════════ + // 1. validateJWT — Header Format Validation + // ═══════════════════════════════════════════════ + + public function testValidateJWT_RejectsEmptyHeader() + { + $result = JWTToken::validateJWT(''); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid Authorization header', $result['message']); + } + + public function testValidateJWT_RejectsMissingBearerPrefix() + { + $token = $this->createToken(['id' => 1]); + + $result = JWTToken::validateJWT($token); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid Authorization header', $result['message']); + } + + public function testValidateJWT_RejectsBasicAuthScheme() + { + $result = JWTToken::validateJWT('Basic dXNlcjpwYXNz'); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid Authorization header', $result['message']); + } + + public function testValidateJWT_RejectsBearerWithExtraSpaces() + { + $token = $this->createToken(['id' => 1]); + + $result = JWTToken::validateJWT("Bearer $token"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid Authorization header', $result['message']); + } + + public function testValidateJWT_BearerIsCaseSensitive() + { + $token = $this->createToken(['id' => 1]); + + $result = JWTToken::validateJWT("bearer $token"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid Authorization header', $result['message']); + } + + // ═══════════════════════════════════════════════ + // 2. validateJWT — JWT Structure Validation + // ═══════════════════════════════════════════════ + + public function testValidateJWT_RejectsMalformedJWT_TwoParts() + { + $result = JWTToken::validateJWT('Bearer header.payload'); + + $this->assertFalse($result['status']); + $this->assertEquals('Malformed JWT', $result['message']); + } + + public function testValidateJWT_RejectsMalformedJWT_FourParts() + { + $result = JWTToken::validateJWT('Bearer a.b.c.d'); + + $this->assertFalse($result['status']); + $this->assertEquals('Malformed JWT', $result['message']); + } + + public function testValidateJWT_RejectsMalformedJWT_SinglePart() + { + $result = JWTToken::validateJWT('Bearer notajwt'); + + $this->assertFalse($result['status']); + $this->assertEquals('Malformed JWT', $result['message']); + } + + // ═══════════════════════════════════════════════ + // 3. validateJWT — Algorithm Security + // ═══════════════════════════════════════════════ + + public function testValidateJWT_RejectsNoneAlgorithm() + { + $header = base64_encode(json_encode(['alg' => 'none', 'typ' => 'JWT'])); + $payload = base64_encode(json_encode(['id' => 1])); + $fakeToken = "$header.$payload."; + + $result = JWTToken::validateJWT("Bearer $fakeToken"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid or unsupported JWT algorithm', $result['message']); + } + + public function testValidateJWT_RejectsMissingAlgorithm() + { + $header = base64_encode(json_encode(['typ' => 'JWT'])); + $payload = base64_encode(json_encode(['id' => 1])); + $fakeToken = "$header.$payload.fakesig"; + + $result = JWTToken::validateJWT("Bearer $fakeToken"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid or unsupported JWT algorithm', $result['message']); + } + + public function testValidateJWT_RejectsHS256Algorithm() + { + $token = JWT::encode(['id' => 1], $this->jwtSecret, 'HS256'); + + $result = JWTToken::validateJWT("Bearer $token"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid or unsupported JWT algorithm', $result['message']); + } + + // ═══════════════════════════════════════════════ + // 4. validateJWT — Signature Validation + // ═══════════════════════════════════════════════ + + public function testValidateJWT_RejectsTokenSignedWithWrongSecret() + { + $token = JWT::encode(['id' => 1, 'pre_hr_id' => 5], 'attacker-secret', 'HS512'); + + $result = JWTToken::validateJWT("Bearer $token"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid token signature', $result['message']); + } + + public function testValidateJWT_RejectsTamperedPayload() + { + $token = $this->createToken(['id' => 1, 'pre_hr_id' => 5]); + $parts = explode('.', $token); + + // Attacker tampers payload to escalate to a different branch + $parts[1] = rtrim(base64_encode(json_encode(['id' => 1, 'pre_hr_id' => 999])), '='); + $tampered = implode('.', $parts); + + $result = JWTToken::validateJWT("Bearer $tampered"); + + $this->assertFalse($result['status']); + } + + // ═══════════════════════════════════════════════ + // 5. validateJWT — Valid Token Decoding + // ═══════════════════════════════════════════════ + + public function testValidateJWT_AcceptsValidHRToken() + { + $payload = [ + 'id' => 10, + 'pre_hr_id' => 5, + 'pre_client_id' => md5('1'), + 'pre_branch_id' => 1, + 'post_client_id' => md5('2'), + 'allowed_modules' => '["dashboard","reports"]', + ]; + + $result = JWTToken::validateJWT($this->bearerHeader($payload)); + + $this->assertTrue($result['status']); + $this->assertArrayHasKey('decoded', $result); + $this->assertEquals(5, $result['decoded']['pre_hr_id']); + $this->assertEquals(10, $result['decoded']['id']); + $this->assertEquals(1, $result['decoded']['pre_branch_id']); + } + + public function testValidateJWT_AcceptsValidEmployeeToken() + { + $payload = [ + 'id' => 1, + 'emp_code' => 'EMP001', + 'token_type' => 'pre', + ]; + + $result = JWTToken::validateJWT($this->bearerHeader($payload)); + + $this->assertTrue($result['status']); + $this->assertEquals('EMP001', $result['decoded']['emp_code']); + } + + public function testValidateJWT_PreservesAllPayloadFields() + { + $payload = [ + 'id' => 10, + 'pre_hr_id' => 5, + 'pre_client_id' => 'hashed_client', + 'pre_branch_id' => 3, + 'post_client_id' => 'hashed_post', + 'allowed_modules' => '["m1"]', + ]; + + $result = JWTToken::validateJWT($this->bearerHeader($payload)); + $decoded = $result['decoded']; + + $this->assertEquals($payload['pre_hr_id'], $decoded['pre_hr_id']); + $this->assertEquals($payload['pre_branch_id'], $decoded['pre_branch_id']); + $this->assertEquals($payload['pre_client_id'], $decoded['pre_client_id']); + $this->assertEquals($payload['post_client_id'], $decoded['post_client_id']); + } + + // ═══════════════════════════════════════════════ + // 6. Token Differentiation (HR vs Employee) + // ═══════════════════════════════════════════════ + + public function testHRToken_DoesNotContainEmpCode() + { + $hrPayload = ['id' => 10, 'pre_hr_id' => 5, 'pre_branch_id' => 1]; + + $result = JWTToken::validateJWT($this->bearerHeader($hrPayload)); + + $this->assertTrue($result['status']); + $this->assertArrayNotHasKey('emp_code', $result['decoded']); + } + + public function testEmployeeToken_ContainsEmpCode() + { + $empPayload = ['id' => 1, 'emp_code' => 'EMP001']; + + $result = JWTToken::validateJWT($this->bearerHeader($empPayload)); + + $this->assertTrue($result['status']); + $this->assertArrayHasKey('emp_code', $result['decoded']); + } + + public function testFilterRoutesHRToLevelContactModel() + { + $decoded = ['id' => 10, 'pre_hr_id' => 5, 'pre_branch_id' => 1]; + + $this->assertFalse(isset($decoded['emp_code']), 'HR token should NOT have emp_code'); + $id = $decoded['pre_hr_id'] ?? null; + $this->assertEquals(5, $id, 'HR lookup should use pre_hr_id'); + } + + public function testFilterRoutesEmployeeToEmployeeModel() + { + $decoded = ['id' => 1, 'emp_code' => 'EMP001']; + + $this->assertTrue(isset($decoded['emp_code']), 'Employee token should have emp_code'); + $id = $decoded['id'] ?? null; + $this->assertEquals(1, $id, 'Employee lookup should use id'); + } + + // ═══════════════════════════════════════════════ + // 7. Multi-Branch Token Generation + // ═══════════════════════════════════════════════ + + public function testEachBranchGetsUniqueToken() + { + $clientId = md5('42'); + $branches = [ + ['id' => 10, 'pre_hr_id' => 5, 'pre_client_id' => $clientId, 'pre_branch_id' => 1], + ['id' => 11, 'pre_hr_id' => 6, 'pre_client_id' => $clientId, 'pre_branch_id' => 2], + ['id' => 12, 'pre_hr_id' => 7, 'pre_client_id' => $clientId, 'pre_branch_id' => 3], + ]; + + $tokens = []; + foreach ($branches as $branch) { + $token = $this->createToken($branch); + $result = JWTToken::validateJWT("Bearer $token"); + + $this->assertTrue($result['status']); + $this->assertEquals($clientId, $result['decoded']['pre_client_id']); + $tokens[] = $token; + } + + $this->assertCount(3, array_unique($tokens), 'Each branch must produce a distinct token'); + } + + public function testBranchTokensDecodeToCorrectBranchId() + { + $branchIds = [1, 2, 3]; + + foreach ($branchIds as $i => $branchId) { + $payload = [ + 'id' => 10 + $i, + 'pre_hr_id' => 5 + $i, + 'pre_branch_id' => $branchId, + ]; + + $result = JWTToken::validateJWT($this->bearerHeader($payload)); + + $this->assertTrue($result['status']); + $this->assertEquals($branchId, $result['decoded']['pre_branch_id']); + } + } + + // ═══════════════════════════════════════════════ + // 8. Sliding Expiration Logic + // ═══════════════════════════════════════════════ + + public function testTokenTimeoutEnvIsReadable() + { + $this->assertEquals($this->tokenTimeout, (int)getenv('TOKENTIMEOUT')); + } + + public function testNewExpiryIsInTheFuture() + { + $before = time(); + $newExpiry = time() + (int)getenv('TOKENTIMEOUT'); + + $this->assertGreaterThanOrEqual($before + $this->tokenTimeout, $newExpiry); + } + + public function testActiveTokenPassesExpiryCheck() + { + $tokenTimeOut = time() + 600; + + $this->assertGreaterThan(time(), $tokenTimeOut, 'Active token_time_out must be in the future'); + } + + public function testExpiredTokenFailsExpiryCheck() + { + $tokenTimeOut = time() - 1; + + $this->assertLessThanOrEqual(time(), $tokenTimeOut, 'Expired token_time_out must be <= current time'); + } + + public function testNullExpiryFailsExpiryCheck() + { + $tokenTimeOut = null; + + $this->assertTrue($tokenTimeOut <= time(), 'null token_time_out should fail the expiry check'); + } + + // ═══════════════════════════════════════════════ + // 9. Multi-Branch Refresh Logic (the fix) + // ═══════════════════════════════════════════════ + + public function testBranchRefresh_UsesMobileWhenAvailable() + { + $user = ['mobile' => '9876543210', 'email' => 'hr@test.com']; + + $field = !empty($user['mobile']) ? 'mobile' : 'email'; + + $this->assertEquals('mobile', $field); + $this->assertEquals('9876543210', $user[$field]); + } + + public function testBranchRefresh_FallsBackToEmailWhenMobileEmpty() + { + $user = ['mobile' => '', 'email' => 'hr@test.com']; + + $field = !empty($user['mobile']) ? 'mobile' : 'email'; + + $this->assertEquals('email', $field); + $this->assertEquals('hr@test.com', $user[$field]); + } + + public function testBranchRefresh_FallsBackToEmailWhenMobileNull() + { + $user = ['mobile' => null, 'email' => 'hr@test.com']; + + $field = !empty($user['mobile']) ? 'mobile' : 'email'; + + $this->assertEquals('email', $field); + } + + public function testBranchRefresh_HRUserTakesMultiBranchPath() + { + $decoded = ['id' => 10, 'pre_hr_id' => 5, 'pre_branch_id' => 1]; + + $isMultiBranch = !isset($decoded['emp_code']); + + $this->assertTrue($isMultiBranch, 'HR user (no emp_code) should take the multi-branch refresh path'); + } + + public function testBranchRefresh_EmployeeTakesSingleRowPath() + { + $decoded = ['id' => 1, 'emp_code' => 'EMP001']; + + $isSingleRow = isset($decoded['emp_code']); + + $this->assertTrue($isSingleRow, 'Employee (with emp_code) should take the single-row refresh path'); + } + + /** + * Simulates the branch-switching scenario to verify the refresh logic + * targets all branches, not just the active one. + */ + public function testBranchRefresh_SimulateMultiBranchExpiryUpdate() + { + $sameMobile = '9876543210'; + + // Three level_contacts rows for the same HR person across branches + $branchRows = [ + ['id' => 5, 'mobile' => $sameMobile, 'email' => 'hr@test.com', 'contact_type' => 'client', 'is_active' => 1, 'token_time_out' => time() + 1800], + ['id' => 6, 'mobile' => $sameMobile, 'email' => 'hr@test.com', 'contact_type' => 'client', 'is_active' => 1, 'token_time_out' => time() + 1800], + ['id' => 7, 'mobile' => $sameMobile, 'email' => 'hr@test.com', 'contact_type' => 'client', 'is_active' => 1, 'token_time_out' => time() + 1800], + ]; + + // User is on Branch A (id=5). AuthJWT decoded token says pre_hr_id = 5. + $currentUser = $branchRows[0]; + $decoded = ['id' => 10, 'pre_hr_id' => 5, 'pre_branch_id' => 1]; + + // The filter's multi-branch refresh logic: + $field = !empty($currentUser['mobile']) ? 'mobile' : 'email'; + $newExpiry = time() + $this->tokenTimeout; + + // Simulate: update all rows where $field matches (same as the WHERE clause in AuthJWT) + $updatedRows = array_filter($branchRows, function ($row) use ($field, $currentUser) { + return $row[$field] === $currentUser[$field] + && $row['contact_type'] === 'client' + && $row['is_active'] === 1; + }); + + foreach ($updatedRows as &$row) { + $row['token_time_out'] = $newExpiry; + } + unset($row); + + // ALL three branches must have the new expiry + $this->assertCount(3, $updatedRows, 'All three branch rows should be updated'); + foreach ($updatedRows as $row) { + $this->assertEquals($newExpiry, $row['token_time_out']); + } + } + + /** + * Contrast test: the old single-row logic would only update one branch. + */ + public function testBranchRefresh_OldLogicWouldLeaveOtherBranchesStale() + { + $loginTime = time(); + $timeout = $this->tokenTimeout; + $sameMobile = '9876543210'; + + $branchRows = [ + ['id' => 5, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ['id' => 6, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ['id' => 7, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ]; + + // Simulate 20 minutes of activity on Branch A only (old single-row logic) + $simulatedNow = $loginTime + 1200; // 20 min later + $branchRows[0]['token_time_out'] = $simulatedNow + $timeout; // only branch A refreshed + + // Branch B and C still have the original login-time expiry + $this->assertEquals($loginTime + $timeout, $branchRows[1]['token_time_out']); + $this->assertEquals($loginTime + $timeout, $branchRows[2]['token_time_out']); + + // After 31 minutes, Branch B and C would be expired + $switchTime = $loginTime + 1860; // 31 min later + $this->assertLessThanOrEqual($switchTime, $branchRows[1]['token_time_out'], + 'OLD logic: Branch B would have expired by the time user switches'); + $this->assertLessThanOrEqual($switchTime, $branchRows[2]['token_time_out'], + 'OLD logic: Branch C would have expired by the time user switches'); + + // But Branch A is still alive + $this->assertGreaterThan($switchTime, $branchRows[0]['token_time_out'], + 'Branch A stays alive because it was actively refreshed'); + } + + /** + * Proves the new logic keeps all branches alive. + */ + public function testBranchRefresh_NewLogicKeepsAllBranchesAlive() + { + $loginTime = time(); + $timeout = $this->tokenTimeout; + $sameMobile = '9876543210'; + + $branchRows = [ + ['id' => 5, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ['id' => 6, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ['id' => 7, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ]; + + // Simulate 20 minutes of activity on Branch A (new multi-branch logic) + $simulatedNow = $loginTime + 1200; + $newExpiry = $simulatedNow + $timeout; + + // New logic refreshes ALL rows with same mobile + foreach ($branchRows as &$row) { + if ($row['mobile'] === $sameMobile) { + $row['token_time_out'] = $newExpiry; + } + } + unset($row); + + // After 31 minutes from login, ALL branches are still alive + $switchTime = $loginTime + 1860; + + foreach ($branchRows as $i => $row) { + $this->assertGreaterThan($switchTime, $row['token_time_out'], + "NEW logic: Branch id={$row['id']} should still be alive after switching"); + } + } + + // ═══════════════════════════════════════════════ + // 10. AuthJWT Filter — Rejection Tests + // ═══════════════════════════════════════════════ + + private function createFilter(): AuthJWT + { + // AuthJWT has require_once('../vendor/autoload.php') which resolves + // relative to CWD. In Apache CWD is public/, in PHPUnit it's the + // project root. Temporarily switch CWD so the require resolves. + $cwd = getcwd(); + chdir(PUBLICPATH); + try { + return new AuthJWT(); + } finally { + chdir($cwd); + } + } + + public function testFilter_RejectsMissingAuthHeader() + { + $filter = $this->createFilter(); + $request = $this->createMockRequest(''); + + $result = $filter->before($request); + + $this->assertNotTrue($result, 'Filter must reject requests without Authorization header'); + } + + public function testFilter_RejectsInvalidJWT() + { + $filter = $this->createFilter(); + $request = $this->createMockRequest('Bearer not.a.valid-jwt'); + + $result = $filter->before($request); + + $this->assertNotTrue($result, 'Filter must reject requests with an invalid JWT'); + } + + public function testFilter_RejectsTokenWithoutId() + { + $token = $this->createToken(['name' => 'no-id-field']); + $filter = $this->createFilter(); + $request = $this->createMockRequest("Bearer $token"); + + $result = $filter->before($request); + + $this->assertNotTrue($result, 'Filter must reject tokens without an id field'); + } + + public function testFilter_RejectsWrongAlgorithmToken() + { + $token = JWT::encode(['id' => 1], $this->jwtSecret, 'HS256'); + $filter = $this->createFilter(); + $request = $this->createMockRequest("Bearer $token"); + + $result = $filter->before($request); + + $this->assertNotTrue($result, 'Filter must reject tokens using non-HS512 algorithms'); + } +}