From 616d65023b4c9da27e0e6039f1ff4caa3c2a63fb Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Tue, 4 Oct 2022 09:41:40 +0300 Subject: [PATCH] oidc auth --- application/Espo/Controllers/Oidc.php | 77 ++++ .../Core/Authentication/Authentication.php | 19 + .../Authentication/ConfigDataProvider.php | 3 +- .../Authentication/Jwt/DefaultKeyFactory.php | 50 +++ .../Authentication/Jwt/Exceptions/Expired.php | 32 ++ .../Authentication/Jwt/Exceptions/Invalid.php | 34 ++ .../Jwt/Exceptions/NotBefore.php | 32 ++ .../Jwt/Exceptions/SignatureNotVerified.php | 32 ++ .../Jwt/Exceptions/UnsupportedKey.php | 34 ++ .../Espo/Core/Authentication/Jwt/Key.php | 39 ++ .../Core/Authentication/Jwt/KeyFactory.php | 41 ++ .../Espo/Core/Authentication/Jwt/Keys/Rsa.php | 96 +++++ .../Authentication/Jwt/SignatureVerifier.php | 35 ++ .../Jwt/SignatureVerifierFactory.php | 35 ++ .../Jwt/SignatureVerifiers/Hmac.php | 85 ++++ .../Jwt/SignatureVerifiers/Rsa.php | 125 ++++++ .../Espo/Core/Authentication/Jwt/Token.php | 110 +++++ .../Core/Authentication/Jwt/Token/Header.php | 119 ++++++ .../Core/Authentication/Jwt/Token/Payload.php | 216 ++++++++++ .../Espo/Core/Authentication/Jwt/Util.php | 51 +++ .../Core/Authentication/Jwt/Validator.php | 68 ++++ .../Espo/Core/Authentication/Logout.php | 42 ++ .../Core/Authentication/Logout/Params.php | 40 ++ .../Core/Authentication/Logout/Result.php | 55 +++ .../Core/Authentication/LogoutFactory.php | 71 ++++ .../Authentication/Oidc/BackchannelLogout.php | 130 ++++++ .../Oidc/DefaultSignatureVerifierFactory.php | 93 +++++ .../Core/Authentication/Oidc/KeysProvider.php | 215 ++++++++++ .../Espo/Core/Authentication/Oidc/Login.php | 380 ++++++++++++++++++ .../Espo/Core/Authentication/Oidc/Logout.php | 68 ++++ .../Espo/Core/Authentication/Oidc/Sync.php | 296 ++++++++++++++ .../Authentication/Oidc/TokenValidator.php | 98 +++++ .../Espo/Core/Binding/DefaultBinding.php | 15 + .../Espo/ORM/Repository/RDBRepository.php | 4 +- .../Espo/Resources/defaults/config.php | 4 + .../Espo/Resources/i18n/en_US/Settings.json | 36 +- .../Espo/Resources/i18n/en_US/User.json | 1 + .../Espo/Resources/metadata/app/config.json | 66 +++ .../metadata/authenticationMethods/Oidc.json | 245 +++++++++++ .../metadata/entityDefs/Settings.json | 85 ++++ .../Resources/metadata/entityDefs/User.json | 3 +- application/Espo/Resources/routes.json | 18 + application/Espo/Tools/Oidc/Service.php | 135 +++++++ client/src/app.js | 11 +- client/src/handlers/login/oidc.js | 219 ++++++++++ .../views/fields/link-multiple-with-role.js | 18 +- client/src/views/login.js | 6 +- .../settings/fields/oidc-redirect-uri.js | 47 +++ .../src/views/settings/fields/oidc-teams.js | 47 +++ composer.json | 3 +- composer.lock | 229 ++++++++++- .../Core/Authentication/Jwt/TokenTest.php | 119 ++++++ .../Authentication/Logins/Oidc/SyncTest.php | 90 +++++ 53 files changed, 4208 insertions(+), 14 deletions(-) create mode 100644 application/Espo/Controllers/Oidc.php create mode 100644 application/Espo/Core/Authentication/Jwt/DefaultKeyFactory.php create mode 100644 application/Espo/Core/Authentication/Jwt/Exceptions/Expired.php create mode 100644 application/Espo/Core/Authentication/Jwt/Exceptions/Invalid.php create mode 100644 application/Espo/Core/Authentication/Jwt/Exceptions/NotBefore.php create mode 100644 application/Espo/Core/Authentication/Jwt/Exceptions/SignatureNotVerified.php create mode 100644 application/Espo/Core/Authentication/Jwt/Exceptions/UnsupportedKey.php create mode 100644 application/Espo/Core/Authentication/Jwt/Key.php create mode 100644 application/Espo/Core/Authentication/Jwt/KeyFactory.php create mode 100644 application/Espo/Core/Authentication/Jwt/Keys/Rsa.php create mode 100644 application/Espo/Core/Authentication/Jwt/SignatureVerifier.php create mode 100644 application/Espo/Core/Authentication/Jwt/SignatureVerifierFactory.php create mode 100644 application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Hmac.php create mode 100644 application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Rsa.php create mode 100644 application/Espo/Core/Authentication/Jwt/Token.php create mode 100644 application/Espo/Core/Authentication/Jwt/Token/Header.php create mode 100644 application/Espo/Core/Authentication/Jwt/Token/Payload.php create mode 100644 application/Espo/Core/Authentication/Jwt/Util.php create mode 100644 application/Espo/Core/Authentication/Jwt/Validator.php create mode 100644 application/Espo/Core/Authentication/Logout.php create mode 100644 application/Espo/Core/Authentication/Logout/Params.php create mode 100644 application/Espo/Core/Authentication/Logout/Result.php create mode 100644 application/Espo/Core/Authentication/LogoutFactory.php create mode 100644 application/Espo/Core/Authentication/Oidc/BackchannelLogout.php create mode 100644 application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php create mode 100644 application/Espo/Core/Authentication/Oidc/KeysProvider.php create mode 100644 application/Espo/Core/Authentication/Oidc/Login.php create mode 100644 application/Espo/Core/Authentication/Oidc/Logout.php create mode 100644 application/Espo/Core/Authentication/Oidc/Sync.php create mode 100644 application/Espo/Core/Authentication/Oidc/TokenValidator.php create mode 100644 application/Espo/Resources/metadata/authenticationMethods/Oidc.json create mode 100644 application/Espo/Tools/Oidc/Service.php create mode 100644 client/src/handlers/login/oidc.js create mode 100644 client/src/views/settings/fields/oidc-redirect-uri.js create mode 100644 client/src/views/settings/fields/oidc-teams.js create mode 100644 tests/unit/Espo/Core/Authentication/Jwt/TokenTest.php create mode 100644 tests/unit/Espo/Core/Authentication/Logins/Oidc/SyncTest.php diff --git a/application/Espo/Controllers/Oidc.php b/application/Espo/Controllers/Oidc.php new file mode 100644 index 0000000000..80fe79b09c --- /dev/null +++ b/application/Espo/Controllers/Oidc.php @@ -0,0 +1,77 @@ +service = $service; + } + + /** + * @throws Forbidden + * @throws Error + */ + public function getActionAuthorizationData(Request $request, Response $response): void + { + $data = $this->service->getAuthorizationData(); + + $response->writeBody(Json::encode($data)); + } + + /** + * @throws BadRequest + * @throws ForbiddenSilent + */ + public function postActionBackchannelLogout(Request $request, Response $response): void + { + $token = $request->getParsedBody()->logout_token ?? null; + + if (!$token || !is_string($token)) { + throw new BadRequest(); + } + + $this->service->backchannelLogout($token); + + $response->writeBody('true'); + } +} diff --git a/application/Espo/Core/Authentication/Authentication.php b/application/Espo/Core/Authentication/Authentication.php index e588fe2fc1..393fe58618 100644 --- a/application/Espo/Core/Authentication/Authentication.php +++ b/application/Espo/Core/Authentication/Authentication.php @@ -29,6 +29,7 @@ namespace Espo\Core\Authentication; +use Espo\Core\Authentication\Logout\Params as LogoutParams; use Espo\Repositories\UserData as UserDataRepository; use Espo\Entities\Portal; use Espo\Entities\User; @@ -65,6 +66,7 @@ class Authentication private const HEADER_CREATE_TOKEN_SECRET = 'Espo-Authorization-Create-Token-Secret'; private const HEADER_BY_TOKEN = 'Espo-Authorization-By-Token'; private const HEADER_ANOTHER_USER = 'X-Another-User'; + private const HEADER_LOGOUT_REDIRECT_URL = 'X-Logout-Redirect-Url'; private const COOKIE_AUTH_TOKEN_SECRET = 'auth-token-secret'; @@ -79,6 +81,7 @@ class Authentication private TwoFactorLoginFactory $twoFactorLoginFactory; private AuthTokenManager $authTokenManager; private HookManager $hookManager; + private LogoutFactory $logoutFactory; private Log $log; public function __construct( @@ -91,6 +94,7 @@ class Authentication AuthTokenManager $authTokenManager, HookManager $hookManager, Log $log, + LogoutFactory $logoutFactory, bool $allowAnyAccess = false ) { $this->allowAnyAccess = $allowAnyAccess; @@ -103,6 +107,7 @@ class Authentication $this->twoFactorLoginFactory = $twoFactorLoginFactory; $this->authTokenManager = $authTokenManager; $this->hookManager = $hookManager; + $this->logoutFactory = $logoutFactory; $this->log = $log; } @@ -550,6 +555,20 @@ class Authentication } } + $method = $this->configDataProvider->getDefaultAuthenticationMethod(); + + 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); + } + } + return true; } diff --git a/application/Espo/Core/Authentication/ConfigDataProvider.php b/application/Espo/Core/Authentication/ConfigDataProvider.php index fa82fa742a..16ea7ba583 100644 --- a/application/Espo/Core/Authentication/ConfigDataProvider.php +++ b/application/Espo/Core/Authentication/ConfigDataProvider.php @@ -30,6 +30,7 @@ namespace Espo\Core\Authentication; use Espo\Core\Authentication\Login\MetadataParams; +use Espo\Core\Authentication\Logins\Espo; use Espo\Core\Utils\Config; use Espo\Core\Utils\Metadata; @@ -112,7 +113,7 @@ class ConfigDataProvider */ public function getDefaultAuthenticationMethod(): string { - return $this->config->get('authenticationMethod', 'Espo'); + return $this->config->get('authenticationMethod', Espo::NAME); } /** diff --git a/application/Espo/Core/Authentication/Jwt/DefaultKeyFactory.php b/application/Espo/Core/Authentication/Jwt/DefaultKeyFactory.php new file mode 100644 index 0000000000..1fdc77220b --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/DefaultKeyFactory.php @@ -0,0 +1,50 @@ +kty ?? null; + + if ($kty === self::TYPE_RSA) { + return Rsa::fromRaw($raw); + } + + throw new UnsupportedKey(); + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Exceptions/Expired.php b/application/Espo/Core/Authentication/Jwt/Exceptions/Expired.php new file mode 100644 index 0000000000..4e9dcd84b1 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Exceptions/Expired.php @@ -0,0 +1,32 @@ +kid ?? null; + $kty = $raw->kty ?? null; + $alg = $raw->alg ?? null; + $n = $raw->n ?? null; + $e = $raw->e ?? null; + + if ($kid === null || $kty === null) { + throw new UnexpectedValueException("Bad JWK value."); + } + + if ($n === null || $e === null) { + throw new UnexpectedValueException("Bad JWK RSE key. No `n` or `e` values."); + } + + $this->kid = $kid; + $this->kty = $kty; + $this->alg = $alg; + $this->n = $n; + $this->e = $e; + } + + public static function fromRaw(stdClass $raw): self + { + return new self($raw); + } + + public function getKid(): string + { + return $this->kid; + } + + public function getKty(): string + { + return $this->kty; + } + + public function getAlg(): ?string + { + return $this->alg; + } + + public function getN(): string + { + return $this->n; + } + + public function getE(): string + { + return $this->e; + } +} diff --git a/application/Espo/Core/Authentication/Jwt/SignatureVerifier.php b/application/Espo/Core/Authentication/Jwt/SignatureVerifier.php new file mode 100644 index 0000000000..e9ed592a8d --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/SignatureVerifier.php @@ -0,0 +1,35 @@ + 'SHA256', + self::HS384 => 'SHA384', + self::HS512 => 'SHA512', + ]; + + private const HS256 = 'HS256'; + private const HS384 = 'HS384'; + private const HS512 = 'HS512'; + + private string $algorithm; + private string $key; + + public function __construct( + string $algorithm, + string $key + ) { + $this->algorithm = $algorithm; + $this->key = $key; + + if (!in_array($algorithm, self::SUPPORTED_ALGORITHM_LIST)) { + throw new RuntimeException("Unsupported algorithm {$algorithm}."); + } + } + + public function verify(Token $token): bool + { + $input = $token->getSigningInput(); + $signature = $token->getSignature(); + + $functionAlgorithm = self::ALGORITHM_MAP[$this->algorithm] ?? null; + + if (!$functionAlgorithm) { + throw new LogicException(); + } + + $hash = hash_hmac($functionAlgorithm, $input, $this->key, true); + + return $hash === $signature; + } +} diff --git a/application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Rsa.php b/application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Rsa.php new file mode 100644 index 0000000000..c83448a031 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Rsa.php @@ -0,0 +1,125 @@ + 'SHA256', + self::RS384 => 'SHA384', + self::RS512 => 'SHA512', + ]; + + private const RS256 = 'RS256'; + private const RS384 = 'RS384'; + private const RS512 = 'RS512'; + + private string $algorithm; + /** @var Key[] */ + private array $keys; + + /** + * @param Key[] $keys + */ + public function __construct(string $algorithm, array $keys) + { + $this->algorithm = $algorithm; + $this->keys = $keys; + + if (!in_array($algorithm, self::SUPPORTED_ALGORITHM_LIST)) { + throw new RuntimeException("Unsupported algorithm {$algorithm}."); + } + } + + public function verify(Token $token): bool + { + $input = $token->getSigningInput(); + $signature = $token->getSignature(); + $kid = $token->getHeader()->getKid(); + + $functionAlgorithm = self::ALGORITHM_MAP[$this->algorithm] ?? null; + + if (!$functionAlgorithm) { + throw new LogicException(); + } + + $key = array_values( + array_filter($this->keys, fn ($key) => $key->getKid() === $kid) + )[0] ?? null; + + if (!$key) { + return false; + } + + if (!$key instanceof RsaKey) { + throw new RuntimeException("Wrong key."); + } + + $publicKey = openssl_pkey_get_public($this->getPemFromKey($key)); + + if ($publicKey === false) { + throw new RuntimeException("Bad RSA public key."); + } + + $result = openssl_verify($input, $signature, $publicKey, $functionAlgorithm); + + if ($result === false) { + throw new RuntimeException("RSA public key verify error: " . openssl_error_string()); + } + + return $result === 1; + } + + private function getPemFromKey(RsaKey $key): string + { + $publicKey = PublicKeyLoader::load([ + 'n' => new BigInteger('0x' . bin2hex(Util::base64UrlDecode($key->getN())), 16), + 'e' => new BigInteger('0x' . bin2hex(Util::base64UrlDecode($key->getE())), 16), + ]); + + return $publicKey->toString('PKCS8'); + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Token.php b/application/Espo/Core/Authentication/Jwt/Token.php new file mode 100644 index 0000000000..3422ae2685 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Token.php @@ -0,0 +1,110 @@ +token = $token; + + $parts = explode('.', $token); + + if (count($parts) < 3) { + throw new RuntimeException("Too few JWT parts."); + } + + list($this->headerPart, $this->payloadPart, $this->signaturePart) = $parts; + + $this->headerRaw = Util::base64UrlDecode($this->headerPart); + $this->payloadRaw = Util::base64UrlDecode($this->payloadPart); + $this->signatureRaw = Util::base64UrlDecode($this->signaturePart); + + $this->header = Header::fromRaw($this->headerRaw); + $this->payload = Payload::fromRaw($this->payloadRaw); + } + + public static function create(string $token): self + { + return new self($token); + } + + public function getToken(): string + { + return $this->token; + } + + public function getSigningInput(): string + { + return $this->headerPart . '.' . $this->payloadPart; + } + + public function getHeader(): Header + { + return $this->header; + } + + public function getPayload(): Payload + { + return $this->payload; + } + + public function getSignature(): string + { + return $this->signatureRaw; + } + + public function getHeaderRaw(): string + { + return $this->headerRaw; + } + + public function getPayloadRaw(): string + { + return $this->payloadRaw; + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Token/Header.php b/application/Espo/Core/Authentication/Jwt/Token/Header.php new file mode 100644 index 0000000000..17924a80b0 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Token/Header.php @@ -0,0 +1,119 @@ + */ + private array $data; + + /** + * @param array $data + */ + private function __construct( + string $alg, + ?string $kid, + array $data + ) { + $this->alg = $alg; + $this->kid = $kid; + $this->data = $data; + } + + /** + * @return mixed + */ + public function get(string $name) + { + return $this->data[$name] ?? null; + } + + public static function fromRaw(string $raw): self + { + $parsed = null; + + try { + $parsed = Json::decode($raw); + } + catch (JsonException $e) {} + + if (!$parsed instanceof stdClass) { + throw new RuntimeException(); + } + + $alg = self::obtainFromParsedString($parsed, 'alg'); + $kid = self::obtainFromParsedStringNull($parsed, 'kid'); + + return new self( + $alg, + $kid, + get_object_vars($parsed) + ); + } + + private static function obtainFromParsedString(stdClass $parsed, string $name): string + { + $value = $parsed->$name ?? null; + + if (!is_string($value)) { + throw new RuntimeException("No or bad `{$name}` in JWT header."); + } + + return $value; + } + + private static function obtainFromParsedStringNull(stdClass $parsed, string $name): ?string + { + $value = $parsed->$name ?? null; + + if ($value !== null && !is_string($value)) { + throw new RuntimeException("Bad `{$name}` in JWT header."); + } + + return $value; + } + + public function getAlg(): string + { + return $this->alg; + } + + public function getKid(): ?string + { + return $this->kid; + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Token/Payload.php b/application/Espo/Core/Authentication/Jwt/Token/Payload.php new file mode 100644 index 0000000000..7ca4a97dbd --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Token/Payload.php @@ -0,0 +1,216 @@ + */ + private array $data; + + /** + * @param string[] $aud + * @param array $data + */ + private function __construct( + ?string $sub, + ?string $iss, + array $aud, + ?int $exp, + ?int $iat, + ?int $nbf, + ?string $nonce, + ?int $authTime, + ?string $sid, + array $data + ) { + $this->sub = $sub; + $this->iss = $iss; + $this->aud = $aud; + $this->exp = $exp; + $this->iat = $iat; + $this->nbf = $nbf; + $this->nonce = $nonce; + $this->authTime = $authTime; + $this->sid = $sid; + $this->data = $data; + } + + public function getSub(): ?string + { + return $this->sub; + } + + public function getIss(): ?string + { + return $this->iss; + } + + public function getExp(): ?int + { + return $this->exp; + } + + public function getIat(): ?int + { + return $this->iat; + } + + public function getNbf(): ?int + { + return $this->nbf; + } + + /** + * @return string[] + */ + public function getAud(): array + { + return $this->aud; + } + + public function getNonce(): ?string + { + return $this->nonce; + } + + public function getAuthTime(): ?int + { + return $this->authTime; + } + + public function getSid(): ?string + { + return $this->sid; + } + + /** + * @return mixed + */ + public function get(string $name) + { + return $this->data[$name] ?? null; + } + + public static function fromRaw(string $raw): self + { + $parsed = null; + + try { + $parsed = Json::decode($raw); + } + catch (JsonException $e) {} + + if (!$parsed instanceof stdClass) { + throw new RuntimeException(); + } + + $sub = $parsed->sub ?? null; + $iss = $parsed->iss ?? null; + $aud = $parsed->aud ?? null; + $exp = $parsed->exp ?? null; + $iat = $parsed->iat ?? null; + $nbf = $parsed->nbf ?? null; + $nonce = $parsed->nonce ?? null; + $authTime = $parsed->auth_time ?? null; + $sid = $parsed->sid ?? null; + + if (is_string($aud)) { + $aud = [$aud]; + } + + if ($aud === null) { + $aud = []; + } + + if ($iss !== null && !is_string($sub)) { + throw new RuntimeException("Bad `sub`."); + } + + if ($iss !== null && !is_string($iss)) { + throw new RuntimeException("Bad `iss`."); + } + + if (!is_array($aud)) { + throw new RuntimeException("Bad `aud`."); + } + + if ($exp !== null && !is_int($exp)) { + throw new RuntimeException("No or bad `exp`."); + } + + if ($iat !== null && !is_int($iat)) { + throw new RuntimeException("No or bad `iat`."); + } + + if ($nbf !== null && !is_int($nbf)) { + throw new RuntimeException("No or bad `nbf`."); + } + + if ($nonce !== null && !is_string($nonce)) { + throw new RuntimeException("Bad `nonce`."); + } + + if ($authTime !== null && !is_int($authTime)) { + throw new RuntimeException("Bad `auth_time`."); + } + + if ($sid !== null && !is_string($sid)) { + throw new RuntimeException("Bad `sid`."); + } + + return new self( + $sub, + $iss, + $aud, + $exp, + $iat, + $nbf, + $nonce, + $authTime, + $sid, + get_object_vars($parsed) + ); + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Util.php b/application/Espo/Core/Authentication/Jwt/Util.php new file mode 100644 index 0000000000..2ed38cae24 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Util.php @@ -0,0 +1,51 @@ +timeLeeway = $timeLeeway ?? self::DEFAULT_TIME_LEEWAY; + $this->now = $now; + } + + /** + * @throws Expired + * @throws NotBefore + */ + public function validate(Token $token): void + { + $exp = $token->getPayload()->getExp(); + $nbf = $token->getPayload()->getNbf(); + + $now = $this->now ?? time(); + + if ($exp && $exp + $this->timeLeeway <= $now) { + throw new Expired("JWT expired."); + } + + if ($nbf && $now < $nbf - $this->timeLeeway) { + throw new NotBefore("JWT used before allowed time."); + } + } +} diff --git a/application/Espo/Core/Authentication/Logout.php b/application/Espo/Core/Authentication/Logout.php new file mode 100644 index 0000000000..cc340736c1 --- /dev/null +++ b/application/Espo/Core/Authentication/Logout.php @@ -0,0 +1,42 @@ +redirectUrl = $redirectUrl; + + return $obj; + } + + public function getRedirectUrl(): ?string + { + return $this->redirectUrl; + } +} diff --git a/application/Espo/Core/Authentication/LogoutFactory.php b/application/Espo/Core/Authentication/LogoutFactory.php new file mode 100644 index 0000000000..83b4cec829 --- /dev/null +++ b/application/Espo/Core/Authentication/LogoutFactory.php @@ -0,0 +1,71 @@ +injectableFactory = $injectableFactory; + $this->metadata = $metadata; + } + + public function create(string $method): Logout + { + $className = $this->getClassName($method); + + if (!$className) { + throw new RuntimeException(); + } + + return $this->injectableFactory->create($className); + } + + public function isCreatable(string $method): bool + { + return (bool) $this->getClassName($method); + } + + /** + * @return ?class-string + */ + private function getClassName(string $method): ?string + { + return $this->metadata->get(['authenticationMethods', $method, 'logoutClassName']); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/BackchannelLogout.php b/application/Espo/Core/Authentication/Oidc/BackchannelLogout.php new file mode 100644 index 0000000000..06f3e63f50 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/BackchannelLogout.php @@ -0,0 +1,130 @@ +log = $log; + $this->validator = $validator; + $this->tokenValidator = $tokenValidator; + $this->config = $config; + $this->entityManager = $entityManager; + $this->authTokenManger = $authTokenManger; + } + + /** + * @throws SignatureNotVerified + * @throws Invalid + */ + public function logout(string $rawToken): void + { + $token = Token::create($rawToken); + + $this->log->debug("OIDC logout: JWT header: " . $token->getHeaderRaw()); + $this->log->debug("OIDC logout: JWT payload: " . $token->getPayloadRaw()); + + $this->validator->validate($token); + $this->tokenValidator->validateSignature($token); + $this->tokenValidator->validateFields($token); + + $usernameClaim = $this->config->get('oidcUsernameClaim'); + + if (!$usernameClaim) { + throw new Invalid("No username claim in config."); + } + + $username = $token->getPayload()->get($usernameClaim); + + if (!$username) { + throw new Invalid("No username claim `{$usernameClaim}` in token."); + } + + $user = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([ + 'userName' => $username, + ]) + ->findOne(); + + if (!$user) { + return; + } + + if ($user->isPortal()) { + return; + } + + $authTokenList = $this->entityManager + ->getRDBRepositoryByClass(AuthTokenEntity::class) + ->where([ + 'userId' => $user->getId(), + 'isActive' => true, + ]) + ->find(); + + foreach ($authTokenList as $authToken) { + assert($authToken instanceof AuthToken); + + $this->authTokenManger->inactivate($authToken); + } + } +} diff --git a/application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php b/application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php new file mode 100644 index 0000000000..f97f7fafa4 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php @@ -0,0 +1,93 @@ + Rsa::class, + self::RS384 => Rsa::class, + self::RS512 => Rsa::class, + self::HS256 => Hmac::class, + self::HS384 => Hmac::class, + 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 create(string $algorithm): SignatureVerifier + { + /** @var ?class-string $className */ + $className = self::ALGORITHM_VERIFIER_CLASS_NAME_MAP[$algorithm] ?? null; + + if (!$className) { + throw new RuntimeException("Not supported algorithm {$algorithm}."); + } + + if ($className === Rsa::class) { + $keys = $this->keysProvider->get(); + + return new Rsa($algorithm, $keys); + } + + if ($className === Hmac::class) { + $key = $this->config->get('oidcClientSecret'); + + if (!$key) { + throw new RuntimeException("No client secret."); + } + + return new Hmac($algorithm, $key); + } + + throw new RuntimeException(); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/KeysProvider.php b/application/Espo/Core/Authentication/Oidc/KeysProvider.php new file mode 100644 index 0000000000..0116f46c4a --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/KeysProvider.php @@ -0,0 +1,215 @@ +dataCache = $dataCache; + $this->config = $config; + $this->factory = $factory; + $this->log = $log; + } + + /** + * @return Key[] + */ + public function get(): array + { + $list = []; + + $rawKeys = $this->getRaw(); + + foreach ($rawKeys as $raw) { + try { + $list[] = $this->factory->create($raw); + } + catch (UnsupportedKey $e) { + $this->log->debug("OIDC: Unsupported key " . print_r($raw, true)); + } + } + + return $list; + } + + /** + * @return stdClass[] + */ + private function getRaw(): array + { + $raw = $this->getRawFromCache(); + + if (!$raw) { + $raw = $this->load(); + + $this->storeRawToCache($raw); + } + + return $raw; + } + + /** + * @return stdClass[] + */ + private function load(): array + { + /** @var ?string $endpoint */ + $endpoint = $this->config->get('oidcJwksEndpoint'); + + if (!$endpoint) { + throw new RuntimeException("JSON Web Key Set endpoint not specified in settings."); + } + + $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', + ]); + + /** @var string|false $response */ + $response = curl_exec($curl); + $error = curl_error($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE) ?? 0; + + curl_close($curl); + + if ($response === false) { + $response = ''; + } + + if ($error) { + throw new RuntimeException("OIDC: JWKS request error. Status: {$status}."); + } + + $parsedResponse = null; + + try { + $parsedResponse = Json::decode($response); + } + catch (JsonException $e) {} + + if (!$parsedResponse instanceof stdClass || !isset($parsedResponse->keys)) { + throw new RuntimeException("OIDC: JWKS bad response."); + } + + return $parsedResponse->keys; + } + + /** + * @return ?stdClass[] + */ + private function getRawFromCache(): ?array + { + if (!$this->config->get('useCache')) { + return null; + } + + if (!$this->dataCache->has(self::CACHE_KEY)) { + return null; + } + + $data = $this->dataCache->get(self::CACHE_KEY); + + if (!$data instanceof stdClass) { + return null; + } + + /** @var ?int $timestamp */ + $timestamp = $data->timestamp; + + if (!$timestamp) { + return null; + } + + $period = '-' . ($this->config->get('oidcJwksCachePeriod') ?? self::CACHE_PERIOD); + + if ($timestamp < DateTime::createNow()->modify($period)->getTimestamp()) { + return null; + } + + /** @var ?stdClass[] $keys */ + $keys = $data->keys ?? null; + + if ($keys === null) { + return null; + } + + return $keys; + } + + /** + * @param stdClass[] $raw + */ + private function storeRawToCache(array $raw): void + { + if (!$this->config->get('useCache')) { + return; + } + + $data = (object) [ + 'timestamp' => time(), + 'keys' => $raw, + ]; + + $this->dataCache->store(self::CACHE_KEY, $data); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/Login.php b/application/Espo/Core/Authentication/Oidc/Login.php new file mode 100644 index 0000000000..74ebfef372 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/Login.php @@ -0,0 +1,380 @@ +espoLogin = $espoLogin; + $this->config = $config; + $this->log = $log; + $this->entityManager = $entityManager; + $this->sync = $sync; + $this->validator = $validator; + $this->tokenValidator = $tokenValidator; + } + + public function login(Data $data, Request $request): Result + { + if ($data->getUsername() !== self::OIDC_USERNAME) { + return $this->loginFallback($data, $request); + } + + $code = $data->getPassword(); + + if (!$code) { + return Result::fail(FailReason::NO_PASSWORD); + } + + return $this->loginWithCode($code, $request); + } + + private function loginWithCode(string $code, Request $request): Result + { + /** @var ?string $endpoint */ + $endpoint = $this->config->get('oidcTokenEndpoint'); + /** @var ?string $clientId */ + $clientId = $this->config->get('oidcClientId'); + $redirectUri = rtrim($this->config->get('siteUrl'), '/') . '/oauth-callback.php'; + + if (!$endpoint) { + throw new RuntimeException("No token endpoint."); + } + + if (!$clientId) { + throw new RuntimeException("No client ID."); + } + + [$rawToken, $failResult] = $this->requestToken($endpoint, $clientId, $code, $redirectUri); + + if ($failResult) { + return $failResult; + } + + if (!$rawToken) { + throw new LogicException(); + } + + try { + $token = Token::create($rawToken); + } + catch (RuntimeException $e) { + $message = self::composeLogMessage('JWT parsing error.'); + + if ($e->getMessage()) { + $message .= " " . $e->getMessage(); + } + + $this->log->error($message); + + throw new RuntimeException("JWT parsing error."); + } + + $this->log->debug("OIDC: JWT header: " . $token->getHeaderRaw()); + $this->log->debug("OIDC: JWT payload: " . $token->getPayloadRaw()); + + try { + $this->validateToken($token); + } + catch (Invalid $e) { + $this->log->error("OIDC: " . $e->getMessage()); + + return Result::fail(FailReason::DENIED); + } + + $tokenPayload = $token->getPayload(); + + $nonce = $request->getHeader(self::NONCE_HEADER); + + if ($nonce && $nonce !== $tokenPayload->getNonce()) { + $this->log->warning(self::composeLogMessage('JWT nonce mismatch.')); + + return Result::fail(FailReason::DENIED); + } + + $user = $this->obtainUser($tokenPayload); + + if (!$user) { + return Result::fail(FailReason::USER_NOT_FOUND); + } + + return Result::success($user); + } + + private function loginFallback(Data $data, Request $request): Result + { + if ( + !$data->getAuthToken() && + !$this->config->get('oidcFallback') + ) { + return Result::fail(FailReason::METHOD_NOT_ALLOWED); + } + + $result = $this->espoLogin->login($data, $request); + + $user = $result->getUser(); + + if ( + !$data->getAuthToken() && + $user && + $user->isRegular() && + !$this->config->get('oidcAllowRegularUserFallback') + // Portal users are allowed. + ) { + return Result::fail(FailReason::METHOD_NOT_ALLOWED); + } + + return $result; + } + + /** + * @return array{?string, ?Result} + */ + private function requestToken(string $endpoint, string $clientId, string $code, string $redirectUri): array + { + $params = [ + 'grant_type' => 'authorization_code', + 'client_id' => $clientId, + 'code' => $code, + 'redirect_uri' => $redirectUri, + ]; + + $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 => 'POST', + CURLOPT_POSTFIELDS => http_build_query($params), + CURLOPT_HTTPHEADER => ['content-type: application/x-www-form-urlencoded'], + ]); + + /** @var string|false $response */ + $response = curl_exec($curl); + $error = curl_error($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE) ?? 0; + + curl_close($curl); + + if ($response === false) { + $response = ''; + } + + if ($error) { + if ($status === 400) { + $this->log->error(self::composeLogMessage('Bad token request.', $status, $response)); + + throw new RuntimeException(); + } + + $this->log->warning(self::composeLogMessage('Token request error.', $status, $response)); + + return [null, Result::fail(FailReason::DENIED)]; + } + + $parsedResponse = null; + + try { + $parsedResponse = Json::decode($response); + } + catch (JsonException $e) {} + + if (!$parsedResponse instanceof stdClass) { + $this->log->error(self::composeLogMessage('Bad token response.', $status, $response)); + + throw new RuntimeException(); + } + + $token = $parsedResponse->id_token ?? null; + + if (!$token || !is_string($token)) { + $this->log->error(self::composeLogMessage('Bad token response.', $status, $response)); + + throw new RuntimeException(); + } + + return [$token, null]; + } + + 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}"; + } + + private function obtainUser(Payload $payload): ?User + { + $user = $this->findUser($payload); + + if ($user) { + $this->syncUser($user, $payload); + + return $user; + } + + return $this->tryToCreateUser($payload); + } + + private function findUser(Payload $payload): ?User + { + $usernameClaim = $this->config->get('oidcUsernameClaim'); + + if (!$usernameClaim) { + throw new RuntimeException("No username claim in config."); + } + + $username = $payload->get($usernameClaim); + + if (!$username) { + throw new RuntimeException("No username claim `{$usernameClaim}` in token."); + } + + $username = $this->sync->normalizeUsername($username); + + /** @var ?User $user */ + $user = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where(['userName' => $username]) + ->findOne(); + + if (!$user) { + return null; + } + + if (!$user->isActive()) { + return null; + } + + if (!$user->isRegular() && !$user->isAdmin()) { + return null; + } + + if ($user->isSuperAdmin()) { + return null; + } + + if ($user->isAdmin() && !$this->config->get('oidcAllowAdminUser')) { + return null; + } + + return $user; + } + + private function tryToCreateUser(Payload $payload): ?User + { + if (!$this->config->get('oidcCreateUser')) { + return null; + } + + $usernameClaim = $this->config->get('oidcUsernameClaim'); + + if (!$usernameClaim) { + throw new RuntimeException("Could not create a user. No OIDC username claim in config."); + } + + $username = $payload->get($usernameClaim); + + if (!$username) { + throw new RuntimeException("Could not create a user. No username claim returned in token."); + } + + return $this->sync->createUser($payload); + } + + private function syncUser(User $user, Payload $payload): void + { + if (!$this->config->get('oidcSync') && !$this->config->get('oidcSyncTeams')) { + return; + } + + $this->sync->syncUser($user, $payload); + } + + /** + * @throws SignatureNotVerified + * @throws Invalid + */ + private function validateToken(Token $token): void + { + $this->validator->validate($token); + $this->tokenValidator->validateFields($token); + $this->tokenValidator->validateSignature($token); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/Logout.php b/application/Espo/Core/Authentication/Oidc/Logout.php new file mode 100644 index 0000000000..4b1fb906c6 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/Logout.php @@ -0,0 +1,68 @@ +config = $config; + } + + 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') ?? ''; + $siteUrl = rtrim($this->config->get('siteUrl') ?? '', '/'); + + if ($url) { + $url = str_replace('{clientId}', urlencode($oidcClientId), $url); + $url = str_replace('{siteUrl}', urlencode($siteUrl), $url); + } + + // @todo Check session is set if 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 new file mode 100644 index 0000000000..1131f0a3f6 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/Sync.php @@ -0,0 +1,296 @@ +entityManager = $entityManager; + $this->config = $config; + $this->linkMultipleSaver = $linkMultipleSaver; + $this->emailAddressSaver = $emailAddressSaver; + $this->phoneNumberSaver = $phoneNumberSaver; + $this->passwordHash = $passwordHash; + $this->aclCacheClearer = $aclCacheClearer; + } + + public function createUser(Payload $payload): User + { + $username = $this->getUsernameFromToken($payload); + + $this->validateUsername($username); + + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getNew(); + + $user->set([ + 'type' => User::TYPE_REGULAR, + 'userName' => $username, + 'password' => $this->passwordHash->hash(Util::generatePassword(10, 4, 2, true)), + ]); + + $user->set($this->getUserDataFromToken($payload)); + $user->set($this->getUserTeamsDataFromToken($payload)); + + $this->saveUser($user); + + return $user; + } + + public function syncUser(User $user, Payload $payload): void + { + $username = $this->getUsernameFromToken($payload); + + $this->validateUsername($username); + + if ($user->getUserName() !== $username) { + throw new RuntimeException("Could not sync user. Username mismatch."); + } + + if ($this->config->get('oidcSync')) { + $user->set($this->getUserDataFromToken($payload)); + } + + $clearAclCache = false; + + if ($this->config->get('oidcSyncTeams')) { + $user->loadLinkMultipleField('teams'); + + $user->set($this->getUserTeamsDataFromToken($payload)); + + $clearAclCache = $user->isAttributeChanged('teamsIds'); + } + + $this->saveUser($user); + + if ($clearAclCache) { + $this->aclCacheClearer->clearForUser($user); + } + } + + private function saveUser(User $user): void + { + $this->entityManager->saveEntity($user, [ + // Prevent `user` service being loaded by hooks. + 'skipHooks' => true, + 'keepNew' => true, + 'keepDirty' => true, + ]); + + $saverParams = SaverParams::create()->withRawOptions(['skipLinkMultipleHooks' => true]); + + $this->linkMultipleSaver->process($user, 'teams', $saverParams); + $this->linkMultipleSaver->process($user, 'portals', $saverParams); + $this->linkMultipleSaver->process($user, 'portalRoles', $saverParams); + $this->emailAddressSaver->process($user, $saverParams); + $this->phoneNumberSaver->process($user, $saverParams); + + $user->setAsNotNew(); + $user->updateFetchedValues(); + + $this->entityManager->refreshEntity($user); + } + + /** + * @return array + */ + private function getUserDataFromToken(Payload $payload): array + { + return [ + 'emailAddress' => $payload->get('email'), + 'phoneNumber' => $payload->get('phone_number'), + 'emailAddressData' => null, + 'phoneNumberData' => null, + 'firstName' => $payload->get('given_name'), + 'lastName' => $payload->get('family_name'), + 'middle_name' => $payload->get('middle_name'), + 'gender' => + in_array($payload->get('gender'), ['male', 'female']) ? + ucfirst($payload->get('gender') ?? '') : + null, + ]; + } + + /** + * @return array + */ + private function getUserTeamsDataFromToken(Payload $payload): array + { + return [ + 'teamsIds' => $this->getTeamIdList($payload), + ]; + } + + private function getUsernameFromToken(Payload $payload): string + { + $usernameClaim = $this->config->get('oidcUsernameClaim'); + + if (!$usernameClaim) { + throw new RuntimeException("No OIDC username claim in config."); + } + + $username = $payload->get($usernameClaim); + + if (!$username) { + throw new RuntimeException("No username claim returned in token."); + } + + if (!is_string($username)) { + throw new RuntimeException("Bad username claim returned in token."); + } + + return $this->normalizeUsername($username); + } + + /** + * @return string[] + */ + private function getTeamIdList(Payload $payload): array + { + /** @var string[] $idList */ + $idList = $this->config->get('oidcTeamsIds') ?? []; + /** @var stdClass $columns */ + $columns = $this->config->get('oidcTeamsColumns') ?? (object) []; + + if ($idList === []) { + return []; + } + + $groupList = $this->getGroups($payload); + + $resultIdList = []; + + foreach ($idList as $id) { + $group = ($columns->$id ?? (object) [])->group ?? null; + + if (!$group || in_array($group, $groupList)) { + $resultIdList[] = $id; + } + } + + return $resultIdList; + } + + /** + * @return string[] + */ + private function getGroups(Payload $payload): array + { + /** @var ?string $groupClaim */ + $groupClaim = $this->config->get('oidcGroupClaim'); + + if (!$groupClaim) { + return []; + } + + $value = $payload->get($groupClaim); + + if (!$value) { + return []; + } + + if (is_string($value)) { + return [$value]; + } + + if (!is_array($value)) { + return []; + } + + $list = []; + + foreach ($value as $item) { + if (is_string($item)) { + $list[] = $item; + } + } + + return $list; + } + + private function validateUsername(string $username): void + { + $maxLength = $this->entityManager + ->getDefs() + ->getEntity(User::ENTITY_TYPE) + ->getAttribute('userName') + ->getLength(); + + if ($maxLength && $maxLength < strlen($username)) { + throw new RuntimeException("Value in username claim exceeds max length of `{$maxLength}`. " . + "Increase maxLength parameter for User.userName field (up to 255)."); + } + } + + public function normalizeUsername(string $username): string + { + /** @var string $regExp */ + $regExp = $this->config->get('userNameRegularExpression'); + + if (!$regExp) { + throw new RuntimeException("No `userNameRegularExpression` in config."); + } + + /** @var string $result */ + $result = preg_replace("/{$regExp}/", '_', $username); + + /** @var string */ + return str_replace(' ', '_', $result); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/TokenValidator.php b/application/Espo/Core/Authentication/Oidc/TokenValidator.php new file mode 100644 index 0000000000..63bbb0a41c --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/TokenValidator.php @@ -0,0 +1,98 @@ +config = $config; + $this->signatureVerifierFactory = $signatureVerifierFactory; + } + + /** + * @throws SignatureNotVerified + * @throws Invalid + */ + public function validateSignature(Token $token): void + { + $algorithm = $token->getHeader()->getAlg(); + + /** @var string[] $allowedAlgorithmList */ + $allowedAlgorithmList = $this->config->get('oidcJwtSignatureAlgorithmList') ?? []; + + if (!in_array($algorithm, $allowedAlgorithmList)) { + throw new Invalid("JWT signing algorithm `{$algorithm}` not allowed."); + } + + $verifier = $this->signatureVerifierFactory->create($algorithm); + + if (!$verifier->verify($token)) { + throw new SignatureNotVerified("JWT signature not verified."); + } + } + + /** + * @throws Invalid + */ + public function validateFields(Token $token): void + { + /** @var ?string $oidcClientId */ + $oidcClientId = $this->config->get('oidcClientId'); + + if (!$oidcClientId) { + throw new RuntimeException("OIDC: No client ID."); + } + + if (!in_array($oidcClientId, $token->getPayload()->getAud())) { + throw new Invalid("JWT the `aud` field does not contain matching client ID."); + } + + if (!$token->getPayload()->getSub()) { + throw new Invalid("JWT does not contain the `sub` value."); + } + + if (!$token->getPayload()->getIss()) { + throw new Invalid("JWT does not contain the `iss` value."); + } + } +} diff --git a/application/Espo/Core/Binding/DefaultBinding.php b/application/Espo/Core/Binding/DefaultBinding.php index c52e9dcd56..c2719fb33e 100644 --- a/application/Espo/Core/Binding/DefaultBinding.php +++ b/application/Espo/Core/Binding/DefaultBinding.php @@ -29,6 +29,8 @@ namespace Espo\Core\Binding; +use Espo\Core\Authentication\Jwt\SignatureVerifierFactory; + class DefaultBinding implements BindingProcessor { public function process(Binder $binder): void @@ -219,6 +221,19 @@ class DefaultBinding implements BindingProcessor 'Espo\\Core\\Sms\\Sender', 'Espo\\Core\\Sms\\SenderFactory' ); + + $binder + ->bindImplementation( + 'Espo\\Core\\Authentication\\Jwt\\KeyFactory', + 'Espo\\Core\\Authentication\\Jwt\\DefaultKeyFactory' + ); + + $binder + ->for('Espo\\Core\\Authentication\\Oidc\\TokenValidator') + ->bindImplementation( + 'Espo\\Core\\Authentication\\Jwt\\SignatureVerifierFactory', + 'Espo\\Core\\Authentication\\Oidc\\DefaultSignatureVerifierFactory' + ); } private function bindAcl(Binder $binder): void diff --git a/application/Espo/ORM/Repository/RDBRepository.php b/application/Espo/ORM/Repository/RDBRepository.php index 7e6f754f31..3dad6fa6ef 100644 --- a/application/Espo/ORM/Repository/RDBRepository.php +++ b/application/Espo/ORM/Repository/RDBRepository.php @@ -189,7 +189,9 @@ class RDBRepository implements Repository } } else { - $entity->updateFetchedValues(); + if (empty($options['keepDirty'])) { + $entity->updateFetchedValues(); + } } if ($entity instanceof BaseEntity) { diff --git a/application/Espo/Resources/defaults/config.php b/application/Espo/Resources/defaults/config.php index 237ecfd196..8edeb9d72b 100644 --- a/application/Espo/Resources/defaults/config.php +++ b/application/Espo/Resources/defaults/config.php @@ -220,5 +220,9 @@ return [ 'clientCspScriptSourceList' => [ 'https://maps.googleapis.com', ], + 'oidcJwtSignatureAlgorithmList' => ['RS256'], + 'oidcUsernameClaim' => 'sub', + 'oidcFallback' => true, + 'oidcScopes' => ['profile', 'email', 'phone'], 'isInstalled' => false, ]; diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index e36c2fc4e8..0bf5de9f78 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -140,9 +140,30 @@ "auth2FA": "Enable 2-Factor Authentication", "auth2FAForced": "Force regular users to set up 2FA", "auth2FAMethodList": "Available 2FA methods", - "workingTimeCalendar": "Working Time Calendar" + "workingTimeCalendar": "Working Time Calendar", + "oidcClientId": "OIDC Client ID", + "oidcClientSecret": "OIDC Client Secret", + "oidcAuthorizationRedirectUri": "OIDC Authorization Redirect URI", + "oidcAuthorizationEndpoint": "OIDC Authorization Endpoint", + "oidcTokenEndpoint": "OIDC Token Endpoint", + "oidcJwksEndpoint": "OIDC JSON Web Key Set Endpoint", + "oidcJwtSignatureAlgorithmList": "OIDC JWT Allowed Signature Algorithms", + "oidcScopes": "OIDC Scopes", + "oidcGroupClaim": "OIDC Group Claim", + "oidcCreateUser": "OIDC Create User", + "oidcUsernameClaim": "OIDC Username Claim", + "oidcTeams": "OIDC Teams", + "oidcSync": "OIDC Sync", + "oidcSyncTeams": "OIDC Sync Teams", + "oidcFallback": "OIDC Fallback Login", + "oidcAllowRegularUserFallback": "OIDC Allow fallback login for regular users", + "oidcAllowAdminUser": "OIDC Allow OIDC login for admin users", + "oidcLogoutUrl": "OIDC Logout URL" }, "options": { + "authenticationMethod": { + "Oidc": "OIDC" + }, "currencyFormat": { "1": "10 USD", "2": "$10", @@ -237,7 +258,15 @@ "daemonMaxProcessNumber": "Max number of cron processes run simultaneously.", "daemonProcessTimeout": "Max execution time (in seconds) allocated for a single cron process.", "cronDisabled": "Cron will not run.", - "maintenanceMode": "Only administrators will have access to the system." + "maintenanceMode": "Only administrators will have access to the system.", + "oidcGroupClaim": "A claim to user for team mapping.", + "oidcFallback": "Allow login by username/password.", + "oidcCreateUser": "Create a new user in Espo when no matching user found.", + "oidcSync": "Sync user data (on every login).", + "oidcSyncTeams": "Sync user teams (on every login).", + "oidcUsernameClaim": "A claim to use for a username (for user matching and creation).", + "oidcTeams": "Espo teams mapped against groups/teams/roles of the identity provider. Teams with an empty mapping value will be always assigned to a user (when creating or syncing).", + "oidcLogoutUrl": "An URL the browser will redirect to after logging out from Espo. Intended for clearing the session information in the browser and doing logging out on the provider side. Usually the URL contains a redirect-URL parameter, to return back to Espo.\n\nAvailable placeholders:\n* `{siteUrl}`\n* `{clientId}`" }, "labels": { "Group Tab": "Group Tab", @@ -258,7 +287,8 @@ "Admin Notifications": "Admin Notifications", "Passwords": "Passwords", "2-Factor Authentication": "2-Factor Authentication", - "Attachments": "Attachments" + "Attachments": "Attachments", + "IdP Group": "IdP Group" }, "messages": { "ldapTestConnection": "The connection successfully established." diff --git a/application/Espo/Resources/i18n/en_US/User.json b/application/Espo/Resources/i18n/en_US/User.json index f157b8f3f0..101049fd73 100644 --- a/application/Espo/Resources/i18n/en_US/User.json +++ b/application/Espo/Resources/i18n/en_US/User.json @@ -107,6 +107,7 @@ "passwordChanged": "Password has been changed", "userCantBeEmpty": "Username can not be empty", "wrongUsernamePassword": "Wrong username/password", + "failedToLogIn": "Failed to log in", "emailAddressCantBeEmpty": "Email Address can not be empty", "userNameEmailAddressNotFound": "Username/Email Address not found", "forbidden": "Forbidden, please try later", diff --git a/application/Espo/Resources/metadata/app/config.json b/application/Espo/Resources/metadata/app/config.json index a709655c08..0e12ac49ba 100644 --- a/application/Espo/Resources/metadata/app/config.json +++ b/application/Espo/Resources/metadata/app/config.json @@ -21,6 +21,72 @@ }, "workingTimeCalendar": { "level": "admin" + }, + "oidcClientId": { + "level": "admin" + }, + "oidcClientSecret": { + "level": "admin" + }, + "oidcAuthorizationEndpoint": { + "level": "admin" + }, + "oidcTokenEndpoint": { + "level": "admin" + }, + "oidcJwksEndpoint": { + "level": "admin" + }, + "oidcJwksCachePeriod": { + "level": "admin" + }, + "oidcJwtSignatureAlgorithmList": { + "level": "admin" + }, + "oidcScopes": { + "level": "admin" + }, + "oidcGroupClaim": { + "level": "admin" + }, + "oidcCreateUser": { + "level": "admin" + }, + "oidcUsernameClaim": { + "level": "admin" + }, + "oidcTeamsIds": { + "level": "admin" + }, + "oidcTeamsNames": { + "level": "admin" + }, + "oidcTeamsColumns": { + "level": "admin" + }, + "oidcSync": { + "level": "admin" + }, + "oidcSyncTeams": { + "level": "admin" + }, + "oidcFallback": { + "level": "admin" + }, + "oidcAllowRegularUserFallback": { + "level": "admin" + }, + "oidcAllowAdminUser": { + "level": "admin" + }, + "oidcAuthorizationPrompt": { + "level": "admin" + }, + "oidcAuthorizationMaxAge": { + "level": "admin" + }, + "oidcLogoutUrl": { + "level": "admin" } } } diff --git a/application/Espo/Resources/metadata/authenticationMethods/Oidc.json b/application/Espo/Resources/metadata/authenticationMethods/Oidc.json new file mode 100644 index 0000000000..45ed7d4d1b --- /dev/null +++ b/application/Espo/Resources/metadata/authenticationMethods/Oidc.json @@ -0,0 +1,245 @@ +{ + "implementationClassName": "Espo\\Core\\Authentication\\Oidc\\Login", + "logoutClassName": "Espo\\Core\\Authentication\\Oidc\\Logout", + "login": { + "handler": "handlers/login/oidc", + "fallbackConfigParam": "oidcFallback" + }, + "settings": { + "isAvailable": true, + "layout": { + "label": "OIDC", + "rows": [ + [ + { + "name": "oidcClientId" + }, + { + "name": "oidcClientSecret" + } + ], + [ + { + "name": "oidcAuthorizationRedirectUri", + "view": "views/settings/fields/oidc-redirect-uri", + "params": { + "readOnly": true, + "copyToClipboard": true + } + }, + false + ], + [ + { + "name": "oidcAuthorizationEndpoint" + }, + { + "name": "oidcTokenEndpoint" + } + ], + [ + { + "name": "oidcJwksEndpoint" + }, + { + "name": "oidcJwtSignatureAlgorithmList" + } + ], + [ + { + "name": "oidcScopes" + }, + { + "name": "oidcUsernameClaim" + } + ], + [ + { + "name": "oidcCreateUser" + }, + { + "name": "oidcSync" + } + ], + [ + { + "name": "oidcTeams" + }, + { + "name": "oidcGroupClaim" + } + ], + [ + { + "name": "oidcSyncTeams" + }, + false + ], + [ + { + "name": "oidcFallback" + }, + { + "name": "oidcAllowRegularUserFallback" + } + ], + [ + { + "name": "oidcAllowAdminUser" + }, + { + "name": "oidcLogoutUrl" + } + ] + ] + }, + "fieldList": [ + "oidcClientId", + "oidcClientSecret", + "oidcAuthorizationEndpoint", + "oidcTokenEndpoint", + "oidcJwksEndpoint", + "oidcJwtSignatureAlgorithmList", + "oidcScopes", + "oidcGroupClaim", + "oidcCreateUser", + "oidcUsernameClaim", + "oidcTeams", + "oidcSync", + "oidcSyncTeams", + "oidcAuthorizationRedirectUri", + "oidcFallback", + "oidcAllowRegularUserFallback", + "oidcAllowAdminUser", + "oidcLogoutUrl" + ], + "dynamicLogic": { + "fields": { + "oidcClientId": { + "required": { + "conditionGroup": [ + { + "type": "equals", + "attribute": "authenticationMethod", + "value": "Oidc" + } + ] + } + }, + "oidcAuthorizationEndpoint": { + "required": { + "conditionGroup": [ + { + "type": "equals", + "attribute": "authenticationMethod", + "value": "Oidc" + } + ] + } + }, + "oidcTokenEndpoint": { + "required": { + "conditionGroup": [ + { + "type": "equals", + "attribute": "authenticationMethod", + "value": "Oidc" + } + ] + } + }, + "oidcUsernameClaim": { + "required": { + "conditionGroup": [ + { + "type": "equals", + "attribute": "authenticationMethod", + "value": "Oidc" + } + ] + } + }, + "oidcJwtSignatureAlgorithmList": { + "required": { + "conditionGroup": [ + { + "type": "equals", + "attribute": "authenticationMethod", + "value": "Oidc" + } + ] + } + }, + "oidcJwksEndpoint": { + "required": { + "conditionGroup": [ + { + "type": "equals", + "attribute": "authenticationMethod", + "value": "Oidc" + }, + { + "type": "or", + "value": [ + { + "type": "contains", + "attribute": "oidcJwtSignatureAlgorithmList", + "value": "RS256" + }, + { + "type": "contains", + "attribute": "oidcJwtSignatureAlgorithmList", + "value": "RS384" + }, + { + "type": "contains", + "attribute": "oidcJwtSignatureAlgorithmList", + "value": "RS512" + } + ] + } + ] + } + }, + "oidcAllowRegularUserFallback": { + "invalid": { + "conditionGroup": [ + { + "type": "equals", + "attribute": "authenticationMethod", + "value": "Oidc" + }, + { + "type": "isTrue", + "attribute": "oidcAllowRegularUserFallback" + }, + { + "type": "isFalse", + "attribute": "oidcFallback" + } + ] + } + }, + "oidcAllowAdminUser": { + "invalid": { + "conditionGroup": [ + { + "type": "equals", + "attribute": "authenticationMethod", + "value": "Oidc" + }, + { + "type": "isFalse", + "attribute": "oidcAllowAdminUser" + }, + { + "type": "isFalse", + "attribute": "oidcFallback" + } + ] + } + } + } + } + } +} diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index d865ac00b5..2e0ce3117d 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -755,6 +755,91 @@ "type": "link", "tooltip": true, "entity": "WorkingTimeCalendar" + }, + "oidcClientId": { + "type": "varchar" + }, + "oidcClientSecret": { + "type": "password" + }, + "oidcAuthorizationEndpoint": { + "type": "url", + "strip": false + }, + "oidcTokenEndpoint": { + "type": "url", + "strip": false + }, + "oidcJwksEndpoint": { + "type": "url", + "strip": false + }, + "oidcJwtSignatureAlgorithmList": { + "type": "multiEnum", + "options": [ + "RS256", + "RS384", + "RS512", + "HS256", + "HS384", + "HS512" + ] + }, + "oidcScopes": { + "type": "multiEnum", + "allowCustomOptions": true, + "options": [ + "profile", + "email", + "phone", + "address" + ] + }, + "oidcGroupClaim": { + "type": "varchar", + "tooltip": true + }, + "oidcCreateUser": { + "type": "bool", + "tooltip": true + }, + "oidcUsernameClaim": { + "type": "varchar", + "options": [ + "sub", + "preferred_username", + "email" + ], + "tooltip": true + }, + "oidcTeams": { + "type": "linkMultiple", + "entity": "Team", + "additionalAttributeList": ["columns"], + "view": "views/settings/fields/oidc-teams", + "tooltip": true + }, + "oidcSync": { + "type": "bool", + "tooltip": true + }, + "oidcSyncTeams": { + "type": "bool", + "tooltip": true + }, + "oidcFallback": { + "type": "bool", + "tooltip": true + }, + "oidcAllowRegularUserFallback": { + "type": "bool" + }, + "oidcAllowAdminUser": { + "type": "bool" + }, + "oidcLogoutUrl": { + "type": "varchar", + "tooltip": true } } } diff --git a/application/Espo/Resources/metadata/entityDefs/User.json b/application/Espo/Resources/metadata/entityDefs/User.json index 8d8b6043e3..f03cc236bd 100644 --- a/application/Espo/Resources/metadata/entityDefs/User.json +++ b/application/Espo/Resources/metadata/entityDefs/User.json @@ -10,7 +10,8 @@ "maxLength", "tooltipText", "inlineEditDisabled" - ] + ], + "index": true }, "name": { "type": "personName", diff --git a/application/Espo/Resources/routes.json b/application/Espo/Resources/routes.json index e569fc6313..c7e14aac76 100644 --- a/application/Espo/Resources/routes.json +++ b/application/Espo/Resources/routes.json @@ -284,6 +284,24 @@ "action": "chunk" } }, + { + "route": "Oidc/authorizationData", + "method": "get", + "params": { + "controller": "Oidc", + "action": "authorizationData" + }, + "noAuth": true + }, + { + "route": "Oidc/backchannelLogout", + "method": "post", + "params": { + "controller": "Oidc", + "action": "backchannelLogout" + }, + "noAuth": true + }, { "route": "/:controller/:id", "method": "get", diff --git a/application/Espo/Tools/Oidc/Service.php b/application/Espo/Tools/Oidc/Service.php new file mode 100644 index 0000000000..dd85dd3a9f --- /dev/null +++ b/application/Espo/Tools/Oidc/Service.php @@ -0,0 +1,135 @@ +config = $config; + $this->backchannelLogout = $backchannelLogout; + } + + /** + * @return array{ + * clientId: non-empty-string, + * endpoint: non-empty-string, + * redirectUri: non-empty-string, + * scopes: non-empty-array, + * claims: ?string, + * prompt: 'login'|'consent'|'select_account', + * maxAge: ?int, + * } + * @throws Forbidden + * @throws Error + */ + public function getAuthorizationData(): array + { + if ($this->config->get('authenticationMethod') !== OidcLogin::NAME) { + throw new ForbiddenSilent(); + } + + /** @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'); + + if (!$clientId) { + throw new Error("No client ID."); + } + + if (!$endpoint) { + throw new Error("No authorization endpoint."); + } + + $redirectUri = rtrim($this->config->get('siteUrl') ?? '', '/') . '/oauth-callback.php'; + + array_unshift($scopes, 'openid'); + + $claims = null; + + if ($groupClaim) { + $claims = Json::encode([ + 'id_token' => [ + $groupClaim => ['essential' => true], + ], + ]); + } + + /** @var 'login'|'consent'|'select_account' $prompt */ + $prompt = $this->config->get('oidcAuthorizationPrompt') ?? 'consent'; + /** @var ?int $maxAge */ + $maxAge = $this->config->get('oidcAuthorizationMaxAge'); + + return [ + 'clientId' => $clientId, + 'endpoint' => $endpoint, + 'redirectUri' => $redirectUri, + 'scopes' => $scopes, + 'claims' => $claims, + 'prompt' => $prompt, + 'maxAge' => $maxAge, + ]; + } + + /** + * @throws ForbiddenSilent + */ + public function backchannelLogout(string $rawToken): void + { + if ($this->config->get('authenticationMethod') !== OidcLogin::NAME) { + throw new ForbiddenSilent(); + } + + try { + $this->backchannelLogout->logout($rawToken); + } + catch (Invalid $e) { + throw new ForbiddenSilent("OIDC logout: Invalid JWT. " . $e->getMessage()); + } + } +} diff --git a/client/src/app.js b/client/src/app.js index 0c93086ba8..2703e615d2 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -931,8 +931,15 @@ function ( if (arr.length > 1) { Ajax.postRequest('App/action/destroyAuthToken', { - token: arr[1] - }); + token: arr[1] + }) + .then((data, status, xhr) => { + let redirectUrl = xhr.getResponseHeader('X-Logout-Redirect-Url'); + + if (redirectUrl) { + setTimeout(() => window.location.href = redirectUrl, 50); + } + }); } } diff --git a/client/src/handlers/login/oidc.js b/client/src/handlers/login/oidc.js new file mode 100644 index 0000000000..ea749d51be --- /dev/null +++ b/client/src/handlers/login/oidc.js @@ -0,0 +1,219 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2022 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('handlers/login/oidc', ['handlers/login'], function (Dep) { + + /** + * Custom login handling. To be extended. + * + * @class + * @name Class + * @memberOf module:handlers/login/oidc + * @extends module:handlers/login.Class + */ + class Class extends Dep { + + process() { + Espo.Ui.notify(' ... '); + + return new Promise((resolve, reject) => { + Espo.Ajax.getRequest('Oidc/authorizationData') + .then(data => { + Espo.Ui.notify(false); + + this.processWithData(data) + .then((code, nonce) => { + let authString = Base64.encode('**oidc:' + code); + + let headers = { + 'Espo-Authorization': authString, + 'Authorization': 'Basic ' + authString, + 'X-Oidc-Authorization-Nonce': nonce, + }; + + resolve(headers); + }) + .catch(() => { + reject(); + }); + }) + .catch(() => { + Espo.Ui.notify(false) + + reject(); + }); + }); + } + + /** + * @private + * @param {{ + * endpoint: string, + * clientId: string, + * redirectUri: string, + * scopes: string[], + * claims: ?string, + * prompt: 'login'|'consent'|'select_account', + * maxAge: ?Number, + * }} data + * @return {Promise} + */ + processWithData(data) { + let state = (Math.random() + 1).toString(36).substring(7); + let nonce = (Math.random() + 1).toString(36).substring(7); + + let params = { + client_id: data.clientId, + redirect_uri: data.redirectUri, + response_type: 'code', + scope: data.scopes.join(' '), + state: state, + nonce: nonce, + prompt: data.prompt, + }; + + if (data.maxAge || data.maxAge === 0) { + params.max_age = data.maxAge; + } + + if (data.claims) { + params.claims = data.claims; + } + + let partList = Object.entries(params) + .map(([key, value]) => { + return key + '=' + encodeURIComponent(value); + }); + + let url = data.endpoint + '?' + partList.join('&'); + + return this.processWindow(url, state, nonce); + } + + /** + * @private + * @param {string} url + * @param {string} state + * @param {string} nonce + * @return {Promise} + */ + processWindow(url, state, nonce) { + let proxy = window.open(url, 'ConnectWithOAuth', 'location=0,status=0,width=800,height=800'); + + return new Promise((resolve, reject) => { + let fail = () => { + window.clearInterval(interval); + + if (!proxy.closed) { + proxy.close(); + } + + reject(); + }; + + let interval = window.setInterval(() => { + if (proxy.closed) { + fail(); + + return; + } + + let url; + + try { + url = proxy.location.href; + } + catch (e) { + return; + } + + if (!url) { + return; + } + + let parsedData = this.parseWindowUrl(url); + + if (!parsedData) { + fail(); + Espo.Ui.error('Could not parse URL', true); + + return; + } + + if ((parsedData.error || parsedData.code) && parsedData.state !== state) { + fail(); + Espo.Ui.error('State mismatch', true); + + return; + } + + if (parsedData.error) { + fail(); + Espo.Ui.error(parsedData.errorDescription || this.loginView.translate('Error'), true); + + return; + } + + if (parsedData.code) { + window.clearInterval(interval); + proxy.close(); + + resolve(parsedData.code, nonce); + } + }, 300); + }); + } + + /** + * @param {string} url + * @return {?{ + * code: ?string, + * state: ?string, + * error: ?string, + * errorDescription: ?string, + * }} + */ + parseWindowUrl(url) { + try { + let params = new URL(url).searchParams; + + return { + code: params.get('code'), + state: params.get('state'), + error: params.get('error'), + errorDescription: params.get('errorDescription'), + }; + } + catch(e) { + return null; + } + } + } + + return Class; +}); diff --git a/client/src/views/fields/link-multiple-with-role.js b/client/src/views/fields/link-multiple-with-role.js index a998d0f688..b418ed2896 100644 --- a/client/src/views/fields/link-multiple-with-role.js +++ b/client/src/views/fields/link-multiple-with-role.js @@ -31,7 +31,7 @@ define('views/fields/link-multiple-with-role', ['views/fields/link-multiple'], f /** * A link-multiple field with a relation column. * - * @deprecated Use `link-multiple-with-columns`. + * @inheritDoc Prefer using `link-multiple-with-columns` instead. * * @class * @name Class @@ -61,6 +61,18 @@ define('views/fields/link-multiple-with-role', ['views/fields/link-multiple'], f */ emptyRoleValue: null, + /** + * A role placeholder text. + */ + rolePlaceholderText: null, + + /** + * A role value max length. + * + * @protected + */ + roleMaxLength: 50, + /** * @const */ @@ -300,11 +312,11 @@ define('views/fields/link-multiple-with-role', ['views/fields/link-multiple'], f $role = this.getJQSelect(id, role); } else { - let text = this.translate(this.roleField, 'fields', this.roleFieldScope); + let text = this.rolePlaceholderText || this.translate(this.roleField, 'fields', this.roleFieldScope); $role = $('') .addClass('role form-control input-sm pull-right') - .attr('maxlength', 50) // @todo Get the value from metadata. + .attr('maxlength', this.roleMaxLength) // @todo Get the value from metadata. .attr('placeholder', text) .attr('data-id', id) .attr('value', role || ''); diff --git a/client/src/views/login.js b/client/src/views/login.js index 467d395e74..088ee56b5a 100644 --- a/client/src/views/login.js +++ b/client/src/views/login.js @@ -381,7 +381,11 @@ define('views/login', ['view'], function (Dep) { $cell.removeClass('has-error'); }); - Espo.Ui.error(this.translate('wrongUsernamePassword', 'messages', 'User')); + let messageKey = this.handler ? + 'failedToLogIn' : + 'wrongUsernamePassword'; + + Espo.Ui.error(this.translate(messageKey, 'messages', 'User')); }, /** diff --git a/client/src/views/settings/fields/oidc-redirect-uri.js b/client/src/views/settings/fields/oidc-redirect-uri.js new file mode 100644 index 0000000000..127b20dcc1 --- /dev/null +++ b/client/src/views/settings/fields/oidc-redirect-uri.js @@ -0,0 +1,47 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2022 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/settings/fields/oidc-redirect-uri', ['views/fields/varchar'], function (Dep) { + + return Dep.extend({ + + detailTemplateContent: `{{value}}`, + + data: function () { + return { + value: this.getValueForDisplay(), + }; + }, + + getValueForDisplay: function () { + let siteUrl = (this.getConfig().get('siteUrl') || '').replace(/\/+$/, ''); + + return siteUrl + '/oauth-callback.php'; + }, + }); +}); diff --git a/client/src/views/settings/fields/oidc-teams.js b/client/src/views/settings/fields/oidc-teams.js new file mode 100644 index 0000000000..c154067ef0 --- /dev/null +++ b/client/src/views/settings/fields/oidc-teams.js @@ -0,0 +1,47 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2022 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/settings/fields/oidc-teams', ['views/fields/link-multiple-with-role'], function (Dep) { + + return Dep.extend({ + + forceRoles: true, + + roleType: 'varchar', + + columnName: 'group', + + roleMaxLength: 255, + + setup: function () { + Dep.prototype.setup.call(this); + + this.rolePlaceholderText = this.translate('IdP Group', 'labels', 'Settings'); + }, + }); +}); diff --git a/composer.json b/composer.json index d3493b4cee..65b7b6e07f 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,8 @@ "doctrine/dbal": "^3.3.3", "league/flysystem-async-aws-s3": "^2.0", "johngrogg/ics-parser": "^3.0", - "laminas/laminas-zendframework-bridge": "^1.4" + "laminas/laminas-zendframework-bridge": "^1.4", + "phpseclib/phpseclib": "^3.0" }, "require-dev": { "phpunit/phpunit": "^9", diff --git a/composer.lock b/composer.lock index 152e86d59d..e1daef7902 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "eeb8a42a7e5e88311097944d4662e2b0", + "content-hash": "cdaefeee4273d13c3941a5e2479a4388", "packages": [ { "name": "async-aws/core", @@ -2455,6 +2455,123 @@ }, "time": "2020-11-07T02:01:34+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.6.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "58c3f47f650c94ec05a151692652a868995d2938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/58c3f47f650c94ec05a151692652a868995d2938", + "reference": "58c3f47f650c94ec05a151692652a868995d2938", + "shasum": "" + }, + "require": { + "php": "^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2022-06-14T06:56:20+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "phpoffice/phpspreadsheet", "version": "1.16.0", @@ -2556,6 +2673,116 @@ }, "time": "2020-12-31T18:03:49+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.16", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "7181378909ed8890be4db53d289faac5b77f8b05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/7181378909ed8890be4db53d289faac5b77f8b05", + "reference": "7181378909ed8890be4db53d289faac5b77f8b05", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.16" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2022-09-05T18:03:08+00:00" + }, { "name": "psr/cache", "version": "1.0.1", diff --git a/tests/unit/Espo/Core/Authentication/Jwt/TokenTest.php b/tests/unit/Espo/Core/Authentication/Jwt/TokenTest.php new file mode 100644 index 0000000000..e96b1dfbf6 --- /dev/null +++ b/tests/unit/Espo/Core/Authentication/Jwt/TokenTest.php @@ -0,0 +1,119 @@ +assertEquals('HS256', $token->getHeader()->getAlg()); + $this->assertEquals('1234567890', $token->getPayload()->getSub()); + $this->assertEquals('John Doe', $token->getPayload()->get('name')); + } + + public function testVerifySignatureHS256(): void + { + $raw = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." . + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0" . + "IjoxNTE2MjM5MDIyfQ.S2ZL7D-D3VeduQ44Cy2qLRFxHV43gRGSZtlfJ2MJ57g"; + + $token = Token::create($raw); + + $this->assertEquals('HS256', $token->getHeader()->getAlg()); + $this->assertEquals('1234567890', $token->getPayload()->getSub()); + + $verifier1 = new Hmac('HS256', '123456789'); + $this->assertTrue($verifier1->verify($token)); + + $verifier2 = new Hmac('HS256', '0000000000'); + $this->assertFalse($verifier2->verify($token)); + + $verifier3 = new Hmac('HS512', '123456789'); + $this->assertFalse($verifier3->verify($token)); + } + + public function testVerifySignatureRS256(): void + { + $raw = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjAwMSJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9l" . + "IiwiaWF0IjoxNTE2MjM5MDIyfQ.ZIOKC5KV6E1omC_KnQHgG5h9Z8G3g8jc1uUiI0RkQITAM-oS_3qivauy5jJnMX_N9-Bz2ZZSQ6" . + "4AA_LbELCSoLnQ4HJBRzK8XfNeTVwhebjelUc8b_qcWC16lUWydd1GFYQQYbPfis_EN0UNM5TvfPpZ24YPujxJqZ0vrOOM-T6U73PtQ" . + "TLfErmdO8cd_drHc75lSmWvXjpRl7zAj9vGhO_nJRh3-tPGprkFMvC4FLWOF5L_4eS3bJhhj7GUxyYYNi2ATbO7SRCPUp2Ck_aiJNlbS" . + "Vdhbt_2Ls8VGnSPDdPf9UDUiXqYadueqmyrRucbBNHYn46cehnuONJa3gMaSA"; + + $token = Token::create($raw); + + $pem = + "-----BEGIN PUBLIC KEY-----\r\n" . + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\r\n" . + "4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u\r\n" . + "+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh\r\n" . + "kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ\r\n" . + "0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg\r\n" . + "cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc\r\n" . + "mwIDAQAB\r\n" . + "-----END PUBLIC KEY-----\r\n"; + + $pk = openssl_pkey_get_public($pem); + $keyData = openssl_pkey_get_details($pk); + + $rawKey = (object) [ + 'kid' => '001', + 'alg' => 'RS256', + 'kty' => 'RSA', + 'n' => Strings::base64url_encode($keyData['rsa']['n']), + 'e' => Strings::base64url_encode($keyData['rsa']['e']), + ]; + + $key = (new DefaultKeyFactory())->create($rawKey); + + $verifier1 = new Rsa('RS256', [$key]); + $this->assertTrue($verifier1->verify($token)); + + $verifier1 = new Rsa('RS256', [$this->createMock(Key::class), $key]); + $this->assertTrue($verifier1->verify($token)); + + $verifier1 = new Rsa('RS512', [$key]); + $this->assertFalse($verifier1->verify($token)); + } +} diff --git a/tests/unit/Espo/Core/Authentication/Logins/Oidc/SyncTest.php b/tests/unit/Espo/Core/Authentication/Logins/Oidc/SyncTest.php new file mode 100644 index 0000000000..636a8967f3 --- /dev/null +++ b/tests/unit/Espo/Core/Authentication/Logins/Oidc/SyncTest.php @@ -0,0 +1,90 @@ +config = $this->createMock(Config::class); + + $this->sync = new Sync( + $this->createMock(EntityManager::class), + $this->config, + $this->createMock(LinkMultipleSaver::class), + $this->createMock(EmailAddressSaver::class), + $this->createMock(PhoneNumberSaver::class), + $this->createMock(PasswordHash::class), + $this->createMock(Clearer::class) + ); + } + + public function testNormalizeUsername(): void + { + $this->config + ->expects($this->any()) + ->method('get') + ->with('userNameRegularExpression') + ->willReturn('[^a-z0-9\-@_\.\s]'); + + $this->assertEquals( + 'test_name', + $this->sync->normalizeUsername('test_name') + ); + + $this->assertEquals( + 'test_name', + $this->sync->normalizeUsername('test|name') + ); + + $this->assertEquals( + 'test@name', + $this->sync->normalizeUsername('test@name') + ); + + $this->assertEquals( + 'test_name', + $this->sync->normalizeUsername('test name') + ); + } +}