diff --git a/application/Espo/Classes/FieldValidators/AuthenticationProvider/MethodValid.php b/application/Espo/Classes/FieldValidators/AuthenticationProvider/MethodValid.php new file mode 100644 index 0000000000..9ab056bbe6 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/AuthenticationProvider/MethodValid.php @@ -0,0 +1,62 @@ + + */ +class MethodValid implements Validator +{ + public function __construct(private Metadata $metadata) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->get($field); + + if (!$value) { + return Failure::create(); + } + + $isAvailable = $this->metadata->get(['authenticationMethods', $value, 'provider', 'isAvailable']); + + if (!$isAvailable) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Controllers/App.php b/application/Espo/Controllers/App.php index 89e8ea7099..8a10e525a1 100644 --- a/application/Espo/Controllers/App.php +++ b/application/Espo/Controllers/App.php @@ -33,6 +33,8 @@ use Espo\Core\Exceptions\BadRequest; use Espo\Core\Authentication\Authentication; use Espo\Core\Api\Request; use Espo\Core\Api\Response; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; use Espo\Core\InjectableFactory; use Espo\Tools\App\AppService as Service; @@ -56,6 +58,7 @@ class App /** * @throws BadRequest + * @throws Forbidden */ public function postActionDestroyAuthToken(Request $request, Response $response): bool { @@ -67,7 +70,14 @@ class App $auth = $this->injectableFactory->create(Authentication::class); - return $auth->destroyAuthToken($data->token, $request, $response); + try { + $auth->destroyAuthToken($data->token, $request, $response); + + return true; + } + catch (NotFound) { + return false; + } } private function getService(): Service diff --git a/application/Espo/Controllers/AuthenticationProvider.php b/application/Espo/Controllers/AuthenticationProvider.php new file mode 100644 index 0000000000..ff89e3ef57 --- /dev/null +++ b/application/Espo/Controllers/AuthenticationProvider.php @@ -0,0 +1,44 @@ +user->isAdmin()) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Core/Authentication/Authentication.php b/application/Espo/Core/Authentication/Authentication.php index e1f46370c9..00a7b4c3bb 100644 --- a/application/Espo/Core/Authentication/Authentication.php +++ b/application/Espo/Core/Authentication/Authentication.php @@ -29,8 +29,8 @@ namespace Espo\Core\Authentication; -use Espo\Core\Authentication\Logout\Params as LogoutParams; -use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; use Espo\Repositories\UserData as UserDataRepository; use Espo\Entities\Portal; use Espo\Entities\User; @@ -38,6 +38,9 @@ use Espo\Entities\AuthLogRecord; use Espo\Entities\AuthToken as AuthTokenEntity; use Espo\Entities\UserData; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Authentication\Logout\Params as LogoutParams; +use Espo\Core\Authentication\Util\MethodProvider; use Espo\Core\Authentication\Result\FailReason; use Espo\Core\Authentication\TwoFactor\LoginFactory as TwoFactorLoginFactory; use Espo\Core\Authentication\AuthToken\Manager as AuthTokenManager; @@ -45,7 +48,6 @@ use Espo\Core\Authentication\AuthToken\Data as AuthTokenData; use Espo\Core\Authentication\AuthToken\AuthToken; use Espo\Core\Authentication\Hook\Manager as HookManager; use Espo\Core\Authentication\Login\Data as LoginData; - use Espo\Core\ApplicationUser; use Espo\Core\ApplicationState; use Espo\Core\Api\Request; @@ -54,6 +56,7 @@ use Espo\Core\Utils\Log; use Espo\Core\ORM\EntityManagerProxy; use Espo\Core\Exceptions\ServiceUnavailable; +use LogicException; use RuntimeException; /** @@ -71,50 +74,22 @@ class Authentication private const COOKIE_AUTH_TOKEN_SECRET = 'auth-token-secret'; - private bool $allowAnyAccess; - private ?Portal $portal = null; - - private ApplicationUser $applicationUser; - private ApplicationState $applicationState; - private ConfigDataProvider $configDataProvider; - private EntityManagerProxy $entityManager; - private LoginFactory $loginFactory; - private TwoFactorLoginFactory $twoFactorLoginFactory; - private AuthTokenManager $authTokenManager; - private HookManager $hookManager; - private LogoutFactory $logoutFactory; - private Log $log; - public function __construct( - ApplicationUser $applicationUser, - ApplicationState $applicationState, - ConfigDataProvider $configDataProvider, - EntityManagerProxy $entityManagerProxy, - LoginFactory $loginFactory, - TwoFactorLoginFactory $twoFactorLoginFactory, - AuthTokenManager $authTokenManager, - HookManager $hookManager, - Log $log, - LogoutFactory $logoutFactory, - bool $allowAnyAccess = false - ) { - $this->allowAnyAccess = $allowAnyAccess; - - $this->applicationUser = $applicationUser; - $this->applicationState = $applicationState; - $this->configDataProvider = $configDataProvider; - $this->entityManager = $entityManagerProxy; - $this->loginFactory = $loginFactory; - $this->twoFactorLoginFactory = $twoFactorLoginFactory; - $this->authTokenManager = $authTokenManager; - $this->hookManager = $hookManager; - $this->logoutFactory = $logoutFactory; - $this->log = $log; - } + private ApplicationUser $applicationUser, + private ApplicationState $applicationState, + private ConfigDataProvider $configDataProvider, + private EntityManagerProxy $entityManager, + private LoginFactory $loginFactory, + private TwoFactorLoginFactory $twoFactorLoginFactory, + private AuthTokenManager $authTokenManager, + private HookManager $hookManager, + private Log $log, + private LogoutFactory $logoutFactory, + private MethodProvider $methodProvider + ) {} /** * Process logging in. - * Note: This method can change the state of the object (by setting the `portal` property.). * * @throws ServiceUnavailable */ @@ -122,22 +97,22 @@ class Authentication { $username = $data->getUsername(); $password = $data->getPassword(); - $authenticationMethod = $data->getMethod(); + $method = $data->getMethod(); $byTokenOnly = $data->byTokenOnly(); if ( - $authenticationMethod && - !$this->configDataProvider->authenticationMethodIsApi($authenticationMethod) + $method && + !$this->configDataProvider->authenticationMethodIsApi($method) ) { $this->log - ->warning("AUTH: Trying to use not allowed authentication method '{$authenticationMethod}'."); + ->warning("AUTH: Trying to use not allowed authentication method '{$method}'."); return $this->processFail(Result::fail(FailReason::METHOD_NOT_ALLOWED), $data, $request); } $this->hookManager->processBeforeLogin($data, $request); - if (!$authenticationMethod && $password === null) { + if (!$method && $password === null) { $this->log->error("AUTH: Trying to login w/o password."); return Result::fail(FailReason::NO_PASSWORD); @@ -145,7 +120,7 @@ class Authentication $authToken = null; - if (!$authenticationMethod) { + if (!$method) { $authToken = $this->authTokenManager->get($password); } @@ -173,7 +148,7 @@ class Authentication $byTokenAndUsername = $request->getHeader(self::HEADER_BY_TOKEN) === 'true'; - if ($authenticationMethod && $byTokenAndUsername) { + if ($method && $byTokenAndUsername) { return Result::fail(FailReason::DISCREPANT_DATA); } @@ -195,9 +170,9 @@ class Authentication } } - $authenticationMethod ??= $this->configDataProvider->getDefaultAuthenticationMethod(); + $method ??= $this->methodProvider->get(); - $login = $this->loginFactory->create($authenticationMethod, $this->isPortal()); + $login = $this->loginFactory->create($method, $this->isPortal()); $loginData = LoginData ::createBuilder() @@ -211,7 +186,7 @@ class Authentication $user = $result->getUser(); $authLogRecord = !$authTokenIsFound ? - $this->createAuthLogRecord($username, $user, $request, $authenticationMethod) : + $this->createAuthLogRecord($username, $user, $request, $method) : null; if ($result->isFail()) { @@ -356,40 +331,18 @@ class Authentication } } - private function setPortal(Portal $portal): void - { - $this->portal = $portal; - } - private function isPortal(): bool { - return $this->portal || $this->applicationState->isPortal(); + return $this->applicationState->isPortal(); } private function getPortal(): Portal { - if ($this->portal) { - return $this->portal; - } - return $this->applicationState->getPortal(); } private function processAuthTokenCheck(AuthToken $authToken): bool { - if ($this->allowAnyAccess && $authToken->getPortalId() && !$this->isPortal()) { - /** @var ?Portal $portal */ - $portal = $this->entityManager->getEntity('Portal', $authToken->getPortalId()); - - if ($portal) { - $this->setPortal($portal); - } - } - - if ($this->allowAnyAccess) { - return true; - } - if ($this->isPortal() && $authToken->getPortalId() !== $this->getPortal()->getId()) { $this->log->info("AUTH: Trying to login to portal with a token not related to portal."); @@ -552,12 +505,31 @@ class Authentication return $authToken; } - public function destroyAuthToken(string $token, Request $request, Response $response): bool + /** + * Destroy an auth token. + * + * @param string $token A token to destroy. + * @param Request $request A request. + * @param Response $response A response. + * @throws Forbidden + * @throws NotFound + */ + public function destroyAuthToken(string $token, Request $request, Response $response): void { $authToken = $this->authTokenManager->get($token); if (!$authToken) { - return false; + throw new NotFound("Auth token not found."); + } + + if (!$this->applicationState->hasUser()) { + throw new LogicException("No logged user."); + } + + $user = $this->applicationState->getUser(); + + if ($authToken->getUserId() !== $user->getId()) { + throw new Forbidden("Auth token for another user."); } $this->authTokenManager->inactivate($authToken); @@ -570,28 +542,28 @@ class Authentication } } - $method = $this->configDataProvider->getDefaultAuthenticationMethod(); + $method = $this->methodProvider->get(); - if ($this->logoutFactory->isCreatable($method)) { - $logout = $this->logoutFactory->create($method); - - $result = $logout->logout($authToken, LogoutParams::create()); - - $redirectUrl = $result->getRedirectUrl(); - - if ($redirectUrl) { - $response->setHeader(self::HEADER_LOGOUT_REDIRECT_URL, $redirectUrl); - } + if (!$this->logoutFactory->isCreatable($method)) { + return; } - return true; + $result = $this->logoutFactory + ->create($method) + ->logout($authToken, LogoutParams::create()); + + $redirectUrl = $result->getRedirectUrl(); + + if ($redirectUrl) { + $response->setHeader(self::HEADER_LOGOUT_REDIRECT_URL, $redirectUrl); + } } private function createAuthLogRecord( ?string $username, ?User $user, Request $request, - ?string $authenticationMethod = null + ?string $method = null ): ?AuthLogRecord { if ($username === self::LOGOUT_USERNAME) { @@ -616,7 +588,7 @@ class Authentication 'requestTime' => $request->getServerParam('REQUEST_TIME_FLOAT'), 'requestMethod' => $request->getMethod(), 'requestUrl' => $requestUrl, - 'authenticationMethod' => $authenticationMethod, + 'authenticationMethod' => $method, ]); if ($this->isPortal()) { diff --git a/application/Espo/Core/Authentication/AuthenticationFactory.php b/application/Espo/Core/Authentication/AuthenticationFactory.php index 97446d4916..884b99f05e 100644 --- a/application/Espo/Core/Authentication/AuthenticationFactory.php +++ b/application/Espo/Core/Authentication/AuthenticationFactory.php @@ -33,22 +33,11 @@ use Espo\Core\InjectableFactory; class AuthenticationFactory { - private InjectableFactory $injectableFactory; - - public function __construct(InjectableFactory $injectableFactory) - { - $this->injectableFactory = $injectableFactory; - } + public function __construct(private InjectableFactory $injectableFactory) + {} public function create(): Authentication { return $this->injectableFactory->create(Authentication::class); } - - public function createWithAnyAccessAllowed(): Authentication - { - return $this->injectableFactory->createWith(Authentication::class, [ - 'allowAnyAccess' => true, - ]); - } } diff --git a/application/Espo/Core/Authentication/Oidc/BackchannelLogout.php b/application/Espo/Core/Authentication/Oidc/BackchannelLogout.php index 4ca5325fb7..146f535df3 100644 --- a/application/Espo/Core/Authentication/Oidc/BackchannelLogout.php +++ b/application/Espo/Core/Authentication/Oidc/BackchannelLogout.php @@ -35,7 +35,6 @@ use Espo\Core\Authentication\Jwt\Exceptions\Invalid; use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified; use Espo\Core\Authentication\Jwt\Token; use Espo\Core\Authentication\Jwt\Validator; -use Espo\Core\Utils\Config; use Espo\Core\Utils\Log; use Espo\Entities\AuthToken as AuthTokenEntity; use Espo\Entities\User; @@ -48,28 +47,14 @@ use Espo\ORM\EntityManager; */ class BackchannelLogout { - private Log $log; - private Validator $validator; - private TokenValidator $tokenValidator; - private Config $config; - private EntityManager $entityManager; - private AuthTokenManager $authTokenManger; - public function __construct( - Log $log, - Validator $validator, - TokenValidator $tokenValidator, - Config $config, - EntityManager $entityManager, - AuthTokenManager $authTokenManger - ) { - $this->log = $log; - $this->validator = $validator; - $this->tokenValidator = $tokenValidator; - $this->config = $config; - $this->entityManager = $entityManager; - $this->authTokenManger = $authTokenManger; - } + private Log $log, + private Validator $validator, + private TokenValidator $tokenValidator, + private ConfigDataProvider $configDataProvider, + private EntityManager $entityManager, + private AuthTokenManager $authTokenManger + ) {} /** * @throws SignatureNotVerified @@ -86,7 +71,7 @@ class BackchannelLogout $this->tokenValidator->validateSignature($token); $this->tokenValidator->validateFields($token); - $usernameClaim = $this->config->get('oidcUsernameClaim'); + $usernameClaim = $this->configDataProvider->getUsernameClaim(); if (!$usernameClaim) { throw new Invalid("No username claim in config."); diff --git a/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php b/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php new file mode 100644 index 0000000000..b8df54e9b1 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php @@ -0,0 +1,220 @@ +object = $this->getAuthenticationProvider() ?? $this->config; + } + + private function isAuthenticationProvider(): bool + { + return $this->object instanceof AuthenticationProvider; + } + + private function getAuthenticationProvider(): ?AuthenticationProvider + { + if (!$this->applicationState->isPortal()) { + return null; + } + + $link = $this->applicationState->getPortal()->getAuthenticationProvider(); + + if (!$link) { + return null; + } + + /** @var ?AuthenticationProvider */ + return $this->entityManager->getEntityById(AuthenticationProvider::ENTITY_TYPE, $link->getId()); + } + + public function getRedirectUri(): string + { + return rtrim($this->config->get('siteUrl'), '/') . '/oauth-callback.php'; + } + + public function getClientId(): ?string + { + return $this->object->get('oidcClientId'); + } + + public function getClientSecret(): ?string + { + return $this->object->get('oidcClientSecret'); + } + + public function getAuthorizationEndpoint(): ?string + { + return $this->object->get('oidcAuthorizationEndpoint'); + } + + public function getTokenEndpoint(): ?string + { + return $this->object->get('oidcTokenEndpoint'); + } + + public function getJwksEndpoint(): ?string + { + return $this->object->get('oidcJwksEndpoint'); + } + + /** + * @return string[] + */ + public function getJwtSignatureAlgorithmList(): array + { + return $this->object->get('oidcJwtSignatureAlgorithmList') ?? []; + } + + /** + * @return string[] + */ + public function getScopes(): array + { + /** @var string[] */ + return $this->object->get('oidcScopes') ?? []; + } + + public function getLogoutUrl(): ?string + { + return $this->object->get('oidcLogoutUrl'); + } + + public function getUsernameClaim(): ?string + { + return $this->object->get('oidcUsernameClaim'); + } + + public function createUser(): bool + { + return (bool) $this->object->get('oidcCreateUser'); + } + + public function sync(): bool + { + return (bool) $this->object->get('oidcSync'); + } + + public function syncTeams(): bool + { + if ($this->isAuthenticationProvider()) { + return false; + } + + return (bool) $this->config->get('oidcSyncTeams'); + } + + public function fallback(): bool + { + if ($this->isAuthenticationProvider()) { + return false; + } + + return (bool) $this->config->get('oidcFallback'); + } + + public function allowRegularUserFallback(): bool + { + if ($this->isAuthenticationProvider()) { + return false; + } + + return (bool) $this->config->get('oidcAllowRegularUserFallback'); + } + + public function allowAdminUser(): bool + { + if ($this->isAuthenticationProvider()) { + return false; + } + + return (bool) $this->config->get('oidcAllowAdminUser'); + } + + public function getGroupClaim(): ?string + { + if ($this->isAuthenticationProvider()) { + return null; + } + + return $this->config->get('oidcGroupClaim'); + } + + /** + * @return ?string[] + */ + public function getTeamIds(): ?array + { + if ($this->isAuthenticationProvider()) { + return null; + } + + return $this->config->get('oidcTeamsIds') ?? []; + } + + public function getTeamColumns(): ?stdClass + { + if ($this->isAuthenticationProvider()) { + return null; + } + + return $this->config->get('oidcTeamsColumns') ?? (object) []; + } + + public function getAuthorizationPrompt(): string + { + return $this->config->get('oidcAuthorizationPrompt') ?? 'consent'; + } + + public function getAuthorizationMaxAge(): ?int + { + return $this->config->get('oidcAuthorizationMaxAge'); + } + + public function getJwksCachePeriod(): string + { + return $this->config->get('oidcJwksCachePeriod') ?? self::JWKS_CACHE_PERIOD; + } +} diff --git a/application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php b/application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php index 58e2445ad2..af34cbf1b0 100644 --- a/application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php +++ b/application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php @@ -33,7 +33,6 @@ use Espo\Core\Authentication\Jwt\SignatureVerifier; use Espo\Core\Authentication\Jwt\SignatureVerifierFactory; use Espo\Core\Authentication\Jwt\SignatureVerifiers\Hmac; use Espo\Core\Authentication\Jwt\SignatureVerifiers\Rsa; -use Espo\Core\Utils\Config; use RuntimeException; class DefaultSignatureVerifierFactory implements SignatureVerifierFactory @@ -54,14 +53,10 @@ class DefaultSignatureVerifierFactory implements SignatureVerifierFactory self::HS512 => Hmac::class, ]; - private Config $config; - private KeysProvider $keysProvider; - - public function __construct(Config $config, KeysProvider $keysProvider) - { - $this->config = $config; - $this->keysProvider = $keysProvider; - } + public function __construct( + private KeysProvider $keysProvider, + private ConfigDataProvider $configDataProvider + ) {} public function create(string $algorithm): SignatureVerifier { @@ -79,7 +74,7 @@ class DefaultSignatureVerifierFactory implements SignatureVerifierFactory } if ($className === Hmac::class) { - $key = $this->config->get('oidcClientSecret'); + $key = $this->configDataProvider->getClientSecret(); if (!$key) { throw new RuntimeException("No client secret."); diff --git a/application/Espo/Core/Authentication/Oidc/DefaultUserProvider.php b/application/Espo/Core/Authentication/Oidc/DefaultUserProvider.php index 64e731d120..0e5774e8bf 100644 --- a/application/Espo/Core/Authentication/Oidc/DefaultUserProvider.php +++ b/application/Espo/Core/Authentication/Oidc/DefaultUserProvider.php @@ -29,8 +29,9 @@ namespace Espo\Core\Authentication\Oidc; +use Espo\Core\ApplicationState; use Espo\Core\Authentication\Jwt\Token\Payload; -use Espo\Core\Utils\Config; +use Espo\Core\Utils\Log; use Espo\Entities\User; use Espo\ORM\EntityManager; use RuntimeException; @@ -38,9 +39,11 @@ use RuntimeException; class DefaultUserProvider implements UserProvider { public function __construct( - private Config $config, + private ConfigDataProvider $configDataProvider, private Sync $sync, - private EntityManager $entityManager + private EntityManager $entityManager, + private ApplicationState $applicationState, + private Log $log ) {} public function get(Payload $payload): ?User @@ -58,7 +61,7 @@ class DefaultUserProvider implements UserProvider private function findUser(Payload $payload): ?User { - $usernameClaim = $this->config->get('oidcUsernameClaim'); + $usernameClaim = $this->configDataProvider->getUsernameClaim(); if (!$usernameClaim) { throw new RuntimeException("No username claim in config."); @@ -86,15 +89,41 @@ class DefaultUserProvider implements UserProvider return null; } - if (!$user->isRegular() && !$user->isAdmin()) { + $userId = $user->getId(); + + $isPortal = $this->applicationState->isPortal(); + + if (!$isPortal && !$user->isRegular() && !$user->isAdmin()) { + $this->log->info("Oidc: User {$userId} found but it's neither regular user not admin."); + return null; } + if ($isPortal && !$user->isPortal()) { + $this->log->info("Oidc: User {$userId} found but it's not portal user."); + + return null; + } + + if ($isPortal) { + $portalId = $this->applicationState->getPortalId(); + + if (!$user->getPortals()->hasId($portalId)) { + $this->log->info("Oidc: User {$userId} found but it's not related to current portal."); + + return null; + } + } + if ($user->isSuperAdmin()) { + $this->log->info("Oidc: User {$userId} found but it's super-admin, not allowed."); + return null; } - if ($user->isAdmin() && !$this->config->get('oidcAllowAdminUser')) { + if ($user->isAdmin() && !$this->configDataProvider->allowAdminUser()) { + $this->log->info("Oidc: User {$userId} found but it's admin, not allowed."); + return null; } @@ -103,11 +132,11 @@ class DefaultUserProvider implements UserProvider private function tryToCreateUser(Payload $payload): ?User { - if (!$this->config->get('oidcCreateUser')) { + if (!$this->configDataProvider->createUser()) { return null; } - $usernameClaim = $this->config->get('oidcUsernameClaim'); + $usernameClaim = $this->configDataProvider->getUsernameClaim(); if (!$usernameClaim) { throw new RuntimeException("Could not create a user. No OIDC username claim in config."); @@ -124,7 +153,10 @@ class DefaultUserProvider implements UserProvider private function syncUser(User $user, Payload $payload): void { - if (!$this->config->get('oidcSync') && !$this->config->get('oidcSyncTeams')) { + if ( + !$this->configDataProvider->sync() && + !$this->configDataProvider->syncTeams() + ) { return; } diff --git a/application/Espo/Core/Authentication/Oidc/KeysProvider.php b/application/Espo/Core/Authentication/Oidc/KeysProvider.php index 03cfd35f25..41fb0f8275 100644 --- a/application/Espo/Core/Authentication/Oidc/KeysProvider.php +++ b/application/Espo/Core/Authentication/Oidc/KeysProvider.php @@ -44,25 +44,15 @@ use stdClass; class KeysProvider { private const CACHE_KEY = 'oidcJwks'; - private const CACHE_PERIOD = '10 minutes'; private const REQUEST_TIMEOUT = 10; - private DataCache $dataCache; - private Config $config; - private KeyFactory $factory; - private Log $log; - public function __construct( - DataCache $dataCache, - Config $config, - KeyFactory $factory, - Log $log - ) { - $this->dataCache = $dataCache; - $this->config = $config; - $this->factory = $factory; - $this->log = $log; - } + private DataCache $dataCache, + private Config $config, + private ConfigDataProvider $configDataProvider, + private KeyFactory $factory, + private Log $log + ) {} /** * @return Key[] @@ -106,8 +96,7 @@ class KeysProvider */ private function load(): array { - /** @var ?string $endpoint */ - $endpoint = $this->config->get('oidcJwksEndpoint'); + $endpoint = $this->configDataProvider->getJwksEndpoint(); if (!$endpoint) { throw new RuntimeException("JSON Web Key Set endpoint not specified in settings."); @@ -145,7 +134,7 @@ class KeysProvider try { $parsedResponse = Json::decode($response); } - catch (JsonException $e) {} + catch (JsonException) {} if (!$parsedResponse instanceof stdClass || !isset($parsedResponse->keys)) { throw new RuntimeException("OIDC: JWKS bad response."); @@ -180,7 +169,7 @@ class KeysProvider return null; } - $period = '-' . ($this->config->get('oidcJwksCachePeriod') ?? self::CACHE_PERIOD); + $period = '-' . $this->configDataProvider->getJwksCachePeriod(); if ($timestamp < DateTime::createNow()->modify($period)->getTimestamp()) { return null; diff --git a/application/Espo/Core/Authentication/Oidc/Login.php b/application/Espo/Core/Authentication/Oidc/Login.php index 7ce7521e94..e55f86e714 100644 --- a/application/Espo/Core/Authentication/Oidc/Login.php +++ b/application/Espo/Core/Authentication/Oidc/Login.php @@ -30,6 +30,7 @@ namespace Espo\Core\Authentication\Oidc; use Espo\Core\Api\Request; +use Espo\Core\ApplicationState; use Espo\Core\Authentication\Login as LoginInterface; use Espo\Core\Authentication\Login\Data; use Espo\Core\Authentication\Jwt\Token; @@ -39,7 +40,6 @@ use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified; use Espo\Core\Authentication\Jwt\Validator; use Espo\Core\Authentication\Result; use Espo\Core\Authentication\Result\FailReason; -use Espo\Core\Utils\Config; use Espo\Core\Utils\Json; use Espo\Core\Utils\Log; use JsonException; @@ -57,11 +57,12 @@ class Login implements LoginInterface public function __construct( private Espo $espoLogin, - private Config $config, private Log $log, + private ConfigDataProvider $configDataProvider, private Validator $validator, private TokenValidator $tokenValidator, - private UserProvider $userProvider + private UserProvider $userProvider, + private ApplicationState $applicationState ) {} public function login(Data $data, Request $request): Result @@ -81,13 +82,10 @@ class Login implements LoginInterface private function loginWithCode(string $code, Request $request): Result { - /** @var ?string $endpoint */ - $endpoint = $this->config->get('oidcTokenEndpoint'); - /** @var ?string $clientId */ - $clientId = $this->config->get('oidcClientId'); - /** @var ?string $clientSecret */ - $clientSecret = $this->config->get('oidcClientSecret'); - $redirectUri = rtrim($this->config->get('siteUrl'), '/') . '/oauth-callback.php'; + $endpoint = $this->configDataProvider->getTokenEndpoint(); + $clientId = $this->configDataProvider->getClientId(); + $clientSecret = $this->configDataProvider->getClientSecret(); + $redirectUri = $this->configDataProvider->getRedirectUri(); if (!$endpoint) { throw new RuntimeException("No token endpoint."); @@ -161,7 +159,14 @@ class Login implements LoginInterface { if ( !$data->getAuthToken() && - !$this->config->get('oidcFallback') + !$this->configDataProvider->fallback() + ) { + return Result::fail(FailReason::METHOD_NOT_ALLOWED); + } + + if ( + !$data->getAuthToken() && + $this->applicationState->isPortal() ) { return Result::fail(FailReason::METHOD_NOT_ALLOWED); } @@ -170,16 +175,27 @@ class Login implements LoginInterface $user = $result->getUser(); + if (!$user) { + return $result; + } + + if ($data->getAuthToken()) { + // Allow fallback when logged by auth token. + return $result; + } + if ( - !$data->getAuthToken() && - $user && $user->isRegular() && - !$this->config->get('oidcAllowRegularUserFallback') + !$this->configDataProvider->allowRegularUserFallback() // Portal users are allowed. ) { return Result::fail(FailReason::METHOD_NOT_ALLOWED); } + if ($user->isPortal()) { + return Result::fail(FailReason::METHOD_NOT_ALLOWED); + } + return $result; } @@ -243,7 +259,7 @@ class Login implements LoginInterface try { $parsedResponse = Json::decode($response); } - catch (JsonException $e) {} + catch (JsonException) {} if (!$parsedResponse instanceof stdClass) { $this->log->error(self::composeLogMessage('Bad token response.', $status, $response)); diff --git a/application/Espo/Core/Authentication/Oidc/Logout.php b/application/Espo/Core/Authentication/Oidc/Logout.php index 34e9d8a4c2..9da230db6e 100644 --- a/application/Espo/Core/Authentication/Oidc/Logout.php +++ b/application/Espo/Core/Authentication/Oidc/Logout.php @@ -37,31 +37,23 @@ use Espo\Core\Utils\Config; class Logout implements LogoutInterface { - private Config $config; - - public function __construct(Config $config) - { - $this->config = $config; - } + public function __construct( + private Config $config, + private ConfigDataProvider $configDataProvider + ) {} public function logout(AuthToken $authToken, Params $params): Result { - if ($authToken->getPortalId()) { - return Result::create(); - } - - /** @var ?string $url */ - $url = $this->config->get('oidcLogoutUrl'); - /** @var string $oidcClientId */ - $oidcClientId = $this->config->get('oidcClientId') ?? ''; + $url = $this->configDataProvider->getLogoutUrl(); + $clientId = $this->configDataProvider->getClientId() ?? ''; $siteUrl = rtrim($this->config->get('siteUrl') ?? '', '/'); if ($url) { - $url = str_replace('{clientId}', urlencode($oidcClientId), $url); + $url = str_replace('{clientId}', urlencode($clientId), $url); $url = str_replace('{siteUrl}', urlencode($siteUrl), $url); } - // @todo Check session is set if auth token to bypass fallback logins. + // @todo Check session is set in auth token to bypass fallback logins. return Result::create()->withRedirectUrl($url); } diff --git a/application/Espo/Core/Authentication/Oidc/Sync.php b/application/Espo/Core/Authentication/Oidc/Sync.php index c3d8634aeb..e356705d95 100644 --- a/application/Espo/Core/Authentication/Oidc/Sync.php +++ b/application/Espo/Core/Authentication/Oidc/Sync.php @@ -30,7 +30,9 @@ namespace Espo\Core\Authentication\Oidc; use Espo\Core\Acl\Cache\Clearer as AclCacheClearer; +use Espo\Core\ApplicationState; use Espo\Core\Authentication\Jwt\Token\Payload; +use Espo\Core\Field\LinkMultiple; use Espo\Core\FieldProcessing\EmailAddress\Saver as EmailAddressSaver; use Espo\Core\FieldProcessing\PhoneNumber\Saver as PhoneNumberSaver; use Espo\Core\FieldProcessing\Relation\LinkMultipleSaver; @@ -42,35 +44,20 @@ use Espo\Core\Utils\Util; use Espo\Entities\User; use Espo\ORM\EntityManager; use RuntimeException; -use stdClass; class Sync { - private EntityManager $entityManager; - private Config $config; - private LinkMultipleSaver $linkMultipleSaver; - private EmailAddressSaver $emailAddressSaver; - private PhoneNumberSaver $phoneNumberSaver; - private PasswordHash $passwordHash; - private AclCacheClearer $aclCacheClearer; - public function __construct( - EntityManager $entityManager, - Config $config, - LinkMultipleSaver $linkMultipleSaver, - EmailAddressSaver $emailAddressSaver, - PhoneNumberSaver $phoneNumberSaver, - PasswordHash $passwordHash, - AclCacheClearer $aclCacheClearer - ) { - $this->entityManager = $entityManager; - $this->config = $config; - $this->linkMultipleSaver = $linkMultipleSaver; - $this->emailAddressSaver = $emailAddressSaver; - $this->phoneNumberSaver = $phoneNumberSaver; - $this->passwordHash = $passwordHash; - $this->aclCacheClearer = $aclCacheClearer; - } + private EntityManager $entityManager, + private Config $config, + private ConfigDataProvider $configDataProvider, + private LinkMultipleSaver $linkMultipleSaver, + private EmailAddressSaver $emailAddressSaver, + private PhoneNumberSaver $phoneNumberSaver, + private PasswordHash $passwordHash, + private AclCacheClearer $aclCacheClearer, + private ApplicationState $applicationState + ) {} public function createUser(Payload $payload): User { @@ -78,6 +65,7 @@ class Sync $this->validateUsername($username); + /** @var User $user */ $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getNew(); $user->set([ @@ -89,6 +77,13 @@ class Sync $user->set($this->getUserDataFromToken($payload)); $user->set($this->getUserTeamsDataFromToken($payload)); + if ($this->applicationState->isPortal()) { + $portalId = $this->applicationState->getPortalId(); + + $user->set('type', User::TYPE_PORTAL); + $user->setPortals(LinkMultiple::create()->withAddedId($portalId)); + } + $this->saveUser($user); return $user; @@ -104,13 +99,13 @@ class Sync throw new RuntimeException("Could not sync user. Username mismatch."); } - if ($this->config->get('oidcSync')) { + if ($this->configDataProvider->sync()) { $user->set($this->getUserDataFromToken($payload)); } $clearAclCache = false; - if ($this->config->get('oidcSyncTeams')) { + if ($this->configDataProvider->syncTeams()) { $user->loadLinkMultipleField('teams'); $user->set($this->getUserTeamsDataFromToken($payload)); @@ -180,7 +175,7 @@ class Sync private function getUsernameFromToken(Payload $payload): string { - $usernameClaim = $this->config->get('oidcUsernameClaim'); + $usernameClaim = $this->configDataProvider->getUsernameClaim(); if (!$usernameClaim) { throw new RuntimeException("No OIDC username claim in config."); @@ -204,10 +199,8 @@ class Sync */ private function getTeamIdList(Payload $payload): array { - /** @var string[] $idList */ - $idList = $this->config->get('oidcTeamsIds') ?? []; - /** @var stdClass $columns */ - $columns = $this->config->get('oidcTeamsColumns') ?? (object) []; + $idList = $this->configDataProvider->getTeamIds() ?? []; + $columns = $this->configDataProvider->getTeamColumns() ?? (object) []; if ($idList === []) { return []; @@ -233,8 +226,7 @@ class Sync */ private function getGroups(Payload $payload): array { - /** @var ?string $groupClaim */ - $groupClaim = $this->config->get('oidcGroupClaim'); + $groupClaim = $this->configDataProvider->getGroupClaim(); if (!$groupClaim) { return []; diff --git a/application/Espo/Core/Authentication/Oidc/TokenValidator.php b/application/Espo/Core/Authentication/Oidc/TokenValidator.php index 098010eaaa..2886ccc3a8 100644 --- a/application/Espo/Core/Authentication/Oidc/TokenValidator.php +++ b/application/Espo/Core/Authentication/Oidc/TokenValidator.php @@ -33,21 +33,14 @@ use Espo\Core\Authentication\Jwt\Exceptions\Invalid; use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified; use Espo\Core\Authentication\Jwt\SignatureVerifierFactory; use Espo\Core\Authentication\Jwt\Token; -use Espo\Core\Utils\Config; use RuntimeException; class TokenValidator { - private Config $config; - private SignatureVerifierFactory $signatureVerifierFactory; - public function __construct( - Config $config, - SignatureVerifierFactory $signatureVerifierFactory - ) { - $this->config = $config; - $this->signatureVerifierFactory = $signatureVerifierFactory; - } + private ConfigDataProvider $configDataProvider, + private SignatureVerifierFactory $signatureVerifierFactory + ) {} /** * @throws SignatureNotVerified @@ -57,8 +50,7 @@ class TokenValidator { $algorithm = $token->getHeader()->getAlg(); - /** @var string[] $allowedAlgorithmList */ - $allowedAlgorithmList = $this->config->get('oidcJwtSignatureAlgorithmList') ?? []; + $allowedAlgorithmList = $this->configDataProvider->getJwtSignatureAlgorithmList(); if (!in_array($algorithm, $allowedAlgorithmList)) { throw new Invalid("JWT signing algorithm `{$algorithm}` not allowed."); @@ -76,8 +68,7 @@ class TokenValidator */ public function validateFields(Token $token): void { - /** @var ?string $oidcClientId */ - $oidcClientId = $this->config->get('oidcClientId'); + $oidcClientId = $this->configDataProvider->getClientId(); if (!$oidcClientId) { throw new RuntimeException("OIDC: No client ID."); diff --git a/application/Espo/Core/Authentication/Util/MethodProvider.php b/application/Espo/Core/Authentication/Util/MethodProvider.php new file mode 100644 index 0000000000..631783835c --- /dev/null +++ b/application/Espo/Core/Authentication/Util/MethodProvider.php @@ -0,0 +1,115 @@ +applicationState->isPortal()) { + $method = $this->getForPortal($this->applicationState->getPortal()); + + if ($method) { + return $method; + } + + return $this->getDefaultForPortal(); + } + + return $this->configDataProvider->getDefaultAuthenticationMethod(); + } + + /** + * Get an authentication method for portals. The method that is applied via the authentication provider link. + * If no provider, then returns null. + */ + public function getForPortal(Portal $portal): ?string + { + $providerId = $portal->getAuthenticationProvider()?->getId(); + + if (!$providerId) { + return null; + } + + /** @var ?AuthenticationProvider $provider */ + $provider = $this->entityManager->getEntityById(AuthenticationProvider::ENTITY_TYPE, $providerId); + + if (!$provider) { + throw new RuntimeException("No authentication provider for portal."); + } + + $method = $provider->getMethod(); + + if (!$method) { + throw new RuntimeException("No method in authentication provider."); + } + + return $method; + } + + /** + * Get a default authentication method for portals. Should be used if a portal does not have + * an authentication provider. + */ + private function getDefaultForPortal(): string + { + $method = $this->configDataProvider->getDefaultAuthenticationMethod(); + + $allow = $this->metadata->get(['authenticationMethods', $method, 'portalDefault']); + + if (!$allow) { + return Espo::NAME; + } + + return $method; + } +} diff --git a/application/Espo/Core/EntryPoint/EntryPointManager.php b/application/Espo/Core/EntryPoint/EntryPointManager.php index c5551cfbdd..36784d115d 100644 --- a/application/Espo/Core/EntryPoint/EntryPointManager.php +++ b/application/Espo/Core/EntryPoint/EntryPointManager.php @@ -30,28 +30,21 @@ namespace Espo\Core\EntryPoint; use Espo\Core\Exceptions\NotFound; - -use Espo\Core\{ - Exceptions\NotFoundSilent, - InjectableFactory, - Utils\ClassFinder, - Api\Request, - Api\Response, -}; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Exceptions\NotFoundSilent; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\ClassFinder; /** * Runs entry points. */ class EntryPointManager { - private InjectableFactory $injectableFactory; - private ClassFinder $classFinder; - - public function __construct(InjectableFactory $injectableFactory, ClassFinder $classFinder) - { - $this->injectableFactory = $injectableFactory; - $this->classFinder = $classFinder; - } + public function __construct( + private InjectableFactory $injectableFactory, + private ClassFinder $classFinder + ) {} /** * @throws NotFound @@ -78,20 +71,6 @@ class EntryPointManager return $className::$authRequired ?? true; } - /** - * @throws NotFound - */ - public function checkNotStrictAuth(string $name): bool - { - $className = $this->getClassName($name); - - if (!$className) { - throw new NotFoundSilent("Entry point '{$name}' not found."); - } - - return $className::$notStrictAuth ?? false; - } - /** * @throws NotFound */ diff --git a/application/Espo/Core/EntryPoint/Starter.php b/application/Espo/Core/EntryPoint/Starter.php index c8c08a44bc..d1d7ad5fd3 100644 --- a/application/Espo/Core/EntryPoint/Starter.php +++ b/application/Espo/Core/EntryPoint/Starter.php @@ -67,7 +67,6 @@ class Starter /** * @throws BadRequest - * @throws NotFound */ public function start(?string $entryPoint = null, bool $final = false): void { @@ -92,7 +91,6 @@ class Starter try { $authRequired = $this->entryPointManager->checkAuthRequired($entryPoint); - $authNotStrict = $this->entryPointManager->checkNotStrictAuth($entryPoint); } catch (NotFound $exception) { $this->errorOutput->processWithBodyPrinting($requestWrapped, $responseWrapped, $exception); @@ -102,7 +100,7 @@ class Starter return; } - if ($authRequired && !$authNotStrict && !$final) { + if ($authRequired && !$final) { $portalId = $this->detectPortalId($requestWrapped); if ($portalId) { @@ -116,8 +114,7 @@ class Starter $entryPoint, $requestWrapped, $responseWrapped, - $authRequired, - $authNotStrict + $authRequired ); (new ResponseEmitter())->emit($responseWrapped->getResponse()); @@ -127,8 +124,7 @@ class Starter string $entryPoint, RequestWrapper $requestWrapped, ResponseWrapper $responseWrapped, - bool $authRequired, - bool $authNotStrict + bool $authRequired ): void { try { @@ -136,8 +132,7 @@ class Starter $entryPoint, $requestWrapped, $responseWrapped, - $authRequired, - $authNotStrict + $authRequired ); } catch (Exception $exception) { @@ -146,19 +141,17 @@ class Starter } /** - * @throws \Espo\Core\Exceptions\NotFound + * @throws NotFound + * @throws BadRequest */ private function processRequestInternal( string $entryPoint, RequestWrapper $request, ResponseWrapper $response, - bool $authRequired, - bool $authNotStrict + bool $authRequired ): void { - $authentication = $authNotStrict ? - $this->authenticationFactory->createWithAnyAccessAllowed() : - $this->authenticationFactory->create(); + $authentication = $this->authenticationFactory->create(); $apiAuth = $this->authBuilderFactory ->create() diff --git a/application/Espo/Core/EntryPoint/Traits/NotStrictAuth.php b/application/Espo/Core/EntryPoint/Traits/NotStrictAuth.php index 9dc071ae75..e6209201fd 100644 --- a/application/Espo/Core/EntryPoint/Traits/NotStrictAuth.php +++ b/application/Espo/Core/EntryPoint/Traits/NotStrictAuth.php @@ -30,7 +30,7 @@ namespace Espo\Core\EntryPoint\Traits; /** - * To be used with EntryPoint interface. + * @deprecated */ trait NotStrictAuth { diff --git a/application/Espo/Core/EntryPoints/NotStrictAuth.php b/application/Espo/Core/EntryPoints/NotStrictAuth.php index 2866d00f9f..89aec906ce 100644 --- a/application/Espo/Core/EntryPoints/NotStrictAuth.php +++ b/application/Espo/Core/EntryPoints/NotStrictAuth.php @@ -30,7 +30,7 @@ namespace Espo\Core\EntryPoints; /** - * @deprecated Use `Espo\Core\EntryPoint\Traits\NotStrictAuth` instead. + * @deprecated */ trait NotStrictAuth { diff --git a/application/Espo/Entities/AuthenticationProvider.php b/application/Espo/Entities/AuthenticationProvider.php new file mode 100644 index 0000000000..cc440f7774 --- /dev/null +++ b/application/Espo/Entities/AuthenticationProvider.php @@ -0,0 +1,42 @@ +get('method'); + } +} diff --git a/application/Espo/Entities/Portal.php b/application/Espo/Entities/Portal.php index f27e2b0bdf..74c7cd9305 100644 --- a/application/Espo/Entities/Portal.php +++ b/application/Espo/Entities/Portal.php @@ -29,6 +29,8 @@ namespace Espo\Entities; +use Espo\Core\Field\Link; + class Portal extends \Espo\Core\ORM\Entity { public const ENTITY_TYPE = 'Portal'; @@ -64,4 +66,10 @@ class Portal extends \Espo\Core\ORM\Entity { return $this->get('url'); } + + public function getAuthenticationProvider(): ?Link + { + /** @var ?Link */ + return $this->getValueObject('authenticationProvider'); + } } diff --git a/application/Espo/Entities/User.php b/application/Espo/Entities/User.php index a56ae18d53..180b765bc9 100644 --- a/application/Espo/Entities/User.php +++ b/application/Espo/Entities/User.php @@ -172,6 +172,19 @@ class User extends Person return $this; } + public function getPortals(): LinkMultiple + { + /** @var LinkMultiple */ + return $this->getValueObject('portals'); + } + + public function setPortals(LinkMultiple $portals): self + { + $this->setValueObject('portals', $portals); + + return $this; + } + public function setRoles(LinkMultiple $roles): self { $this->setValueObject('roles', $roles); diff --git a/application/Espo/EntryPoints/Avatar.php b/application/Espo/EntryPoints/Avatar.php index f286a5eb00..616e0d4d76 100644 --- a/application/Espo/EntryPoints/Avatar.php +++ b/application/Espo/EntryPoints/Avatar.php @@ -32,7 +32,6 @@ namespace Espo\EntryPoints; use Espo\Core\ApplicationUser; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Error; -use Espo\Core\EntryPoints\NotStrictAuth; use Espo\Core\Api\Request; use Espo\Core\Api\Response; use Espo\Core\Exceptions\ForbiddenSilent; @@ -44,8 +43,6 @@ use Identicon\Identicon; class Avatar extends Image { - use NotStrictAuth; - protected string $systemColor = '#a4b5bd'; /** @var array */ diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index f16cab48ed..2937a22972 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -77,6 +77,7 @@ "Layout Sets": "Layout Sets", "Working Time Calendars": "Working Time Calendars", "Group Email Folders": "Group Email Folders", + "Authentication Providers": "Authentication Providers", "Success": "Success", "Fail": "Fail", "Configuration Instructions": "Configuration Instructions", @@ -284,6 +285,7 @@ "systemRequirements": "System Requirements for EspoCRM.", "apiUsers": "Separate users for integration purposes.", "webhooks": "Manage webhooks.", + "authenticationProviders": "Additional authentication providers for portals.", "emailAddresses": "All email addresses stored in the system.", "phoneNumbers": "All phone numbers stored in the system.", "dashboardTemplates": "Deploy dashboards to users.", diff --git a/application/Espo/Resources/i18n/en_US/AuthenticationProvider.json b/application/Espo/Resources/i18n/en_US/AuthenticationProvider.json new file mode 100644 index 0000000000..887a94cd1d --- /dev/null +++ b/application/Espo/Resources/i18n/en_US/AuthenticationProvider.json @@ -0,0 +1,8 @@ +{ + "fields": { + "method": "Method" + }, + "labels": { + "Create AuthenticationProvider": "Create Provider" + } +} diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index d18b62d32b..b743b78a23 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -58,7 +58,8 @@ "Webhook": "Webhook", "Mass Action": "Mass Action", "WorkingTimeCalendar": "Working Time Calendar", - "WorkingTimeRange": "Working Time Range" + "WorkingTimeRange": "Working Time Range", + "AuthenticationProvider": "Authentication Provider" }, "scopeNamesPlural": { "Note": "Notes", @@ -107,7 +108,8 @@ "LayoutSet": "Layout Sets", "Webhook": "Webhooks", "WorkingTimeCalendar": "Working Time Calendars", - "WorkingTimeRange": "Working Time Ranges" + "WorkingTimeRange": "Working Time Ranges", + "AuthenticationProvider": "Authentication Providers" }, "labels": { "Sort": "Sort", diff --git a/application/Espo/Resources/i18n/en_US/Portal.json b/application/Espo/Resources/i18n/en_US/Portal.json index dd7330c6b5..0f09299a1a 100644 --- a/application/Espo/Resources/i18n/en_US/Portal.json +++ b/application/Espo/Resources/i18n/en_US/Portal.json @@ -18,6 +18,7 @@ "weekStart": "First Day of Week", "defaultCurrency": "Default Currency", "layoutSet": "Layout Set", + "authenticationProvider": "Authentication Provider", "customUrl": "Custom URL", "customId": "Custom ID" }, @@ -25,6 +26,7 @@ "users": "Users", "portalRoles": "Roles", "layoutSet": "Layout Set", + "authenticationProvider": "Authentication Provider", "notes": "Notes" }, "tooltips": { diff --git a/application/Espo/Resources/layouts/AuthenticationProvider/detail.json b/application/Espo/Resources/layouts/AuthenticationProvider/detail.json new file mode 100644 index 0000000000..160883d671 --- /dev/null +++ b/application/Espo/Resources/layouts/AuthenticationProvider/detail.json @@ -0,0 +1,14 @@ +[ + { + "rows": [ + [ + {"name": "name"}, + false + ], + [ + {"name": "method"}, + false + ] + ] + } +] diff --git a/application/Espo/Resources/layouts/AuthenticationProvider/list.json b/application/Espo/Resources/layouts/AuthenticationProvider/list.json new file mode 100644 index 0000000000..3974aec6b4 --- /dev/null +++ b/application/Espo/Resources/layouts/AuthenticationProvider/list.json @@ -0,0 +1,10 @@ +[ + { + "name":"name", + "link": true + }, + { + "name":"method", + "width": 45 + } +] diff --git a/application/Espo/Resources/layouts/Portal/detail.json b/application/Espo/Resources/layouts/Portal/detail.json index cb0fb341c7..ecb9fdca08 100644 --- a/application/Espo/Resources/layouts/Portal/detail.json +++ b/application/Espo/Resources/layouts/Portal/detail.json @@ -5,7 +5,7 @@ [{"name": "name"}, {"name": "isActive"}], [{"name": "url"}, {"name": "isDefault"}], [{"name": "portalRoles"}, {"name": "customId"}], - [false, {"name": "customUrl"}] + [{"name": "authenticationProvider"}, {"name": "customUrl"}] ] }, { @@ -25,4 +25,4 @@ [{"name": "dashboardLayout", "fullWidth": true}] ] } -] \ No newline at end of file +] diff --git a/application/Espo/Resources/metadata/app/adminPanel.json b/application/Espo/Resources/metadata/app/adminPanel.json index c449b0edb2..44e5f1f5c3 100644 --- a/application/Espo/Resources/metadata/app/adminPanel.json +++ b/application/Espo/Resources/metadata/app/adminPanel.json @@ -310,6 +310,12 @@ "iconClass": "fas fa-list-ul", "description": "jobs" }, + { + "url": "#Admin/authenticationProviders", + "label": "Authentication Providers", + "iconClass": "fas fa-sign-in-alt", + "description": "authenticationProviders" + }, { "url": "#Admin/emailAddresses", "label": "Email Addresses", diff --git a/application/Espo/Resources/metadata/authenticationMethods/Espo.json b/application/Espo/Resources/metadata/authenticationMethods/Espo.json index 6e2ea09226..cc241ae63b 100644 --- a/application/Espo/Resources/metadata/authenticationMethods/Espo.json +++ b/application/Espo/Resources/metadata/authenticationMethods/Espo.json @@ -1,4 +1,5 @@ { + "portalDefault": true, "settings": { "isAvailable": true } diff --git a/application/Espo/Resources/metadata/authenticationMethods/LDAP.json b/application/Espo/Resources/metadata/authenticationMethods/LDAP.json index 68c24dff82..1fff0d57f2 100644 --- a/application/Espo/Resources/metadata/authenticationMethods/LDAP.json +++ b/application/Espo/Resources/metadata/authenticationMethods/LDAP.json @@ -1,5 +1,6 @@ { "implementationClassName": "Espo\\Core\\Authentication\\Ldap\\LdapLogin", + "portalDefault": true, "settings": { "isAvailable": true, "layout": { diff --git a/application/Espo/Resources/metadata/authenticationMethods/Oidc.json b/application/Espo/Resources/metadata/authenticationMethods/Oidc.json index 45ed7d4d1b..3df347eea3 100644 --- a/application/Espo/Resources/metadata/authenticationMethods/Oidc.json +++ b/application/Espo/Resources/metadata/authenticationMethods/Oidc.json @@ -5,6 +5,9 @@ "handler": "handlers/login/oidc", "fallbackConfigParam": "oidcFallback" }, + "provider": { + "isAvailable": true + }, "settings": { "isAvailable": true, "layout": { diff --git a/application/Espo/Resources/metadata/clientDefs/AuthenticationProvider.json b/application/Espo/Resources/metadata/clientDefs/AuthenticationProvider.json new file mode 100644 index 0000000000..7f0a16538d --- /dev/null +++ b/application/Espo/Resources/metadata/clientDefs/AuthenticationProvider.json @@ -0,0 +1,13 @@ +{ + "controller": "controllers/record", + "recordViews": { + "detail": "views/authentication-provider/record/detail", + "edit": "views/authentication-provider/record/edit" + }, + "searchPanelDisabled": true, + "inlineEditDisabled": true, + "duplicateDisabled": true, + "massUpdateDisabled": true, + "massRemoveDisabled": true, + "mergeDisabled": true +} diff --git a/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json b/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json new file mode 100644 index 0000000000..85404ca8f5 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json @@ -0,0 +1,75 @@ +{ + "fields": { + "name": { + "type": "varchar", + "required": true + }, + "method": { + "type": "enum", + "view": "views/authentication-provider/fields/method", + "translation": "Settings.options.authenticationMethod", + "required": true, + "validatorClassNameMap": { + "valid": "Espo\\Classes\\FieldValidators\\AuthenticationProvider\\MethodValid" + } + }, + "oidcAuthorizationRedirectUri": { + "type": "varchar", + "notStorable": true, + "readOnly": true + }, + "oidcClientId": { + "type": "varchar" + }, + "oidcClientSecret": { + "type": "password" + }, + "oidcAuthorizationEndpoint": { + "type": "url", + "strip": false + }, + "oidcTokenEndpoint": { + "type": "url", + "strip": false + }, + "oidcJwksEndpoint": { + "type": "url", + "strip": false + }, + "oidcJwtSignatureAlgorithmList": { + "type": "multiEnum", + "optionsPath": "entityDefs.Settings.fields.oidcJwtSignatureAlgorithmList.options", + "default": [ + "RS256" + ] + }, + "oidcScopes": { + "type": "multiEnum", + "allowCustomOptions": true, + "optionsPath": "entityDefs.Settings.fields.oidcScopes.options", + "default": [ + "profile", + "email", + "phone" + ] + }, + "oidcCreateUser": { + "type": "bool", + "tooltip": true + }, + "oidcUsernameClaim": { + "type": "varchar", + "optionsPath": "entityDefs.Settings.fields.oidcUsernameClaim.options", + "tooltip": true, + "default": "sub" + }, + "oidcSync": { + "type": "bool", + "tooltip": true + }, + "oidcLogoutUrl": { + "type": "varchar", + "tooltip": true + } + } +} diff --git a/application/Espo/Resources/metadata/entityDefs/Portal.json b/application/Espo/Resources/metadata/entityDefs/Portal.json index 94c8d6cb59..fcffeff81a 100644 --- a/application/Espo/Resources/metadata/entityDefs/Portal.json +++ b/application/Espo/Resources/metadata/entityDefs/Portal.json @@ -98,6 +98,9 @@ "type": "link", "tooltip": true }, + "authenticationProvider": { + "type": "link" + }, "modifiedAt": { "type": "datetime", "readOnly": true @@ -145,6 +148,10 @@ "type": "belongsTo", "entity": "LayoutSet", "foreign": "portals" + }, + "authenticationProvider": { + "type": "belongsTo", + "entity": "AuthenticationProvider" } }, "collection": { diff --git a/application/Espo/Resources/metadata/scopes/AuthenticationProvider.json b/application/Espo/Resources/metadata/scopes/AuthenticationProvider.json new file mode 100644 index 0000000000..fad789a560 --- /dev/null +++ b/application/Espo/Resources/metadata/scopes/AuthenticationProvider.json @@ -0,0 +1,4 @@ +{ + "entity": true, + "exportFormatList": ["csv"] +} diff --git a/application/Espo/Tools/App/SettingsService.php b/application/Espo/Tools/App/SettingsService.php index f793336b18..737fc5c27b 100644 --- a/application/Espo/Tools/App/SettingsService.php +++ b/application/Espo/Tools/App/SettingsService.php @@ -29,19 +29,19 @@ namespace Espo\Tools\App; -use Espo\Core\Authentication\Logins\Espo; use Espo\ORM\Entity; use Espo\ORM\EntityManager; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Authentication\Util\MethodProvider as AuthenticationMethodProvider; use Espo\Core\ApplicationState; use Espo\Core\Acl; use Espo\Core\InjectableFactory; - use Espo\Core\DataManager; use Espo\Core\FieldValidation\FieldValidationManager; use Espo\Core\Utils\Currency\DatabasePopulator as CurrencyDatabasePopulator; - use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Config; use Espo\Core\Utils\Config\ConfigWriter; @@ -54,40 +54,19 @@ use stdClass; class SettingsService { - private ApplicationState $applicationState; - private Config $config; - private ConfigWriter $configWriter; - private Metadata $metadata; - private Acl $acl; - private EntityManager $entityManager; - private DataManager $dataManager; - private FieldValidationManager $fieldValidationManager; - private InjectableFactory $injectableFactory; - private Access $access; - public function __construct( - ApplicationState $applicationState, - Config $config, - ConfigWriter $configWriter, - Metadata $metadata, - Acl $acl, - EntityManager $entityManager, - DataManager $dataManager, - FieldValidationManager $fieldValidationManager, - InjectableFactory $injectableFactory, - Access $access - ) { - $this->applicationState = $applicationState; - $this->config = $config; - $this->configWriter = $configWriter; - $this->metadata = $metadata; - $this->acl = $acl; - $this->entityManager = $entityManager; - $this->dataManager = $dataManager; - $this->fieldValidationManager = $fieldValidationManager; - $this->injectableFactory = $injectableFactory; - $this->access = $access; - } + private ApplicationState $applicationState, + private Config $config, + private ConfigWriter $configWriter, + private Metadata $metadata, + private Acl $acl, + private EntityManager $entityManager, + private DataManager $dataManager, + private FieldValidationManager $fieldValidationManager, + private InjectableFactory $injectableFactory, + private Access $access, + private AuthenticationMethodProvider $authenticationMethodProvider + ) {} public function getConfigData(): stdClass { @@ -133,7 +112,7 @@ class SettingsService */ private function getLoginData(): ?array { - $method = $this->config->get('authenticationMethod') ?? Espo::NAME; + $method = $this->authenticationMethodProvider->get(); /** @var array $mData */ $mData = $this->metadata->get(['authenticationMethods', $method, 'login']) ?? []; @@ -145,7 +124,9 @@ class SettingsService return null; } - if ($this->applicationState->isPortal()) { + $isProvider = $this->isPortalWithAuthenticationProvider(); + + if (!$isProvider && $this->applicationState->isPortal()) { /** @var ?bool $portal */ $portal = $mData['portal'] ?? null; @@ -173,6 +154,10 @@ class SettingsService $fallback = $fallbackConfigParam && $this->config->get($fallbackConfigParam); } + if ($isProvider) { + $fallback = false; + } + /** @var stdClass $data */ $data = (object) ($mData['data'] ?? []); @@ -184,10 +169,21 @@ class SettingsService ]; } + private function isPortalWithAuthenticationProvider(): bool + { + if (!$this->applicationState->isPortal()) { + return false; + } + + $portal = $this->applicationState->getPortal(); + + return (bool) $this->authenticationMethodProvider->getForPortal($portal); + } + /** - * @throws \Espo\Core\Exceptions\BadRequest + * @throws BadRequest * @throws Forbidden - * @throws \Espo\Core\Exceptions\Error + * @throws Error */ public function setConfigData(stdClass $data): void { @@ -372,7 +368,7 @@ class SettingsService } /** - * @throws \Espo\Core\Exceptions\BadRequest + * @throws BadRequest */ private function processValidation(Entity $entity, stdClass $data): void { diff --git a/application/Espo/Tools/Oidc/Service.php b/application/Espo/Tools/Oidc/Service.php index 430eb6989a..8acf710113 100644 --- a/application/Espo/Tools/Oidc/Service.php +++ b/application/Espo/Tools/Oidc/Service.php @@ -30,30 +30,27 @@ namespace Espo\Tools\Oidc; use Espo\Core\Authentication\Jwt\Exceptions\Invalid; +use Espo\Core\Authentication\Oidc\ConfigDataProvider; use Espo\Core\Authentication\Oidc\Login as OidcLogin; use Espo\Core\Authentication\Oidc\BackchannelLogout; +use Espo\Core\Authentication\Util\MethodProvider; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Forbidden; -use Espo\Core\Exceptions\ForbiddenSilent; -use Espo\Core\Utils\Config; use Espo\Core\Utils\Json; class Service { - private Config $config; - private BackchannelLogout $backchannelLogout; - - public function __construct(Config $config, BackchannelLogout $backchannelLogout) - { - $this->config = $config; - $this->backchannelLogout = $backchannelLogout; - } + public function __construct( + private BackchannelLogout $backchannelLogout, + private MethodProvider $methodProvider, + private ConfigDataProvider $configDataProvider + ) {} /** * @return array{ * clientId: non-empty-string, * endpoint: non-empty-string, - * redirectUri: non-empty-string, + * redirectUri: string, * scopes: non-empty-array, * claims: ?string, * prompt: 'login'|'consent'|'select_account', @@ -64,19 +61,15 @@ class Service */ public function getAuthorizationData(): array { - if ($this->config->get('authenticationMethod') !== OidcLogin::NAME) { - throw new ForbiddenSilent(); + if ($this->methodProvider->get() !== OidcLogin::NAME) { + throw new Forbidden(); } - /** @var ?string $clientId */ - $clientId = $this->config->get('oidcClientId'); - /** @var ?string $endpoint */ - $endpoint = $this->config->get('oidcAuthorizationEndpoint'); - /** @var string[] $scopes */ - $scopes = $this->config->get('oidcScopes') ?? []; - - /** @var ?string $groupClaim */ - $groupClaim = $this->config->get('oidcGroupClaim'); + $clientId = $this->configDataProvider->getClientId(); + $endpoint = $this->configDataProvider->getAuthorizationEndpoint(); + $scopes = $this->configDataProvider->getScopes(); + $groupClaim = $this->configDataProvider->getGroupClaim(); + $redirectUri = $this->configDataProvider->getRedirectUri(); if (!$clientId) { throw new Error("No client ID."); @@ -86,8 +79,6 @@ class Service throw new Error("No authorization endpoint."); } - $redirectUri = rtrim($this->config->get('siteUrl') ?? '', '/') . '/oauth-callback.php'; - array_unshift($scopes, 'openid'); $claims = null; @@ -101,9 +92,8 @@ class Service } /** @var 'login'|'consent'|'select_account' $prompt */ - $prompt = $this->config->get('oidcAuthorizationPrompt') ?? 'consent'; - /** @var ?int $maxAge */ - $maxAge = $this->config->get('oidcAuthorizationMaxAge'); + $prompt = $this->configDataProvider->getAuthorizationPrompt(); + $maxAge = $this->configDataProvider->getAuthorizationMaxAge(); return [ 'clientId' => $clientId, @@ -117,19 +107,19 @@ class Service } /** - * @throws ForbiddenSilent + * @throws Forbidden */ public function backchannelLogout(string $rawToken): void { - if ($this->config->get('authenticationMethod') !== OidcLogin::NAME) { - throw new ForbiddenSilent(); + if ($this->methodProvider->get() !== OidcLogin::NAME) { + throw new Forbidden(); } try { $this->backchannelLogout->logout($rawToken); } catch (Invalid $e) { - throw new ForbiddenSilent("OIDC logout: Invalid JWT. " . $e->getMessage()); + throw new Forbidden("OIDC logout: Invalid JWT. " . $e->getMessage()); } } } diff --git a/client/src/controllers/admin.js b/client/src/controllers/admin.js index 84f33e9714..7d376e78f2 100644 --- a/client/src/controllers/admin.js +++ b/client/src/controllers/admin.js @@ -197,6 +197,10 @@ function (Dep, /** typeof module:search-manager.Class */SearchManager, _) { this.getRouter().dispatch('Attachment', 'list', {fromAdmin: true}); }, + actionAuthenticationProviders: function () { + this.getRouter().dispatch('AuthenticationProvider', 'list', {fromAdmin: true}); + }, + actionEmailAddresses: function () { this.getRouter().dispatch('EmailAddress', 'list', {fromAdmin: true}); }, diff --git a/client/src/helpers/misc/authentication-provider.js b/client/src/helpers/misc/authentication-provider.js new file mode 100644 index 0000000000..5ef89f0726 --- /dev/null +++ b/client/src/helpers/misc/authentication-provider.js @@ -0,0 +1,247 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * 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 General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('helpers/misc/authentication-provider', [], function () { + + /** + * @memberOf module:helpers/misc/authentication-provider + */ + class Class { + /** + * @param {module:views/record/detail.Class} view A view. + */ + constructor(view) { + /** + * @private + * @type {module:views/record/detail.Class} + */ + this.view = view; + + this.metadata = view.getMetadata(); + + /** + * @private + * @type {module:model.Class} + */ + this.model = view.model; + + /** @var {Object.>} defs */ + let defs = view.getMetadata().get(['authenticationMethods']) || {}; + + /** + * @private + * @type {string[]} + */ + this.methodList = Object.keys(defs).filter(item => { + /** @var {Object.} */ + let data = defs[item].provider || {}; + + return data.isAvailable; + }); + + /** @private */ + this.authFields = {}; + + /** @private */ + this.dynamicLogicDefs = { + fields: {}, + panels: {}, + }; + } + + /** + * + * @param {function(): void} callback + */ + setupPanelsVisibility(callback) { + this.handlePanelsVisibility(callback); + + this.view.listenTo(this.model, 'change:method', () => this.handlePanelsVisibility(callback)); + } + + /** + * @private + * @param {string} method + * @param {string} param + * @return {*} + */ + getFromMetadata(method, param) { + return this.metadata + .get(['authenticationMethods', method, 'provider', param]) || + this.metadata + .get(['authenticationMethods', method, 'settings', param]); + } + + /** + * @return {Object} + */ + setupMethods() { + this.methodList.forEach(method => this.setupMethod(method)); + + return this.dynamicLogicDefs; + } + + /** + * @private + */ + setupMethod(method) { + /** @var {string[]} */ + let fieldList = this.getFromMetadata(method, 'fieldList') || []; + + fieldList = fieldList.filter(item => this.model.hasField(item)); + + this.authFields[method] = fieldList; + + let mDynamicLogicFieldsDefs = (this.getFromMetadata(method, 'dynamicLogic') || {}).fields || {}; + + for (let f in mDynamicLogicFieldsDefs) { + if (!fieldList.includes(f)) { + continue; + } + + let defs = this.modifyDynamicLogic(mDynamicLogicFieldsDefs[f]); + + this.dynamicLogicDefs.fields[f] = Espo.Utils.cloneDeep(defs); + } + } + + /** + * @private + */ + modifyDynamicLogic(defs) { + defs = Espo.Utils.clone(defs); + + if (Array.isArray(defs)) { + return defs.map(item => this.modifyDynamicLogic(item)); + } + + if (typeof defs === 'object') { + let o = {}; + + for (let property in defs) { + let value = defs[property]; + + if (property === 'attribute' && value === 'authenticationMethod') { + value = 'method'; + } + + o[property] = this.modifyDynamicLogic(value); + } + + return o; + } + + return defs; + } + + modifyDetailLayout(layout) { + this.methodList.forEach(method => { + let mLayout = this.getFromMetadata(method, 'layout'); + + if (!mLayout) { + return; + } + + mLayout = Espo.Utils.cloneDeep(mLayout); + mLayout.name = method; + + this.prepareLayout(mLayout, method); + + layout.push(mLayout); + }); + } + + prepareLayout(layout, method) { + layout.rows.forEach(row => { + row + .filter(item => !item.noLabel && !item.labelText && item.name) + .forEach(item => { + if (item === null) { + return; + } + + let labelText = this.view.translate(item.name, 'fields', 'Settings'); + + item.options = item.options || {}; + + if (labelText && labelText.toLowerCase().indexOf(method.toLowerCase() + ' ') === 0) { + item.labelText = labelText.substring(method.length + 1); + } + + item.options.tooltipText = this.view.translate(item.name, 'tooltips', 'Settings'); + }); + }); + + layout.rows = layout.rows.map(row => { + row = row.map(cell => { + if ( + cell && + cell.name && + !this.model.hasField(cell.name) + ) { + return false; + } + + return cell; + }) + + return row; + }); + } + + /** + * @private + * @param {function(): void} callback + */ + handlePanelsVisibility(callback) { + let authenticationMethod = this.model.get('method'); + + this.methodList.forEach(method => { + let fieldList = (this.authFields[method] || []); + + if (method !== authenticationMethod) { + this.view.hidePanel(method); + + fieldList.forEach(field => { + this.view.hideField(field); + }); + + return; + } + + this.view.showPanel(method); + + fieldList.forEach(field => this.view.showField(field)); + + callback(); + }); + } + } + + return Class; +}); diff --git a/client/src/views/authentication-provider/fields/method.js b/client/src/views/authentication-provider/fields/method.js new file mode 100644 index 0000000000..c66c197913 --- /dev/null +++ b/client/src/views/authentication-provider/fields/method.js @@ -0,0 +1,50 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * 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 General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of tтhe "EspoCRM" word. + ************************************************************************/ + +define('views/authentication-provider/fields/method', ['views/fields/enum'], function (Dep) { + + return Dep.extend({ + + setupOptions: function () { + /** @var {Object.>} defs */ + let defs = this.getMetadata().get(['authenticationMethods']) || {}; + + let options = Object.keys(defs) + .filter(item => { + /** @var {Object.} */ + let data = defs[item].provider || {}; + + return data.isAvailable; + }); + + options.unshift(''); + + this.params.options = options; + }, + }); +}); diff --git a/client/src/views/authentication-provider/record/detail.js b/client/src/views/authentication-provider/record/detail.js new file mode 100644 index 0000000000..0f78ae894c --- /dev/null +++ b/client/src/views/authentication-provider/record/detail.js @@ -0,0 +1,62 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * 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 General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/authentication-provider/record/detail', ['views/record/detail', 'helpers/misc/authentication-provider'], +function (Dep, Helper) { + + return Dep.extend({ + + editModeDisabled: true, + + /** + * @private + * @type {module:helpers/misc/authentication-provider.Class} + */ + helper: null, + + setup: function () { + this.helper = new Helper(this); + + Dep.prototype.setup.call(this); + }, + + setupBeforeFinal: function () { + this.dynamicLogicDefs = this.helper.setupMethods(); + + Dep.prototype.setupBeforeFinal.call(this); + + this.helper.setupPanelsVisibility(() => { + this.processDynamicLogic(); + }); + }, + + modifyDetailLayout: function (layout) { + this.helper.modifyDetailLayout(layout); + }, + }); +}); diff --git a/client/src/views/authentication-provider/record/edit.js b/client/src/views/authentication-provider/record/edit.js new file mode 100644 index 0000000000..579aa856ce --- /dev/null +++ b/client/src/views/authentication-provider/record/edit.js @@ -0,0 +1,62 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * 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 General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/authentication-provider/record/edit', ['views/record/edit', 'helpers/misc/authentication-provider'], +function (Dep, Helper) { + + return Dep.extend({ + + saveAndNewAction: false, + + /** + * @private + * @type {module:helpers/misc/authentication-provider.Class} + */ + helper: null, + + setup: function () { + this.helper = new Helper(this); + + Dep.prototype.setup.call(this); + }, + + setupBeforeFinal: function () { + this.dynamicLogicDefs = this.helper.setupMethods(); + + Dep.prototype.setupBeforeFinal.call(this); + + this.helper.setupPanelsVisibility(() => { + this.processDynamicLogic(); + }); + }, + + modifyDetailLayout: function (layout) { + this.helper.modifyDetailLayout(layout); + }, + }); +}); diff --git a/client/src/views/settings/fields/oidc-redirect-uri.js b/client/src/views/settings/fields/oidc-redirect-uri.js index 5995b04d5b..6aca4fced0 100644 --- a/client/src/views/settings/fields/oidc-redirect-uri.js +++ b/client/src/views/settings/fields/oidc-redirect-uri.js @@ -32,16 +32,54 @@ define('views/settings/fields/oidc-redirect-uri', ['views/fields/varchar'], func detailTemplateContent: `{{value}}`, + portalCollection: null, + data: function () { + let valueIsSet = this.model.entityType !== 'AuthenticationProvider' || + this.portalCollection; + return { value: this.getValueForDisplay(), + valueIsSet: valueIsSet, }; }, getValueForDisplay: function () { + if (this.model.entityType === 'AuthenticationProvider') { + if (!this.portalCollection) { + return null; + } + + return this.portalCollection.models + .map(model => { + let url = (model.get('url') || '').replace(/\/+$/, ''); + + return url + '/oauth-callback.php'; + }) + .join('\n'); + } + let siteUrl = (this.getConfig().get('siteUrl') || '').replace(/\/+$/, ''); return siteUrl + '/oauth-callback.php'; }, + + setup: function () { + Dep.prototype.setup.call(this); + + if (this.model.entityType === 'AuthenticationProvider') { + this.getCollectionFactory() + .create('Portal') + .then(collection => { + collection.data.select = ['url', 'isDefault']; + + collection.fetch().then(() => { + this.portalCollection = collection; + + this.reRender(); + }) + }); + } + }, }); }); diff --git a/public/portal/oauth-callback.php b/public/portal/oauth-callback.php new file mode 100644 index 0000000000..fa23058349 --- /dev/null +++ b/public/portal/oauth-callback.php @@ -0,0 +1,41 @@ +run( + EntryPoint::class, + Params::create()->with('entryPoint', 'oauthCallback') +); diff --git a/tests/unit/Espo/Core/Authentication/Logins/Oidc/SyncTest.php b/tests/unit/Espo/Core/Authentication/Logins/Oidc/SyncTest.php index a872f02a9d..7f8889ea99 100644 --- a/tests/unit/Espo/Core/Authentication/Logins/Oidc/SyncTest.php +++ b/tests/unit/Espo/Core/Authentication/Logins/Oidc/SyncTest.php @@ -30,6 +30,8 @@ namespace tests\unit\Espo\Core\Authentication\Logins\Oidc; use Espo\Core\Acl\Cache\Clearer; +use Espo\Core\ApplicationState; +use Espo\Core\Authentication\Oidc\ConfigDataProvider; use Espo\Core\Authentication\Oidc\Sync; use Espo\Core\FieldProcessing\EmailAddress\Saver as EmailAddressSaver; use Espo\Core\FieldProcessing\PhoneNumber\Saver as PhoneNumberSaver; @@ -43,19 +45,23 @@ class SyncTest extends TestCase { private ?Sync $sync = null; private ?Config $config = null; + private ?ConfigDataProvider $configDataProvider = null; protected function setUp(): void { $this->config = $this->createMock(Config::class); + $this->configDataProvider = $this->createMock(ConfigDataProvider::class); $this->sync = new Sync( $this->createMock(EntityManager::class), $this->config, + $this->configDataProvider, $this->createMock(LinkMultipleSaver::class), $this->createMock(EmailAddressSaver::class), $this->createMock(PhoneNumberSaver::class), $this->createMock(PasswordHash::class), - $this->createMock(Clearer::class) + $this->createMock(Clearer::class), + $this->createMock(ApplicationState::class) ); }