From b3a106a084ed67d8293b350d2f8660e026fc3728 Mon Sep 17 00:00:00 2001 From: Alessio Date: Thu, 1 Oct 2020 16:38:45 +0200 Subject: [PATCH 1/3] Allow to connect an identity after registration --- src/Auth/AuthenticatesUsersWithIdentity.php | 17 +- src/Auth/ConnectUserIdentity.php | 162 ++++++++++++++++++ src/Facades/Identity.php | 5 + src/Support/FindIdentity.php | 22 +++ src/View/Components/IdentityLink.php | 5 +- stubs/Identities/Auth/ConnectController.stub | 42 +++++ .../Identities/Auth/ConnectController.php | 50 ++++++ tests/Unit/IdentityLinkTest.php | 10 ++ tests/Unit/IdentityServiceProviderTest.php | 4 + 9 files changed, 300 insertions(+), 17 deletions(-) create mode 100644 src/Auth/ConnectUserIdentity.php create mode 100644 src/Support/FindIdentity.php create mode 100644 stubs/Identities/Auth/ConnectController.stub create mode 100644 tests/Fixtures/Http/Controllers/Identities/Auth/ConnectController.php diff --git a/src/Auth/AuthenticatesUsersWithIdentity.php b/src/Auth/AuthenticatesUsersWithIdentity.php index 69bd4a8..eee016c 100644 --- a/src/Auth/AuthenticatesUsersWithIdentity.php +++ b/src/Auth/AuthenticatesUsersWithIdentity.php @@ -2,19 +2,18 @@ namespace Oneofftech\Identities\Auth; -use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Auth; use Illuminate\Foundation\Auth\RedirectsUsers; use Illuminate\Validation\ValidationException; use Oneofftech\Identities\Facades\Identity; -use Oneofftech\Identities\Facades\IdentityCrypt; +use Oneofftech\Identities\Support\FindIdentity; use Oneofftech\Identities\Support\InteractsWithPreviousUrl; trait AuthenticatesUsersWithIdentity { - use RedirectsUsers, InteractsWithPreviousUrl; + use RedirectsUsers, InteractsWithPreviousUrl, FindIdentity; /** * Redirect the user to the provider authentication page. @@ -71,18 +70,6 @@ public function login(Request $request, $provider) return $this->sendLoginResponse($request); } - /** - * @return \Illuminate\Contracts\Auth\Authenticatable|null - */ - protected function findUserFromIdentity($identity, $provider) - { - try { - return Identity::findUserByIdentity($provider, IdentityCrypt::hash($identity->getId())); - } catch (ModelNotFoundException $mntfex) { - return null; - } - } - /** * Get the failed login response instance. * diff --git a/src/Auth/ConnectUserIdentity.php b/src/Auth/ConnectUserIdentity.php new file mode 100644 index 0000000..5c67da8 --- /dev/null +++ b/src/Auth/ConnectUserIdentity.php @@ -0,0 +1,162 @@ +savePreviousUrl(); + + // get additional user defined attributes + $this->pushAttributes($request); + + return Identity::driver($provider) + ->redirectUrl(route('oneofftech::connect.callback', ['provider' => $provider])) + ->redirect(); + } + + /** + * Obtain the user information from Authentication provider. + * + * if the identity exists it will be updated, otherwise a new identity will be created + * + * @return \Illuminate\Http\Response + */ + public function connect(Request $request, $provider) + { + // Load the previous url from the + // session to redirect back in + // case of errors + $previous_url = $this->getPreviousUrl(); + + $oauthUser = Identity::driver($provider) + ->redirectUrl(route('oneofftech::connect.callback', ['provider' => $provider])) + ->user(); + + // if user denies the authorization request we get + // GuzzleHttp\Exception\ClientException + // Client error: `POST https://gitlab.com/oauth/token` resulted in a `401 Unauthorized` + // response: {"error":"invalid_grant","error_description":"The provided authorization grant is invalid, expired, revoked, does not ma (truncated...) + + // GuzzleHttp\Exception\ClientException + // Client error: `POST https://gitlab/oauth/token` resulted in a `401 Unauthorized` + // response: {"error":"invalid_grant","error_description":"The provided authorization grant is invalid, expired, revoked, does not ma (truncated...) + + $user = $request->user(); + + // create or update the user's identity + + list($user, $identity) = DB::transaction(function () use ($user, $provider, $oauthUser) { + $identity = $this->createIdentity($user, $provider, $oauthUser); + + return [$user, $identity]; + }); + + // todo: event(new Connected($user, $identity)); + + return $this->sendConnectionResponse($request, $identity); + } + + protected function createIdentity($user, $provider, $oauthUser) + { + return $user->identities()->updateOrCreate( + [ + 'provider'=> $provider, + 'provider_id'=> IdentityCrypt::hash($oauthUser->getId()) + ], + [ + 'token'=> IdentityCrypt::encryptString($oauthUser->token), + 'refresh_token'=> IdentityCrypt::encryptString($oauthUser->refreshToken), + 'expires_at'=> $oauthUser->expiresIn ? now()->addSeconds($oauthUser->expiresIn) : null, + 'registration' => true, + ] + ); + } + + protected function sendConnectionResponse(Request $request, $identity) + { + $request->session()->regenerate(); + + if ($response = $this->connected($request, $this->guard()->user(), $identity)) { + return $response; + } + + return redirect()->intended($this->redirectPath()); + } + + /** + * The user identity has been connected. + * + * @param mixed $user + * @param mixed $identity + * @return mixed + */ + protected function connected(Request $request, $user, $identity) + { + // + } + + /** + * The attributes that should be retrieved from + * the request to append to the redirect + * + * @var array + */ + protected function redirectAttributes() + { + if (method_exists($this, 'attributes')) { + return $this->attributes(); + } + + return property_exists($this, 'attributes') ? $this->attributes : []; + } + + protected function pushAttributes($request) + { + $attributes = $this->redirectAttributes() ?? []; + + if (empty($attributes)) { + return; + } + + $request->session()->put('_oot.identities.attributes', json_encode($request->only($attributes))); + } + + protected function pullAttributes($request) + { + $attributes = $this->redirectAttributes() ?? []; + + if (empty($attributes)) { + return []; + } + + $savedAttributes = $request->session()->pull('_oot.identities.attributes') ?? null; + + if (! $savedAttributes) { + return []; + } + + return json_decode($savedAttributes, true); + } +} diff --git a/src/Facades/Identity.php b/src/Facades/Identity.php index 528af55..8796a4d 100644 --- a/src/Facades/Identity.php +++ b/src/Facades/Identity.php @@ -66,6 +66,11 @@ public static function routes() ->name("oneofftech::register.provider"); $router->get('register-via/{provider}/callback', "$namespace\Http\Controllers\Identities\Auth\RegisterController@register") ->name("oneofftech::register.callback"); + + $router->get('connect-via/{provider}', "$namespace\Http\Controllers\Identities\Auth\ConnectController@redirect") + ->name("oneofftech::connect.provider"); + $router->get('connect-via/{provider}/callback', "$namespace\Http\Controllers\Identities\Auth\ConnectController@connect") + ->name("oneofftech::connect.callback"); } /** diff --git a/src/Support/FindIdentity.php b/src/Support/FindIdentity.php new file mode 100644 index 0000000..3499531 --- /dev/null +++ b/src/Support/FindIdentity.php @@ -0,0 +1,22 @@ +getId())); + } catch (ModelNotFoundException $mntfex) { + return null; + } + } +} diff --git a/src/View/Components/IdentityLink.php b/src/View/Components/IdentityLink.php index 1894b83..8935c2c 100644 --- a/src/View/Components/IdentityLink.php +++ b/src/View/Components/IdentityLink.php @@ -8,11 +8,12 @@ class IdentityLink extends Component { - private static $availableActions = ['register', 'login']; + private static $availableActions = ['register', 'login', 'connect']; private static $actionLabels = [ 'register' => 'Register via :Provider', - 'login' => 'Log in via :Provider' + 'login' => 'Log in via :Provider', + 'connect' => 'Connect :Provider', ]; /** diff --git a/stubs/Identities/Auth/ConnectController.stub b/stubs/Identities/Auth/ConnectController.stub new file mode 100644 index 0000000..96e8055 --- /dev/null +++ b/stubs/Identities/Auth/ConnectController.stub @@ -0,0 +1,42 @@ +middleware('auth'); + } +} diff --git a/tests/Fixtures/Http/Controllers/Identities/Auth/ConnectController.php b/tests/Fixtures/Http/Controllers/Identities/Auth/ConnectController.php new file mode 100644 index 0000000..fb32456 --- /dev/null +++ b/tests/Fixtures/Http/Controllers/Identities/Auth/ConnectController.php @@ -0,0 +1,50 @@ +middleware('auth'); + } + + /** + * Get a validator for an incoming connection request. + * + * @param array $data + * @return \Illuminate\Contracts\Validation\Validator + */ + protected function validator(array $data) + { + return Validator::make($data, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'password' => ['sometimes', 'required', 'string', 'min:8', 'confirmed'], + ]); + } +} diff --git a/tests/Unit/IdentityLinkTest.php b/tests/Unit/IdentityLinkTest.php index 4388da6..c8f4887 100644 --- a/tests/Unit/IdentityLinkTest.php +++ b/tests/Unit/IdentityLinkTest.php @@ -69,6 +69,16 @@ public function test_register_link_rendered() $this->assertStringContainsString('My label', $view); } + public function test_connect_link_rendered() + { + $component = new IdentityLink('gitlab', 'connect', 'My label'); + + $view = $this->render($component); + + $this->assertStringContainsString('http://localhost/connect-via/gitlab', $view); + $this->assertStringContainsString('My label', $view); + } + private function render(Component $component) { return view($component->resolveView(), $component->data())->render(); diff --git a/tests/Unit/IdentityServiceProviderTest.php b/tests/Unit/IdentityServiceProviderTest.php index 81793c3..8531855 100644 --- a/tests/Unit/IdentityServiceProviderTest.php +++ b/tests/Unit/IdentityServiceProviderTest.php @@ -75,6 +75,8 @@ public function test_routes_are_registered() $this->assertTrue($router->has('oneofftech::login.callback')); $this->assertTrue($router->has('oneofftech::register.provider')); $this->assertTrue($router->has('oneofftech::register.callback')); + $this->assertTrue($router->has('oneofftech::connect.provider')); + $this->assertTrue($router->has('oneofftech::connect.callback')); $routes = collect($router->getRoutes()->getRoutes())->map(function ($r) { return $r->getActionName(); @@ -84,6 +86,8 @@ public function test_routes_are_registered() $this->assertContains('\App\Http\Controllers\Identities\Auth\LoginController@login', $routes); $this->assertContains('\App\Http\Controllers\Identities\Auth\RegisterController@redirect', $routes); $this->assertContains('\App\Http\Controllers\Identities\Auth\RegisterController@register', $routes); + $this->assertContains('\App\Http\Controllers\Identities\Auth\ConnectController@redirect', $routes); + $this->assertContains('\App\Http\Controllers\Identities\Auth\ConnectController@connect', $routes); } public function test_events_are_registered() From a43e69593ce2847b8bfdb3309ca8071db4c99fc3 Mon Sep 17 00:00:00 2001 From: Alessio Date: Thu, 8 Oct 2020 14:27:21 +0200 Subject: [PATCH 2/3] Extract handling of additional attributes, also for connect action --- src/Auth/ConnectUserIdentity.php | 53 +++++-------------- src/Auth/RegistersUsersWithIdentity.php | 46 +--------------- .../InteractsWithAdditionalAttributes.php | 50 +++++++++++++++++ 3 files changed, 64 insertions(+), 85 deletions(-) create mode 100644 src/Support/InteractsWithAdditionalAttributes.php diff --git a/src/Auth/ConnectUserIdentity.php b/src/Auth/ConnectUserIdentity.php index 5c67da8..7148ee8 100644 --- a/src/Auth/ConnectUserIdentity.php +++ b/src/Auth/ConnectUserIdentity.php @@ -4,16 +4,18 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Auth; use Illuminate\Foundation\Auth\RedirectsUsers; use Illuminate\Support\Facades\DB; use Oneofftech\Identities\Facades\IdentityCrypt; use Oneofftech\Identities\Facades\Identity; -use Oneofftech\Identities\Support\InteractsWithPreviousUrl; use Oneofftech\Identities\Support\FindIdentity; +use Oneofftech\Identities\Support\InteractsWithPreviousUrl; +use Oneofftech\Identities\Support\InteractsWithAdditionalAttributes; trait ConnectUserIdentity { - use RedirectsUsers, InteractsWithPreviousUrl, FindIdentity; + use RedirectsUsers, InteractsWithPreviousUrl, InteractsWithAdditionalAttributes, FindIdentity; /** * Redirect the user to the Authentication provider authentication page. @@ -98,7 +100,7 @@ protected function sendConnectionResponse(Request $request, $identity) { $request->session()->regenerate(); - if ($response = $this->connected($request, $this->guard()->user(), $identity)) { + if ($response = $this->connected($this->guard()->user(), $identity, $this->pullAttributes($request), $request)) { return $response; } @@ -110,53 +112,22 @@ protected function sendConnectionResponse(Request $request, $identity) * * @param mixed $user * @param mixed $identity + * @param array $attributes + * @param \Illuminate\Http\Request $request * @return mixed */ - protected function connected(Request $request, $user, $identity) + protected function connected($user, $identity, array $attributes, Request $request) { // } /** - * The attributes that should be retrieved from - * the request to append to the redirect + * Get the guard to retrieve currently authenticated user. * - * @var array + * @return \Illuminate\Contracts\Auth\StatefulGuard */ - protected function redirectAttributes() - { - if (method_exists($this, 'attributes')) { - return $this->attributes(); - } - - return property_exists($this, 'attributes') ? $this->attributes : []; - } - - protected function pushAttributes($request) - { - $attributes = $this->redirectAttributes() ?? []; - - if (empty($attributes)) { - return; - } - - $request->session()->put('_oot.identities.attributes', json_encode($request->only($attributes))); - } - - protected function pullAttributes($request) + protected function guard() { - $attributes = $this->redirectAttributes() ?? []; - - if (empty($attributes)) { - return []; - } - - $savedAttributes = $request->session()->pull('_oot.identities.attributes') ?? null; - - if (! $savedAttributes) { - return []; - } - - return json_decode($savedAttributes, true); + return Auth::guard(); } } diff --git a/src/Auth/RegistersUsersWithIdentity.php b/src/Auth/RegistersUsersWithIdentity.php index 58b7222..94d0b9a 100644 --- a/src/Auth/RegistersUsersWithIdentity.php +++ b/src/Auth/RegistersUsersWithIdentity.php @@ -14,10 +14,11 @@ use Laravel\Socialite\AbstractUser as SocialiteUser; use Oneofftech\Identities\Facades\Identity; use Oneofftech\Identities\Support\InteractsWithPreviousUrl; +use Oneofftech\Identities\Support\InteractsWithAdditionalAttributes; trait RegistersUsersWithIdentity { - use RedirectsUsers, InteractsWithPreviousUrl; + use RedirectsUsers, InteractsWithPreviousUrl, InteractsWithAdditionalAttributes; /** * Redirect the user to the Authentication provider authentication page. @@ -163,47 +164,4 @@ protected function guard() { return Auth::guard(); } - - /** - * The attributes that should be retrieved from - * the request to append to the redirect - * - * @var array - */ - protected function redirectAttributes() - { - if (method_exists($this, 'attributes')) { - return $this->attributes(); - } - - return property_exists($this, 'attributes') ? $this->attributes : []; - } - - protected function pushAttributes($request) - { - $attributes = $this->redirectAttributes() ?? []; - - if (empty($attributes)) { - return; - } - - $request->session()->put('_oot.identities.attributes', json_encode($request->only($attributes))); - } - - protected function pullAttributes($request) - { - $attributes = $this->redirectAttributes() ?? []; - - if (empty($attributes)) { - return []; - } - - $savedAttributes = $request->session()->pull('_oot.identities.attributes') ?? null; - - if (! $savedAttributes) { - return []; - } - - return json_decode($savedAttributes, true); - } } diff --git a/src/Support/InteractsWithAdditionalAttributes.php b/src/Support/InteractsWithAdditionalAttributes.php new file mode 100644 index 0000000..c5e5c1e --- /dev/null +++ b/src/Support/InteractsWithAdditionalAttributes.php @@ -0,0 +1,50 @@ +attributes(); + } + + return property_exists($this, 'attributes') ? $this->attributes : []; + } + + protected function pushAttributes($request) + { + $attributes = $this->redirectAttributes() ?? []; + + if (empty($attributes)) { + return; + } + + $request->session()->put('_oot.identities.attributes', json_encode($request->only($attributes))); + } + + protected function pullAttributes($request) + { + $attributes = $this->redirectAttributes() ?? []; + + if (empty($attributes)) { + return []; + } + + $savedAttributes = $request->session()->pull('_oot.identities.attributes') ?? null; + + if (! $savedAttributes) { + return []; + } + + return json_decode($savedAttributes, true); + } +} From ff6b02856abe7f2c6840371a49d6bbb26010a9ad Mon Sep 17 00:00:00 2001 From: Alessio Date: Thu, 8 Oct 2020 16:31:47 +0200 Subject: [PATCH 3/3] Update readme --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0bd7f5a..9b4d5fd 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ as well as the community driven [Socialite Providers](https://socialiteproviders - Handle user registration via third party providers; - Handle user log in via third party providers; +- Allow existing user to link a third party identity; - Customizable controllers, migration and models that will live in your application namespace; - Save identity and token inside the database, using [encryption and pseudoanonimization](#how-data-is-stored-in-the-database); -- Provide login/register button as Blade component; +- Provide login/register/connect button as Blade component; - Support all [Laravel Socialite](https://laravel.com/docs/socialite) and [Socialite Providers](https://socialiteproviders.com/); - Add custom providers. @@ -130,7 +131,7 @@ however we provide a Blade Component to quickly add login and register links/but class="button button--primary" /> ``` -The available `action`s are `login` and `register`. The `provider` refers to what +The available `action`s are `login`, `connect` and `register`. The `provider` refers to what identity provider to use, the name of the provider is the same as the Socialite providers' name. See [Blade components](https://laravel.com/docs/blade#components) for more.