From 3865a9b0e3bc2c40019c77505af62fc5d737463c Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Fri, 3 Apr 2026 15:17:00 +0530 Subject: [PATCH] GWM : saml integration --- app/Config/Routes.php | 16 ++ app/Controllers/SamlController.php | 248 +++++++++++++++++++++++ app/Libraries/Saml/advanced_settings.php | 28 +++ app/Libraries/Saml/certs/sp.crt | 20 ++ app/Libraries/Saml/certs/sp.key | 28 +++ app/Libraries/Saml/settings.php | 45 ++++ app/Models/SamlClientModel.php | 31 +++ composer.json | 1 + 8 files changed, 417 insertions(+) create mode 100644 app/Controllers/SamlController.php create mode 100644 app/Libraries/Saml/advanced_settings.php create mode 100644 app/Libraries/Saml/certs/sp.crt create mode 100644 app/Libraries/Saml/certs/sp.key create mode 100644 app/Libraries/Saml/settings.php create mode 100644 app/Models/SamlClientModel.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index cb4c782..d8b5b98 100755 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -571,3 +571,19 @@ $routes->group('test',function($routes){ $routes->get('logo_renaming','TestingController::logo_renaming'); }); + + + +//saml - routes +$routes->group("employeeRest", ['filter' => ['appSignature','AuthApiRateLimitFilter'] ], function ($routes) { + + $routes->post('login/start', 'SamlController::startLogin'); + $routes->get('saml/login', 'SamlController::login'); + $routes->match(['get', 'post'], 'saml/slo', 'SamlController::slo'); + +}); + +$routes->post('saml/acs', 'SamlController::acs'); +$routes->get('saml/logout', 'SamlController::logout'); +$routes->get('saml/metadata', 'SamlController::metadata'); + diff --git a/app/Controllers/SamlController.php b/app/Controllers/SamlController.php new file mode 100644 index 0000000..5c90998 --- /dev/null +++ b/app/Controllers/SamlController.php @@ -0,0 +1,248 @@ +request->getPost('email'); + + if (! $email) { + return $this->response->setStatusCode(400)->setBody('Email required'); + } + + $email = trim((string) $email); + $at = strrchr($email, '@'); + if ($at === false) { + return $this->response->setStatusCode(400)->setBody('Invalid email'); + } + + $domain = strtolower(substr($at, 1)); + + $model = new SamlClientModel(); + $samlClient = $model->getByDomain($domain); + + if (! $samlClient) { + return $this->response->setStatusCode(404)->setBody('SAML not configured for this domain'); + } + + session()->set('saml_client_id', $samlClient['id']); + + return redirect()->to(site_url('saml/login')); + } + private function loadMergedSettings(): array + { + $settings = require APPPATH . 'Libraries/Saml/settings.php'; + $advanced = require APPPATH . 'Libraries/Saml/advanced_settings.php'; + + return array_merge($settings, $advanced); + } + + private function getSamlAuth(): SamlAuth + { + $samlClientId = session()->get('saml_client_id'); + + if (! $samlClientId) { + throw new \RuntimeException('SAML client not found in session'); + } + + $model = new SamlClientModel(); + $samlClient = $model->find($samlClientId); + + if (! $samlClient) { + throw new \RuntimeException('Invalid SAML client'); + } + + $settings = $this->loadMergedSettings(); + + $settings['idp'] = [ + 'entityId' => $samlClient['saml_entity_id'], + 'singleSignOnService' => [ + 'url' => $samlClient['saml_sso_url'], + ], + 'singleLogoutService' => [ + 'url' => $samlClient['saml_slo_url'] ?? '', + ], + 'x509cert' => $samlClient['saml_x509_cert'], + ]; + + return new SamlAuth($settings); + } + + public function login() + { + try { + $auth = $this->getSamlAuth(); + } catch (\Throwable $e) { + return redirect()->to(site_url('login'))->with('error', $e->getMessage()); + } + + $url = $auth->login(null, [], false, false, true); + + return redirect()->to($url); + } + + public function acs() + { + try { + $auth = $this->getSamlAuth(); + } catch (\Throwable $e) { + return redirect()->to(site_url('login'))->with('error', $e->getMessage()); + } + + $auth->processResponse(); + + if (! $auth->isAuthenticated()) { + return $this->response->setJSON($auth->getErrors()); + } + + $attributes = $auth->getAttributes(); + $nameId = $auth->getNameId(); + + $email = $this->resolveEmailFromSaml($nameId, $attributes); + + $UserModel = new UserModel(); + $user = $UserModel->getUserByEmail($email); + + if (! $user || $user->is_active === '0') { + session()->remove('saml_client_id'); + + return redirect()->to(site_url('login'))->with('error', 'User not registered or inactive'); + } + + $user_team = $UserModel->getUserTeamsByUserID($user->id); + + session()->regenerate(true); + + $session_data = [ + 'isLoggedIn' => true, + 'userid' => $user->id, + 'userData' => $user, + 'userProfile' => null, + 'user_team' => $user_team, + 'saml_name_id' => $nameId, + 'saml_attrs' => $attributes, + ]; + set_session_data($session_data); + set_session_data(['fingerprint' => generateFingerprint()]); + + $this->getUserDeviceInfo($user->id, 'NhanceUser'); + + return redirect()->to(site_url('dashboard/view')); + } + + protected function getUserDeviceInfo(int $userId, string $type_of_user): void + { + $userAgent = $this->request->getUserAgent(); + $datd = [ + 'user_id' => $userId, + 'user_type' => $type_of_user, + 'ip' => $this->request->getIPAddress(), + 'platform' => $userAgent->getPlatform(), + 'broswer' => $userAgent->getBrowser(), + ]; + $AuthHistoryModel = new \App\Models\AuthHistoryModel(); + $AuthHistoryModel->insert($datd); + } + + /** + * @param array $attributes + */ + private function resolveEmailFromSaml(?string $nameId, array $attributes): string + { + if ($nameId && filter_var($nameId, FILTER_VALIDATE_EMAIL)) { + return $nameId; + } + + $keys = [ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + 'http://schemas.microsoft.com/identity/claims/emailaddress', + 'email', + 'Email', + 'mail', + ]; + + foreach ($keys as $k) { + if (! empty($attributes[$k][0])) { + return (string) $attributes[$k][0]; + } + } + + foreach ($attributes as $vals) { + if (is_array($vals) && ! empty($vals[0]) && filter_var($vals[0], FILTER_VALIDATE_EMAIL)) { + return (string) $vals[0]; + } + } + + return (string) $nameId; + } + + public function logout() + { + try { + $auth = $this->getSamlAuth(); + $url = $auth->logout(null, [], null, null, true); + } catch (SamlError $e) { + session()->destroy(); + + return redirect()->to(site_url('login')); + } catch (\Throwable $e) { + session()->destroy(); + + return redirect()->to(site_url('login')); + } + + session()->destroy(); + + return redirect()->to($url); + } + + public function slo() + { + try { + $auth = $this->getSamlAuth(); + } catch (\Throwable $e) { + return redirect()->to(site_url('login')); + } + + try { + $redirectUrl = $auth->processSLO(false, null, false, null, true); + } catch (SamlError $e) { + return redirect()->to(site_url('login')); + } + + if (! empty($auth->getErrors())) { + return $this->response->setJSON($auth->getErrors()); + } + + if ($redirectUrl) { + return redirect()->to($redirectUrl); + } + + return redirect()->to(site_url('login')); + } + + public function metadata() + { + $settings = $this->loadMergedSettings(); + + try { + $samlSettings = new SamlSettings($settings, true); + $metadata = $samlSettings->getSPMetadata(); + } catch (\Throwable $e) { + return $this->response->setStatusCode(500)->setBody($e->getMessage()); + } + + return $this->response + ->setHeader('Content-Type', 'application/xml; charset=utf-8') + ->setBody($metadata); + } +} diff --git a/app/Libraries/Saml/advanced_settings.php b/app/Libraries/Saml/advanced_settings.php new file mode 100644 index 0000000..8c95d56 --- /dev/null +++ b/app/Libraries/Saml/advanced_settings.php @@ -0,0 +1,28 @@ + [ + 'requests' => true, + 'responses' => true, + ], + 'security' => [ + 'nameIdEncrypted' => false, + 'authnRequestsSigned' => false, + 'logoutRequestSigned' => false, + 'logoutResponseSigned' => false, + 'signMetadata' => false, + 'wantMessagesSigned' => false, + 'wantAssertionsEncrypted' => false, + 'wantAssertionsSigned' => false, + 'wantNameId' => true, + 'wantNameIdEncrypted' => false, + 'requestedAuthnContext' => false, + 'wantXMLValidation' => true, + 'relaxDestinationValidation' => false, + 'destinationStrictlyMatches' => false, + 'signatureAlgorithm' => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + 'digestAlgorithm' => 'http://www.w3.org/2001/04/xmlenc#sha256', + 'lowercaseUrlencoding' => false, + ], + // Add contactPerson/organization only when fully populated. +]; diff --git a/app/Libraries/Saml/certs/sp.crt b/app/Libraries/Saml/certs/sp.crt new file mode 100644 index 0000000..cf723f4 --- /dev/null +++ b/app/Libraries/Saml/certs/sp.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTzCCAjegAwIBAgIUGlFK78mvjuT7qCQCzb31hoEopdUwDQYJKoZIhvcNAQEL +BQAwNzEXMBUGA1UEAwwObmhhbmNlLXNhbWwtc3AxDzANBgNVBAoMBk5IQU5DRTEL +MAkGA1UEBhMCSU4wHhcNMjYwNDAzMDYzMDU2WhcNMjcwNDAzMDYzMDU2WjA3MRcw +FQYDVQQDDA5uaGFuY2Utc2FtbC1zcDEPMA0GA1UECgwGTkhBTkNFMQswCQYDVQQG +EwJJTjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJIC9MYFsI24T1/4 +AFo+OM9BuHec92t5YVj16+cKQ4Nozq7KccnDJg8S9shU75+aCr4X6wmdVq6cJfBA +UaBh1QdIFPJZIwjCUGLnpN+LYX6szFt3YjiRya7Yb3WOQGuMON5i7If/QR5aAHA+ +bRsXPu2XGBnLoZOdFhxRogCy5bXAPug668mJPcMh7/pJdkuAFg7Kdnso8PUsjkAf +D83GHutX0U+8o2thzM9apWv8/7f7WGxR/COyWszZ++xKpxt7k6oehnoHJhMqkEiD +BCBNcKc7eHnxiR/SpIMqbbuDPp0eUo1ohT17hJXeAo9eFjbH9cstmv+ADuzm9YyH +tfj1ohcCAwEAAaNTMFEwHQYDVR0OBBYEFKPaXcBR6Zsnq/mSl8kuXNS6doszMB8G +A1UdIwQYMBaAFKPaXcBR6Zsnq/mSl8kuXNS6doszMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQELBQADggEBAHndqjXvGdTj5Bu2X1H+/SVeq9gYvX3jJh7x2Sam +RaVq9swZS6EPfZP6oMOmzkgVzQBPk/QRHlXpuVjdMn4shdWUhNJJ1CGiQPNmg2el +cYhnwCcRcf/MO4a2zyiv3ZhRW8BmhnnxYLFZuS6F95RMbQgXMfJAwQEX29L8L/w9 +Js93wqqObjFh8kaJRKCrLtph736Dj6juB+o6Lx1tMpZbTssXFiVHtlqn6/vO2R0i +BMsbHNcSp6gkNRituEMlO9pto4HBDXeBD4gC28BmXnqem7LoMmeLVH9xcD9KFoCq +DXYNYOfCjZc6z/mdbDmKpWvyQAlaqrekyZZaMTPzCl3c5w0= +-----END CERTIFICATE----- diff --git a/app/Libraries/Saml/certs/sp.key b/app/Libraries/Saml/certs/sp.key new file mode 100644 index 0000000..5048a20 --- /dev/null +++ b/app/Libraries/Saml/certs/sp.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCSAvTGBbCNuE9f ++ABaPjjPQbh3nPdreWFY9evnCkODaM6uynHJwyYPEvbIVO+fmgq+F+sJnVaunCXw +QFGgYdUHSBTyWSMIwlBi56Tfi2F+rMxbd2I4kcmu2G91jkBrjDjeYuyH/0EeWgBw +Pm0bFz7tlxgZy6GTnRYcUaIAsuW1wD7oOuvJiT3DIe/6SXZLgBYOynZ7KPD1LI5A +Hw/Nxh7rV9FPvKNrYczPWqVr/P+3+1hsUfwjslrM2fvsSqcbe5OqHoZ6ByYTKpBI +gwQgTXCnO3h58Ykf0qSDKm27gz6dHlKNaIU9e4SV3gKPXhY2x/XLLZr/gA7s5vWM +h7X49aIXAgMBAAECggEAGy8ctk9x3vjNIl9wZVzHQ+MG/pIFSIepNaBXgsTY6/rT +3BwJ0lgYWl8b/hE+KbdKv7iBRGF8NXcR4yh+af8846WqbLJmwOc4gymAezQeezCd +vXu9GC4gYAKgwcCxwrQxFEpTokBGNenowf0FYDFUQHTMgmT0mKB68NvL7xhfhxZT +P+hdQiCb8cZvGrdmcfUC67hlXSH9ePKQkb8H5e2HULpOiShoZEeoZk+bRq22YewP +bZ+D0f8Eez5WUSKLRqeasGQvhsAwX491TsP0QeBWILp0KTiKCufuDW9bE1Vvb8h2 +xFepUhKvCLT0ZboKhl20K4P6NPOFYaO9Tcr5inTmRQKBgQDEfeSDe5N9563UcLy2 +X325E84xguyEDhyBmrJ3JcmiPnLorSCLaX9CZUi4+BMLN+5bJG7iz4jgfJp/y9ns +VPPxZyXAJ2RicJkhiLmARWHAo317booyqJDH+/I9gxY5l0fCDMoMLbeEOBsJIlAk +zeTEIIJkr5o2xz+7dLeCdujGDQKBgQC+O05g82tLTLDRliYYvNLlbPf8zgkn8yLx +RRkkKO/svXokKLttiwbACuED2eR07seA3AZb1qAdj7V7HBb20axamdtZvKCfHMEw +LdZwW1qimIxhkkwGgiBC3bHYvG2BytohU/kQx+0vWFaYOkK8ihsrrrcNILpKjaH2 +PV0M4iUDswKBgBY4MkYYDFa5gzO5x+1LoRjzv2Zj6sEII3sYdkP49vMs4qujIEID +nQtyDqY0D1s+aOrPlOZ7F3xjOslm0O7jsG5E/sTa74QePYLIRknWDrbNBhyWJHSU +EUM8H2mLUFEU5V1xOsvjw5PlEFGZGrz+t3biQjyGiwbUw0U8bqAHOE1lAoGAYoZm +3tHUFUjgH3zruE470HWyru2rUlScGWfXUKIfOXcdRpMOF/s0gMxhpFP6/hEZpQTQ +CkrL3OOsc9mljyojYT1knUKT0jTbXe+vq7u04petxW83DvvgZ6FY1k8pTFraxP4v +9mAF2UqgdvFd1TaWQfaYeiUkNy7J3rYDdoO99f8CgYB0GYjD5XzDG4YoftVv4Za5 +WPl5MJhiVLuZfvg0aPUkL/J38nQJ66x287wW7aFMr5E86d6QWuVDeE9n3aAnIE8J +NNPpJtFpe3dl/I/XJId0xWiFGyyTz5D7XP9pljlfz6ZV17cZlqvQf6gXg2/ELgtu +A8li+XiGe3QPsoZWYZMd4Q== +-----END PRIVATE KEY----- diff --git a/app/Libraries/Saml/settings.php b/app/Libraries/Saml/settings.php new file mode 100644 index 0000000..7b7e46a --- /dev/null +++ b/app/Libraries/Saml/settings.php @@ -0,0 +1,45 @@ + true, + 'debug' => false, + 'baseurl' => $base . '/', + + 'sp' => [ + 'entityId' => $base . '/saml/metadata', + 'assertionConsumerService' => [ + 'url' => $base . '/saml/acs', + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + ], + 'singleLogoutService' => [ + 'url' => $base . '/saml/slo', + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ], + 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + 'x509cert' => $spCert, + 'privateKey' => $spKey, + ], + + // Placeholder IdP (must pass Settings validation; replaced before Auth by DB row) + 'idp' => [ + 'entityId' => $base . '/saml/placeholder-idp', + 'singleSignOnService' => [ + 'url' => $base . '/saml/placeholder-sso', + ], + 'singleLogoutService' => [ + 'url' => '', + ], + 'x509cert' => $spCert, + ], +]; diff --git a/app/Models/SamlClientModel.php b/app/Models/SamlClientModel.php new file mode 100644 index 0000000..3ccffda --- /dev/null +++ b/app/Models/SamlClientModel.php @@ -0,0 +1,31 @@ +where('LOWER(email_domain)', $domain)->first(); + } +} diff --git a/composer.json b/composer.json index e2f8787..718fd8f 100755 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "google/apiclient": "^2.18", "kreait/firebase-php": "^7.0", "laminas/laminas-escaper": "^2.9", + "onelogin/php-saml": "^4.3", "php-amqplib/php-amqplib": "^2.8", "phpmailer/phpmailer": "^6.9", "phpoffice/phpspreadsheet": "^2.1",