diff --git a/README.md b/README.md index 2ba023f..aeedead 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,9 @@ $middlewareQueue->add(new \ADmad\SocialAuth\Middleware\SocialAuthMiddleware([ ], 'options' => [ 'identity.fields' => [ - 'email', - // To get a full list of all possible values, refer to - // https://developers.facebook.com/docs/graph-api/reference/user + 'email', + // To get a full list of all possible values, refer to + // https://developers.facebook.com/docs/graph-api/reference/user ], ], ], @@ -139,8 +139,11 @@ instance as argument and must return an entity for the user. ```php // src/Model/Table/UsersTable.php +use \Cake\Datasource\EntityInterface; +use \Cake\Http\Session; -public function getUser(\Cake\Datasource\EntityInterface $profile, \Cake\Http\Session $session) { +public function getUser(EntityInterface $profile, Session $session) +{ // Make sure here that all the required fields are actually present if (empty($profile->email)) { throw new \RuntimeException('Could not find email in social profile.'); @@ -177,52 +180,136 @@ public function getUser(\Cake\Datasource\EntityInterface $profile, \Cake\Http\Se } ``` -Upon successful authentication an `SocialAuth.afterIdentify` event is -dispatched with the user entity. You can setup a listener for this event to -perform required tasks after a successful authentication. The listener can -optionally return an user entity as event result. +Instead of adding a `getUser` method to your `UsersTable` you can also setup a +listener for the `SocialAuth.createUser` callback and return a `User` entity from +the listener callback, in a similar way as shown above. -The user identity is persisted to session under key you have specified in -middleware config (`Auth.User` by default). +Upon successful authentication the user identity is persisted to the session +under the key you have specified in the middleware config (`Auth.User` by default). -In case of authentication failure user is redirected back to login URL with -`error` query string variable. It can have one of these values: +After that the user is redirected to protected page they tried to access before +login or to the URL specified in `loginRedirect` config. -- `provider_failure`: Auth through provider failed. Details will be logged in - `error.log` if `logErrors` option is set to `true`. -- `finder_failure`: Finder failed to return user record. An e.g. of this is - a user has been authenticated through provider but your finder has condition - to not return inactivate user. +In case of authentication failure the user is redirected back to login URL. -### Event Listener +### Events -To set up a listener for the `SocialAuth.afterIdentify` event, you can for example -add this to your `UsersTable::initialize()` method: -```php -use Cake\Event\EventManager; +#### SocialAuth.createUser -// at the end of the initialize() method -EventManager::instance()->on('SocialAuth.afterIdentify', [$this, 'updateUser']); -``` +After authentication from the social auth provider if a related use record is not +found then `SocialAuth.createUser` is triggered. As an alternative to adding a +new `createUser()` method in your `UsersTable` as mentioned above you can instead +use this event to return an entity for a new user. + +#### SocialAuth.afterIdentify + +Upon successful authentication a `SocialAuth.afterIdentify` event is +dispatched with the user entity. You can setup a listener for this event to +perform required tasks. The listener can optionally return a user entity as +event result. + +#### SocialAuth.beforeRedirect + +After the completion of authentication process before the user is redirected +to required URL a `SocialAuth.beforeRedirect` event is triggered. This event +for e.g. can be used to set a visual notification like flash message to indicate +the result of the authentication process to the user. + +Here's an e.g. listener with callbacks to the above method: -Then create such method in this table class: ```php - /** - * @param \Cake\Event\EventInterface $event - * @param \Cake\Datasource\EntityInterface $user - * @return \Cake\Datasource\EntityInterface - */ - public function updateUser(EventInterface $event, $user) - { - // You can access the profile through $user->social_profile->... +// src/Event/SocialAuthListener.php + +namespace App\Event; + +use ADmad\SocialAuth\Middleware\SocialAuthMiddleware; +use Cake\Datasource\EntityInterface; +use Cake\Event\EventInterface; +use Cake\Event\EventListenerInterface; +use Cake\Http\ServerRequest; +use Cake\ORM\Locator\LocatorAwareTrait; + +class SocialAuthListener implements EventListenerInterface +{ + use LocatorAwareTrait; + + public function implementedEvents(): array + { + return [ + SocialAuthMiddleware::EVENT_AFTER_IDENTIFY => 'afterIdentify', + SocialAuthMiddleware::EVENT_BEFORE_REDIRECT => 'beforeRedirect', + // Uncomment below if you want to use the event listener to return + // an entity for a new user instead of directly using `createUser()` table method. + // SocialAuthMiddleware::EVENT_CREATE_USER => 'createUser', + ]; + } + + public function afterIdentify(EventInterface $event, EntityInterface $user): EntityInterface + { + // Update last login time + $user->set('last_login', date('Y-m-d H:i:s')); - // Additional mapping operations - // $user->last_login = date('Y-m-d H:i:s'); + // You can access the profile using $user->social_profile - $this->saveOrFail($user); + $this->getTableLocator()->get('User')->save($user); return $user; } + + public function beforeRedirect(EventInterface $event, $redirectUrl, string $status, ServerRequest $request): void + { + $messages = (array)$request->session->read('Flash.flash'); + + // Set flash message + switch ($status) { + case SocialAuthMiddleware::AUTH_STATUS_SUCCESS: + $messages[] = [ + 'message' => __('You are now logged in'), + 'key' => 'flash', + 'element' => 'flash/success', + 'params' => [], + ]; + break; + + // Auth through provider failed. Details will be logged in + // `error.log` if `logErrors` option is set to `true`. + case SocialAuthMiddleware::AUTH_STATUS_PROVIDER_FAILURE: + + // Table finder failed to return user record. An e.g. of this is a + // user has been authenticated through provider but your finder has + // conditionto not return an inactivated user. + case SocialAuthMiddleware::AUTH_STATUS_FINDER_FAILURE: + $messages[] = [ + 'message' => __('Authentication failed'), + 'key' => 'flash', + 'element' => 'flash/error', + 'params' => [], + ]; + break; + } + + $request->getSession()->write('Flash.flash', $messages); + + // You can return a modified $rediretUrl if needed. + } + + public function createUser(EventInterface $event, EntityInterface $profile, Session $session): EventInterface + { + // Create and save entity for new user as shown in "createUser()" method above + + return $user; + } +``` + +Attach the listener in your `Application` class: + +```php +// src/Application.php +use App\Event\SocialAuthListener; +use Cake\Event\EventManager; + +// In Application::bootstrap() or Application::middleware() +EventManager::instance()->on(new SocialAuthListener()); ``` Copyright diff --git a/src/Middleware/SocialAuthMiddleware.php b/src/Middleware/SocialAuthMiddleware.php index 8097564..27b1822 100644 --- a/src/Middleware/SocialAuthMiddleware.php +++ b/src/Middleware/SocialAuthMiddleware.php @@ -19,6 +19,7 @@ use Cake\Http\Client; use Cake\Http\Response; use Cake\Http\ServerRequest; +use Cake\Http\Session as CakeSession; use Cake\Log\Log; use Cake\ORM\Locator\LocatorAwareTrait; use Cake\Routing\Router; @@ -34,7 +35,7 @@ use SocialConnect\Common\HttpStack; use SocialConnect\Provider\AccessTokenInterface; use SocialConnect\Provider\Exception\InvalidResponse; -use SocialConnect\Provider\Session\Session; +use SocialConnect\Provider\Session\Session as SocialConnectSession; use SocialConnect\Provider\Session\SessionInterface; use Zend\Diactoros\RequestFactory; use Zend\Diactoros\StreamFactory; @@ -48,14 +49,53 @@ class SocialAuthMiddleware implements MiddlewareInterface, EventDispatcherInterf /** * The query string key used for remembering the referrered page when * getting redirected to login. + * + * @var string */ public const QUERY_STRING_REDIRECT = 'redirect'; + /** + * The name of the event that is fired for a new user. + * + * @var string + */ + public const EVENT_CREATE_USER = 'SocialAuth.createUser'; + /** * The name of the event that is fired after user identification. + * + * @var string */ public const EVENT_AFTER_IDENTIFY = 'SocialAuth.afterIdentify'; + /** + * The name of the event that is fired before redirection after authentication success/failure + * + * @var string + */ + public const EVENT_BEFORE_REDIRECT = 'SocialAuth.beforeRedirect'; + + /** + * Auth success status. + * + * @var string + */ + public const AUTH_STATUS_SUCCESS = 'success'; + + /** + * Auth provider failure status. + * + * @var string + */ + public const AUTH_STATUS_PROVIDER_FAILURE = 'provider_failure'; + + /** + * Auth finder failure status. + * + * @var string + */ + public const AUTH_STATUS_FINDER_FAILURE = 'finder_failure'; + /** * Default config. * @@ -211,15 +251,19 @@ protected function _handleCallbackAction(ServerRequest $request): Response $profile = $this->_getProfile($providerName, $request); if (!$profile) { + $redirectUrl = $this->_triggerBeforeRedirect($request, $config['loginUrl'], $this->_error); + return $response->withLocation( - Router::url($config['loginUrl'], true) . '?error=' . $this->_error + Router::url($redirectUrl, true) ); } $user = $this->_getUser($profile, $request->getSession()); if (!$user) { + $redirectUrl = $this->_triggerBeforeRedirect($request, $config['loginUrl'], $this->_error); + return $response->withLocation( - Router::url($config['loginUrl'], true) . '?error=' . $this->_error + Router::url($config['loginUrl'], true) ); } @@ -237,8 +281,10 @@ protected function _handleCallbackAction(ServerRequest $request): Response $request->getSession()->write($config['sessionKey'], $user); + $redirectUrl = $this->_triggerBeforeRedirect($request, $this->_getRedirectUrl($request)); + return $response->withLocation( - Router::url($this->_getRedirectUrl($request), true) + Router::url($redirectUrl, true) ); } @@ -269,7 +315,7 @@ protected function _getProfile($providerName, ServerRequest $request): ?EntityIn $accessToken = $provider->getAccessTokenByRequestParameters($request->getQueryParams()); $identity = $provider->getIdentity($accessToken); } catch (SocialConnectException $e) { - $this->_error = 'provider_failure'; + $this->_error = self::AUTH_STATUS_PROVIDER_FAILURE; if ($this->getConfig('logErrors')) { Log::error($this->_getLogMessage($request, $e)); @@ -302,7 +348,7 @@ protected function _getProfile($providerName, ServerRequest $request): ?EntityIn * @return \Cake\Datasource\EntityInterface|null User array or entity * on success, null on failure. */ - protected function _getUser(EntityInterface $profile, $session): ?EntityInterface + protected function _getUser(EntityInterface $profile, CakeSession $session): ?EntityInterface { $user = null; @@ -321,7 +367,7 @@ protected function _getUser(EntityInterface $profile, $session): ?EntityInterfac if (!$user) { if ($profile->get('user_id')) { - $this->_error = 'finder_failure'; + $this->_error = self::AUTH_STATUS_FINDER_FAILURE; return null; } @@ -410,14 +456,24 @@ protected function _patchProfile( * @param \Cake\Http\Session $session Session instance. * @return \Cake\Datasource\EntityInterface User entity. */ - protected function _getUserEntity(EntityInterface $profile, $session): EntityInterface + protected function _getUserEntity(EntityInterface $profile, CakeSession $session): EntityInterface { - $callbackMethod = $this->getConfig('getUserCallback'); - - $user = call_user_func([$this->_userModel, $callbackMethod], $profile, $session); + $event = $this->dispatchEvent(self::EVENT_CREATE_USER, [ + 'profile' => $profile, + 'session' => $session, + ]); + + $user = $event->getResult(); + if ($user === null) { + $user = call_user_func( + [$this->_userModel, $this->getConfig('getUserCallback')], + $profile, + $session + ); + } if (!($user instanceof EntityInterface)) { - throw new RuntimeException('"getUserCallback" method must return a user entity.'); + throw new RuntimeException('The callback for new user must return an entity.'); } return $user; @@ -476,7 +532,7 @@ protected function _getService(ServerRequest $request): Service /** @psalm-suppress PossiblyNullArgument */ $this->_service = new Service( $httpStack, - $this->_session ?: new Session(), + $this->_session ?: new SocialConnectSession(), $serviceConfig ); @@ -524,6 +580,33 @@ protected function _getRedirectUrl(ServerRequest $request) return $this->getConfig('loginRedirect'); } + /** + * Trigger "beforeRedirect" event. + * + * @param \Psr\Http\Message\ServerRequestInterface $request Request instance. + * @param string|array $redirectUrl Redirect URL. + * @param string $status Auth status. + * @return string|array + */ + protected function _triggerBeforeRedirect( + $request, + $redirectUrl, + string $status = self::AUTH_STATUS_SUCCESS + ) { + $event = $this->dispatchEvent(self::EVENT_BEFORE_REDIRECT, [ + 'redirectUrl' => $redirectUrl, + 'status' => $status, + 'request' => $request, + ]); + + $result = $event->getResult(); + if ($result !== null) { + $redirectUrl = $result; + } + + return $redirectUrl; + } + /** * Generate the error log message. *