Implemented e-mail confirmations

pull/26/head
Ondřej Hruška 6 years ago
parent df0e06045f
commit 904e29955d
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 22
      app/Http/Controllers/AccountController.php
  2. 61
      app/Http/Controllers/Auth/ConfirmEmailController.php
  3. 3
      app/Http/Controllers/Auth/LoginController.php
  4. 1
      app/Http/Kernel.php
  5. 24
      app/Http/Middleware/ActivatedAccount.php
  6. 16
      app/Models/EmailConfirmation.php
  7. 24
      app/Models/User.php
  8. 75
      app/Notifications/ConfirmEmail.php
  9. 3
      app/helpers.php
  10. 30
      resources/views/layouts/app.blade.php
  11. 4
      resources/views/table/_action-buttons.blade.php
  12. 6
      routes/login.php
  13. 28
      routes/web.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());

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\EmailConfirmation;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
class ConfirmEmailController extends Controller
{
public function resendConfirmation()
{
$user = user();
/** @var EmailConfirmation[]|Collection $ec */
$ec = $user->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));
}
}

@ -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()

@ -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,

@ -0,0 +1,24 @@
<?php
namespace App\Http\Middleware;
use Closure;
class ActivatedAccount
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if (guest() || !user()->confirmed) {
abort(403, "Only users with active accounts can do this.");
}
return $next($request);
}
}

@ -1,6 +1,8 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
/**
* E-mail confirmation
@ -11,15 +13,29 @@ namespace App\Models;
* @property string $token
* @property string $email
* @property-read User $user
* @method $this|Builder valid()
*/
class EmailConfirmation extends BaseModel
{
protected $guarded = [];
const UPDATED_AT = null; // disable update timestamp
const EXPIRATION_H = 2; // 2h
/** Authoring user */
public function user()
{
return $this->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));
}
}

@ -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));
}
}

@ -0,0 +1,75 @@
<?php
namespace App\Notifications;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Lang;
class ConfirmEmail extends Notification
{
/**
* The email confirm token.
*
* @var string
*/
public $token;
/**
* The new e-mail
*
* @var string
*/
public $newEmail;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct($newEmail, $token)
{
$this->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.');
}
}

@ -15,6 +15,9 @@ function guest() {
return \Auth::guest();
}
/**
* @return \App\Models\User|null
*/
function user() {
return \Auth::user();
}

@ -23,6 +23,36 @@
<main class="py-4">
<div class="container">
{{-- Warning about not activated account --}}
@auth
@if(!user()->confirmed)
<div class="alert alert-warning alert-important" role="alert" >
<button type="button" class="close" data-dismiss="alert" aria-hidden="true" >&times;</button>
Your account is not activated yet, some options are locked!<br/>
@if(user()->emailConfirmations()->valid()->exists())
{{--
There is a pending confirmation, whose e-mail may differ from what
the user has saved in the rpofile (due to email change attempt)
--}}
Check your e-mail (<u>{{user()->emailConfirmations()->valid()->first()->email}}</u>) for the confirmation link, or <a href="{{route('resend-email-confirmation')}}">request a new one</a>.
The confirmation message could fall into your Spam folder.<br>
If this address is not correct, you can fix it in your <a href="{{route('account.edit')}}">account settings</a>.
@else
{{-- There are no pending confirmations --}}
@if(user()->email)
{{-- User has e-mail set in the profile, probably from registration --}}
You can confirm your e-mail (<u>{{$user->email}}</u>) by <a href="{{route('resend-email-confirmation')}}">requesting a confirmation link</a>.
If this address is not correct, you can fix it in your <a href="{{route('account.edit')}}">account settings</a>.
@else
{{-- User has no e-mail, maybe registered through a provider without e-mails --}}
Set your e-mail in <a href="{{route('account.edit')}}">account settings</a> and you
will receive a confirmation link.
@endif
@endif
</div>
@endif
@endauth
@include('flash::message')
@yield('content')
</div>

@ -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 --}}
<a href="" class="btn btn-outline-primary py-1 btn-sm" title="Forks"

@ -8,6 +8,12 @@ use SocialNorm\ProviderUser;
Auth::routes();
Route::get('/auth/confirm-email', 'Auth\ConfirmEmailController@confirmEmailAndLogin')
->name('confirm-email')->middleware('auth');
Route::get('/auth/resend-email-confirmation', 'Auth\ConfirmEmailController@resendConfirmation')
->name('resend-email-confirmation');
// ----------------- SOCIAL LOGIN --------------------
function _loginVia($method) {

@ -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');

Loading…
Cancel
Save