diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/README.md b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/README.md new file mode 100644 index 000000000..4cfa51b2f --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/README.md @@ -0,0 +1,15 @@ +## About +The goal of this module is to give ability +to log into Vitrual Y using OAuth2 protocol, in case if your CRM is Reclique. + +## How to use this integration. + +1. Enable this module. +2. Setup your Reclique SSO OAuth2 credentials +here: /admin/openy/virtual-ymca/gc-auth-settings/provider/reclique_sso +3. Set Reclique SSO OAuth2 as your main authorization plugin +at the Virtual YMCA settings: /admin/openy/openy-gc-auth/settings + +## I need help. +In case, if you need help, please write your question +at the #developers channel at Open Y slack. diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/config/install/openy_gc_auth.provider.reclique_sso.yml b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/config/install/openy_gc_auth.provider.reclique_sso.yml new file mode 100644 index 000000000..452a827d2 --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/config/install/openy_gc_auth.provider.reclique_sso.yml @@ -0,0 +1,5 @@ +authorization_server: 'https://[association_slug].recliquecore.com' +client_id: '' +client_secret: '' +error_accompanying_message: 'Please contact us if you have any questions.' +login_mode: 'present_login_button' diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.info.yml b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.info.yml new file mode 100644 index 000000000..ca8589412 --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.info.yml @@ -0,0 +1,9 @@ +name: Reclique SSO OAuth2 Virtual YMCA integration +type: module +description: 'Provides Reclique SSO OAuth2 authentication plugin for Virtual YMCA' +package: Open Y Virtual YMCA +version: 0.1 +core: 8.x +core_version_requirement: ^8 || ^9 +dependencies: + - drupal:openy_gc_auth diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.module b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.module new file mode 100644 index 000000000..8d40ec875 --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.module @@ -0,0 +1,19 @@ +bundle(); + if ($bundle === 'gated_content_login') { + $build['#cache']['max-age'] = 0; + } +} diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.routing.yml b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.routing.yml new file mode 100644 index 000000000..ad60ea5a1 --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.routing.yml @@ -0,0 +1,19 @@ +openy_gc_auth_reclique_sso.authenticate_redirect: + path: '/openy-gc-auth-reclique-sso/authenticate-redirect' + defaults: + _controller: '\Drupal\openy_gc_auth_reclique_sso\Controller\SSOController::authenticateRedirect' + _title: 'Reclique SSO OAuth2 authenticate redirect' + requirements: + _permission: 'access content' + options: + no_cache: 'TRUE' + +openy_gc_auth_reclique_sso.authenticate_callback: + path: '/openy-gc-auth-reclique-sso/authenticate-callback' + defaults: + _controller: '\Drupal\openy_gc_auth_reclique_sso\Controller\SSOController::authenticateCallback' + _title: 'Reclique SSO OAuth2 authenticate callback' + requirements: + _permission: 'access content' + options: + no_cache: 'TRUE' diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.services.yml b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.services.yml new file mode 100644 index 000000000..647498a90 --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/openy_gc_auth_reclique_sso.services.yml @@ -0,0 +1,6 @@ +services: + openy_gc_auth.reclique_sso_client: + class: Drupal\openy_gc_auth_reclique_sso\SSOClient + arguments: [ '@logger.factory', '@config.factory', + '@http_client', '@csrf_token', + '@user.private_tempstore' ] diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Controller/SSOController.php b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Controller/SSOController.php new file mode 100644 index 000000000..0862afbf2 --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Controller/SSOController.php @@ -0,0 +1,162 @@ +configFactory = $configFactory; + $this->configOpenyGatedContent = $configFactory->get('openy_gated_content.settings'); + $this->gcUserAuthorizer = $gcUserAuthorizer; + $this->recliqueSSOClient = $recliqueSSOClient; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('openy_gc_auth.user_authorizer'), + $container->get('openy_gc_auth.reclique_sso_client') + ); + } + + /** + * Redirect, login user and return authorization code. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * Current request object. + * + * @return \Drupal\Core\Routing\TrustedRedirectResponse + * Redirect to the Reclique login page. + */ + public function authenticateRedirect(Request $request): TrustedRedirectResponse { + if (!empty($this->response)) { + return $this->response; + } + + $oAuth2AuthenticateUrl = $this->recliqueSSOClient->buildAuthenticationUrl($request); + $this->response = new TrustedRedirectResponse($oAuth2AuthenticateUrl); + $this->response->addCacheableDependency((new CacheableMetadata())->setCacheMaxAge(0)); + return $this->response; + } + + /** + * Receive authorization code, load user data and authorize user. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * Current request object. + * + * @return mixed + * Returns RedirectResponse or JsonResponse. + */ + public function authenticateCallback(Request $request) { + // Check code that was generated by Open Y. + if (!$this->recliqueSSOClient->validateCsrfToken($request->get('state'))) { + return new RedirectResponse( + URL::fromUserInput( + $this->configOpenyGatedContent->get('virtual_y_login_url'), + ['query' => ['error' => '1']] + )->toString() + ); + } + + $access_token = $this->recliqueSSOClient->exchangeCodeForAccessToken($request->get('code')); + + if (!$access_token) { + return new RedirectResponse( + URL::fromUserInput( + $this->configOpenyGatedContent->get('virtual_y_login_url'), + ['query' => ['error' => '1']] + )->toString() + ); + } + + $userData = $this->recliqueSSOClient->requestUserData($access_token); + + if (!$userData) { + return new RedirectResponse( + URL::fromUserInput( + $this->configOpenyGatedContent->get('virtual_y_login_url'), + ['query' => ['error' => '1']] + )->toString() + ); + } + + if ($this->recliqueSSOClient->validateUserSubscription($userData)) { + [$name, $email] = $this->recliqueSSOClient + ->prepareUserNameAndEmail($userData); + + // Authorize user (register, login, log, etc). + $this->gcUserAuthorizer->authorizeUser($name, $email); + + return new RedirectResponse($this->configOpenyGatedContent->get('virtual_y_url')); + } + + // Redirect back to Virual Y login page. + return new RedirectResponse( + URL::fromUserInput( + $this->configOpenyGatedContent->get('virtual_y_login_url'), + ['query' => ['error' => '1']] + )->toString() + ); + } + +} diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Form/ContinueWithRecliqueLoginForm.php b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Form/ContinueWithRecliqueLoginForm.php new file mode 100644 index 000000000..29f58687f --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Form/ContinueWithRecliqueLoginForm.php @@ -0,0 +1,55 @@ + 'link', + '#url' => Url::fromRoute('openy_gc_auth_reclique_sso.authenticate_redirect'), + '#title' => $this->t('Enter Virtual Y'), + '#attributes' => [ + 'class' => [ + 'gc-button', + ], + ], + ]; + + $form['#attributes'] = [ + 'class' => 'text-center', + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $form_state->setRedirectUrl(Url::fromRoute('openy_gc_auth_reclique_sso.authenticate_redirect')); + } + +} diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Form/TryAgainForm.php b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Form/TryAgainForm.php new file mode 100644 index 000000000..3c8c4efc6 --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Form/TryAgainForm.php @@ -0,0 +1,105 @@ +currentRequest = $requestStack->getCurrentRequest(); + $this->configFactory = $config_factory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('request_stack'), + $container->get('config.factory') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'openy_gc_auth_reclique_sso_try_again'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + if (!empty($this->currentRequest->query->get('error'))) { + $form['error'] = [ + '#markup' => '

' . $this->t('There may be a problem with your account') . '

', + ]; + + $form['error_contact_message'] = [ + '#markup' => '
' . $this->configFactory->get('openy_gc_auth.provider.reclique_sso') + ->get('error_accompanying_message') . '
', + ]; + } + + $form['#action'] = $this->configFactory->get('openy_gated_content.settings') + ->get('virtual_y_login_url'); + + $form['submit'] = [ + '#type' => 'link', + '#url' => Url::fromRoute('openy_gc_auth_reclique_sso.authenticate_redirect'), + '#title' => $this->t('Try again'), + '#attributes' => [ + 'class' => [ + 'gc-button', + ], + ], + ]; + + $form['#attributes'] = [ + 'class' => 'text-center', + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $form_state->setRedirectUrl(Url::fromRoute('openy_gc_auth_reclique_sso.authenticate_redirect')); + } + +} diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Plugin/GCIdentityProvider/RecliqueSSO.php b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Plugin/GCIdentityProvider/RecliqueSSO.php new file mode 100644 index 000000000..21023a4bc --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/Plugin/GCIdentityProvider/RecliqueSSO.php @@ -0,0 +1,189 @@ +request = $requestStack->getCurrentRequest(); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('config.factory'), + $container->get('entity_type.manager'), + $container->get('request_stack'), + $container->get('form_builder') + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration(): array { + return [ + 'authorization_server' => 'https://[association_slug].recliquecore.com', + 'login_mode' => 'present_login_button', + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $config = $this->getConfiguration(); + $form = parent::buildConfigurationForm($form, $form_state); + + $form['authorization_server'] = [ + '#type' => 'url', + '#title' => $this->t('Authorization server'), + '#default_value' => $config['authorization_server'], + '#description' => $this->t('It is most likely "https://[association_slug].recliquecore.com", where association_slug should be provided from Reclique.'), + ]; + + $form['client_id'] = [ + '#type' => 'textfield', + '#title' => $this->t('Client Id'), + '#default_value' => $config['client_id'], + '#description' => $this->t('Your Reclique client id.'), + ]; + + $form['client_secret'] = [ + '#type' => 'textfield', + '#title' => $this->t('Client Secret'), + '#default_value' => $config['client_secret'], + '#description' => $this->t('Your Reclique client secret.'), + ]; + + $form['error_accompanying_message'] = [ + '#title' => $this->t('Authentication error message'), + '#description' => $this->t('Message displayed to user when he failed to log in using this plugin.'), + '#type' => 'textfield', + '#default_value' => $config['error_accompanying_message'], + '#required' => FALSE, + ]; + + $form['login_mode'] = [ + '#title' => $this->t('Login mode'), + '#type' => 'radios', + '#default_value' => $config['login_mode'], + '#required' => TRUE, + '#options' => [ + 'present_login_button' => $this->t('Present login button'), + 'redirect_immediately' => $this->t('Redirect immediately'), + ], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + if (!$form_state->getErrors()) { + $this->configuration['authorization_server'] = $form_state->getValue('authorization_server'); + $this->configuration['client_id'] = $form_state->getValue('client_id'); + $this->configuration['client_secret'] = $form_state->getValue('client_secret'); + $this->configuration['error_accompanying_message'] = $form_state->getValue('error_accompanying_message'); + $this->configuration['login_mode'] = $form_state->getValue('login_mode'); + + parent::submitConfigurationForm($form, $form_state); + } + } + + /** + * {@inheritdoc} + */ + public function getLoginForm() { + if ($this->request->query->has('error')) { + return $this->formBuilder->getForm('Drupal\openy_gc_auth_reclique_sso\Form\TryAgainForm'); + } + + if ($this->configuration['login_mode'] === 'present_login_button') { + return $this->formBuilder->getForm('Drupal\openy_gc_auth_reclique_sso\Form\ContinueWithRecliqueLoginForm'); + } + + // Forcing no-cache at redirect headers. + $headers = [ + 'Cache-Control' => 'no-cache', + ]; + $response = new RedirectResponse( + Url::fromRoute('openy_gc_auth_reclique_sso.authenticate_redirect') + ->toString(), + 302, + $headers + ); + + return $response->send(); + } + +} diff --git a/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/SSOClient.php b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/SSOClient.php new file mode 100644 index 000000000..41df56ffa --- /dev/null +++ b/modules/openy_gc_auth/modules/openy_gc_auth_reclique_sso/src/SSOClient.php @@ -0,0 +1,245 @@ +logger = $loggerFactory->get('openy_gc_auth_reclique_sso'); + $this->configFactory = $configFactory; + $this->configRecliqueSSO = $configFactory->get('openy_gc_auth.provider.reclique_sso'); + $this->httpClient = $client; + $this->csrfToken = $csrfToken; + $this->tempStore = $temp_store_factory->get('openy_gc_auth_reclique_sso'); + } + + /** + * Build authentication url. + * + * This method also generates scrf token and starts a session + * for anonymous user to be able to verify csrf token after + * user return from authorization server. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * Request. + * + * @return string + * Authentication Url. + */ + public function buildAuthenticationUrl(Request $request) { + $callbackUrl = Url::fromRoute('openy_gc_auth_reclique_sso.authenticate_callback', [], [ + 'absolute' => TRUE, + ])->toString(TRUE)->getGeneratedUrl(); + + $authUrl = Url::fromUserInput( + self::ENDPOINT_AUTHORIZE, [ + 'absolute' => FALSE, + 'https' => TRUE, + 'query' => [ + 'response_type' => 'code', + 'scope' => 'basic', + 'client_id' => $this->configRecliqueSSO->get('client_id'), + 'redirect_uri' => $callbackUrl, + 'state' => $this->csrfToken->get(self::CSRF_TOKEN_VALUE), + ], + ])->toString(TRUE)->getGeneratedUrl(); + + $this->tempStore->set('save-csrf', TRUE); + + return Url::fromUri( + $this->configRecliqueSSO->get('authorization_server') . self::ENDPOINT_LOGIN, [ + 'https' => TRUE, + 'absolute' => TRUE, + 'query' => [ + 'redirect' => $authUrl, + ], + ] + )->toString(TRUE)->getGeneratedUrl(); + } + + /** + * Validate csrf token. + * + * @param string $state + * CSRF State. + * + * @return bool + * Returns TRUE if scrf token is valid. + */ + public function validateCsrfToken($state) { + return $this->csrfToken->validate($state, self::CSRF_TOKEN_VALUE); + } + + /** + * Validate user subscription. + * + * @param object $userData + * User data. + * + * @return bool + * Returns TRUE if user has active subscription. + */ + public function validateUserSubscription($userData) { + return $userData->member->Status === 'Active'; + } + + /** + * Prepare user name and email. + * + * @param object $userData + * User data. + * + * @return array + * Returns name and email. + */ + public function prepareUserNameAndEmail($userData) { + $name = "{$userData->member->FirstName} {$userData->member->LastName} {$userData->member->ID}"; + $email = "reclique_sso-{$userData->member->ID}@virtualy.openy.org"; + return [$name, $email]; + } + + /** + * Request access token. + * + * @param string $code + * Authorization code. + * + * @return string + * Access token. + */ + public function exchangeCodeForAccessToken($code) { + try { + $response = $this->httpClient->request( + 'POST', + $this->configRecliqueSSO->get('authorization_server') . self::ENDPOINT_ACCESS_TOKEN, + [ + 'form_params' => [ + 'grant_type' => 'authorization_code', + 'client_id' => $this->configRecliqueSSO->get('client_id'), + 'client_secret' => $this->configRecliqueSSO->get('client_secret'), + 'code' => urldecode($code), + 'redirect_uri' => Url::fromRoute('openy_gc_auth_reclique_sso.authenticate_callback', [], [ + 'absolute' => TRUE, + ])->toString(TRUE)->getGeneratedUrl(), + ], + ]); + + return json_decode((string) $response->getBody(), FALSE)->access_token; + } + catch (\Exception $e) { + $this->logger->error($e->getMessage()); + } + } + + /** + * Request user data. + * + * @param string $access_token + * Access token. + * + * @return array|mixed + * User Data. + */ + public function requestUserData($access_token) { + try { + $response = $this->httpClient->request( + 'GET', + $this->configRecliqueSSO->get('authorization_server') . self::ENDPOINT_USER_DATA, + [ + 'headers' => [ + 'Authorization' => "Bearer " . $access_token, + ], + ]); + return json_decode((string) $response->getBody(), FALSE); + } + catch (\Exception $e) { + $this->logger->error($e->getMessage()); + } + } + +}