diff --git a/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php b/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php index 5087e3e4bb..111d212540 100644 --- a/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php +++ b/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php @@ -104,6 +104,11 @@ class ConfigDataProvider return $this->object->get('oidcTokenEndpoint'); } + public function getUserInfoEndpoint(): ?string + { + return $this->object->get('oidcUserInfoEndpoint'); + } + public function getJwksEndpoint(): ?string { return $this->object->get('oidcJwksEndpoint'); diff --git a/application/Espo/Core/Authentication/Oidc/Login.php b/application/Espo/Core/Authentication/Oidc/Login.php index 78880408af..cf5736ea76 100644 --- a/application/Espo/Core/Authentication/Oidc/Login.php +++ b/application/Espo/Core/Authentication/Oidc/Login.php @@ -38,6 +38,7 @@ use Espo\Core\Authentication\Logins\Espo; use Espo\Core\Authentication\Jwt\Exceptions\Invalid; use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified; use Espo\Core\Authentication\Jwt\Validator; +use Espo\Core\Authentication\Oidc\UserProvider\UserInfo; use Espo\Core\Authentication\Result; use Espo\Core\Authentication\Result\FailReason; use Espo\Core\Utils\Json; @@ -45,6 +46,7 @@ use Espo\Core\Utils\Log; use JsonException; use LogicException; use RuntimeException; +use SensitiveParameter; use stdClass; class Login implements LoginInterface @@ -62,7 +64,8 @@ class Login implements LoginInterface private Validator $validator, private TokenValidator $tokenValidator, private UserProvider $userProvider, - private ApplicationState $applicationState + private ApplicationState $applicationState, + private UserInfoDataProvider $userInfoDataProvider, ) {} public function login(Data $data, Request $request): Result @@ -99,7 +102,8 @@ class Login implements LoginInterface throw new RuntimeException("No client secret."); } - [$rawToken, $failResult] = $this->requestToken($endpoint, $clientId, $code, $redirectUri, $clientSecret); + [$rawToken, $failResult, $accessToken] = + $this->requestToken($endpoint, $clientId, $code, $redirectUri, $clientSecret); if ($failResult) { return $failResult; @@ -144,7 +148,9 @@ class Login implements LoginInterface return Result::fail(FailReason::DENIED); } - $user = $this->userProvider->get($tokenPayload); + $userInfo = $this->getUserInfo($tokenPayload, $accessToken); + + $user = $this->userProvider->get($userInfo); if (!$user) { return Result::fail(FailReason::USER_NOT_FOUND); @@ -198,7 +204,7 @@ class Login implements LoginInterface } /** - * @return array{?string, ?Result} + * @return array{?string, ?Result, ?string} */ private function requestToken( string $endpoint, @@ -250,7 +256,7 @@ class Login implements LoginInterface $this->log->warning(self::composeLogMessage('Token request error.', $status, $response)); - return [null, Result::fail(FailReason::DENIED)]; + return [null, Result::fail(FailReason::DENIED), null]; } $parsedResponse = null; @@ -266,6 +272,7 @@ class Login implements LoginInterface } $token = $parsedResponse->id_token ?? null; + $accessToken = $parsedResponse->access_token ?? null; if (!$token || !is_string($token)) { $this->log->error(self::composeLogMessage('Bad token response.', $status, $response)); @@ -273,7 +280,7 @@ class Login implements LoginInterface throw new RuntimeException(); } - return [$token, null]; + return [$token, null, $accessToken]; } private static function composeLogMessage(string $text, ?int $status = null, ?string $response = null): string @@ -295,4 +302,21 @@ class Login implements LoginInterface $this->tokenValidator->validateFields($token); $this->tokenValidator->validateSignature($token); } + + private function getUserInfo(Token\Payload $payload, #[SensitiveParameter] ?string $accessToken): UserInfo + { + $endpoint = $this->configDataProvider->getUserInfoEndpoint(); + + if (!$endpoint) { + return new UserInfo($payload, []); + } + + if (!$accessToken) { + throw new RuntimeException("OIDC: No access token received."); + } + + $data = $this->userInfoDataProvider->get($accessToken); + + return new UserInfo($payload, $data); + } } diff --git a/application/Espo/Core/Authentication/Oidc/UserInfoDataProvider.php b/application/Espo/Core/Authentication/Oidc/UserInfoDataProvider.php new file mode 100644 index 0000000000..1c9b2fe664 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserInfoDataProvider.php @@ -0,0 +1,121 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Oidc; + +use Espo\Core\Utils\Json; +use Espo\Core\Utils\Log; +use JsonException; +use RuntimeException; +use SensitiveParameter; + +class UserInfoDataProvider +{ + private const REQUEST_TIMEOUT = 10; + + public function __construct( + private ConfigDataProvider $configDataProvider, + private Log $log, + ) {} + + /** + * @return array + */ + public function get(#[SensitiveParameter] string $accessToken): array + { + return $this->load($accessToken); + } + + /** + * @return array + */ + private function load(#[SensitiveParameter] string $accessToken): array + { + $endpoint = $this->configDataProvider->getUserInfoEndpoint(); + + if (!$endpoint) { + throw new RuntimeException("No userinfo endpoint."); + } + + $curl = curl_init(); + + curl_setopt_array($curl, [ + CURLOPT_URL => $endpoint, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => 'GET', + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $accessToken, + 'Accept: application/json', + ], + ]); + + /** @var string|false $response */ + $response = curl_exec($curl); + $error = curl_error($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); + + curl_close($curl); + + if ($response === false) { + $response = ''; + } + + if ($error || is_int($status) && ($status >= 400 && $status < 500)) { + $this->log->error(self::composeLogMessage('UserInfo response error.', $status, $response)); + + throw new RuntimeException("OIDC: Userinfo request error."); + } + + $parsedResponse = null; + + try { + $parsedResponse = Json::decode($response, true); + } catch (JsonException) {} + + if (!is_array($parsedResponse)) { + throw new RuntimeException("OIDC: Bad userinfo response."); + } + + return $parsedResponse; + } + + private static function composeLogMessage(string $text, ?int $status = null, ?string $response = null): string + { + if ($status === null) { + return "OIDC: $text"; + } + + return "OIDC: $text; Status: $status; Response: $response"; + } +} diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider.php b/application/Espo/Core/Authentication/Oidc/UserProvider.php index f96d9fb71f..64d3a73b29 100644 --- a/application/Espo/Core/Authentication/Oidc/UserProvider.php +++ b/application/Espo/Core/Authentication/Oidc/UserProvider.php @@ -29,10 +29,10 @@ namespace Espo\Core\Authentication\Oidc; -use Espo\Core\Authentication\Jwt\Token\Payload; +use Espo\Core\Authentication\Oidc\UserProvider\UserInfo; use Espo\Entities\User; interface UserProvider { - public function get(Payload $payload): ?User; + public function get(UserInfo $userInfo): ?User; } diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider/DefaultUserProvider.php b/application/Espo/Core/Authentication/Oidc/UserProvider/DefaultUserProvider.php index 08c57fb9d0..270d8722ff 100644 --- a/application/Espo/Core/Authentication/Oidc/UserProvider/DefaultUserProvider.php +++ b/application/Espo/Core/Authentication/Oidc/UserProvider/DefaultUserProvider.php @@ -30,11 +30,11 @@ namespace Espo\Core\Authentication\Oidc\UserProvider; use Espo\Core\ApplicationState; -use Espo\Core\Authentication\Jwt\Token\Payload; use Espo\Core\Authentication\Oidc\ConfigDataProvider; use Espo\Core\Authentication\Oidc\UserProvider; use Espo\Core\Utils\Log; use Espo\Entities\User; + use RuntimeException; class DefaultUserProvider implements UserProvider @@ -44,30 +44,30 @@ class DefaultUserProvider implements UserProvider private Sync $sync, private UserRepository $userRepository, private ApplicationState $applicationState, - private Log $log + private Log $log, ) {} - public function get(Payload $payload): ?User + public function get(UserInfo $userInfo): ?User { - $user = $this->findUser($payload); + $user = $this->findUser($userInfo); if ($user === false) { return null; } if ($user) { - $this->syncUser($user, $payload); + $this->syncUser($user, $userInfo); return $user; } - return $this->tryToCreateUser($payload); + return $this->tryToCreateUser($userInfo); } /** * @return User|false|null */ - private function findUser(Payload $payload): User|bool|null + private function findUser(UserInfo $userInfo): User|bool|null { $usernameClaim = $this->configDataProvider->getUsernameClaim(); @@ -75,10 +75,10 @@ class DefaultUserProvider implements UserProvider throw new RuntimeException("No username claim in config."); } - $username = $payload->get($usernameClaim); + $username = $userInfo->get($usernameClaim); if (!$username) { - throw new RuntimeException("No username claim `$usernameClaim` in token."); + throw new RuntimeException("No username claim `$usernameClaim` in token and userinfo."); } $username = $this->sync->normalizeUsername($username); @@ -136,7 +136,7 @@ class DefaultUserProvider implements UserProvider return $user; } - private function tryToCreateUser(Payload $payload): ?User + private function tryToCreateUser(UserInfo $userInfo): ?User { if (!$this->configDataProvider->createUser()) { return null; @@ -148,16 +148,16 @@ class DefaultUserProvider implements UserProvider throw new RuntimeException("Could not create a user. No OIDC username claim in config."); } - $username = $payload->get($usernameClaim); + $username = $userInfo->get($usernameClaim); if (!$username) { - throw new RuntimeException("Could not create a user. No username claim returned in token."); + throw new RuntimeException("Could not create a user. No username claim in token and userinfo."); } - return $this->sync->createUser($payload); + return $this->sync->createUser($userInfo); } - private function syncUser(User $user, Payload $payload): void + private function syncUser(User $user, UserInfo $userInfo): void { if ( !$this->configDataProvider->sync() && @@ -166,6 +166,6 @@ class DefaultUserProvider implements UserProvider return; } - $this->sync->syncUser($user, $payload); + $this->sync->syncUser($user, $userInfo); } } diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider/Sync.php b/application/Espo/Core/Authentication/Oidc/UserProvider/Sync.php index d28bc934c6..bf285c1e6b 100644 --- a/application/Espo/Core/Authentication/Oidc/UserProvider/Sync.php +++ b/application/Espo/Core/Authentication/Oidc/UserProvider/Sync.php @@ -31,7 +31,6 @@ namespace Espo\Core\Authentication\Oidc\UserProvider; use Espo\Core\Acl\Cache\Clearer as AclCacheClearer; use Espo\Core\ApplicationState; -use Espo\Core\Authentication\Jwt\Token\Payload; use Espo\Core\Authentication\Oidc\ConfigDataProvider; use Espo\Core\Field\LinkMultiple; use Espo\Core\Name\Field; @@ -50,30 +49,31 @@ class Sync private UserRepository $userRepository, private PasswordHash $passwordHash, private AclCacheClearer $aclCacheClearer, - private ApplicationState $applicationState + private ApplicationState $applicationState, ) {} - public function createUser(Payload $payload): User + public function createUser(UserInfo $userInfo): User { - $username = $this->getUsernameFromToken($payload); + $username = $this->getUsernameFromToken($userInfo); $this->usernameValidator->validate($username); $user = $this->userRepository->getNew(); - $user->set([ - 'type' => User::TYPE_REGULAR, - 'userName' => $username, + $user->setType(User::TYPE_REGULAR); + $user->setUserName($username); + + $user->setMultiple([ 'password' => $this->passwordHash->hash(Util::generatePassword(10, 4, 2, true)), ]); - $user->set($this->getUserDataFromToken($payload)); - $user->set($this->getUserTeamsDataFromToken($payload)); + $user->set($this->getUserDataFromToken($userInfo)); + $user->set($this->getUserTeamsDataFromToken($userInfo)); if ($this->applicationState->isPortal()) { $portalId = $this->applicationState->getPortalId(); - $user->set('type', User::TYPE_PORTAL); + $user->setType(User::TYPE_PORTAL); $user->setPortals(LinkMultiple::create()->withAddedId($portalId)); } @@ -82,7 +82,7 @@ class Sync return $user; } - public function syncUser(User $user, Payload $payload): void + public function syncUser(User $user, UserInfo $payload): void { $username = $this->getUsernameFromToken($payload); @@ -116,19 +116,19 @@ class Sync /** * @return array */ - private function getUserDataFromToken(Payload $payload): array + private function getUserDataFromToken(UserInfo $userInfo): array { return [ - 'emailAddress' => $payload->get('email'), - 'phoneNumber' => $payload->get('phone_number'), + 'emailAddress' => $userInfo->get('email'), + 'phoneNumber' => $userInfo->get('phone_number'), 'emailAddressData' => null, 'phoneNumberData' => null, - 'firstName' => $payload->get('given_name'), - 'lastName' => $payload->get('family_name'), - 'middle_name' => $payload->get('middle_name'), + 'firstName' => $userInfo->get('given_name'), + 'lastName' => $userInfo->get('family_name'), + 'middle_name' => $userInfo->get('middle_name'), 'gender' => - in_array($payload->get('gender'), ['male', 'female']) ? - ucfirst($payload->get('gender') ?? '') : + in_array($userInfo->get('gender'), ['male', 'female']) ? + ucfirst($userInfo->get('gender') ?? '') : null, ]; } @@ -136,14 +136,14 @@ class Sync /** * @return array */ - private function getUserTeamsDataFromToken(Payload $payload): array + private function getUserTeamsDataFromToken(UserInfo $userInfo): array { return [ - 'teamsIds' => $this->getTeamIdList($payload), + 'teamsIds' => $this->getTeamIdList($userInfo), ]; } - private function getUsernameFromToken(Payload $payload): string + private function getUsernameFromToken(UserInfo $userInfo): string { $usernameClaim = $this->configDataProvider->getUsernameClaim(); @@ -151,7 +151,7 @@ class Sync throw new RuntimeException("No OIDC username claim in config."); } - $username = $payload->get($usernameClaim); + $username = $userInfo->get($usernameClaim); if (!$username) { throw new RuntimeException("No username claim returned in token."); @@ -167,7 +167,7 @@ class Sync /** * @return string[] */ - private function getTeamIdList(Payload $payload): array + private function getTeamIdList(UserInfo $userInfo): array { $idList = $this->configDataProvider->getTeamIds() ?? []; $columns = $this->configDataProvider->getTeamColumns() ?? (object) []; @@ -176,7 +176,7 @@ class Sync return []; } - $groupList = $this->getGroups($payload); + $groupList = $this->getGroups($userInfo); $resultIdList = []; @@ -194,7 +194,7 @@ class Sync /** * @return string[] */ - private function getGroups(Payload $payload): array + private function getGroups(UserInfo $userInfo): array { $groupClaim = $this->configDataProvider->getGroupClaim(); @@ -202,7 +202,7 @@ class Sync return []; } - $value = $payload->get($groupClaim); + $value = $userInfo->get($groupClaim); if (!$value) { return []; diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider/UserInfo.php b/application/Espo/Core/Authentication/Oidc/UserProvider/UserInfo.php new file mode 100644 index 0000000000..fb1d9ce239 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserProvider/UserInfo.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Oidc\UserProvider; + +use Espo\Core\Authentication\Jwt\Token\Payload; + +class UserInfo +{ + /** + * @internal + * @param array $data + */ + public function __construct( + private Payload $payload, + private array $data, + ) {} + + public function get(string $name): mixed + { + return $this->payload->get($name) ?? $this->data[$name] ?? null; + } +} diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index be738d7744..eccf7605d2 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -151,6 +151,7 @@ "oidcAuthorizationRedirectUri": "OIDC Authorization Redirect URI", "oidcAuthorizationEndpoint": "OIDC Authorization Endpoint", "oidcTokenEndpoint": "OIDC Token Endpoint", + "oidcUserInfoEndpoint": "OIDC UserInfo Endpoint", "oidcJwksEndpoint": "OIDC JSON Web Key Set Endpoint", "oidcJwtSignatureAlgorithmList": "OIDC JWT Allowed Signature Algorithms", "oidcScopes": "OIDC Scopes", diff --git a/application/Espo/Resources/metadata/app/config.json b/application/Espo/Resources/metadata/app/config.json index 8f026d68a7..46dbd468dd 100644 --- a/application/Espo/Resources/metadata/app/config.json +++ b/application/Espo/Resources/metadata/app/config.json @@ -70,6 +70,9 @@ "oidcAuthorizationEndpoint": { "level": "admin" }, + "oidcUserInfoEndpoint": { + "level": "admin" + }, "oidcTokenEndpoint": { "level": "admin" }, diff --git a/application/Espo/Resources/metadata/authenticationMethods/Oidc.json b/application/Espo/Resources/metadata/authenticationMethods/Oidc.json index 8e8b0cdccd..e9460df13d 100644 --- a/application/Espo/Resources/metadata/authenticationMethods/Oidc.json +++ b/application/Espo/Resources/metadata/authenticationMethods/Oidc.json @@ -48,6 +48,12 @@ "name": "oidcJwtSignatureAlgorithmList" } ], + [ + { + "name": "oidcUserInfoEndpoint" + }, + false + ], [ { "name": "oidcScopes" diff --git a/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json b/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json index 2159e3554f..4fa8005a60 100644 --- a/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json +++ b/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json @@ -28,6 +28,10 @@ "type": "url", "strip": false }, + "oidcUserInfoEndpoint": { + "type": "url", + "strip": false + }, "oidcTokenEndpoint": { "type": "url", "strip": false diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index 814452ad6d..95608e49d5 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -824,6 +824,10 @@ "type": "url", "strip": false }, + "oidcUserInfoEndpoint": { + "type": "url", + "strip": false + }, "oidcTokenEndpoint": { "type": "url", "strip": false