From 904e29955dd8d112f68271e8ac0dd7d7147b168d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Tue, 31 Jul 2018 22:42:08 +0200 Subject: [PATCH] Implemented e-mail confirmations --- app/Http/Controllers/AccountController.php | 22 +++--- .../Auth/ConfirmEmailController.php | 61 +++++++++++++++ app/Http/Controllers/Auth/LoginController.php | 3 +- app/Http/Kernel.php | 1 + app/Http/Middleware/ActivatedAccount.php | 24 ++++++ app/Models/EmailConfirmation.php | 16 ++++ app/Models/User.php | 24 ++++++ app/Notifications/ConfirmEmail.php | 75 +++++++++++++++++++ app/helpers.php | 3 + resources/views/layouts/app.blade.php | 30 ++++++++ .../views/table/_action-buttons.blade.php | 4 +- routes/login.php | 6 ++ routes/web.php | 28 ++++--- 13 files changed, 270 insertions(+), 27 deletions(-) create mode 100644 app/Http/Controllers/Auth/ConfirmEmailController.php create mode 100644 app/Http/Middleware/ActivatedAccount.php create mode 100644 app/Notifications/ConfirmEmail.php diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index b767dc4..efbc27e 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\EmailConfirmation; +use App\Models\User; use Hash; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -22,34 +23,29 @@ class AccountController extends Controller public function storeAccount(Request $request) { + /** @var User $user */ + $user = user(); + $input = $this->validate($request, [ 'name' => [ 'required', VALI_NAME, - Rule::unique('users')->ignoreModel(\user()), + Rule::unique('users')->ignoreModel($user), ], 'email' => [ 'required', VALI_EMAIL, - Rule::unique('users')->ignoreModel(\user()), + Rule::unique('users')->ignoreModel($user), ], 'new_password' => ['nullable', 'confirmed', VALI_PASSWORD], ]); - $user = user(); - if ($input->email != $user->email) { - $confirmation = EmailConfirmation::create([ - 'user_id' => $user->id, - 'email' => $input->email, - 'token' => Str::random(60), - ]); - - flash()->warning("New e-mail confirmation sent to $input->email.")->important(); + $user->sendEmailConfirmationLink($input->email); - // TODO send the e-mail + flash()->warning("E-mail confirmation sent to $input->email.")->important(); - unset($input->email); + unset($input->email); // prevent updating the field in the model via fill } $user->fill($input->all()); diff --git a/app/Http/Controllers/Auth/ConfirmEmailController.php b/app/Http/Controllers/Auth/ConfirmEmailController.php new file mode 100644 index 0000000..7939c76 --- /dev/null +++ b/app/Http/Controllers/Auth/ConfirmEmailController.php @@ -0,0 +1,61 @@ +emailConfirmations()->valid()->get(); + $email = ''; + + if ($ec->count() == 0) { + user()->sendEmailConfirmationLink($email = $user->email); + } else { + user()->sendEmailConfirmationLink($email = $ec[0]->email); + } + + flash()->success("A new confirmation link was sent to your e-mail $email"); // not important, will fade + + return back(); + } + + public function confirmEmailAndLogin(Request $request) + { + $input = $this->validate($request, [ + 'token' => 'string|required', + ]); + + $ec = EmailConfirmation::where('token', $input->token)->valid()->first(); + if (!$ec) abort(400, "Invalid or expired token."); + + $u = $ec->user; + if (!$u) abort(400, "User account does not exist."); + + if ($ec->email) $u->email = $ec->email; + $wasConfirmed = $u->confirmed; + $u->confirmed = true; + $u->save(); + $ec->delete(); + + \Auth::login($u, true); + + if ($wasConfirmed) { + // user just changed an e-mail + flash()->success("Your new e-mail $ec->email was confirmed!")->important(); + } else { + flash()->success("Your e-mail $ec->email was confirmed, your account is now active!")->important(); + } + + return redirect(route('profile.view', $u->name)); + } +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 43508ab..5180596 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Models\EmailConfirmation; use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -36,7 +37,7 @@ class LoginController extends Controller */ public function __construct() { - $this->middleware('guest')->except('logout'); + $this->middleware('guest')->except(['logout', 'confirmEmailAndLogin']); } public function username() diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 68b67a4..749392b 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -52,6 +52,7 @@ class Kernel extends HttpKernel * @var array */ protected $routeMiddleware = [ + 'activated' => \App\Http\Middleware\ActivatedAccount::class, 'auth' => \Illuminate\Auth\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, diff --git a/app/Http/Middleware/ActivatedAccount.php b/app/Http/Middleware/ActivatedAccount.php new file mode 100644 index 0000000..1cd1dce --- /dev/null +++ b/app/Http/Middleware/ActivatedAccount.php @@ -0,0 +1,24 @@ +confirmed) { + abort(403, "Only users with active accounts can do this."); + } + + return $next($request); + } +} diff --git a/app/Models/EmailConfirmation.php b/app/Models/EmailConfirmation.php index 709e5a4..1e46287 100644 --- a/app/Models/EmailConfirmation.php +++ b/app/Models/EmailConfirmation.php @@ -1,6 +1,8 @@ belongsTo(User::class, 'user_id'); } + + public function scopeValid(Builder $query) + { + return $query->where('created_at', '>=', + Carbon::now()->subHours(self::EXPIRATION_H)); + } + + public function scopeExpired(Builder $query) + { + return $query->where('created_at', '<', + Carbon::now()->subHours(self::EXPIRATION_H)); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 6946d6d..7a88274 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,8 @@ namespace App\Models; use AdamWathan\EloquentOAuth\OAuthIdentity; use App\Models\Concerns\InstanceCache; use App\Models\Concerns\Reportable; +use App\Notifications\ConfirmEmail; +use Hash; use Illuminate\Database\Eloquent\Collection; use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notification; @@ -16,6 +18,7 @@ use Illuminate\Foundation\Auth\Access\Authorizable; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; +use MightyPork\Utils\Str; /** * A user in the application @@ -36,6 +39,7 @@ use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; * @property-read TableComment[]|Collection $tableComments * @property-read Table[]|Collection $favouriteTables * @property-read Table[]|Collection $followedDiscussions + * @property-read EmailConfirmation[]|Collection $emailConfirmations * @property-read ContentReport[]|Collection $authoredReports * @property-read Notification[]|Collection $notifications * @property-read Notification[]|Collection $readNotifications @@ -132,6 +136,12 @@ class User extends BaseModel implements return $this->hasMany(ContentReport::class, 'author_id'); } + /** User's e-mail confirmations */ + public function emailConfirmations() + { + return $this->hasMany(EmailConfirmation::class); + } + // --------- Relation Helpers --------- public function addFavourite(Table $table) @@ -198,4 +208,18 @@ class User extends BaseModel implements ->where('user_id', $this->id) ->where('table_id', $table->id)->exists(); } + + public function sendEmailConfirmationLink($newEmail) + { + // delete old confirmation tokens + $this->emailConfirmations()->delete(); + + $confirmation = EmailConfirmation::create([ + 'user_id' => $this->id, + 'email' => $newEmail, + 'token' => Hash::make(Str::random(40)), + ]); + + $this->notify(new ConfirmEmail($newEmail, $confirmation->token)); + } } diff --git a/app/Notifications/ConfirmEmail.php b/app/Notifications/ConfirmEmail.php new file mode 100644 index 0000000..70f2dea --- /dev/null +++ b/app/Notifications/ConfirmEmail.php @@ -0,0 +1,75 @@ +newEmail = $newEmail; + $this->token = $token; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $notifiable + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + /** @var User $notifiable */ + $notifiable->email = $this->newEmail; // hack to send to the new e-mail + + return (new MailMessage) + ->subject('E-Mail Confirmation') + + ->line( + 'Please confirm that you just requested using this e-mail with the datatable.directory + account '.$notifiable->handle.'. It will be used to login, to recover forgotten passwords, + and for sending notifications. You can always opt out of e-mail notifications in the settings.') + + ->action('Confirm E-Mail', url(config('app.url').route('confirm-email', ['token' => $this->token], false))) + + ->line( + 'If you did not request this action, no further action is required. The confirmation link + will expire in a few hours.'); + } +} diff --git a/app/helpers.php b/app/helpers.php index 931a2a4..dacd548 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -15,6 +15,9 @@ function guest() { return \Auth::guest(); } +/** + * @return \App\Models\User|null + */ function user() { return \Auth::user(); } diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 77b52d3..bd7b9e5 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -23,6 +23,36 @@
+ {{-- Warning about not activated account --}} + @auth + @if(!user()->confirmed) + + @endif + @endauth + @include('flash::message') @yield('content')
diff --git a/resources/views/table/_action-buttons.blade.php b/resources/views/table/_action-buttons.blade.php index 57edce6..8cdfe37 100644 --- a/resources/views/table/_action-buttons.blade.php +++ b/resources/views/table/_action-buttons.blade.php @@ -10,9 +10,9 @@ @sr(Table actions) {{-- Disabled until implemented --}} -@if(false) +@if(true) - @if(guest() || user()->ownsTable($table)) + @if(guest() || !user()->confirmed || user()->ownsTable($table)) {{-- Passive fork buttons with counter --}} name('confirm-email')->middleware('auth'); + +Route::get('/auth/resend-email-confirmation', 'Auth\ConfirmEmailController@resendConfirmation') + ->name('resend-email-confirmation'); + // ----------------- SOCIAL LOGIN -------------------- function _loginVia($method) { diff --git a/routes/web.php b/routes/web.php index e4b7471..a33779f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -25,12 +25,19 @@ Route::get('/', 'DashController@view')->name('dash'); // redirect home to user home Route::get('/home', function () { - if (guest()) return redirect('/'); + if (guest()) return redirect(route('dash')); return redirect(route('profile.view', user()->name)); })->name('home'); -Route::group(['middleware' => 'auth'], function () { +// Table resource +Route::get('@{user}', 'ProfileController@view')->name('profile.view'); +Route::get('@{user}/{table}', 'TableController@view')->name('table.view'); +Route::get('@{user}/{table}/export', 'TableController@export')->name('table.export'); + + +// Routes for confirmed users +Route::group(['middleware' => ['auth', 'activated']], function () { // Table resource Route::group([ 'prefix' => 'table', @@ -39,6 +46,14 @@ Route::group(['middleware' => 'auth'], function () { Route::post('create', 'TableController@storeNew')->name('table.storeNew'); }); + Route::get('@{user}/{table}/settings', 'TableController@settings')->name('table.conf'); + Route::post('@{user}/{table}/settings', 'TableController@storeSettings')->name('table.storeConf'); + Route::post('@{user}/{table}/delete', 'TableController@delete')->name('table.delete'); +}); + +// Routes for all authed users +Route::group(['middleware' => 'auth'], function () { + Route::group([ 'prefix' => 'profile', ], function () { @@ -53,13 +68,4 @@ Route::group(['middleware' => 'auth'], function () { Route::post('store', 'AccountController@storeAccount')->name('account.store'); Route::get('forget-social-login/{id}', 'AccountController@forgetSocialLogin')->name('forget-identity'); }); - - Route::get('@{user}/{table}/settings', 'TableController@settings')->name('table.conf'); - Route::post('@{user}/{table}/settings', 'TableController@storeSettings')->name('table.storeConf'); - Route::post('@{user}/{table}/delete', 'TableController@delete')->name('table.delete'); }); - -// Table resource - located at the end to work as a fallback -Route::get('@{user}', 'ProfileController@view')->name('profile.view'); -Route::get('@{user}/{table}', 'TableController@view')->name('table.view'); -Route::get('@{user}/{table}/export', 'TableController@export')->name('table.export');