Preliminary email confirmation implementation

Whenever a user registers or changes their email, they are sent an
email containing a link which they must click to confirm it.

Upon registering, a user won’t be assigned to any groups and therefore
won’t have permission to do anything (but they can still log in!) Upon
confirming their email for the first time, their account will be
assigned to the Member group and thus “activated”.
This commit is contained in:
Toby Zerner 2015-02-16 14:52:53 +10:30
parent b6ef1f296e
commit 0e4e44c358
14 changed files with 241 additions and 45 deletions

View File

@ -32,6 +32,7 @@ class CoreServiceProvider extends ServiceProvider
Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\DiscussionMetadataUpdater'); Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\DiscussionMetadataUpdater');
Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\UserMetadataUpdater'); Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\UserMetadataUpdater');
Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\RenamedPostCreator'); Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\RenamedPostCreator');
Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\EmailConfirmationMailer');
Post::addType('comment', 'Flarum\Core\Posts\CommentPost'); Post::addType('comment', 'Flarum\Core\Posts\CommentPost');
Post::addType('renamed', 'Flarum\Core\Posts\RenamedPost'); Post::addType('renamed', 'Flarum\Core\Posts\RenamedPost');

View File

@ -0,0 +1,36 @@
<?php namespace Flarum\Core\Listeners;
use Illuminate\Mail\Mailer;
use Laracasts\Commander\Events\EventListener;
use Flarum\Core\Users\Events\UserWasRegistered;
use Flarum\Core\Users\Events\EmailWasChanged;
class EmailConfirmationMailer extends EventListener
{
protected $mailer;
public function __construct(Mailer $mailer)
{
$this->mailer = $mailer;
}
public function whenUserWasRegistered(UserWasRegistered $event)
{
$user = $event->user;
$data = [
'user' => $user,
'url' => route('flarum.confirm', ['id' => $user->id, 'token' => $user->confirmation_token])
];
$this->mailer->send('flarum::emails.confirm', $data, function ($message) use ($user) {
$message->to($user->email)->subject('Welcome!');
});
}
public function whenEmailWasChanged(EmailWasChanged $event)
{
}
}

View File

@ -0,0 +1,7 @@
<?php namespace Flarum\Core\Support\Exceptions;
use Exception;
class InvalidConfirmationTokenException extends Exception
{
}

View File

@ -24,11 +24,11 @@ class UserTableSeeder extends Seeder
for ($i = 0; $i < 100; $i++) { for ($i = 0; $i < 100; $i++) {
$user = User::create([ $user = User::create([
'username' => $faker->userName, 'username' => $faker->userName,
'email' => $faker->safeEmail, 'email' => $faker->safeEmail,
'password' => 'password', 'is_confirmed' => true,
'join_time' => $faker->dateTimeThisYear, 'password' => 'password',
'time_zone' => $faker->timezone 'join_time' => $faker->dateTimeThisYear
]); ]);
// Assign the users to the 'Member' group, and possibly some others. // Assign the users to the 'Member' group, and possibly some others.
@ -49,6 +49,7 @@ class UserTableSeeder extends Seeder
// Guests can view the forum // Guests can view the forum
['group.2' , 'forum' , 'view'], ['group.2' , 'forum' , 'view'],
['group.2' , 'forum' , 'register'],
// Members can create and reply to discussions + edit their own stuff // Members can create and reply to discussions + edit their own stuff
['group.3' , 'forum' , 'startDiscussion'], ['group.3' , 'forum' , 'startDiscussion'],

View File

@ -0,0 +1,14 @@
<?php namespace Flarum\Core\Users\Commands;
class ConfirmEmailCommand
{
public $userId;
public $token;
public function __construct($userId, $token)
{
$this->userId = $userId;
$this->token = $token;
}
}

View File

@ -0,0 +1,38 @@
<?php namespace Flarum\Core\Users\Commands;
use Laracasts\Commander\CommandHandler;
use Laracasts\Commander\Events\DispatchableTrait;
use Event;
use Flarum\Core\Users\UserRepository;
class ConfirmEmailCommandHandler implements CommandHandler
{
use DispatchableTrait;
protected $userRepo;
public function __construct(UserRepository $userRepo)
{
$this->userRepo = $userRepo;
}
public function handle($command)
{
$user = $this->userRepo->findOrFail($command->userId);
$user->confirmEmail($command->token);
// If the user hasn't yet had their account activated,
if (! $user->join_time) {
$user->activate();
}
Event::fire('Flarum.Core.Users.Commands.ConfirmEmail.UserWillBeSaved', [$user, $command]);
$this->userRepo->save($user);
$this->dispatchEventsFor($user);
return $user;
}
}

View File

@ -0,0 +1,11 @@
<?php namespace Flarum\Core\Users\Commands;
use Flarum\Core\Support\CommandValidator;
class ConfirmEmailValidator extends CommandValidator
{
public function validate($command)
{
}
}

View File

@ -39,9 +39,8 @@ class RegisterUserCommandHandler implements CommandHandler
); );
Event::fire('Flarum.Core.Users.Commands.RegisterUser.UserWillBeSaved', [$user, $command]); Event::fire('Flarum.Core.Users.Commands.RegisterUser.UserWillBeSaved', [$user, $command]);
$this->userRepo->save($user); $this->userRepo->save($user);
$this->userRepo->syncGroups($user, [3]); // default groups
$this->dispatchEventsFor($user); $this->dispatchEventsFor($user);
return $user; return $user;

View File

@ -0,0 +1,13 @@
<?php namespace Flarum\Core\Users\Events;
use Flarum\Core\Users\User;
class EmailWasConfirmed
{
public $user;
public function __construct(User $user)
{
$this->user = $user;
}
}

View File

@ -0,0 +1,13 @@
<?php namespace Flarum\Core\Users\Events;
use Flarum\Core\Users\User;
class UserWasActivated
{
public $user;
public function __construct(User $user)
{
$this->user = $user;
}
}

View File

@ -14,12 +14,13 @@ use Laracasts\Commander\Events\EventGenerator;
use Flarum\Core\Entity; use Flarum\Core\Entity;
use Flarum\Core\Groups\Group; use Flarum\Core\Groups\Group;
use Flarum\Core\Support\Exceptions\PermissionDeniedException; use Flarum\Core\Support\Exceptions\PermissionDeniedException;
use Flarum\Core\Support\Exceptions\InvalidConfirmationTokenException;
class User extends Entity implements UserInterface, RemindableInterface class User extends Entity implements UserInterface, RemindableInterface
{ {
use EventGenerator; use EventGenerator;
use Permissible; use Permissible;
use UserTrait, RemindableTrait; use UserTrait, RemindableTrait;
protected static $rules = [ protected static $rules = [
@ -35,7 +36,7 @@ class User extends Entity implements UserInterface, RemindableInterface
protected $table = 'users'; protected $table = 'users';
protected $hidden = ['password']; protected $hidden = ['password'];
public static function boot() public static function boot()
{ {
parent::boot(); parent::boot();
@ -61,12 +62,20 @@ class User extends Entity implements UserInterface, RemindableInterface
public function setUsernameAttribute($username) public function setUsernameAttribute($username)
{ {
if ($username === $this->username) {
return;
}
$this->attributes['username'] = $username; $this->attributes['username'] = $username;
$this->raise(new Events\UserWasRenamed($this)); $this->raise(new Events\UserWasRenamed($this));
} }
public function setEmailAttribute($email) public function setEmailAttribute($email)
{ {
if ($email === $this->email) {
return;
}
$this->attributes['email'] = $email; $this->attributes['email'] = $email;
$this->raise(new Events\EmailWasChanged($this)); $this->raise(new Events\EmailWasChanged($this));
} }
@ -77,6 +86,14 @@ class User extends Entity implements UserInterface, RemindableInterface
$this->raise(new Events\PasswordWasChanged($this)); $this->raise(new Events\PasswordWasChanged($this));
} }
public function activate()
{
$this->join_time = time();
$this->groups()->sync([3]);
$this->raise(new Events\UserWasActivated($this));
}
public static function register($username, $email, $password) public static function register($username, $email, $password)
{ {
$user = new static; $user = new static;
@ -84,13 +101,39 @@ class User extends Entity implements UserInterface, RemindableInterface
$user->username = $username; $user->username = $username;
$user->email = $email; $user->email = $email;
$user->password = $password; $user->password = $password;
$user->join_time = time();
$user->refreshConfirmationToken();
$user->raise(new Events\UserWasRegistered($user)); $user->raise(new Events\UserWasRegistered($user));
return $user; return $user;
} }
public function validateConfirmationToken($token)
{
return ! $this->is_confirmed
&& $token
&& $this->confirmation_token === $token;
}
public function refreshConfirmationToken()
{
$this->is_confirmed = false;
$this->confirmation_token = str_random(30);
}
public function confirmEmail($token)
{
if (! $this->validateConfirmationToken($token)) {
throw new InvalidConfirmationTokenException;
}
$this->is_confirmed = true;
$this->confirmation_token = null;
$this->raise(new Events\EmailWasConfirmed($this));
}
public function getDates() public function getDates()
{ {
return ['join_time', 'last_seen_time', 'read_time']; return ['join_time', 'last_seen_time', 'read_time'];

View File

@ -5,37 +5,38 @@ use Illuminate\Database\Migrations\Migration;
class CreateUsersTable extends Migration { class CreateUsersTable extends Migration {
/** /**
* Run the migrations. * Run the migrations.
* *
* @return void * @return void
*/ */
public function up() public function up()
{ {
Schema::create('users', function(Blueprint $table) Schema::create('users', function(Blueprint $table)
{ {
$table->increments('id'); $table->increments('id');
$table->string('username'); $table->string('username');
$table->string('email'); $table->string('email');
$table->string('password'); $table->boolean('is_confirmed')->default(0);
$table->string('token'); $table->string('confirmation_token')->nullable();
$table->dateTime('join_time'); $table->string('password');
$table->string('time_zone'); $table->string('token');
$table->dateTime('last_seen_time')->nullable(); $table->dateTime('join_time')->nullable();
$table->dateTime('read_time')->nullable(); $table->dateTime('last_seen_time')->nullable();
$table->integer('discussions_count')->unsigned()->default(0); $table->dateTime('read_time')->nullable();
$table->integer('posts_count')->unsigned()->default(0); $table->integer('discussions_count')->unsigned()->default(0);
}); $table->integer('posts_count')->unsigned()->default(0);
} });
}
/** /**
* Reverse the migrations. * Reverse the migrations.
* *
* @return void * @return void
*/ */
public function down() public function down()
{ {
Schema::drop('users'); Schema::drop('users');
} }
} }

View File

@ -1,7 +1,13 @@
<?php <?php
Route::get('/', function() Route::get('/', function () {
{ return View::make('flarum.web::index')
return View::make('flarum.web::index') ->with('title', Config::get('flarum::forum_title', 'Flarum Demo Forum'));
->with('title', Config::get('flarum::forum_title', 'Flarum Demo Forum'));
}); });
Route::get('confirm/{id}/{token}', ['as' => 'flarum.confirm', function ($userId, $token) {
$command = new Flarum\Core\Users\Commands\ConfirmEmailCommand($userId, $token);
$commandBus = App::make('Laracasts\Commander\CommandBus');
$commandBus->execute($command);
}]);

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
</head>
<body>
<h2>Welcome, {{ $user->username }}</h2>
<div>
To confirm your email, click here: {{ $url }}
</div>
</body>
</html>