Page MenuHomeDevCentral

D445.diff
No OneTemporary

D445.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/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,151 @@
+<?php
+
+namespace AuthGrove\Http\Controllers\Account;
+
+use AuthGrove\Http\Controllers\Controller;
+use AuthGrove\Models\UserExternalSource;
+use AuthGrove\Undo\UndoStack;
+
+use Illuminate\Http\Request;
+
+use OutOfBoundsException;
+
+/**
+ * A CRUD controller for relationships between users
+ * and users' external login sources.
+ */
+class ExternalSourcesController extends Controller {
+
+ /**
+ * The logged-in user
+ *
+ * @var \AuthGrove\Models\User
+ */
+ private $user;
+
+ /**
+ * The current session
+ *
+ * @var \Symfony\Component\HttpFoundation\Session\SessionInterface
+ */
+ 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) {
+ try {
+ $this->getUndoStack()->undo($operation);
+ } catch (OutOfBoundsException $ex) {
+ 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.
+ *
+ * @param \AuthGrove\Models\UserExternalSource $source
+ */
+ protected function unlinkSource (UserExternalSource $source) {
+ // Deletes.
+ $source->delete();
+
+ // Stores operation in the undo stack.
+ $undoOperationId = $this->allowUndo($source);
+ //TODO: 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,19 @@
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,26 @@
return $this->attributes['username'];
}
+ ///
+ /// External sources
+ ///
+
+ /**
+ * Get the comments for the blog post.
+ */
+ public function externalSources() {
+ return $this->hasMany(UserExternalSource::class);
+ }
+
+ /**
+ * Gets external sources linked to this user account.
+ *
+ * @return lluminate\Database\Eloquent\Collection a collection of UserExternalSource items
+ */
+ public function getExternalSources () {
+ return $this->externalSources()->orderBy('created_at', 'asc')->get();
+ }
+
/**
* Tries to get the local user matching an external source.
*
@@ -105,14 +125,14 @@
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) {
return false;
}
- $user = $source->getUser();
+ $user = $source->user;
return true;
}
}
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\WithUndo;
+
+class UserExternalSource extends Model implements Undoable {
+
+ use WithUndo;
/**
* 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,10 +64,10 @@
/**
* Gets user attached to this source.
*
- * @return User
+ * @return AuthGrove\Models\User
*/
- public function getUser () {
- return User::find($this->user_id);
+ public function user () {
+ return $this->belongsTo(User::class);
}
}
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/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
@@ -5,32 +5,33 @@
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/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
@@ -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/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
@@ -5,9 +5,6 @@
use AuthGrove\Http\Controllers\Auth\AuthController;
use AuthGrove\Tests\TestCase;
-/**
- * Test User model.
- */
class AuthControllerTest extends TestCase {
function testGetRoute () {
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;
+use AuthGrove\Tests\TestCase;
+
+/**
+ * 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;
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Fri, Dec 20, 10:07 (21 h, 26 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2307944
Default Alt Text
D445.diff (50 KB)

Event Timeline