Page MenuHomeDevCentral

D445.id1147.diff
No OneTemporary

D445.id1147.diff

diff --git a/.env.example b/.env.example
--- a/.env.example
+++ b/.env.example
@@ -16,3 +16,7 @@
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
+
+GITHUB_ENABLE=0
+GITHUB_CLIENT_ID=your-app-client-id
+GITHUB_CLIENT_SECRET=your-app-client-secret
\ No newline at end of file
diff --git a/app/Events/ExternalUserAuthorizeEvent.php b/app/Events/ExternalUserAuthorizeEvent.php
new file mode 100644
--- /dev/null
+++ b/app/Events/ExternalUserAuthorizeEvent.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace AuthGrove\Events;
+
+use Illuminate\Queue\SerializesModels;
+
+use AuthGrove\Events\Event as BaseEvent;
+
+class ExternalUserAuthorizeEvent extends BaseEvent {
+
+ use SerializesModels;
+
+ ///
+ /// Properties
+ ///
+
+ /**
+ * The external authorization source, e.g. github
+ *
+ * @var string
+ */
+ public $externalSource;
+
+ /**
+ * The external user information returned by the API
+ *
+ * @var Laravel\Socialite\Contracts\User
+ */
+ public $externalUser;
+
+}
diff --git a/app/Events/NewExternalUserAuthorizeEvent.php b/app/Events/NewExternalUserAuthorizeEvent.php
new file mode 100644
--- /dev/null
+++ b/app/Events/NewExternalUserAuthorizeEvent.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace AuthGrove\Events;
+
+class NewExternalUserAuthorizeEvent extends ExternalUserAuthorizeEvent {
+
+ ///
+ /// Properties specific for new authorize events
+ ///
+
+ /**
+ * If set, the external user matches this local user, identified by user ID.
+ *
+ * If null, there isn't any constraint.
+ *
+ * @var int|null
+ */
+ public $constraintByUserId = null;
+
+}
diff --git a/app/Helpers/.gitkeep b/app/Helpers/.gitkeep
deleted file mode 100644
diff --git a/app/Helpers/Routing.php b/app/Helpers/Routing.php
new file mode 100644
--- /dev/null
+++ b/app/Helpers/Routing.php
@@ -0,0 +1,28 @@
+<?php
+
+use AuthGrove\Http\Controllers\Auth\AuthController;
+
+/*
+|--------------------------------------------------------------------------
+| Blade helper global functions
+|--------------------------------------------------------------------------
+|
+| This file register global helper functions to act as convenient aliases
+| for methods normally requiring a fully qualified class name with namespaces
+| to use in Blade template.
+|
+| e.g. {{ authurl('login') }} is a shorthand syntax for the longer construct
+[ {{ url(AuthGrove\Http\Controllers\Auth\AuthController::getRoute('login')) }}
+|
+*/
+
+/**
+ * Gets the full URL of a specified auth route.
+ *
+ * @param string $action The authentication action (e.g. login) [facultative]
+ * @return string The full URL (e.g. https://grove.domain.tld/auth/login)
+ */
+function authurl ($action = '') {
+ $route = AuthController::getRoute($action);
+ return url($route);
+}
diff --git a/app/Http/Controllers/Account/ExternalSourcesController.php b/app/Http/Controllers/Account/ExternalSourcesController.php
new file mode 100644
--- /dev/null
+++ b/app/Http/Controllers/Account/ExternalSourcesController.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace AuthGrove\Http\Controllers\Account;
+
+use AuthGrove\Http\Controllers\Controller;
+use AuthGrove\Models\UserExternalSource;
+
+use Illuminate\Http\Request;
+
+
+class ExternalSourcesController extends Controller {
+
+ /**
+ * The logged-in user
+ *
+ * @var AuthGrove\Models\User
+ */
+ private $user;
+
+ /**
+ * The current session
+ *
+ * @var object
+ */
+ private $session;
+
+ /**
+ * Creates a new controller instance.
+ *
+ * @return void
+ */
+ public function __construct (Request $request) {
+ $this->middleware('auth');
+ $this->user = $request->user();
+ $this->session = $request->session();
+ }
+
+ ///
+ /// Route methods
+ ///
+
+ /**
+ * Displays a listing of the resource.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function index () {
+ return view('account.external-sources.index', [
+ 'sources' => $this->user->getExternalSources(),
+ ]);
+ }
+
+ /**
+ * Displays the specified resource.
+ *
+ * @param int $id
+ * @return \Illuminate\Http\Response
+ */
+ public function show ($id) {
+ //
+ }
+
+ /**
+ * Removes the specified resource from storage.
+ *
+ * @param int $id
+ * @return \Illuminate\Http\Response
+ */
+ public function destroy ($id) {
+ if (!is_numeric($id)) {
+ abort(403, "Destroy requests must be called with an integer.");
+ }
+
+ $source = UserExternalSource::find((int)$id);
+
+ // Probably a refresh on an already visited page.
+ if ($source === null) {
+ return redirect('/account/external-sources')->withErrors([
+ 'crud' => trans('externalsources.link-doesnt-exist'),
+ ]);
+ }
+
+ // The user can only delete source linked to their own account.
+ if ($source->user_id !== $this->user->id) {
+ return redirect('/account/external-sources')->withErrors([
+ 'crud' => trans('externalsources.link-belongs-to-another'),
+ ]);
+ }
+
+ // We can safely delete the link. Note this is only a record in our db
+ // we mark as deleted. That doesn't make any revoke API call to the
+ // external source.
+ return $this->unlinkSource($source);
+ }
+
+ /**
+ * Restablishes a previously deleted link.
+ *
+ * @param string $operation The operation ID
+ * @return \Illuminate\Http\Response
+ */
+ public function undo ($operation) {
+ $undo = $this->session->pull('undo');
+ if ($undo === null || !UserExternalSource::undo($undo, $operation, $source)) {
+ return redirect('/account/external-sources')->withErrors([
+ 'status' => trans('undo.failure'),
+ ]);
+ }
+
+ // TODO: log the restore
+
+ return redirect('/account/external-sources')->with([
+ 'status' => trans('undo.success'),
+ ]);
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ /**
+ * Unlinks the external source from this account.
+ */
+ protected function unlinkSource (UserExternalSource $source) {
+ // Deletes.
+ $source->delete();
+
+ // Allows undo operation.
+ $undoOperationId = $source->storeUndoToSession($this->session);
+ //TODO: use UndoStack instead and specify a short timeout (60 secs?)
+
+ // Redirects the user to the external sources dashboard.
+ $status = trans(
+ 'externalsources.link-deleted',
+ [ 'url' => '/account/external-sources/undo/' . $undoOperationId ]
+ );
+ return redirect('/account/external-sources')->with([
+ 'status' => $status,
+ ]);
+ }
+
+}
diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php
--- a/app/Http/Controllers/Auth/AuthController.php
+++ b/app/Http/Controllers/Auth/AuthController.php
@@ -2,13 +2,15 @@
namespace AuthGrove\Http\Controllers\Auth;
+use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Registrar as RegistrarContract;
use Illuminate\Foundation\Auth\AuthenticatesAndRegistersUsers;
use Illuminate\Foundation\Auth\ThrottlesLogins;
+use Illuminate\Http\Request;
use AuthGrove\Http\Controllers\Controller;
+use AuthGrove\Services\AuthenticatesExternalUsers;
use AuthGrove\Services\Registrar;
-use AuthGrove\Models\User;
use Config;
use Route;
@@ -21,12 +23,16 @@
|--------------------------------------------------------------------------
|
| This controller handles the registration of new users, as well as the
- | authentication of existing users. By default, this controller uses
- | a simple trait to add these behaviors. Why don't you explore it?
+ | authentication of existing users.
|
*/
- use AuthenticatesAndRegistersUsers, ThrottlesLogins, Registrar;
+ use AuthenticatesAndRegistersUsers, ThrottlesLogins, Registrar,
+ AuthenticatesExternalUsers;
+
+ ///
+ /// Properties
+ ///
/**
* Where to redirect users after login / registration.
@@ -43,13 +49,46 @@
protected $username = 'username';
/**
+ * @var Illuminate\Http\Request
+ */
+ private $request;
+
+ /**
+ * @var Symfony\Component\HttpFoundation\Session\SessionInterface
+ */
+ private $session;
+
+ /**
+ * The list of the method allowed for authentified users.
+ *
+ * @var array
+ */
+ protected $nonGuestMethods = [
+ 'logout',
+
+ // External login
+ 'redirectToProvider',
+ 'handleProviderCallback',
+ ];
+
+ ///
+ /// Constructor
+ ///
+
+ /**
* Create a new authentication controller instance.
*
+ * @param Illuminate\Http\Request $request The HTTP request
* @return void
*/
- public function __construct()
- {
- $this->middleware($this->guestMiddleware(), ['except' => 'logout']);
+ public function __construct (Request $request) {
+ $this->middleware(
+ $this->guestMiddleware(),
+ [ 'except' => $this->nonGuestMethods ]
+ );
+
+ $this->request = $request;
+ $this->session = $request->session();
}
///
@@ -106,6 +145,25 @@
// Reset password (with a token received by mail)
Route::get($auth . '/reset/{token?}', ['as' => 'auth.password.reset', 'uses' => 'Auth\PasswordController@getReset']);
Route::post($auth . '/reset', ['as' => 'auth.password.reset', 'uses' => 'Auth\PasswordController@reset']);
+
+ //External providers
+ static::registerExternalProviderRoutes();
}
+ /**
+ * Registers routes to redirectToProvider and handleProviderCallback.
+ */
+ public static function registerExternalProviderRoutes () {
+ $auth = static::getRoutePrefix();
+
+ Route::get(
+ $auth . '/external/{driver?}',
+ 'Auth\AuthController@redirectToProvider'
+ );
+ Route::get(
+ $auth . '/external/{driver?}/authorize',
+ 'Auth\AuthController@handleProviderCallback'
+ );
+ }
+
}
diff --git a/app/Http/Controllers/LoginDashboardController.php b/app/Http/Controllers/LoginDashboardController.php
--- a/app/Http/Controllers/LoginDashboardController.php
+++ b/app/Http/Controllers/LoginDashboardController.php
@@ -39,6 +39,7 @@
'home',
[
'user' => $user->getInformation(),
+ 'status' => trans('panel.loggedin'),
]
);
}
diff --git a/app/Http/routes.php b/app/Http/routes.php
--- a/app/Http/routes.php
+++ b/app/Http/routes.php
@@ -29,6 +29,20 @@
Route::group(['middleware' => 'web'], function () {
AuthGrove\Http\Controllers\Auth\AuthController::registerRoutes();
+ //AuthGrove\Http\Controllers\Account\ExternalSourcesController::registerRoutes();
Route::get('/', 'LoginDashboardController@index');
+ Route::get('/account', 'LoginDashboardController@index');
+
+ // External sources
+ Route::get(
+ 'account/external-sources/undo/{operation}',
+ 'Account\ExternalSourcesController@undo'
+ );
+ Route::resource(
+ 'account/external-sources',
+ 'Account\ExternalSourcesController',
+ ['only' => ['index', 'show', 'destroy']]
+ );
});
+
diff --git a/app/Jobs/LinkExternalUserAccount.php b/app/Jobs/LinkExternalUserAccount.php
new file mode 100644
--- /dev/null
+++ b/app/Jobs/LinkExternalUserAccount.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace AuthGrove\Jobs;
+
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+
+use AuthGrove\Events\NewExternalUserAuthorizeEvent;
+use AuthGrove\Jobs\Job;
+use AuthGrove\Models\User;
+use AuthGrove\Models\UserExternalSource;
+
+use Auth;
+use Session;
+
+class LinkExternalUserAccount extends Job implements ShouldQueue {
+
+ use InteractsWithQueue, SerializesModels;
+
+ ///
+ /// Properties, constructor, job handler
+ ///
+
+ /**
+ * @var NewExternalUserAuthorizeEvent;
+ */
+ private $event;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * Initializes a new instance of LinkExternalUserAccount.
+ *
+ * @param AuthGrove\Events\NewExternalUserAuthorizeEvent $event The event containing link information
+ * @param AuthGrove\Models\User $user The local user to link the external account to
+ */
+ public function __construct (NewExternalUserAuthorizeEvent $event, User $user) {
+ $this->event = $event;
+ $this->user = $user;
+ }
+
+ /**
+ * Executes the job.
+ *
+ * @return void
+ */
+ public function handle () {
+ if ($this->canLinkAccounts()) {
+ $this->linkAccounts();
+ }
+ }
+
+ ///
+ /// Tasks methods
+ ///
+
+ /**
+ * Determines if the requirements to link accounts are met.
+ *
+ * @return bool
+ */
+ public function canLinkAccounts () {
+ $userId = $this->event->constraintByUserId;
+
+ // We allow to link account when:
+ // - there is no constraint (external scenario 3 puts the constraint)
+ // - the user id matches the constraint
+ return $userId === null || $userId === $this->user->id;
+ }
+
+ /**
+ * Links external and local accounts.
+ */
+ public function linkAccounts () {
+ UserExternalSource::create([
+ 'source_name' => $this->event->externalSource,
+ 'source_username' => $this->event->externalUser->getNickname(),
+ 'source_user_id' => $this->event->externalUser->getId(),
+ 'user_id' => $this->user->id,
+ ]);
+ Session::flash(
+ 'status',
+ trans('auth.external-source-successfully-linked')
+ );
+ }
+
+}
diff --git a/app/Jobs/LogExternalUserAuthorize.php b/app/Jobs/LogExternalUserAuthorize.php
new file mode 100644
--- /dev/null
+++ b/app/Jobs/LogExternalUserAuthorize.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace AuthGrove\Jobs;
+
+use AuthGrove\Jobs\Job;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+
+class LogExternalUserAuthorize extends Job implements ShouldQueue {
+
+ use InteractsWithQueue, SerializesModels;
+
+ /**
+ * Initializes a new instance of LogExternalUserAuthorize.
+ *
+ * @return void
+ */
+ public function __construct() {
+ }
+
+ /**
+ * Executes the job.
+ *
+ * @return void
+ */
+ public function handle () {
+ }
+
+}
diff --git a/app/Jobs/LogLogin.php b/app/Jobs/LogLogin.php
new file mode 100644
--- /dev/null
+++ b/app/Jobs/LogLogin.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace AuthGrove\Jobs;
+
+use AuthGrove\Jobs\Job;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+
+class LogLogin extends Job implements ShouldQueue {
+
+ use InteractsWithQueue, SerializesModels;
+
+ /**
+ * Initializes a new instance of LogExternalUserAuthorize.
+ *
+ * @return void
+ */
+ public function __construct() {
+ }
+
+ /**
+ * Executes the job.
+ *
+ * @return void
+ */
+ public function handle () {
+ }
+
+}
diff --git a/app/Listeners/.gitkeep b/app/Listeners/.gitkeep
deleted file mode 100644
diff --git a/app/Listeners/ExternalUserListener.php b/app/Listeners/ExternalUserListener.php
new file mode 100644
--- /dev/null
+++ b/app/Listeners/ExternalUserListener.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace AuthGrove\Listeners;
+
+use Illuminate\Events\Dispatcher;
+
+use AuthGrove\Events\ExternalUserAuthorizeEvent;
+use AuthGrove\Events\NewExternalUserAuthorizeEvent;
+use AuthGrove\Jobs\LinkExternalUserAccount;
+use AuthGrove\Jobs\LogExternalUserAuthorize;
+
+use Auth;
+
+class ExternalUserListener {
+
+ ///
+ /// Events handlers
+ ///
+
+ public function onNewExternalUserAuthorize (NewExternalUserAuthorizeEvent $event) {
+ $this->linkAccounts($event);
+ $this->logExternalUserAuthorize($event);
+ }
+
+ public function onExternalUserAuthorize (ExternalUserAuthorizeEvent $event) {
+ $this->logExternalUserAuthorize($event);
+ }
+
+ ///
+ /// Tasks methods
+ ///
+
+ protected function logExternalUserAuthorize (ExternalUserAuthorizeEvent $event) {
+
+ }
+
+ protected function linkAccounts (NewExternalUserAuthorizeEvent $event) {
+ $job = new LinkExternalUserAccount($event, Auth::user());
+ $job->handle();
+ }
+
+ ///
+ /// Events listener
+ ///
+
+ /**
+ * Registers the listeners for the subscriber.
+ *
+ * @param Illuminate\Events\Dispatcher $events The events dispatcher
+ */
+ public function subscribe (Dispatcher $events) {
+ $class = get_class($this);
+ $events->listen(
+ NewExternalUserAuthorizeEvent::class,
+ "$class@onNewExternalUserAuthorize"
+ );
+ $events->listen(
+ ExternalUserAuthorizeEvent::class,
+ "$class@onExternalUserAuthorize"
+ );
+ }
+
+}
diff --git a/app/Listeners/PostLoginActionsListener.php b/app/Listeners/PostLoginActionsListener.php
new file mode 100644
--- /dev/null
+++ b/app/Listeners/PostLoginActionsListener.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace AuthGrove\Listeners;
+
+use Illuminate\Auth\Events\Login as LoginEvent;
+use Illuminate\Contracts\Auth\Authenticatable;
+use Illuminate\Events\Dispatcher;
+
+use Event;
+use Session;
+
+class PostLoginActionsListener {
+
+ ///
+ /// Event handler
+ ///
+
+ /**
+ * Handles an user login event.
+ *
+ * @param Illuminate\Auth\Events\Login $event
+ */
+ public function onLogin (LoginEvent $event) {
+ $actions = $this->pullPostLoginActions();
+ foreach ($actions as $action) {
+ $this->firePostLoginActionEvent($action['event'], $action['parameters']);
+ }
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ /**
+ * Gets from the session the post login actions. Clears the session key.
+ *
+ * @return array An array with the actions, duplicates removed.
+ */
+ protected function pullPostLoginActions () {
+ $actions = Session::pull('actions.postlogin', []);
+ return array_unique($actions, SORT_REGULAR);
+ }
+
+ /**
+ * Fires the event to trigger the post login action.
+ *
+ * @param string $eventClass The fully qualified name of the class to use to build the event object
+ * @param array $parameters The properties of the evnet object
+ */
+ protected function firePostLoginActionEvent ($eventClass, $parameters) {
+ $event = $this->buildPostLoginActionEvent($eventClass, $parameters);
+ Event::fire($event);
+ }
+
+ /**
+ * Builds the event from data passed as array.
+ *
+ * @param string $eventClass The fully qualified name of the class to use to build the event object
+ * @param array $parameters The properties of the evnet object
+ * @return AuthGrove\Events\Event
+ */
+ protected function buildPostLoginActionEvent ($eventClass, $parameters) {
+ // 1. Initialize a new instance of $eventClass
+
+ if (!class_exists($eventClass)) {
+ throw new \LogicException("Event class doesn't exist: $eventClass");
+ }
+
+ $event = new $eventClass;
+
+ // 2. Map $parameters to object's properties
+
+ foreach ($parameters as $property => $value) {
+ if (!property_exists($event, $property)) {
+ throw new \LogicException("Property doesn't exist in $eventClass: $property");
+ }
+ $event->$property = $value;
+ }
+
+
+ return $event;
+ }
+
+ ///
+ /// Events listener
+ ///
+
+ /**
+ * Registers the listeners for the subscriber.
+ *
+ * @param Illuminate\Events\Dispatcher $events The events dispatcher
+ */
+ public function subscribe (Dispatcher $events) {
+ $class = get_class($this);
+ $events->listen(
+ 'Illuminate\Auth\Events\Login',
+ "$class@onLogin"
+ );
+ }
+
+}
diff --git a/app/Models/User.php b/app/Models/User.php
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -94,6 +94,21 @@
return $this->attributes['username'];
}
+ ///
+ /// External sources
+ ///
+
+ /**
+ * Gets external sources linked to this user account.
+ *
+ * @return lluminate\Database\Eloquent\Collection a collection of UserExternalSource items
+ */
+ public function getExternalSources () {
+ return UserExternalSource::where([
+ 'user_id' => $this->id,
+ ])->orderBy('created_at', 'asc')->get();
+ }
+
/**
* Tries to get the local user matching an external source.
*
@@ -105,7 +120,7 @@
public static function tryGetFromExternalSource ($source_name, $source_user_id, &$user) {
$source = UserExternalSource::where([
'source_name' => $source_name,
- 'source_user_id' => $source_user_id
+ 'source_user_id' => $source_user_id,
])->first();
if ($source === null) {
diff --git a/app/Models/UserExternalSource.php b/app/Models/UserExternalSource.php
--- a/app/Models/UserExternalSource.php
+++ b/app/Models/UserExternalSource.php
@@ -4,7 +4,12 @@
use Illuminate\Database\Eloquent\Model;
-class UserExternalSource extends Model {
+use AuthGrove\Undo\Undoable;
+use AuthGrove\Undo\UndoDelete;
+
+class UserExternalSource extends Model implements Undoable {
+
+ use UndoDelete;
/**
* The database table used by the model.
@@ -18,7 +23,7 @@
*
* @var array
*/
- protected $fillable = ['source_name', 'source_user_id', 'user_id'];
+ protected $fillable = ['source_name', 'source_user_id', 'source_username', 'user_id'];
/**
* The attributes excluded from the model's JSON form.
@@ -37,6 +42,13 @@
];
/**
+ * The attributes that should be mutated to dates.
+ *
+ * @var array
+ */
+ protected $dates = ['created_at', 'updated_at'];
+
+ /**
* Gets fillable but not hidden attributes, plus create/update time.
*
* @return Array
@@ -52,7 +64,7 @@
/**
* Gets user attached to this source.
*
- * @return User
+ * @return AuthGrove\Models\User
*/
public function getUser () {
return User::find($this->user_id);
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,10 +2,6 @@
use Illuminate\Support\ServiceProvider;
-use AuthGrove\Http\Controllers\Auth\AuthController;
-
-use Blade;
-
class AppServiceProvider extends ServiceProvider {
/**
@@ -13,13 +9,8 @@
*
* @return void
*/
- public function boot() {
- // Blade templates can invoke AuthController::getRoute as authurl()
- Blade::directive('authurl', function ($expression) {
- preg_match("@\('(.*)'\)@", $expression, $matches); // ('foo') → foo
- $action = $matches[1];
- return url(AuthController::getRoute($action));
- });
+ public function boot()
+ {
}
/**
diff --git a/app/Providers/HelpersServiceProvider.php b/app/Providers/HelpersServiceProvider.php
--- a/app/Providers/HelpersServiceProvider.php
+++ b/app/Providers/HelpersServiceProvider.php
@@ -21,6 +21,7 @@
* @return void
*/
public function register() {
+ require_once app_path() . '/Helpers/Routing.php';
}
}
diff --git a/app/Services/AuthenticatesExternalUsers.php b/app/Services/AuthenticatesExternalUsers.php
new file mode 100644
--- /dev/null
+++ b/app/Services/AuthenticatesExternalUsers.php
@@ -0,0 +1,395 @@
+<?php
+
+namespace AuthGrove\Services;
+
+use Laravel\Socialite\Contracts\User as ExternalUser;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+
+use AuthGrove\Events\NewExternalUserAuthorizeEvent;
+use AuthGrove\Models\User;
+
+use Auth;
+use Config;
+use Event;
+use Redirect;
+use Socialite;
+
+trait AuthenticatesExternalUsers {
+
+ /*
+ |--------------------------------------------------------------------------
+ | External login trait for registration and login controller
+ |--------------------------------------------------------------------------
+ |
+ | This trait allows a registration & login controller to interact with
+ | external sources like GitHub to authorize externally the user.
+ |
+ | We provide routes to request authorization and to handle reply payload.
+ |
+ | Basically, four scenarii could occur and are handled:
+ |
+ | 1. An exception occurs during remote authorization process.
+ | We can retry, as that's sometimes expected (e.g. go back to an old URL)
+ | but it fails again and again and again, we have to fail loudly.
+ |
+ | 2. The external user payload matches directly by a known ID a local user.
+ | We log in.
+ |
+ | 3. The external user payload matches a local user by e-mail.
+ | We record the information in the session sate, so if the user succeeds
+ | to log in by another way, we'll link local and external accounts.
+ | We redirect the user to login form.
+ |
+ | 4. The external user payload doesn't match a local user (or only by non
+ | reliable field like username).
+ | We record the information in the session sate, so if the user succeeds
+ | to log in or register, we'll link too.
+ | We redirect the user to register form.
+ |
+ */
+
+ ///
+ /// Properties
+ ///
+
+ /**
+ * @var AuthGrove\Models\User
+ */
+ private $localUser = null;
+
+ /**
+ * @var Laravel\Socialite\Contracts\User
+ */
+ private $externalUser;
+
+ ///
+ /// Controller entry points
+ ///
+
+ /**
+ * Redirects the user to the provider authentication page.
+ *
+ * This is typically an entry point for routes like /auth/external/github.
+ *
+ * @param string $driver The provider
+ * @return \Illuminate\Http\Response
+ */
+ public function redirectToProvider($driver) {
+ $this->initializeDriverFeatures($driver);
+
+ return Socialite::driver($driver)->redirect();
+ }
+
+ /**
+ * Handles external source payload.
+ *
+ * This is typically an entry point reserved for the external service,
+ * for routes like /auth/external/github/authorize.
+ *
+ * @param string $driver The provider
+ * @return \Illuminate\Http\Response
+ */
+ public function handleProviderCallback($driver) {
+ $this->initializeDriverFeatures($driver);
+
+ try {
+ $this->externalUser = Socialite::driver($driver)->user();
+ $this->session->put('auth.redirects', 0); // End of redirect cycle
+ } catch (\Exception $ex) {
+ // CASE 1 — An exception during remote authorization process.
+ return $this->handleExternalSourceException($ex);
+ }
+
+ // LOGGED IN USERS CASES — Cases 2 will be annoying, 3 or 4 straightforward.
+ if (Auth::check()) {
+ return $this->handleProviderCallbackForLoggedInUser();
+ }
+
+ // CASE 2 — The external user id is known
+ if ($this->tryGetLocalUserFromExternalSource()) {
+ return $this->handleKnownExternalUser();
+ }
+
+
+ // CASE 3 — E-mail matches: offer to link
+ if ($this->tryFindLocalUserByMail()) {
+ return $this->handleMatchingExternalUser();
+ }
+
+ // CASE 4 — Unknown user: offer to register
+ return $this->handleUnknownExternalUser();
+ }
+
+ ///
+ /// Helper methods to return responses depending of scenarii
+ ///
+
+ /**
+ * Handles an exception thrown by an external source.
+ *
+ * Scenario 1.
+ *
+ * @param \Exception $ex The exception thrown
+ * @return \Illuminate\Http\Response
+ */
+ public function handleExternalSourceException (\Exception $ex) {
+ // When an exception occurs, one of the most frequent case is user
+ // refreshed the page or reached an old URL. We can reinitiate the
+ // request safely. But we need to track this behavior to limit the
+ // number of redirects below the browser value, so when external
+ // source really throws an error, we can notify the user loudly.
+
+ $redirectsCount = $this->session->increment('auth.redirects');
+ if ($redirectsCount < 3) {
+ // Let's try again
+ $url = static::getRoute('external/' . $this->driver);
+ return Redirect::to($url);
+ }
+
+ $this->session->put('auth.redirects', 0); // End of redirect cycle
+ return view('auth.fatal-error')->withErrors([
+ 'context' => trans('auth.external-source-exception'),
+ 'exception' => $e->getMessage(),
+ ]);
+ }
+
+ /**
+ * Handles external source payload for logged-in user.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ protected function handleProviderCallbackForLoggedInUser () {
+ // CASE 2 — The external user id is known
+ if ($this->tryGetLocalUserFromExternalSource()) {
+ return $this->handleAlreadyAssignedExternalUser();
+ }
+
+ // CASE 3/4 — Link
+ return $this->handleLinkExternalUserToLoggedInUser();
+ }
+
+ /**
+ * Handles a known external user authorize payload.
+ * Logs in, redirects to target after-login page (dashboard, another site).
+ *
+ * Scenario 2.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function handleKnownExternalUser () {
+ Auth::login($this->localUser, true);
+ return Redirect::to($this->redirectTo);
+ }
+
+ /**
+ * Handles a matching external user authorize payload.
+ * Offers to login to confirm identity.
+ *
+ * Scenario 3.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function handleMatchingExternalUser () {
+ // We can't directly authenticate the user but we've a probable
+ // match between local data and external source data.
+ // We need to confirm that, with another way to authenticate.
+ $this->saveExternalUserAuthorize();
+
+ return view('auth.login')->withErrors([
+ 'context' => trans('auth.external-source-email-match'),
+ ]);
+ }
+
+ /**
+ * Handles an unknown external user authorize payload.
+ * Offers to register, with a prefilled form.
+ *
+ * Scenario 4.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function handleUnknownExternalUser () {
+ $this->saveExternalUserAuthorize();
+
+ $registerUrl = static::getRoute('register');
+ return redirect($registerUrl)
+ ->withErrors([
+ 'context' => trans('auth.external-source-no-match'),
+ ])
+ ->withInput([
+ 'username' => $this->externalUser->nickname,
+ 'fullname' => $this->externalUser->name,
+ 'email' => $this->externalUser->email,
+ ]);
+ }
+
+ /**
+ * Handles a known external user authorize payload.
+ *
+ * Scenario 2.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ protected function handleAlreadyAssignedExternalUser () {
+ if (Auth::User()->id === $this->localUser->id) {
+ $context = 'auth.external-source-already-linked';
+ } else {
+ // Uh-oh
+ $context = 'auth.external-source-already-assigned';
+ }
+
+ return redirect('/account/external-sources')->withErrors([
+ 'context' => trans($context),
+ ]);
+ }
+
+ /**
+ * Handles a unknown external user authorize payload for a logged in user.
+ *
+ * Scenario 3 (uh-oh) or 4.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ protected function handleLinkExternalUserToLoggedInUser () {
+ $this->linkExternalUserToLoggedInUser();
+
+ return redirect('/account/external-sources')->with([
+ 'status' => trans('auth.external-source-successfully-linked'),
+ ]);
+ }
+
+ protected function linkExternalUserToLoggedInUser () {
+ // We can fire directly the right event: there is no need to wait login.
+ $event = new NewExternalUserAuthorizeEvent;
+ $event->externalSource = $this->driver;
+ $event->externalUser = $this->externalUser;
+
+ Event::fire($event);
+ }
+
+ ///
+ /// Helper methods to manage driver
+ ///
+
+ /**
+ * Ensures driver is enabled and configures callback redirect.
+ */
+ public function initializeDriverFeatures ($driver) {
+ $this->driver = $driver;
+
+ if (!$this->isDriverEnabled()) {
+ throw new NotFoundHttpException;
+ }
+
+ Config::set(
+ 'services.' . $driver . '.redirect',
+ $this->getProviderCallbackUrl()
+ );
+ }
+
+ /**
+ * Checks if the specified driver exists and is enabled.
+ *
+ * @param string $driverToCheck The driver to check
+ * @return bool true if the driver exists AND is enabled; otherwise, false.
+ */
+ public function isDriverEnabled () {
+ $sources = Config::get('auth.sources.external');
+ foreach ($sources as $source => $enabled) {
+ if ($source === $this->driver) {
+ return $enabled;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Helper methods to manage session state
+ ///
+
+ /**
+ * Saves to the session state the external source authorize payload.
+ *
+ * This will allow to save later in database the link between local user
+ * and external user.
+ */
+ public function saveExternalUserAuthorize () {
+ $action = $this->getExternalUserAuthorizePostLoginAction();
+ $this->session->push('actions.postlogin', $action);
+ }
+
+ /**
+ * Gets an array to record external user authorize state in the session.
+ *
+ * @param User|null if know, the local user matching the external user data [facultative]
+ * return array
+ */
+ public function getExternalUserAuthorizePostLoginAction () {
+ $action = [
+ 'event' => NewExternalUserAuthorizeEvent::class,
+ 'parameters' => [
+ 'externalSource' => $this->driver,
+ 'externalUser' => $this->externalUser,
+ ]
+ ];
+
+ // If a local user is known, only them will be able to link the accounts.
+ // That avoids users to register an extraneous account.
+ if ($this->localUser !== null) {
+ $action['parameters']['constraintByUserId'] = $this->localUser->id;
+ }
+
+ return $action;
+ }
+
+ ///
+ /// Helper methods to match local and external users
+ ///
+
+ /**
+ * Finds a local user matching an external user by external user ID.
+ *
+ * If an user is found, the object property localUser is set.
+ *
+ * @return bool
+ */
+ public function tryGetLocalUserFromExternalSource () {
+ return User::tryGetFromExternalSource(
+ $this->driver,
+ $this->externalUser->id,
+ $this->localUser
+ );
+ }
+
+ /**
+ * Finds a local user matching an external user by mail.
+ *
+ * If an user is found, the object property localUser is set.
+ *
+ * @return bool
+ */
+ public function tryFindLocalUserByMail () {
+ $this->localUser = User::where(
+ ['email' => $this->externalUser->email]
+ )->first();
+
+ return $this->localUser !== null;
+ }
+
+ ///
+ /// Routing
+ ///
+
+ /**
+ * The callback URL the external service should use to redirect back to
+ * our application.
+ *
+ * @return string
+ */
+ public function getProviderCallbackUrl () {
+ $url = static::getRoute('external/' . $this->driver . '/authorize');
+ return url($url);
+ }
+
+}
diff --git a/app/Undo/UndoDelete.php b/app/Undo/UndoDelete.php
new file mode 100644
--- /dev/null
+++ b/app/Undo/UndoDelete.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace AuthGrove\Undo;
+
+use Symfony\Component\HttpFoundation\Session\SessionInterface;
+
+/**
+ * Trait offering an implementation for Undoable for Symfony sessions
+ */
+trait UndoDelete {
+
+ /**
+ * Undoes a destructive operation.
+ *
+ * @param AuthGrove\Undo\UndoStore $store
+ * @param string $storeHash
+ * @param mixed $restored The stored instance, to be able to further interact with it after undo
+ * @return bool true if the operation is undone successfully; otherwise, false
+ */
+ public static function undo (UndoStore $store, $storeHash, &$restored) {
+ // Ensures we undo the operation required by the user
+ if (!$store->isSameControlHash($storeHash)) {
+ return false;
+ }
+
+ if (!$store->checkIntegrity()) {
+ return false;
+ }
+
+ $restored = $store->restoreState($return);
+
+ return (bool)$return;
+ }
+
+ /**
+ * Prepares an undo store, ie a glass coffin with a serialized copy of our
+ * instance and instructions how to undo the destructive operation.
+ *
+ * @return AuthGrove\Undo\UndoStore
+ */
+ public function prepareUndoStore () {
+ return new UndoStore($this);
+ }
+
+ /**
+ * Puts to session an undo store.
+ *
+ * @param Symfony\Component\HttpFoundation\Session\SessionInterface $session The session where to store the undo operation at the 'undo' key
+ * @param AuthGrove\Undo\UndoStore $store
+ * @return string An hash to allow to check later the integrity of the undo store or identify it
+ *
+ * @deprecated We're moving to a UndoStack class
+ */
+ public function storeUndoToSession (SessionInterface $session, UndoStore $store = null) {
+ if ($store === null) { // To avoid to break current test code
+ $store = $this->prepareUndoStore();
+ }
+
+ $hash = $store->getControlHash();
+
+ $session->put('undo', $store);
+
+ return $hash;
+ }
+
+}
diff --git a/app/Undo/UndoStack.php b/app/Undo/UndoStack.php
new file mode 100644
--- /dev/null
+++ b/app/Undo/UndoStack.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace AuthGrove\Undo;
+
+use OutOfBoundsException;
+use SplDoublyLinkedList;
+
+/**
+ * A stack of undo stores to maintain a LIFO collection of undoable operations.
+ */
+class UndoStack extends SplDoublyLinkedList {
+
+ ///
+ /// Constructor
+ ///
+
+ public function __construct () {
+ parent::__construct();
+ $this->setIteratorMode(IT_MODE_LIFO | IT_MODE_KEEP);
+ }
+
+ ///
+ /// Stack helper methods
+ ///
+
+ /**
+ * Gets a specified store.
+ *
+ * @param string $hash The hash of the store to get
+ * @var mixed|null
+ */
+ public function getStore ($hash) {
+ foreach ($this as $store) {
+ if ($store->getControlHash() === $hash) {
+ return $store;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Undo helper methods
+ ///
+
+ /**
+ * Undoes the last stacked operation.
+ */
+ public function undoLast () {
+ if ($this->isEmpty()) {
+ return new OutOfBoundsException("UndoStack is empty.");
+ }
+
+ $store = $this->pop();
+ if (!is_a($store, UndoStore::class)) {
+ throw new InvalidArgumentException("UndoStack contained an item of unexpected type.");
+ }
+
+ $store->restoreState();
+ }
+
+ /**
+ * Undoes a specified operation.
+ *
+ * @param string $hash The hash of the store to restore instance state
+ * @param out mixed $return The value returned by the method called to undo the operation
+ * @return Undoable The store's instance, after its state is restored
+ */
+ public function undo ($hash, &$return = null) {
+ $store = $this->getStore($hash);
+ if ($store === null) {
+ throw new OutOfBoundsException("Hash not found.");
+ }
+
+ return $store->restoreState($return);
+ }
+
+}
diff --git a/app/Undo/UndoStore.php b/app/Undo/UndoStore.php
new file mode 100644
--- /dev/null
+++ b/app/Undo/UndoStore.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace AuthGrove\Undo;
+
+use Hash;
+use InvalidArgumentException;
+use OutOfBoundsException;
+
+class UndoStore {
+
+ ///
+ /// Private members
+ ///
+
+ /**
+ * The serialized instance of the object stored.
+ *
+ * @var string
+ */
+ private $serializedInstance;
+
+ /**
+ * The operation control ID.
+ *
+ * @var string
+ */
+ private $controlHash = '';
+
+ ///
+ /// Public properties
+ ///
+
+ /**
+ * The method to call to restore the state.
+ * This method must belongs to the stored object.
+ *
+ * @var string
+ */
+ public $restoreMethod = 'save';
+
+ /**
+ * The parameters of the method to call to restore the state.
+ *
+ * @var array
+ */
+ public $restoreMethodParameters = [];
+
+ /**
+ * Gets the stored instance.
+ *
+ * @return mixed
+ */
+ public function getInstance() {
+ return unserialize($this->serializedInstance);
+ }
+
+ /**
+ * Sets a new instance of an object to store.
+ *
+ * @param mixed The instance to store
+ */
+ public function setInstance ($instance) {
+ $this->serializedInstance = serialize($instance);
+ }
+
+ /**
+ * Gets the control ID of the instance. This allows to ensure its integrity.
+ *
+ * @return string An hash of the instance properties
+ */
+ public function getControlHash () {
+ if ($this->controlHash === '') {
+ $this->computeControlHash();
+ }
+ return $this->controlHash;
+ }
+
+ ///
+ /// Constructor
+ ///
+
+ /**
+ * Initializes a new instance of the UndoStore object.
+ *
+ * @param mixed $instance The instance of the object to store
+ */
+ public function __construct ($instance) {
+ $this->setInstance($instance);
+ }
+
+ ///
+ /// Control ID
+ ///
+
+ /**
+ * Determines if the operation control identifiant is the same than defined in the store.
+ *
+ * This allows for example to avoid to restore stale session data and ensure the user wants really to restore this instance.
+ *
+ * @param string $actualControlHash The operation control id to compare
+ * @return bool
+ */
+ public function isSameControlHash ($actualControlHash) {
+ return $this->controlHash !== '' && $this->controlHash === $actualControlHash;
+ }
+
+ /**
+ * Determines the object integrity is intact, ie properties has not been modified since last control id computation
+ */
+ public function checkIntegrity () {
+ $hash = $this->getControlHashForCurrentData();
+ return hash_equals($hash, $this->controlHash);
+ }
+
+ /**
+ * Computes a control id from the stored information
+ */
+ public function computeControlHash () {
+ $this->controlHash = $this->getControlHashForCurrentData();
+ }
+
+ /**
+ * @return string The control hash
+ */
+ protected function getControlHashForCurrentData () {
+ $data = $this->getDataForControlHash();
+ return hash("ripemd160", $data);
+ }
+
+ /**
+ * Gets a unique string representation of the current store.
+ *
+ * @return string
+ */
+ protected function getDataForControlHash () {
+ return $this->serializedInstance
+ . $this->restoreMethod
+ . serialize($this->restoreMethodParameters);
+ }
+
+ ///
+ /// Restore
+ ///
+
+ /**
+ * Restores previous state of the stored instance.
+ *
+ * @param out mixed $return The restore method's return value
+ * @return mixed The restored instance
+ */
+ public function restoreState (&$return = null) {
+ $instance = $this->getInstance();
+
+ if ($instance === null) {
+ throw new OutOfBoundsException;
+ }
+
+ if (!method_exists($instance, $this->restoreMethod)) {
+ throw new InvalidArgumentException;
+ }
+
+ $return = call_user_func_array(
+ [$instance, $this->restoreMethod],
+ $this->restoreMethodParameters
+ );
+ return $instance;
+ }
+
+}
diff --git a/app/Undo/Undoable.php b/app/Undo/Undoable.php
new file mode 100644
--- /dev/null
+++ b/app/Undo/Undoable.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace AuthGrove\Undo;
+
+use Symfony\Component\HttpFoundation\Session\SessionInterface;
+
+interface Undoable {
+
+ /**
+ * Undoes a destructive operation.
+ *
+ * @return bool
+ */
+ public static function undo (UndoStore $undoOperation, $operationControlHash, &$restored);
+
+ /**
+ * Prepares a store to allow to undo a destructive operation and put in session.
+ *
+ * @param $session
+ * @return bool
+ *
+ * @deprecated TODO: transform storeUndoToSession to into an UndoStack class
+ */
+ public function storeUndoToSession (SessionInterface $session, UndoStore $store = null);
+
+ /**
+ * @return AuthGrove\Undo\UndoStore
+ */
+ public function prepareUndoStore ();
+
+}
diff --git a/config/app.php b/config/app.php
--- a/config/app.php
+++ b/config/app.php
@@ -196,6 +196,8 @@
*/
'listeners' => [
+ AuthGrove\Listeners\PostLoginActionsListener::class,
+ AuthGrove\Listeners\ExternalUserListener::class,
],
/*
diff --git a/config/auth.php b/config/auth.php
--- a/config/auth.php
+++ b/config/auth.php
@@ -106,6 +106,23 @@
/*
|--------------------------------------------------------------------------
+ | Authentication sources
+ |--------------------------------------------------------------------------
+ |
+ | This option lists all the authentication sources: it could be a local
+ | source like username/password or by e-mail, or external authentication
+ | providers we allow to login to through OAuth.
+ |
+ */
+
+ 'sources' => [
+ 'external' => [
+ 'github' => (bool)env('GITHUB_ENABLE'),
+ ]
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
| Routes
|--------------------------------------------------------------------------
|
diff --git a/config/services.php b/config/services.php
--- a/config/services.php
+++ b/config/services.php
@@ -4,6 +4,23 @@
/*
|--------------------------------------------------------------------------
+ | Authentication providers
+ |--------------------------------------------------------------------------
+ |
+ | This file is for storing the credentials for third party services such
+ | as Stripe, Mailgun, Mandrill, and others. This file provides a sane
+ | default location for this type of information, allowing packages
+ | to have a conventional place to find your various credentials.
+ |
+ */
+
+ 'github' => [
+ 'client_id' => env('GITHUB_CLIENT_ID', null),
+ 'client_secret' => env('GITHUB_CLIENT_SECRET', null),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
diff --git a/database/migrations/2016_06_28_034600_create_users_external_sources_table.php b/database/migrations/2016_06_28_034600_create_users_external_sources_table.php
--- a/database/migrations/2016_06_28_034600_create_users_external_sources_table.php
+++ b/database/migrations/2016_06_28_034600_create_users_external_sources_table.php
@@ -3,34 +3,35 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
-class CreateUsersExternalCredentialsTable extends Migration {
+class CreateUsersExternalSourcesTable extends Migration {
- /**
- * Run the migrations.
- *
- * @return void
- */
- public function up() {
- Schema::create('users_external_sources', function(Blueprint $table) {
- $table->increments('id');
- $table->string('source_name');
- $table->string('source_user_id');
- $table->integer('user_id')->unsigned();
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up() {
+ Schema::create('users_external_sources', function(Blueprint $table) {
+ $table->increments('id');
+ $table->string('source_name');
+ $table->string('source_user_id');
+ $table->string('source_username');
+ $table->integer('user_id')->unsigned();
- $table->timestamps();
- $table->softDeletes();
+ $table->timestamps();
+ $table->unique(['source_name', 'source_user_id']);
$table->foreign('user_id')->references('id')->on('users');
- });
- }
+ });
+ }
- /**
- * Reverse the migrations.
- *
- * @return void
- */
- public function down() {
- Schema::drop('users_external_sources');
- }
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down() {
+ Schema::drop('users_external_sources');
+ }
}
diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php
--- a/resources/lang/en/auth.php
+++ b/resources/lang/en/auth.php
@@ -16,4 +16,17 @@
'failed' => 'These credentials do not match our records.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
+ 'fatal-error' => 'A fatal error occured during the authentication process.',
+
+ 'external-source-exception' => 'This error occured when the authentication process tried to reach an external provider to get an authorization:',
+
+ // Logged out users
+ 'external-source-email-match' => "You've been successfully authorized by an external provider. We can link this account with an account here, as the email address matches. Please first log in with another method to securely authenticate yourself.",
+ 'external-source-no-match' => "You've been successfully authorized by an external provider. It seems you don't already have an account here. You can register a new account filling this form. If you already have an account, you can go back to the login screen and try another method to log in. In both cases, we'll link your external account to your local account.",
+
+ // Logged in users
+ 'external-source-already-assigned' => "You've been successfully authorized by an external provider. But this external account is already assigned to ANOTHER local account. If you think your account could have been compromised, please contact support for assistance.",
+ 'external-source-already-linked' => "This external provider account is already linked to your local account.",
+ 'external-source-successfully-linked' => "This external provider account has successfully been linked to your local account.",
+
];
diff --git a/resources/lang/en/externalsources.php b/resources/lang/en/externalsources.php
new file mode 100644
--- /dev/null
+++ b/resources/lang/en/externalsources.php
@@ -0,0 +1,63 @@
+<?php
+
+return [
+
+ /*
+ |--------------------------------------------------------------------------
+ | External sources content
+ |--------------------------------------------------------------------------
+ |
+ | The following language lines are used for several things related to
+ | external sources:
+ |
+ | - Process login/authorize to an external soruce
+ | - Display information in the dashboard
+ | - Error management
+ | - Logging
+ |
+ */
+
+ ///
+ /// Headings
+ ///
+
+ 'heading' => 'External sources',
+
+ ///
+ /// Sources names
+ ///
+
+ 'sources' => [
+ 'github' => 'GitHub',
+ ],
+
+ ///
+ /// /account/external-sources
+ ///
+
+ // This message is displayed when the user don't have any external source
+ // loginlinked to their account
+ 'no-external-source-linked' => "You haven't linked your account to any external source.",
+
+ // These attributes are displayed as table headings
+ 'source-attributes' => [
+ "id" => "Link ID",
+ "source_name" => "Service",
+ "source_user_id" => "Source user ID",
+ "source_username" => "Source username",
+ "created_at" => "Link created",
+ "updated_at" => "Last updated",
+ ],
+
+ // When a user wants to delete a link …
+
+ // … for them
+ 'link-deleted' => "The external source isn't linked anymore to your account. <a href=\":url\">Undo</a>.",
+
+ // … for someone else
+ 'link-belongs-to-another' => "This external source can't be deleted, as it belongs to another account.",
+
+ // … not existing
+ 'link-doesnt-exist' => "The external source link never existed or doesn't exist anymore."
+
+];
diff --git a/resources/lang/en/login.php b/resources/lang/en/login.php
--- a/resources/lang/en/login.php
+++ b/resources/lang/en/login.php
@@ -41,4 +41,7 @@
"resetPassword" => "New password",
"resetButton" => "Reset password",
+ //Back to homepage
+ "goto-login" => "Go back to login screen",
+
];
diff --git a/resources/lang/en/panel.php b/resources/lang/en/panel.php
--- a/resources/lang/en/panel.php
+++ b/resources/lang/en/panel.php
@@ -20,6 +20,12 @@
'toggle-navigation' => 'Toggle Navigation',
///
+ /// Common blocks
+ ///
+
+ 'error' => 'Error',
+
+ ///
/// Home - Status
///
diff --git a/resources/lang/en/undo.php b/resources/lang/en/undo.php
new file mode 100644
--- /dev/null
+++ b/resources/lang/en/undo.php
@@ -0,0 +1,17 @@
+<?php
+
+return [
+
+ /*
+ |--------------------------------------------------------------------------
+ | Undo
+ |--------------------------------------------------------------------------
+ |
+ | The following language lines are the default messages to undo operations.
+ |
+ */
+
+ "success" => "Operation undone.",
+ "failure" => "Can't undo the operation.",
+
+];
diff --git a/resources/views/account/external-sources/index.blade.php b/resources/views/account/external-sources/index.blade.php
new file mode 100644
--- /dev/null
+++ b/resources/views/account/external-sources/index.blade.php
@@ -0,0 +1,50 @@
+@extends('app')
+
+@section('content')
+ <div class="row">
+ <div class="col-md-10 col-md-offset-1">
+ <div class="panel panel-default">
+ <div class="panel-heading">@lang('externalsources.heading')</div>
+
+ <div class="panel-body">
+@if (count($sources) > 0)
+ <table class="table table-striped">
+ <thead>
+ <tr>
+ <th>@lang('externalsources.source-attributes.source_name')</th>
+ <th>@lang('externalsources.source-attributes.source_username')</th>
+ <th>@lang('externalsources.source-attributes.source_user_id')</th>
+ <th>@lang('externalsources.source-attributes.created_at')</th>
+ <th>&nbsp;</th>
+ </tr>
+ </thead>
+ <tbody>
+@foreach ($sources->all() as $source)
+ <tr>
+ <td>@lang("externalsources.sources.$source->source_name")</td>
+ <td>{{ $source->source_username }}</td>
+ <td>{{ $source->source_user_id }}</td>
+ <td>{{ $source->created_at }}</td>
+ <td>
+ <form action="{{ url('account/external-sources/' . $source->id) }}" method="POST">
+ {{ csrf_field() }}
+ {{ method_field('DELETE') }}
+ <button type="submit" class="btn btn-danger">
+ <i class="fa fa-btn fa-trash"></i> Delete
+ </button>
+ </form>
+ </td>
+ </tr>
+@endforeach
+ </tbody>
+ </table>
+@else
+ <p>@lang('externalsources.no-external-source-linked')</p>
+@endif
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
+@endsection
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php
--- a/resources/views/app.blade.php
+++ b/resources/views/app.blade.php
@@ -42,7 +42,7 @@
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">{{ Auth::user()->getName() }} <span class="caret"></span></a>
<ul class="dropdown-menu" role="menu">
- <li><a href="@authurl('logout')">@lang('panel.logout')</a></li>
+ <li><a href="{{ authurl('logout') }}">@lang('panel.logout')</a></li>
</ul>
</li>
</ul>
@@ -50,7 +50,23 @@
</div>
</nav>
- @yield('content')
+<div class="container">
+@if (session('status'))
+ <div class="alert alert-success">
+ {!! session('status') !!}
+ </div>
+
+@endif
+@if (count($errors) > 0)
+@foreach ($errors->all() as $error)
+ <div class="alert alert-danger">
+ {{ $error }}
+ </div>
+
+@endforeach
+@endif
+@yield('content')
+</div>
<!-- Scripts -->
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js" integrity="sha384-CgeP3wqr9h5YanePjYLENwCTSSEz42NJkbFpAFgHWQz7u3Zk8D00752ScNpXqGjS" crossorigin="anonymous"></script>
diff --git a/resources/views/auth/fatal-error.blade.php b/resources/views/auth/fatal-error.blade.php
--- a/resources/views/auth/fatal-error.blade.php
+++ b/resources/views/auth/fatal-error.blade.php
@@ -8,5 +8,5 @@
{{ $error }}<br />
@endforeach
</p>
- <a href="@authurl('login')" class="action-link">@lang('login.goto-login')</a>
+ <a href="{{ authurl('login') }}" class="action-link">@lang('login.goto-login')</a>
@endsection
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php
--- a/resources/views/auth/login.blade.php
+++ b/resources/views/auth/login.blade.php
@@ -3,7 +3,7 @@
@section('card-content')
<h1 class="title">@lang('app.title')</h1>
<img id="profile-img" class="profile-img-card" src="/images/profile-img-blank.png" alt="@lang('login.blankAvatarAlt')" />
- <form class="form-signin" role="form" method="POST" action="@authurl('login')">
+ <form class="form-signin" role="form" method="POST" action="{{ authurl('login') }}">
<div id="identity">
<span id="reauth-username" class="reauth-username"></span>
<input type="text" name="username" id="inputUsername" class="form-control"
@@ -15,7 +15,7 @@
@foreach ($errors->all() as $error)
{{ $error }}<br />
@endforeach
- <a href="@authurl('recover')" class="action-link">@lang('login.passwordRecovery')</a>
+ <a href="{{ authurl('recover') }}" class="action-link">@lang('login.passwordRecovery')</a>
</p>
@endif
@@ -27,7 +27,7 @@
</form>
<!-- /form -->
@if (count($errors) == 0)
- <a href="@authurl('recover')" class="action-link">@lang('login.passwordRecovery')</a><br />
+ <a href="{{ authurl('recover') }}" class="action-link">@lang('login.passwordRecovery')</a><br />
@endif
- <a href="@authurl('register')" class="action-link">@lang('login.registerAccount')</a>
+ <a href="{{ authurl('register') }}" class="action-link">@lang('login.registerAccount')</a>
@endsection
diff --git a/resources/views/auth/recover.blade.php b/resources/views/auth/recover.blade.php
--- a/resources/views/auth/recover.blade.php
+++ b/resources/views/auth/recover.blade.php
@@ -7,7 +7,7 @@
<p class="center"><img src="{{ url('/images/white-check.svg') }}" alt="Check mark" width="100px" /></p>
<p class="nav"><a href="{{ url('/') }}">@lang('pagination.previous') Back to login screen</a></p>
@else
- <form class="form-signin form-recover" role="form" method="POST" action="@authurl('recover')">
+ <form class="form-signin form-recover" role="form" method="POST" action="{{ authurl('recover') }}">
<div id="identity">
<input type="email" name="email" id="inputEmail" class="form-control"
value="{{ old('email') }}" placeholder="@lang('login.email')" required autofocus />
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php
--- a/resources/views/auth/register.blade.php
+++ b/resources/views/auth/register.blade.php
@@ -2,7 +2,7 @@
@section('card-content')
<h1 class="title">@lang('login.registerAccount')</h1>
- <form class="form-signin form-register" role="form" method="POST" action="@authurl('register')">
+ <form class="form-signin form-register" role="form" method="POST" action="{{ authurl('register') }}">
<div id="identity">
<span id="reauth-username" class="reauth-username"></span>
<label for="inputUsername">@lang('login.username')</label>
diff --git a/resources/views/auth/reset.blade.php b/resources/views/auth/reset.blade.php
--- a/resources/views/auth/reset.blade.php
+++ b/resources/views/auth/reset.blade.php
@@ -3,7 +3,7 @@
@section('card-content')
<div class="container-fluid">
<h1 class="title">@lang('login.resetPassword')</h1>
- <form class="form-signin form-reset" role="form" method="POST" action="@authurl('reset')">
+ <form class="form-signin form-reset" role="form" method="POST" action="{{ authurl('reset') }}">
<div id="identity">
<label for="inputEmail">@lang('login.email')</label>
<input type="email" name="email" id="inputEmail" class="form-control"
diff --git a/resources/views/emails/password.blade.php b/resources/views/emails/password.blade.php
--- a/resources/views/emails/password.blade.php
+++ b/resources/views/emails/password.blade.php
@@ -10,7 +10,7 @@
@lang('emails.reset-password-callforaction')
-@authurl('reset/{{ $token }}')
+{{ authurl("reset/$token") }}
@lang('emails.reset-password-origin')
diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php
--- a/resources/views/home.blade.php
+++ b/resources/views/home.blade.php
@@ -1,20 +1,6 @@
@extends('app')
@section('content')
-<div class="container">
- <div class="row">
- <div class="col-md-10 col-md-offset-1">
- <div class="panel panel-default">
- <div class="panel-heading">@lang('panel.status')</div>
-
- <div class="panel-body">
- @lang('panel.loggedin')
-
- </div>
- </div>
- </div>
- </div>
-
<div class="row">
<div class="col-md-10 col-md-offset-1">
<div class="panel panel-default">
diff --git a/tests/Controller/Auth/AuthControllerTest.php b/tests/Controller/Auth/AuthControllerTest.php
--- a/tests/Controller/Auth/AuthControllerTest.php
+++ b/tests/Controller/Auth/AuthControllerTest.php
@@ -2,9 +2,6 @@
use AuthGrove\Http\Controllers\Auth\AuthController;
-/**
- * Test User model.
- */
class AuthControllerTest extends TestCase {
function testGetRoute () {
diff --git a/tests/Helpers/RoutingTest.php b/tests/Helpers/RoutingTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Helpers/RoutingTest.php
@@ -0,0 +1,14 @@
+<?php
+
+class RoutingTest extends TestCase {
+
+ function testGetRoute () {
+ $this->assertStringEndsWith('/auth/login', authurl('login'));
+ $this->assertStringEndsWith('/auth', authurl());
+ $this->assertStringEndsWith('/auth', authurl(''));
+ $this->assertStringEndsWith('/auth', authurl(null));
+ $this->assertStringEndsWith('/auth', authurl(false));
+ $this->assertStringEndsWith('/auth/0', authurl(0));
+ }
+
+}
diff --git a/tests/Jobs/LinkExternalUserAccountTest.php b/tests/Jobs/LinkExternalUserAccountTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Jobs/LinkExternalUserAccountTest.php
@@ -0,0 +1,133 @@
+<?php
+
+use Illuminate\Foundation\Testing\DatabaseTransactions;
+
+use AuthGrove\Events\NewExternalUserAuthorizeEvent;
+use AuthGrove\Jobs\LinkExternalUserAccount;
+use AuthGrove\Models\UserExternalSource;
+
+
+/**
+ * Test LinkExternalUserAccountTest job.
+ */
+class LinkExternalUserAccountTest extends TestCase {
+
+ use DatabaseTransactions;
+
+ /**
+ * @varAuthGrove\Events\NewExternalUserAuthorizeEvent
+ */
+ private $event;
+
+ /**
+ * @varAuthGrove\Events\NewExternalUserAuthorizeEvent
+ */
+ private $user;
+
+ ///
+ /// Sets up, tears down
+ ///
+
+ public function setUp () {
+ parent::setUp();
+
+ $this->cleanDatabase();
+ $this->initializeEvent();
+
+ $this->user = $this->mockUser();
+ }
+
+ public function tearDown () {
+ $this->cleanDatabase();
+
+ parent::tearDown();
+ }
+
+ ///
+ /// Tests
+ ///
+
+ public function testCanLinkAccounts () {
+ $job = new LinkExternalUserAccount($this->event, $this->user);
+ $this->assertTrue($job->canLinkAccounts());
+
+ $this->event->constraintByUserId = 500;
+ $this->assertFalse($job->canLinkAccounts());
+ }
+
+ public function testJob () {
+ // Ensures the link doesn't exist before the job
+ $externalUser = UserExternalSource::where(['user_id' => 1])->first();
+ $this->assertNull($externalUser, "Test can occurs, as there is an unexpected record in the database. Test setUp should have removed it.");
+
+ // Runs job
+ $job = new LinkExternalUserAccount($this->event, $this->user);
+ $job->handle();
+
+ // Test a link now exists
+ $externalUser = UserExternalSource::where(['user_id' => 1])->first();
+ $this->assertNotNull($externalUser);
+ $this->assertSame($externalUser->user_id, 1);
+ $this->assertSame($externalUser->source_user_id, '666');
+ $this->assertSame($externalUser->source_name, 'quux');
+ }
+
+ public function testJobWhenConstraintAllowsLink () {
+ // Ensures the link doesn't exist before the job
+ $externalUser = UserExternalSource::where(['user_id' => 1])->first();
+ $this->assertNull($externalUser, "Test can occurs, as there is an unexpected record in the database. Test setUp should have removed it.");
+
+ // Runs job
+ $this->event->constraintByUserId = 1;
+ $job = new LinkExternalUserAccount($this->event, $this->user);
+ $job->handle();
+
+ // Test a link now exists
+ $externalUser = UserExternalSource::where(['user_id' => 1])->first();
+ $this->assertNotNull($externalUser);
+ $this->assertSame($externalUser->source_name, 'quux');
+ $this->assertSame($externalUser->source_user_id, '666');
+ $this->assertSame($externalUser->source_username, 'foo');
+ $this->assertSame($externalUser->user_id, 1);
+
+ }
+
+ public function testJobWhenConstraintForbidsLink () {
+ // Ensures the link doesn't exist before the job
+ $externalUser = UserExternalSource::where(['user_id' => 1])->first();
+ $this->assertNull($externalUser, "Test can occurs, as there is an unexpected record in the database. Test setUp should have removed it.");
+
+ // Runs job
+ $this->event->constraintByUserId = 500;
+ $job = new LinkExternalUserAccount($this->event, $this->user);
+ $job->handle();
+
+ // Test a link now exists
+ $externalUser = UserExternalSource::where(['user_id' => 1])->first();
+ $this->assertNull($externalUser);
+ }
+
+ ///
+ /// Helper methods to prepare the test environment
+ ///
+
+ protected function cleanDatabase () {
+ UserExternalSource::where(['user_id' => 1])->delete();
+ }
+
+ protected function initializeEvent () {
+ $this->event = new NewExternalUserAuthorizeEvent();
+ $this->event->externalSource = "quux";
+ $this->event->externalUser = Mockery::mock('Laravel\Socialite\Contracts\User');
+
+ $this->event->externalUser
+ ->shouldReceive('getNickname')->andReturn('foo')
+ ->shouldReceive('getId')->andReturn('666');
+ }
+
+ protected function mockUser () {
+ $user = Mockery::mock('AuthGrove\Models\User');
+ $user->shouldReceive('getAttribute')->andReturn(1);
+ return $user;
+ }
+}
diff --git a/tests/Undo/UndoStoreTest.php b/tests/Undo/UndoStoreTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Undo/UndoStoreTest.php
@@ -0,0 +1,130 @@
+<?php
+
+use AuthGrove\Undo\UndoStore;
+
+require_once 'UndoableMock.php';
+
+class UndoStoreTest extends TestCase {
+
+ protected $instance;
+
+ protected $undoStore;
+
+ ///
+ /// Test preparation
+ ///
+
+ public function setUp () {
+ $this->instance = static::mockInstanceToStore();
+ $this->undoStore = $this->getUndoStore();
+
+ parent::setUp();
+ }
+
+ protected static function mockInstanceToStore () {
+ return new UndoableMock();
+ }
+
+ protected function getUndoStore () {
+ return new UndoStore($this->instance);
+ }
+
+ ///
+ /// Tests
+ ///
+
+ public function testSetInstance () {
+ $instance = clone $this->instance;
+ $this->undoStore->setInstance($instance);
+
+ $this->assertEquals(
+ $instance,
+ $this->undoStore->getInstance()
+ );
+ }
+
+ public function testGetInstance () {
+ $this->assertEquals(
+ $this->instance,
+ $this->undoStore->getInstance()
+ );
+ }
+
+ public function testCheckIntegrityWhenNotYetComputed () {
+ // If there is no integrity check, integrity can't be verified
+ $this->assertFalse($this->undoStore->checkIntegrity());
+ }
+
+ public function testCheckIntegrity () {
+ // Compute it. Check it.
+ $controlHash = $this->undoStore->getControlHash();
+ $this->assertInternalType("string", $controlHash);
+ $this->assertTrue($this->undoStore->checkIntegrity());
+ }
+
+ public function testCheckIntegrityWhenDataIsTampered () {
+ $controlHash = $this->undoStore->getControlHash();
+
+ $tamperedInstance = clone $this->instance;
+ $tamperedInstance->foo = 'quux';
+ $this->undoStore->setInstance($tamperedInstance);
+
+ $this->assertFalse($this->undoStore->checkIntegrity());
+
+ $this->undoStore->computeControlHash();
+ $afterTamperControlHash = $this->undoStore->getControlHash();
+ $this->assertNotEquals($controlHash, $afterTamperControlHash);
+ }
+
+ public function testIsSameControlHash () {
+ $controlHash = $this->undoStore->getControlHash();
+ $this->assertTrue(
+ $this->undoStore->isSameControlHash($controlHash)
+ );
+ }
+
+ public function testIsSameControlHashWhenItIsNot () {
+ $this->undoStore->computeControlHash();
+ $this->assertFalse(
+ $this->undoStore->isSameControlHash("somethingelse")
+ );
+ }
+
+ public function testIsSameControlHashWhenEmpty () {
+ $this->assertFalse(
+ $this->undoStore->isSameControlHash("")
+ );
+ }
+
+ public function testRestoreState () {
+ $instance = clone $this->instance;
+
+ $instance->enabled = false;
+ $this->undoStore->setInstance($instance);
+
+ $this->assertFalse(
+ $this->undoStore->getInstance()->enabled
+ );
+
+ $this->assertTrue(
+ $this->undoStore->restoreState()->enabled
+ );
+ }
+
+ /**
+ * @expectedException OutOfBoundsException
+ */
+ public function testRestoreStateWhenThereIsNoInstance () {
+ $this->undoStore->setInstance(null);
+ $this->undoStore->restoreState();
+ }
+
+ /**
+ * @expectedException InvalidArgumentException
+ */
+ public function testRestoreStateWhenTheRestoreMethodDoesNotExist () {
+ $this->undoStore->setInstance(new stdClass);
+ $this->undoStore->restoreState();
+ }
+
+}
diff --git a/tests/Undo/UndoableMock.php b/tests/Undo/UndoableMock.php
new file mode 100644
--- /dev/null
+++ b/tests/Undo/UndoableMock.php
@@ -0,0 +1,32 @@
+<?php
+
+use AuthGrove\Undo\Undoable;
+use AuthGrove\Undo\UndoDelete;
+
+class UndoableMock implements Undoable {
+
+ use UndoDelete;
+
+ ///
+ /// Some dummy properties
+ ///
+
+ public $foo = 'bar';
+
+ public $bar = [7, 21, 42];
+
+ ///
+ /// To mock restore process
+ ///
+
+ public $enabled = true; // never deleted or restored
+
+ public function save () {
+ $this->enabled = true;
+ }
+
+ public function delete () {
+ $this->enabled = false;
+ }
+
+}

File Metadata

Mime Type
text/plain
Expires
Sun, Nov 24, 13:04 (7 h, 20 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2260208
Default Alt Text
D445.id1147.diff (71 KB)

Event Timeline