diff --git a/.env.example.complete b/.env.example.complete index 5eb65c27f..418875165 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -239,6 +239,18 @@ SAML2_USER_TO_GROUPS=false SAML2_GROUP_ATTRIBUTE=group SAML2_REMOVE_FROM_GROUPS=false +# OpenID Connect authentication configuration +OIDC_NAME=SSO +OIDC_DISPLAY_NAME_CLAIMS=name +OIDC_CLIENT_ID=null +OIDC_CLIENT_SECRET=null +OIDC_ISSUER=null +OIDC_ISSUER_DISCOVER=false +OIDC_PUBLIC_KEY=null +OIDC_AUTH_ENDPOINT=null +OIDC_TOKEN_ENDPOINT=null +OIDC_DUMP_USER_DETAILS=false + # Disable default third-party services such as Gravatar and Draw.IO # Service-specific options will override this option DISABLE_EXTERNAL_SERVICES=false diff --git a/app/Auth/Access/ExternalAuthService.php b/app/Auth/Access/GroupSyncService.php similarity index 93% rename from app/Auth/Access/ExternalAuthService.php rename to app/Auth/Access/GroupSyncService.php index 7bd3679ac..ddd539b77 100644 --- a/app/Auth/Access/ExternalAuthService.php +++ b/app/Auth/Access/GroupSyncService.php @@ -6,7 +6,7 @@ use BookStack\Auth\Role; use BookStack\Auth\User; use Illuminate\Support\Collection; -class ExternalAuthService +class GroupSyncService { /** * Check a role against an array of group names to see if it matches. @@ -60,17 +60,17 @@ class ExternalAuthService /** * Sync the groups to the user roles for the current user. */ - public function syncWithGroups(User $user, array $userGroups): void + public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void { // Get the ids for the roles from the names $groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups); // Sync groups - if ($this->config['remove_from_groups']) { + if ($detachExisting) { $user->roles()->sync($groupsAsRoles); $user->attachDefaultRole(); } else { $user->roles()->syncWithoutDetaching($groupsAsRoles); } } -} +} \ No newline at end of file diff --git a/app/Auth/Access/Guards/Saml2SessionGuard.php b/app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php similarity index 92% rename from app/Auth/Access/Guards/Saml2SessionGuard.php rename to app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php index eacd5d21e..6677f5b10 100644 --- a/app/Auth/Access/Guards/Saml2SessionGuard.php +++ b/app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php @@ -10,7 +10,7 @@ namespace BookStack\Auth\Access\Guards; * via the Saml2 controller & Saml2Service. This class provides a safer, thin * version of SessionGuard. */ -class Saml2SessionGuard extends ExternalBaseSessionGuard +class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard { /** * Validate a user's credentials. diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index 7bfdb5328..ddd6ada97 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -13,9 +13,10 @@ use Illuminate\Support\Facades\Log; * Class LdapService * Handles any app-specific LDAP tasks. */ -class LdapService extends ExternalAuthService +class LdapService { protected $ldap; + protected $groupSyncService; protected $ldapConnection; protected $userAvatars; protected $config; @@ -24,20 +25,19 @@ class LdapService extends ExternalAuthService /** * LdapService constructor. */ - public function __construct(Ldap $ldap, UserAvatars $userAvatars) + public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService) { $this->ldap = $ldap; $this->userAvatars = $userAvatars; + $this->groupSyncService = $groupSyncService; $this->config = config('services.ldap'); $this->enabled = config('auth.method') === 'ldap'; } /** * Check if groups should be synced. - * - * @return bool */ - public function shouldSyncGroups() + public function shouldSyncGroups(): bool { return $this->enabled && $this->config['user_to_groups'] !== false; } @@ -285,9 +285,7 @@ class LdapService extends ExternalAuthService } $userGroups = $this->groupFilter($user); - $userGroups = $this->getGroupsRecursive($userGroups, []); - - return $userGroups; + return $this->getGroupsRecursive($userGroups, []); } /** @@ -374,7 +372,7 @@ class LdapService extends ExternalAuthService public function syncGroups(User $user, string $username) { $userLdapGroups = $this->getUserGroups($username); - $this->syncWithGroups($user, $userLdapGroups); + $this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']); } /** diff --git a/app/Auth/Access/LoginService.php b/app/Auth/Access/LoginService.php index e02296b37..f41570417 100644 --- a/app/Auth/Access/LoginService.php +++ b/app/Auth/Access/LoginService.php @@ -47,7 +47,7 @@ class LoginService // Authenticate on all session guards if a likely admin if ($user->can('users-manage') && $user->can('user-roles-manage')) { - $guards = ['standard', 'ldap', 'saml2']; + $guards = ['standard', 'ldap', 'saml2', 'oidc']; foreach ($guards as $guard) { auth($guard)->login($user); } diff --git a/app/Auth/Access/Oidc/OidcAccessToken.php b/app/Auth/Access/Oidc/OidcAccessToken.php new file mode 100644 index 000000000..63853e08a --- /dev/null +++ b/app/Auth/Access/Oidc/OidcAccessToken.php @@ -0,0 +1,54 @@ +validate($options); + } + + + /** + * Validate this access token response for OIDC. + * As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK. + */ + private function validate(array $options): void + { + // access_token: REQUIRED. Access Token for the UserInfo Endpoint. + // Performed on the extended class + + // token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0 + // Bearer Token Usage [RFC6750], for Clients using this subset. + // Note that the token_type value is case-insensitive. + if (strtolower(($options['token_type'] ?? '')) !== 'bearer') { + throw new InvalidArgumentException('The response token type MUST be "Bearer"'); + } + + // id_token: REQUIRED. ID Token. + if (empty($options['id_token'])) { + throw new InvalidArgumentException('An "id_token" property must be provided'); + } + } + + /** + * Get the id token value from this access token response. + */ + public function getIdToken(): string + { + return $this->getValues()['id_token']; + } + +} \ No newline at end of file diff --git a/app/Auth/Access/Oidc/OidcIdToken.php b/app/Auth/Access/Oidc/OidcIdToken.php new file mode 100644 index 000000000..de9c42ab2 --- /dev/null +++ b/app/Auth/Access/Oidc/OidcIdToken.php @@ -0,0 +1,232 @@ +keys = $keys; + $this->issuer = $issuer; + $this->parse($token); + } + + /** + * Parse the token content into its components. + */ + protected function parse(string $token): void + { + $this->tokenParts = explode('.', $token); + $this->header = $this->parseEncodedTokenPart($this->tokenParts[0]); + $this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? ''); + $this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: ''; + } + + /** + * Parse a Base64-JSON encoded token part. + * Returns the data as a key-value array or empty array upon error. + */ + protected function parseEncodedTokenPart(string $part): array + { + $json = $this->base64UrlDecode($part) ?: '{}'; + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; + } + + /** + * Base64URL decode. Needs some character conversions to be compatible + * with PHP's default base64 handling. + */ + protected function base64UrlDecode(string $encoded): string + { + return base64_decode(strtr($encoded, '-_', '+/')); + } + + /** + * Validate all possible parts of the id token. + * @throws OidcInvalidTokenException + */ + public function validate(string $clientId): bool + { + $this->validateTokenStructure(); + $this->validateTokenSignature(); + $this->validateTokenClaims($clientId); + return true; + } + + /** + * Fetch a specific claim from this token. + * Returns null if it is null or does not exist. + * @return mixed|null + */ + public function getClaim(string $claim) + { + return $this->payload[$claim] ?? null; + } + + /** + * Get all returned claims within the token. + */ + public function getAllClaims(): array + { + return $this->payload; + } + + /** + * Validate the structure of the given token and ensure we have the required pieces. + * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2 + * @throws OidcInvalidTokenException + */ + protected function validateTokenStructure(): void + { + foreach (['header', 'payload'] as $prop) { + if (empty($this->$prop) || !is_array($this->$prop)) { + throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token"); + } + } + + if (empty($this->signature) || !is_string($this->signature)) { + throw new OidcInvalidTokenException("Could not parse out a valid signature within the provided token"); + } + } + + /** + * Validate the signature of the given token and ensure it validates against the provided key. + * @throws OidcInvalidTokenException + */ + protected function validateTokenSignature(): void + { + if ($this->header['alg'] !== 'RS256') { + throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); + } + + $parsedKeys = array_map(function($key) { + try { + return new OidcJwtSigningKey($key); + } catch (OidcInvalidKeyException $e) { + throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage()); + } + }, $this->keys); + + $parsedKeys = array_filter($parsedKeys); + + $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; + /** @var OidcJwtSigningKey $parsedKey */ + foreach ($parsedKeys as $parsedKey) { + if ($parsedKey->verify($contentToSign, $this->signature)) { + return; + } + } + + throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys'); + } + + /** + * Validate the claims of the token. + * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation + * @throws OidcInvalidTokenException + */ + protected function validateTokenClaims(string $clientId): void + { + // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) + // MUST exactly match the value of the iss (issuer) Claim. + if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) { + throw new OidcInvalidTokenException('Missing or non-matching token issuer value'); + } + + // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered + // at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected + // if the ID Token does not list the Client as a valid audience, or if it contains additional + // audiences not trusted by the Client. + if (empty($this->payload['aud'])) { + throw new OidcInvalidTokenException('Missing token audience value'); + } + + $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; + if (count($aud) !== 1) { + throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1'); + } + + if ($aud[0] !== $clientId) { + throw new OidcInvalidTokenException('Token audience value did not match the expected client_id'); + } + + // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. + // NOTE: Addressed by enforcing a count of 1 above. + + // 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id + // is the Claim Value. + if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) { + throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id'); + } + + // 5. The current time MUST be before the time represented by the exp Claim + // (possibly allowing for some small leeway to account for clock skew). + if (empty($this->payload['exp'])) { + throw new OidcInvalidTokenException('Missing token expiration time value'); + } + + $skewSeconds = 120; + $now = time(); + if ($now >= (intval($this->payload['exp']) + $skewSeconds)) { + throw new OidcInvalidTokenException('Token has expired'); + } + + // 6. The iat Claim can be used to reject tokens that were issued too far away from the current time, + // limiting the amount of time that nonces need to be stored to prevent attacks. + // The acceptable range is Client specific. + if (empty($this->payload['iat'])) { + throw new OidcInvalidTokenException('Missing token issued at time value'); + } + + $dayAgo = time() - 86400; + $iat = intval($this->payload['iat']); + if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) { + throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid'); + } + + // 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate. + // The meaning and processing of acr Claim Values is out of scope for this document. + // NOTE: Not used for our case here. acr is not requested. + + // 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request + // re-authentication if it determines too much time has elapsed since the last End-User authentication. + // NOTE: Not used for our case here. A max_age request is not made. + + // Custom: Ensure the "sub" (Subject) Claim exists and has a value. + if (empty($this->payload['sub'])) { + throw new OidcInvalidTokenException('Missing token subject value'); + } + } + +} \ No newline at end of file diff --git a/app/Auth/Access/Oidc/OidcInvalidKeyException.php b/app/Auth/Access/Oidc/OidcInvalidKeyException.php new file mode 100644 index 000000000..17c32f416 --- /dev/null +++ b/app/Auth/Access/Oidc/OidcInvalidKeyException.php @@ -0,0 +1,8 @@ + 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'] + * @param array|string $jwkOrKeyPath + * @throws OidcInvalidKeyException + */ + public function __construct($jwkOrKeyPath) + { + if (is_array($jwkOrKeyPath)) { + $this->loadFromJwkArray($jwkOrKeyPath); + } else if (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) { + $this->loadFromPath($jwkOrKeyPath); + } else { + throw new OidcInvalidKeyException('Unexpected type of key value provided'); + } + } + + /** + * @throws OidcInvalidKeyException + */ + protected function loadFromPath(string $path) + { + try { + $this->key = PublicKeyLoader::load( + file_get_contents($path) + )->withPadding(RSA::SIGNATURE_PKCS1); + } catch (\Exception $exception) { + throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}"); + } + + if (!($this->key instanceof RSA)) { + throw new OidcInvalidKeyException("Key loaded from file path is not an RSA key as expected"); + } + } + + /** + * @throws OidcInvalidKeyException + */ + protected function loadFromJwkArray(array $jwk) + { + if ($jwk['alg'] !== 'RS256') { + throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}"); + } + + if (empty($jwk['use'])) { + throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected'); + } + + if ($jwk['use'] !== 'sig') { + throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}"); + } + + if (empty($jwk['e'])) { + throw new OidcInvalidKeyException('An "e" parameter on the provided key is expected'); + } + + if (empty($jwk['n'])) { + throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected'); + } + + $n = strtr($jwk['n'] ?? '', '-_', '+/'); + + try { + /** @var RSA $key */ + $this->key = PublicKeyLoader::load([ + 'e' => new BigInteger(base64_decode($jwk['e']), 256), + 'n' => new BigInteger(base64_decode($n), 256), + ])->withPadding(RSA::SIGNATURE_PKCS1); + } catch (\Exception $exception) { + throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); + } + } + + /** + * Use this key to sign the given content and return the signature. + */ + public function verify(string $content, string $signature): bool + { + return $this->key->verify($content, $signature); + } + + /** + * Convert the key to a PEM encoded key string. + */ + public function toPem(): string + { + return $this->key->toString('PKCS8'); + } + +} \ No newline at end of file diff --git a/app/Auth/Access/Oidc/OidcOAuthProvider.php b/app/Auth/Access/Oidc/OidcOAuthProvider.php new file mode 100644 index 000000000..03230e373 --- /dev/null +++ b/app/Auth/Access/Oidc/OidcOAuthProvider.php @@ -0,0 +1,127 @@ +authorizationEndpoint; + } + + /** + * Returns the base URL for requesting an access token. + */ + public function getBaseAccessTokenUrl(array $params): string + { + return $this->tokenEndpoint; + } + + /** + * Returns the URL for requesting the resource owner's details. + */ + public function getResourceOwnerDetailsUrl(AccessToken $token): string + { + return ''; + } + + /** + * Returns the default scopes used by this provider. + * + * This should only be the scopes that are required to request the details + * of the resource owner, rather than all the available scopes. + */ + protected function getDefaultScopes(): array + { + return ['openid', 'profile', 'email']; + } + + + /** + * Returns the string that should be used to separate scopes when building + * the URL for requesting an access token. + */ + protected function getScopeSeparator(): string + { + return ' '; + } + + /** + * Checks a provider response for errors. + * + * @param ResponseInterface $response + * @param array|string $data Parsed response data + * @return void + * @throws IdentityProviderException + */ + protected function checkResponse(ResponseInterface $response, $data) + { + if ($response->getStatusCode() >= 400 || isset($data['error'])) { + throw new IdentityProviderException( + $data['error'] ?? $response->getReasonPhrase(), + $response->getStatusCode(), + (string) $response->getBody() + ); + } + } + + /** + * Generates a resource owner object from a successful resource owner + * details request. + * + * @param array $response + * @param AccessToken $token + * @return ResourceOwnerInterface + */ + protected function createResourceOwner(array $response, AccessToken $token) + { + return new GenericResourceOwner($response, ''); + } + + /** + * Creates an access token from a response. + * + * The grant that was used to fetch the response can be used to provide + * additional context. + * + * @param array $response + * @param AbstractGrant $grant + * @return OidcAccessToken + */ + protected function createAccessToken(array $response, AbstractGrant $grant) + { + return new OidcAccessToken($response); + } + + +} \ No newline at end of file diff --git a/app/Auth/Access/Oidc/OidcProviderSettings.php b/app/Auth/Access/Oidc/OidcProviderSettings.php new file mode 100644 index 000000000..2b72c54b0 --- /dev/null +++ b/app/Auth/Access/Oidc/OidcProviderSettings.php @@ -0,0 +1,198 @@ +applySettingsFromArray($settings); + $this->validateInitial(); + } + + /** + * Apply an array of settings to populate setting properties within this class. + */ + protected function applySettingsFromArray(array $settingsArray) + { + foreach ($settingsArray as $key => $value) { + if (property_exists($this, $key)) { + $this->$key = $value; + } + } + } + + /** + * Validate any core, required properties have been set. + * @throws InvalidArgumentException + */ + protected function validateInitial() + { + $required = ['clientId', 'clientSecret', 'redirectUri', 'issuer']; + foreach ($required as $prop) { + if (empty($this->$prop)) { + throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); + } + } + + if (strpos($this->issuer, 'https://') !== 0) { + throw new InvalidArgumentException("Issuer value must start with https://"); + } + } + + /** + * Perform a full validation on these settings. + * @throws InvalidArgumentException + */ + public function validate(): void + { + $this->validateInitial(); + $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint']; + foreach ($required as $prop) { + if (empty($this->$prop)) { + throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); + } + } + } + + /** + * Discover and autoload settings from the configured issuer. + * @throws OidcIssuerDiscoveryException + */ + public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes) + { + try { + $cacheKey = 'oidc-discovery::' . $this->issuer; + $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function() use ($httpClient) { + return $this->loadSettingsFromIssuerDiscovery($httpClient); + }); + $this->applySettingsFromArray($discoveredSettings); + } catch (ClientExceptionInterface $exception) { + throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}"); + } + } + + /** + * @throws OidcIssuerDiscoveryException + * @throws ClientExceptionInterface + */ + protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array + { + $issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration'; + $request = new Request('GET', $issuerUrl); + $response = $httpClient->sendRequest($request); + $result = json_decode($response->getBody()->getContents(), true); + + if (empty($result) || !is_array($result)) { + throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}"); + } + + if ($result['issuer'] !== $this->issuer) { + throw new OidcIssuerDiscoveryException("Unexpected issuer value found on discovery response"); + } + + $discoveredSettings = []; + + if (!empty($result['authorization_endpoint'])) { + $discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint']; + } + + if (!empty($result['token_endpoint'])) { + $discoveredSettings['tokenEndpoint'] = $result['token_endpoint']; + } + + if (!empty($result['jwks_uri'])) { + $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient); + $discoveredSettings['keys'] = $this->filterKeys($keys); + } + + return $discoveredSettings; + } + + /** + * Filter the given JWK keys down to just those we support. + */ + protected function filterKeys(array $keys): array + { + return array_filter($keys, function(array $key) { + return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256'; + }); + } + + /** + * Return an array of jwks as PHP key=>value arrays. + * @throws ClientExceptionInterface + * @throws OidcIssuerDiscoveryException + */ + protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array + { + $request = new Request('GET', $uri); + $response = $httpClient->sendRequest($request); + $result = json_decode($response->getBody()->getContents(), true); + + if (empty($result) || !is_array($result) || !isset($result['keys'])) { + throw new OidcIssuerDiscoveryException("Error reading keys from issuer jwks_uri"); + } + + return $result['keys']; + } + + /** + * Get the settings needed by an OAuth provider, as a key=>value array. + */ + public function arrayForProvider(): array + { + $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint']; + $settings = []; + foreach ($settingKeys as $setting) { + $settings[$setting] = $this->$setting; + } + return $settings; + } +} \ No newline at end of file diff --git a/app/Auth/Access/Oidc/OidcService.php b/app/Auth/Access/Oidc/OidcService.php new file mode 100644 index 000000000..d59d274e8 --- /dev/null +++ b/app/Auth/Access/Oidc/OidcService.php @@ -0,0 +1,210 @@ +registrationService = $registrationService; + $this->loginService = $loginService; + $this->httpClient = $httpClient; + } + + /** + * Initiate an authorization flow. + * @return array{url: string, state: string} + */ + public function login(): array + { + $settings = $this->getProviderSettings(); + $provider = $this->getProvider($settings); + return [ + 'url' => $provider->getAuthorizationUrl(), + 'state' => $provider->getState(), + ]; + } + + /** + * Process the Authorization response from the authorization server and + * return the matching, or new if registration active, user matched to + * the authorization server. + * Returns null if not authenticated. + * @throws Exception + * @throws ClientExceptionInterface + */ + public function processAuthorizeResponse(?string $authorizationCode): ?User + { + $settings = $this->getProviderSettings(); + $provider = $this->getProvider($settings); + + // Try to exchange authorization code for access token + $accessToken = $provider->getAccessToken('authorization_code', [ + 'code' => $authorizationCode, + ]); + + return $this->processAccessTokenCallback($accessToken, $settings); + } + + /** + * @throws OidcIssuerDiscoveryException + * @throws ClientExceptionInterface + */ + protected function getProviderSettings(): OidcProviderSettings + { + $config = $this->config(); + $settings = new OidcProviderSettings([ + 'issuer' => $config['issuer'], + 'clientId' => $config['client_id'], + 'clientSecret' => $config['client_secret'], + 'redirectUri' => url('/oidc/callback'), + 'authorizationEndpoint' => $config['authorization_endpoint'], + 'tokenEndpoint' => $config['token_endpoint'], + ]); + + // Use keys if configured + if (!empty($config['jwt_public_key'])) { + $settings->keys = [$config['jwt_public_key']]; + } + + // Run discovery + if ($config['discover'] ?? false) { + $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15); + } + + $settings->validate(); + + return $settings; + } + + /** + * Load the underlying OpenID Connect Provider. + */ + protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider + { + return new OidcOAuthProvider($settings->arrayForProvider(), [ + 'httpClient' => $this->httpClient, + 'optionProvider' => new HttpBasicAuthOptionProvider(), + ]); + } + + /** + * Calculate the display name + */ + protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string + { + $displayNameAttr = $this->config()['display_name_claims']; + + $displayName = []; + foreach ($displayNameAttr as $dnAttr) { + $dnComponent = $token->getClaim($dnAttr) ?? ''; + if ($dnComponent !== '') { + $displayName[] = $dnComponent; + } + } + + if (count($displayName) == 0) { + $displayName[] = $defaultValue; + } + + return implode(' ', $displayName); + } + + /** + * Extract the details of a user from an ID token. + * @return array{name: string, email: string, external_id: string} + */ + protected function getUserDetails(OidcIdToken $token): array + { + $id = $token->getClaim('sub'); + return [ + 'external_id' => $id, + 'email' => $token->getClaim('email'), + 'name' => $this->getUserDisplayName($token, $id), + ]; + } + + /** + * Processes a received access token for a user. Login the user when + * they exist, optionally registering them automatically. + * @throws OpenIdConnectException + * @throws JsonDebugException + * @throws UserRegistrationException + * @throws StoppedAuthenticationException + */ + protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User + { + $idTokenText = $accessToken->getIdToken(); + $idToken = new OidcIdToken( + $idTokenText, + $settings->issuer, + $settings->keys, + ); + + if ($this->config()['dump_user_details']) { + throw new JsonDebugException($idToken->getAllClaims()); + } + + try { + $idToken->validate($settings->clientId); + } catch (OidcInvalidTokenException $exception) { + throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}"); + } + + $userDetails = $this->getUserDetails($idToken); + $isLoggedIn = auth()->check(); + + if (empty($userDetails['email'])) { + throw new OpenIdConnectException(trans('errors.oidc_no_email_address')); + } + + if ($isLoggedIn) { + throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login'); + } + + $user = $this->registrationService->findOrRegister( + $userDetails['name'], $userDetails['email'], $userDetails['external_id'] + ); + + if ($user === null) { + throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); + } + + $this->loginService->login($user, 'oidc'); + return $user; + } + + /** + * Get the OIDC config from the application. + */ + protected function config(): array + { + return config('oidc'); + } +} diff --git a/app/Auth/Access/RegistrationService.php b/app/Auth/Access/RegistrationService.php index 16e3edbb4..48970bd2e 100644 --- a/app/Auth/Access/RegistrationService.php +++ b/app/Auth/Access/RegistrationService.php @@ -11,6 +11,7 @@ use BookStack\Facades\Activity; use BookStack\Facades\Theme; use BookStack\Theming\ThemeEvents; use Exception; +use Illuminate\Support\Str; class RegistrationService { @@ -50,6 +51,31 @@ class RegistrationService return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled'); } + /** + * Attempt to find a user in the system otherwise register them as a new + * user. For use with external auth systems since password is auto-generated. + * @throws UserRegistrationException + */ + public function findOrRegister(string $name, string $email, string $externalId): User + { + $user = User::query() + ->where('external_auth_id', '=', $externalId) + ->first(); + + if (is_null($user)) { + $userData = [ + 'name' => $name, + 'email' => $email, + 'password' => Str::random(32), + 'external_auth_id' => $externalId, + ]; + + $user = $this->registerUser($userData, null, false); + } + + return $user; + } + /** * The registrations flow for all users. * diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 6cbfdac0b..8e076f86c 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -8,7 +8,6 @@ use BookStack\Exceptions\SamlException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use Exception; -use Illuminate\Support\Str; use OneLogin\Saml2\Auth; use OneLogin\Saml2\Error; use OneLogin\Saml2\IdPMetadataParser; @@ -18,20 +17,26 @@ use OneLogin\Saml2\ValidationError; * Class Saml2Service * Handles any app-specific SAML tasks. */ -class Saml2Service extends ExternalAuthService +class Saml2Service { protected $config; protected $registrationService; protected $loginService; + protected $groupSyncService; /** * Saml2Service constructor. */ - public function __construct(RegistrationService $registrationService, LoginService $loginService) + public function __construct( + RegistrationService $registrationService, + LoginService $loginService, + GroupSyncService $groupSyncService + ) { $this->config = config('saml2'); $this->registrationService = $registrationService; $this->loginService = $loginService; + $this->groupSyncService = $groupSyncService; } /** @@ -46,7 +51,7 @@ class Saml2Service extends ExternalAuthService return [ 'url' => $toolKit->login($returnRoute, [], false, false, true), - 'id' => $toolKit->getLastRequestID(), + 'id' => $toolKit->getLastRequestID(), ]; } @@ -195,7 +200,7 @@ class Saml2Service extends ExternalAuthService protected function loadOneloginServiceProviderDetails(): array { $spDetails = [ - 'entityId' => url('/saml2/metadata'), + 'entityId' => url('/saml2/metadata'), 'assertionConsumerService' => [ 'url' => url('/saml2/acs'), ], @@ -206,7 +211,7 @@ class Saml2Service extends ExternalAuthService return [ 'baseurl' => url('/saml2'), - 'sp' => $spDetails, + 'sp' => $spDetails, ]; } @@ -258,6 +263,7 @@ class Saml2Service extends ExternalAuthService /** * Extract the details of a user from a SAML response. + * @return array{external_id: string, name: string, email: string, saml_id: string} */ protected function getUserDetails(string $samlID, $samlAttributes): array { @@ -269,9 +275,9 @@ class Saml2Service extends ExternalAuthService return [ 'external_id' => $externalId, - 'name' => $this->getUserDisplayName($samlAttributes, $externalId), - 'email' => $email, - 'saml_id' => $samlID, + 'name' => $this->getUserDisplayName($samlAttributes, $externalId), + 'email' => $email, + 'saml_id' => $samlID, ]; } @@ -322,31 +328,6 @@ class Saml2Service extends ExternalAuthService return $defaultValue; } - /** - * Get the user from the database for the specified details. - * - * @throws UserRegistrationException - */ - protected function getOrRegisterUser(array $userDetails): ?User - { - $user = User::query() - ->where('external_auth_id', '=', $userDetails['external_id']) - ->first(); - - if (is_null($user)) { - $userData = [ - 'name' => $userDetails['name'], - 'email' => $userDetails['email'], - 'password' => Str::random(32), - 'external_auth_id' => $userDetails['external_id'], - ]; - - $user = $this->registrationService->registerUser($userData, null, false); - } - - return $user; - } - /** * Process the SAML response for a user. Login the user when * they exist, optionally registering them automatically. @@ -363,8 +344,8 @@ class Saml2Service extends ExternalAuthService if ($this->config['dump_user_details']) { throw new JsonDebugException([ - 'id_from_idp' => $samlID, - 'attrs_from_idp' => $samlAttributes, + 'id_from_idp' => $samlID, + 'attrs_from_idp' => $samlAttributes, 'attrs_after_parsing' => $userDetails, ]); } @@ -377,14 +358,17 @@ class Saml2Service extends ExternalAuthService throw new SamlException(trans('errors.saml_already_logged_in'), '/login'); } - $user = $this->getOrRegisterUser($userDetails); + $user = $this->registrationService->findOrRegister( + $userDetails['name'], $userDetails['email'], $userDetails['external_id'] + ); + if ($user === null) { throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); } if ($this->shouldSyncGroups()) { $groups = $this->getUserGroups($samlAttributes); - $this->syncWithGroups($user, $groups); + $this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']); } $this->loginService->login($user, 'saml2'); diff --git a/app/Config/auth.php b/app/Config/auth.php index 23b9039b9..69da69bf1 100644 --- a/app/Config/auth.php +++ b/app/Config/auth.php @@ -11,7 +11,7 @@ return [ // Method of authentication to use - // Options: standard, ldap, saml2 + // Options: standard, ldap, saml2, oidc 'method' => env('AUTH_METHOD', 'standard'), // Authentication Defaults @@ -26,7 +26,7 @@ return [ // All authentication drivers have a user provider. This defines how the // users are actually retrieved out of your database or other storage // mechanisms used by this application to persist your user's data. - // Supported drivers: "session", "api-token", "ldap-session" + // Supported drivers: "session", "api-token", "ldap-session", "async-external-session" 'guards' => [ 'standard' => [ 'driver' => 'session', @@ -37,7 +37,11 @@ return [ 'provider' => 'external', ], 'saml2' => [ - 'driver' => 'saml2-session', + 'driver' => 'async-external-session', + 'provider' => 'external', + ], + 'oidc' => [ + 'driver' => 'async-external-session', 'provider' => 'external', ], 'api' => [ diff --git a/app/Config/oidc.php b/app/Config/oidc.php new file mode 100644 index 000000000..1b50d9d66 --- /dev/null +++ b/app/Config/oidc.php @@ -0,0 +1,35 @@ + env('OIDC_NAME', 'SSO'), + + // Dump user details after a login request for debugging purposes + 'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false), + + // Attribute, within a OpenId token, to find the user's display name + 'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')), + + // OAuth2/OpenId client id, as configured in your Authorization server. + 'client_id' => env('OIDC_CLIENT_ID', null), + + // OAuth2/OpenId client secret, as configured in your Authorization server. + 'client_secret' => env('OIDC_CLIENT_SECRET', null), + + // The issuer of the identity token (id_token) this will be compared with + // what is returned in the token. + 'issuer' => env('OIDC_ISSUER', null), + + // Auto-discover the relevant endpoints and keys from the issuer. + // Fetched details are cached for 15 minutes. + 'discover' => env('OIDC_ISSUER_DISCOVER', false), + + // Public key that's used to verify the JWT token with. + // Can be the key value itself or a local 'file://public.key' reference. + 'jwt_public_key' => env('OIDC_PUBLIC_KEY', null), + + // OAuth2 endpoints. + 'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null), + 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), +]; diff --git a/app/Exceptions/OpenIdConnectException.php b/app/Exceptions/OpenIdConnectException.php new file mode 100644 index 000000000..d58585732 --- /dev/null +++ b/app/Exceptions/OpenIdConnectException.php @@ -0,0 +1,6 @@ +middleware('guest', ['only' => ['getLogin', 'login']]); - $this->middleware('guard:standard,ldap', ['only' => ['login', 'logout']]); + $this->middleware('guard:standard,ldap', ['only' => ['login']]); + $this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]); $this->socialAuthService = $socialAuthService; $this->loginService = $loginService; diff --git a/app/Http/Controllers/Auth/OidcController.php b/app/Http/Controllers/Auth/OidcController.php new file mode 100644 index 000000000..f4103cb0a --- /dev/null +++ b/app/Http/Controllers/Auth/OidcController.php @@ -0,0 +1,51 @@ +oidcService = $oidcService; + $this->middleware('guard:oidc'); + } + + /** + * Start the authorization login flow via OIDC. + */ + public function login() + { + $loginDetails = $this->oidcService->login(); + session()->flash('oidc_state', $loginDetails['state']); + + return redirect($loginDetails['url']); + } + + /** + * Authorization flow redirect callback. + * Processes authorization response from the OIDC Authorization Server. + */ + public function callback(Request $request) + { + $storedState = session()->pull('oidc_state'); + $responseState = $request->query('state'); + + if ($storedState !== $responseState) { + $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); + return redirect('/login'); + } + + $this->oidcService->processAuthorizeResponse($request->query('code')); + return redirect()->intended(); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index a0da220ee..2ee303f3f 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -84,7 +84,7 @@ class UserController extends Controller if ($authMethod === 'standard' && !$sendInvite) { $validationRules['password'] = 'required|min:6'; $validationRules['password-confirm'] = 'required|same:password'; - } elseif ($authMethod === 'ldap' || $authMethod === 'saml2') { + } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') { $validationRules['external_auth_id'] = 'required'; } $this->validate($request, $validationRules); @@ -93,7 +93,7 @@ class UserController extends Controller if ($authMethod === 'standard') { $user->password = bcrypt($request->get('password', Str::random(32))); - } elseif ($authMethod === 'ldap' || $authMethod === 'saml2') { + } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') { $user->external_auth_id = $request->get('external_auth_id'); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4446c2a0a..da41de651 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use BookStack\Exceptions\WhoopsBookStackPrettyHandler; use BookStack\Settings\Setting; use BookStack\Settings\SettingService; use BookStack\Util\CspService; +use GuzzleHttp\Client; use Illuminate\Contracts\Cache\Repository; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Blade; @@ -22,6 +23,7 @@ use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Laravel\Socialite\Contracts\Factory as SocialiteFactory; use Whoops\Handler\HandlerInterface; +use Psr\Http\Client\ClientInterface as HttpClientInterface; class AppServiceProvider extends ServiceProvider { @@ -82,5 +84,11 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(CspService::class, function ($app) { return new CspService(); }); + + $this->app->bind(HttpClientInterface::class, function($app) { + return new Client([ + 'timeout' => 3, + ]); + }); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 37b0e83b9..bc7caa195 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -5,7 +5,7 @@ namespace BookStack\Providers; use BookStack\Api\ApiTokenGuard; use BookStack\Auth\Access\ExternalBaseUserProvider; use BookStack\Auth\Access\Guards\LdapSessionGuard; -use BookStack\Auth\Access\Guards\Saml2SessionGuard; +use BookStack\Auth\Access\Guards\AsyncExternalBaseSessionGuard; use BookStack\Auth\Access\LdapService; use BookStack\Auth\Access\LoginService; use BookStack\Auth\Access\RegistrationService; @@ -37,10 +37,10 @@ class AuthServiceProvider extends ServiceProvider ); }); - Auth::extend('saml2-session', function ($app, $name, array $config) { + Auth::extend('async-external-session', function ($app, $name, array $config) { $provider = Auth::createUserProvider($config['provider']); - return new Saml2SessionGuard( + return new AsyncExternalBaseSessionGuard( $name, $provider, $app['session.store'], diff --git a/composer.json b/composer.json index e59b0d1f0..fa2c0c2b5 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,10 @@ "league/commonmark": "^1.5", "league/flysystem-aws-s3-v3": "^1.0.29", "league/html-to-markdown": "^5.0.0", + "league/oauth2-client": "^2.6", "nunomaduro/collision": "^3.1", "onelogin/php-saml": "^4.0", + "phpseclib/phpseclib": "~3.0", "pragmarx/google2fa": "^8.0", "predis/predis": "^1.1.6", "socialiteproviders/discord": "^4.1", diff --git a/composer.lock b/composer.lock index dee5aff4c..f8a13ba8b 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": "d59a665fcd692fc0ddf12e7e4f96d4f1", + "content-hash": "fc6d8f731e3975127a9101802cc4bb3a", "packages": [ { "name": "aws/aws-crt-php", @@ -58,16 +58,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.198.5", + "version": "3.198.6", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "ec63e1ad1b30689e530089e4c9cb18f2ef5c290b" + "reference": "821b8db50dd39be8ec94f286050a500b5f8a0142" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ec63e1ad1b30689e530089e4c9cb18f2ef5c290b", - "reference": "ec63e1ad1b30689e530089e4c9cb18f2ef5c290b", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/821b8db50dd39be8ec94f286050a500b5f8a0142", + "reference": "821b8db50dd39be8ec94f286050a500b5f8a0142", "shasum": "" }, "require": { @@ -143,9 +143,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.198.5" + "source": "https://github.com/aws/aws-sdk-php/tree/3.198.6" }, - "time": "2021-10-14T18:15:37+00:00" + "time": "2021-10-15T18:38:53+00:00" }, { "name": "bacon/bacon-qr-code", @@ -2371,6 +2371,76 @@ }, "time": "2021-08-15T23:05:49+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "badb01e62383430706433191b82506b6df24ad98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98", + "reference": "badb01e62383430706433191b82506b6df24ad98", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "paragonie/random_compat": "^1 || ^2 || ^9.99", + "php": "^5.6 || ^7.0 || ^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.3", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0" + }, + "time": "2020-10-28T02:03:40+00:00" + }, { "name": "monolog/monolog", "version": "2.3.5", @@ -3199,6 +3269,117 @@ ], "time": "2021-08-28T21:27:29+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.10", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "62fcc5a94ac83b1506f52d7558d828617fac9187" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/62fcc5a94ac83b1506f52d7558d828617fac9187", + "reference": "62fcc5a94ac83b1506f52d7558d828617fac9187", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phing/phing": "~2.7", + "phpunit/phpunit": "^5.7|^6.0|^9.4", + "squizlabs/php_codesniffer": "~2.0" + }, + "suggest": { + "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.10" + }, + "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": "2021-08-16T04:24:45+00:00" + }, { "name": "pragmarx/google2fa", "version": "8.0.0", @@ -9289,6 +9470,7 @@ "type": "github" } ], + "abandoned": true, "time": "2020-09-28T06:45:17+00:00" }, { diff --git a/resources/icons/oidc.svg b/resources/icons/oidc.svg new file mode 100644 index 000000000..a9a2994a7 --- /dev/null +++ b/resources/icons/oidc.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index eb8ba54ea..f023b6bdf 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'No action defined', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.', diff --git a/resources/views/auth/parts/login-form-oidc.blade.php b/resources/views/auth/parts/login-form-oidc.blade.php new file mode 100644 index 000000000..e5e1b70fc --- /dev/null +++ b/resources/views/auth/parts/login-form-oidc.blade.php @@ -0,0 +1,11 @@ +
+ {!! csrf_field() !!} + +
+ +
+ +
diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index c87d84c5e..5fe5f3685 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -221,7 +221,7 @@ 'label' => trans('settings.reg_enable_toggle') ]) - @if(in_array(config('auth.method'), ['ldap', 'saml2'])) + @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
{{ trans('settings.reg_enable_external_warning') }}
@endif diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 2f94398b5..9cea9e1fb 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -22,7 +22,7 @@ @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced') ]) - @if(config('auth.method') === 'ldap' || config('auth.method') === 'saml2') + @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
@include('form.text', ['name' => 'external_auth_id']) diff --git a/resources/views/users/parts/form.blade.php b/resources/views/users/parts/form.blade.php index 7105e2ff1..2a5002c3b 100644 --- a/resources/views/users/parts/form.blade.php +++ b/resources/views/users/parts/form.blade.php @@ -25,7 +25,7 @@
-@if(($authMethod === 'ldap' || $authMethod === 'saml2') && userCan('users-manage')) +@if(in_array($authMethod, ['ldap', 'saml2', 'oidc']) && userCan('users-manage'))
diff --git a/routes/web.php b/routes/web.php index 08adeceb9..254076451 100644 --- a/routes/web.php +++ b/routes/web.php @@ -267,6 +267,10 @@ Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata'); Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); +// OIDC routes +Route::post('/oidc/login', 'Auth\OidcController@login'); +Route::get('/oidc/callback', 'Auth\OidcController@callback'); + // User invitation routes Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword'); Route::post('/register/invite/{token}', 'Auth\UserInviteController@setPassword'); diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index f19011c46..79f00bed0 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -334,6 +334,7 @@ class AuthTest extends TestCase $this->assertTrue(auth()->check()); $this->assertTrue(auth('ldap')->check()); $this->assertTrue(auth('saml2')->check()); + $this->assertTrue(auth('oidc')->check()); } public function test_login_authenticates_nonadmins_on_default_guard_only() @@ -346,6 +347,7 @@ class AuthTest extends TestCase $this->assertTrue(auth()->check()); $this->assertFalse(auth('ldap')->check()); $this->assertFalse(auth('saml2')->check()); + $this->assertFalse(auth('oidc')->check()); } public function test_failed_logins_are_logged_when_message_configured() diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php new file mode 100644 index 000000000..cf04080fc --- /dev/null +++ b/tests/Auth/OidcTest.php @@ -0,0 +1,381 @@ +keyFile = tmpfile(); + $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri']; + file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey()); + + config()->set([ + 'auth.method' => 'oidc', + 'auth.defaults.guard' => 'oidc', + 'oidc.name' => 'SingleSignOn-Testing', + 'oidc.display_name_claims' => ['name'], + 'oidc.client_id' => OidcJwtHelper::defaultClientId(), + 'oidc.client_secret' => 'testpass', + 'oidc.jwt_public_key' => $this->keyFilePath, + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'oidc.authorization_endpoint' => 'https://oidc.local/auth', + 'oidc.token_endpoint' => 'https://oidc.local/token', + 'oidc.discover' => false, + 'oidc.dump_user_details' => false, + ]); + } + + public function tearDown(): void + { + parent::tearDown(); + if (file_exists($this->keyFilePath)) { + unlink($this->keyFilePath); + } + } + + public function test_login_option_shows_on_login_page() + { + $req = $this->get('/login'); + $req->assertSeeText('SingleSignOn-Testing'); + $req->assertElementExists('form[action$="/oidc/login"][method=POST] button'); + } + + public function test_oidc_routes_are_only_active_if_oidc_enabled() + { + config()->set(['auth.method' => 'standard']); + $routes = ['/login' => 'post', '/callback' => 'get']; + foreach ($routes as $uri => $method) { + $req = $this->call($method, '/oidc' . $uri); + $this->assertPermissionError($req); + } + } + + public function test_forgot_password_routes_inaccessible() + { + $resp = $this->get('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->get('/password/reset/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/reset'); + $this->assertPermissionError($resp); + } + + public function test_standard_login_routes_inaccessible() + { + $resp = $this->post('/login'); + $this->assertPermissionError($resp); + } + + public function test_logout_route_functions() + { + $this->actingAs($this->getEditor()); + $this->get('/logout'); + $this->assertFalse(auth()->check()); + } + + public function test_user_invite_routes_inaccessible() + { + $resp = $this->get('/register/invite/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register/invite/abc123'); + $this->assertPermissionError($resp); + } + + public function test_user_register_routes_inaccessible() + { + $resp = $this->get('/register'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register'); + $this->assertPermissionError($resp); + } + + public function test_login() + { + $req = $this->post('/oidc/login'); + $redirect = $req->headers->get('location'); + + $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location'); + $this->assertFalse($this->isAuthenticated()); + $this->assertStringContainsString('scope=openid%20profile%20email', $redirect); + $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect); + $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect); + } + + public function test_login_success_flow() + { + // Start auth + $this->post('/oidc/login'); + $state = session()->get('oidc_state'); + + $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101' + ])]); + + // Callback from auth provider + // App calls token endpoint to get id token + $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + $resp->assertRedirect('/'); + $this->assertCount(1, $transactions); + /** @var Request $tokenRequest */ + $tokenRequest = $transactions[0]['request']; + $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri()); + $this->assertEquals('POST', $tokenRequest->getMethod()); + $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]); + $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody()); + $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody()); + $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody()); + + + $this->assertTrue(auth()->check()); + $this->assertDatabaseHas('users', [ + 'email' => 'benny@example.com', + 'external_auth_id' => 'benny1010101', + 'email_confirmed' => false, + ]); + + $user = User::query()->where('email', '=', 'benny@example.com')->first(); + $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott"); + } + + public function test_callback_fails_if_no_state_present_or_matching() + { + $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + + $this->post('/oidc/login'); + $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + + public function test_dump_user_details_option_outputs_as_expected() + { + config()->set('oidc.dump_user_details', true); + + $resp = $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $resp->assertStatus(200); + $resp->assertJson([ + 'email' => 'benny@example.com', + 'sub' => 'benny505', + "iss" => OidcJwtHelper::defaultIssuer(), + "aud" => OidcJwtHelper::defaultClientId(), + ]); + $this->assertFalse(auth()->check()); + } + + public function test_auth_fails_if_no_email_exists_in_user_data() + { + $this->runLogin([ + 'email' => '', + 'sub' => 'benny505' + ]); + + $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system'); + } + + public function test_auth_fails_if_already_logged_in() + { + $this->asEditor(); + + $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $this->assertSessionError('Already logged in'); + } + + public function test_auth_login_as_existing_user() + { + $editor = $this->getEditor(); + $editor->external_auth_id = 'benny505'; + $editor->save(); + + $this->assertFalse(auth()->check()); + + $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $this->assertTrue(auth()->check()); + $this->assertEquals($editor->id, auth()->user()->id); + } + + public function test_auth_login_as_existing_user_email_with_different_auth_id_fails() + { + $editor = $this->getEditor(); + $editor->external_auth_id = 'editor101'; + $editor->save(); + + $this->assertFalse(auth()->check()); + + $this->runLogin([ + 'email' => $editor->email, + 'sub' => 'benny505' + ]); + + $this->assertSessionError('A user with the email ' . $editor->email . ' already exists but with different credentials.'); + $this->assertFalse(auth()->check()); + } + + public function test_auth_login_with_invalid_token_fails() + { + $this->runLogin([ + 'sub' => null, + ]); + + $this->assertSessionError('ID token validate failed with error: Missing token subject value'); + $this->assertFalse(auth()->check()); + } + + public function test_auth_login_with_autodiscovery() + { + $this->withAutodiscovery(); + + $transactions = &$this->mockHttpClient([ + $this->getAutoDiscoveryResponse(), + $this->getJwksResponse(), + ]); + + $this->assertFalse(auth()->check()); + + $this->runLogin(); + + $this->assertTrue(auth()->check()); + /** @var Request $discoverRequest */ + $discoverRequest = $transactions[0]['request']; + /** @var Request $discoverRequest */ + $keysRequest = $transactions[1]['request']; + + $this->assertEquals('GET', $keysRequest->getMethod()); + $this->assertEquals('GET', $discoverRequest->getMethod()); + $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri()); + $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri()); + } + + public function test_auth_fails_if_autodiscovery_fails() + { + $this->withAutodiscovery(); + $this->mockHttpClient([ + new Response(404, [], 'Not found'), + ]); + + $this->runLogin(); + $this->assertFalse(auth()->check()); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + + public function test_autodiscovery_calls_are_cached() + { + $this->withAutodiscovery(); + + $transactions = &$this->mockHttpClient([ + $this->getAutoDiscoveryResponse(), + $this->getJwksResponse(), + $this->getAutoDiscoveryResponse([ + 'issuer' => 'https://auto.example.com' + ]), + $this->getJwksResponse(), + ]); + + // Initial run + $this->post('/oidc/login'); + $this->assertCount(2, $transactions); + // Second run, hits cache + $this->post('/oidc/login'); + $this->assertCount(2, $transactions); + + // Third run, different issuer, new cache key + config()->set(['oidc.issuer' => 'https://auto.example.com']); + $this->post('/oidc/login'); + $this->assertCount(4, $transactions); + } + + protected function withAutodiscovery() + { + config()->set([ + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'oidc.discover' => true, + 'oidc.authorization_endpoint' => null, + 'oidc.token_endpoint' => null, + 'oidc.jwt_public_key' => null, + ]); + } + + protected function runLogin($claimOverrides = []): TestResponse + { + $this->post('/oidc/login'); + $state = session()->get('oidc_state'); + $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]); + + return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + } + + protected function getAutoDiscoveryResponse($responseOverrides = []): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode(array_merge([ + 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', + 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', + 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', + 'issuer' => OidcJwtHelper::defaultIssuer() + ], $responseOverrides))); + } + + protected function getJwksResponse(): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode([ + 'keys' => [ + OidcJwtHelper::publicJwkKeyArray() + ] + ])); + } + + protected function getMockAuthorizationResponse($claimOverrides = []): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode([ + 'access_token' => 'abc123', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'id_token' => OidcJwtHelper::idToken($claimOverrides) + ])); + } +} diff --git a/tests/Helpers/OidcJwtHelper.php b/tests/Helpers/OidcJwtHelper.php new file mode 100644 index 000000000..0c44efb01 --- /dev/null +++ b/tests/Helpers/OidcJwtHelper.php @@ -0,0 +1,132 @@ + "abc1234def", + "name" => "Barry Scott", + "email" => "bscott@example.com", + "ver" => 1, + "iss" => static::defaultIssuer(), + "aud" => static::defaultClientId(), + "iat" => time(), + "exp" => time() + 720, + "jti" => "ID.AaaBBBbbCCCcccDDddddddEEEeeeeee", + "amr" => ["pwd"], + "idp" => "fghfghgfh546456dfgdfg", + "preferred_username" => "xXBazzaXx", + "auth_time" => time(), + "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", + ]; + } + + public static function idToken($payloadOverrides = [], $headerOverrides = []): string + { + $payload = array_merge(static::defaultPayload(), $payloadOverrides); + $header = array_merge([ + 'kid' => 'xyz456', + 'alg' => 'RS256', + ], $headerOverrides); + + $top = implode('.', [ + static::base64UrlEncode(json_encode($header)), + static::base64UrlEncode(json_encode($payload)), + ]); + + $privateKey = static::privateKeyInstance(); + $signature = $privateKey->sign($top); + return $top . '.' . static::base64UrlEncode($signature); + } + + public static function privateKeyInstance() + { + static $key; + if (is_null($key)) { + $key = RSA::loadPrivateKey(static::privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); + } + + return $key; + } + + public static function base64UrlEncode(string $decoded): string + { + return strtr(base64_encode($decoded), '+/', '-_'); + } + + public static function publicPemKey(): string + { + return "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 +DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm +zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i +iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl ++zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk +WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw +3wIDAQAB +-----END PUBLIC KEY-----"; + } + + public static function privatePemKey(): string + { + return "-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb +NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te +g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC +xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp +06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT +dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 +sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ +6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr +4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF +v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW +fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv +HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 +SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf +z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s +HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA +DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh +ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y +uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 +K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi +6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs +IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd +W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 +9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf +efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII +ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl +q/1PY4iJviGKddtmfClH3v4= +-----END PRIVATE KEY-----"; + } + + public static function publicJwkKeyArray(): array + { + return [ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', + 'use' => 'sig', + 'e' => 'AQAB', + 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', + ]; + } +} \ No newline at end of file diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index e4d27c849..606a3cd9e 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -18,6 +18,10 @@ use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\PageRepo; use BookStack\Settings\SettingService; use BookStack\Uploads\HttpFetcher; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; use Illuminate\Foundation\Testing\Assert as PHPUnit; use Illuminate\Http\JsonResponse; use Illuminate\Support\Env; @@ -25,6 +29,7 @@ use Illuminate\Support\Facades\Log; use Mockery; use Monolog\Handler\TestHandler; use Monolog\Logger; +use Psr\Http\Client\ClientInterface; trait SharedTestHelpers { @@ -244,6 +249,22 @@ trait SharedTestHelpers ->andReturn($returnData); } + /** + * Mock the http client used in BookStack. + * Returns a reference to the container which holds all history of http transactions. + * @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware + */ + protected function &mockHttpClient(array $responses = []): array + { + $container = []; + $history = Middleware::history($container); + $mock = new MockHandler($responses); + $handlerStack = new HandlerStack($mock); + $handlerStack->push($history); + $this->app[ClientInterface::class] = new Client(['handler' => $handlerStack]); + return $container; + } + /** * Run a set test with the given env variable. * Remembers the original and resets the value after test. @@ -323,6 +344,15 @@ trait SharedTestHelpers ); } + /** + * Assert that the session has a particular error notification message set. + */ + protected function assertSessionError(string $message) + { + $error = session()->get('error'); + PHPUnit::assertTrue($error === $message, "Failed asserting the session contains an error. \nFound: {$error}\nExpecting: {$message}"); + } + /** * Set a test handler as the logging interface for the application. * Allows capture of logs for checking against during tests. diff --git a/tests/Unit/OidcIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php new file mode 100644 index 000000000..b08d578b3 --- /dev/null +++ b/tests/Unit/OidcIdTokenTest.php @@ -0,0 +1,164 @@ +assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); + } + + public function test_get_claim_returns_value_if_existing() + { + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); + $this->assertEquals('bscott@example.com', $token->getClaim('email')); + } + + public function test_get_claim_returns_null_if_not_existing() + { + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); + $this->assertEquals(null, $token->getClaim('emails')); + } + + public function test_get_all_claims_returns_all_payload_claims() + { + $defaultPayload = OidcJwtHelper::defaultPayload(); + $token = new OidcIdToken(OidcJwtHelper::idToken($defaultPayload), OidcJwtHelper::defaultIssuer(), []); + $this->assertEquals($defaultPayload, $token->getAllClaims()); + } + + public function test_token_structure_error_cases() + { + $idToken = OidcJwtHelper::idToken(); + $idTokenExploded = explode('.', $idToken); + + $messagesAndTokenValues = [ + ['Could not parse out a valid header within the provided token', ''], + ['Could not parse out a valid header within the provided token', 'cat'], + ['Could not parse out a valid payload within the provided token', $idTokenExploded[0]], + ['Could not parse out a valid payload within the provided token', $idTokenExploded[0] . '.' . 'dog'], + ['Could not parse out a valid signature within the provided token', $idTokenExploded[0] . '.' . $idTokenExploded[1]], + ['Could not parse out a valid signature within the provided token', $idTokenExploded[0] . '.' . $idTokenExploded[1] . '.' . '@$%'], + ]; + + foreach ($messagesAndTokenValues as [$message, $tokenValue]) { + $token = new OidcIdToken($tokenValue, OidcJwtHelper::defaultIssuer(), []); + $err = null; + try { + $token->validate('abc'); + } catch (\Exception $exception) { + $err = $exception; + } + + $this->assertInstanceOf(OidcInvalidTokenException::class, $err, $message); + $this->assertEquals($message, $err->getMessage()); + } + } + + public function test_error_thrown_if_token_signature_not_validated_from_no_keys() + { + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); + $this->expectException(OidcInvalidTokenException::class); + $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $token->validate('abc'); + } + + public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key() + { + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ + array_merge(OidcJwtHelper::publicJwkKeyArray(), [ + 'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w' + ]) + ]); + $this->expectException(OidcInvalidTokenException::class); + $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $token->validate('abc'); + } + + public function test_error_thrown_if_invalid_key_provided() + { + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), ['url://example.com']); + $this->expectException(OidcInvalidTokenException::class); + $this->expectExceptionMessage('Unexpected type of key value provided'); + $token->validate('abc'); + } + + public function test_error_thrown_if_token_algorithm_is_not_rs256() + { + $token = new OidcIdToken(OidcJwtHelper::idToken([], ['alg' => 'HS256']), OidcJwtHelper::defaultIssuer(), []); + $this->expectException(OidcInvalidTokenException::class); + $this->expectExceptionMessage("Only RS256 signature validation is supported. Token reports using HS256"); + $token->validate('abc'); + } + + public function test_token_claim_error_cases() + { + /** @var array $claimOverridesByErrorMessage */ + $claimOverridesByErrorMessage = [ + // 1. iss claim present + ['Missing or non-matching token issuer value', ['iss' => null]], + // 1. iss claim matches provided issuer + ['Missing or non-matching token issuer value', ['iss' => 'https://auth.example.co.uk']], + // 2. aud claim present + ['Missing token audience value', ['aud' => null]], + // 2. aud claim validates all values against those expected (Only expect single) + ['Token audience value has 2 values, Expected 1', ['aud' => ['abc', 'def']]], + // 2. aud claim matches client id + ['Token audience value did not match the expected client_id', ['aud' => 'xxyyzz.aaa.bbccdd.456']], + // 4. azp claim matches client id if present + ['Token authorized party exists but does not match the expected client_id', ['azp' => 'xxyyzz.aaa.bbccdd.456']], + // 5. exp claim present + ['Missing token expiration time value', ['exp' => null]], + // 5. exp claim not expired + ['Token has expired', ['exp' => time() - 360]], + // 6. iat claim present + ['Missing token issued at time value', ['iat' => null]], + // 6. iat claim too far in the future + ['Token issue at time is not recent or is invalid', ['iat' => time() + 600]], + // 6. iat claim too far in the past + ['Token issue at time is not recent or is invalid', ['iat' => time() - 172800]], + + // Custom: sub is present + ['Missing token subject value', ['sub' => null]], + ]; + + foreach ($claimOverridesByErrorMessage as [$message, $overrides]) { + $token = new OidcIdToken(OidcJwtHelper::idToken($overrides), OidcJwtHelper::defaultIssuer(), [ + OidcJwtHelper::publicJwkKeyArray() + ]); + + $err = null; + try { + $token->validate('xxyyzz.aaa.bbccdd.123'); + } catch (\Exception $exception) { + $err = $exception; + } + + $this->assertInstanceOf(OidcInvalidTokenException::class, $err, $message); + $this->assertEquals($message, $err->getMessage()); + } + } + + public function test_keys_can_be_a_local_file_reference_to_pem_key() + { + $file = tmpfile(); + $testFilePath = 'file://' . stream_get_meta_data($file)['uri']; + file_put_contents($testFilePath, OidcJwtHelper::publicPemKey()); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ + $testFilePath + ]); + + $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); + unlink($testFilePath); + } +} \ No newline at end of file