Start user invite system

This commit is contained in:
Dan Brown 2019-08-17 15:52:33 +01:00
parent 4de432b50d
commit 44330bdd24
No known key found for this signature in database
GPG key ID: 46D9F943C24A2EF9
11 changed files with 299 additions and 91 deletions

View file

@ -1,33 +1,18 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Notifications\ConfirmEmail;
use Carbon\Carbon;
use Illuminate\Database\Connection as Database;
class EmailConfirmationService
class EmailConfirmationService extends UserTokenService
{
protected $db;
protected $users;
/**
* EmailConfirmationService constructor.
* @param Database $db
* @param \BookStack\Auth\UserRepo $users
*/
public function __construct(Database $db, UserRepo $users)
{
$this->db = $db;
$this->users = $users;
}
protected $tokenTable = 'email_confirmations';
protected $expiryTime = 24;
/**
* Create new confirmation for a user,
* Also removes any existing old ones.
* @param \BookStack\Auth\User $user
* @param User $user
* @throws ConfirmationEmailException
*/
public function sendConfirmation(User $user)
@ -36,76 +21,10 @@ class EmailConfirmationService
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');
}
$this->deleteConfirmationsByUser($user);
$token = $this->createEmailConfirmation($user);
$this->deleteByUser($user);
$token = $this->createTokenForUser($user);
$user->notify(new ConfirmEmail($token));
}
/**
* Creates a new email confirmation in the database and returns the token.
* @param User $user
* @return string
*/
public function createEmailConfirmation(User $user)
{
$token = $this->getToken();
$this->db->table('email_confirmations')->insert([
'user_id' => $user->id,
'token' => $token,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now()
]);
return $token;
}
/**
* Gets an email confirmation by looking up the token,
* Ensures the token has not expired.
* @param string $token
* @return array|null|\stdClass
* @throws UserRegistrationException
*/
public function getEmailConfirmationFromToken($token)
{
$emailConfirmation = $this->db->table('email_confirmations')->where('token', '=', $token)->first();
// If not found show error
if ($emailConfirmation === null) {
throw new UserRegistrationException(trans('errors.email_confirmation_invalid'), '/register');
}
// If more than a day old
if (Carbon::now()->subDay()->gt(new Carbon($emailConfirmation->created_at))) {
$user = $this->users->getById($emailConfirmation->user_id);
$this->sendConfirmation($user);
throw new UserRegistrationException(trans('errors.email_confirmation_expired'), '/register/confirm');
}
$emailConfirmation->user = $this->users->getById($emailConfirmation->user_id);
return $emailConfirmation;
}
/**
* Delete all email confirmations that belong to a user.
* @param \BookStack\Auth\User $user
* @return mixed
*/
public function deleteConfirmationsByUser(User $user)
{
return $this->db->table('email_confirmations')->where('user_id', '=', $user->id)->delete();
}
/**
* Creates a unique token within the email confirmation database.
* @return string
*/
protected function getToken()
{
$token = str_random(24);
while ($this->db->table('email_confirmations')->where('token', '=', $token)->exists()) {
$token = str_random(25);
}
return $token;
}
}

View file

@ -0,0 +1,23 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Notifications\UserInvite;
class UserInviteService extends UserTokenService
{
protected $tokenTable = 'user_invites';
protected $expiryTime = 336; // Two weeks
/**
* Send an invitation to a user to sign into BookStack
* Removes existing invitation tokens.
* @param User $user
*/
public function sendInvitation(User $user)
{
$this->deleteByUser($user);
$token = $this->createTokenForUser($user);
$user->notify(new UserInvite($token));
}
}

View file

@ -0,0 +1,134 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use Carbon\Carbon;
use Illuminate\Database\Connection as Database;
use stdClass;
class UserTokenService
{
/**
* Name of table where user tokens are stored.
* @var string
*/
protected $tokenTable = 'user_tokens';
/**
* Token expiry time in hours.
* @var int
*/
protected $expiryTime = 24;
protected $db;
/**
* UserTokenService constructor.
* @param Database $db
*/
public function __construct(Database $db)
{
$this->db = $db;
}
/**
* Delete all email confirmations that belong to a user.
* @param User $user
* @return mixed
*/
public function deleteByUser(User $user)
{
return $this->db->table($this->tokenTable)
->where('user_id', '=', $user->id)
->delete();
}
/**
* Get the user id from a token, while check the token exists and has not expired.
* @param string $token
* @return int
* @throws UserTokenNotFoundException
* @throws UserTokenExpiredException
*/
public function checkTokenAndGetUserId(string $token) : int
{
$entry = $this->getEntryByToken($token);
if (is_null($entry)) {
throw new UserTokenNotFoundException('Token "' . $token . '" not found');
}
if ($this->entryExpired($entry)) {
throw new UserTokenExpiredException("Token of id {$token->id} has expired.", $entry->user_id);
}
return $entry->user_id;
}
/**
* Creates a unique token within the email confirmation database.
* @return string
*/
protected function generateToken() : string
{
$token = str_random(24);
while ($this->tokenExists($token)) {
$token = str_random(25);
}
return $token;
}
/**
* Generate and store a token for the given user.
* @param User $user
* @return string
*/
protected function createTokenForUser(User $user) : string
{
$token = $this->generateToken();
$this->db->table($this->tokenTable)->insert([
'user_id' => $user->id,
'token' => $token,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now()
]);
return $token;
}
/**
* Check if the given token exists.
* @param string $token
* @return bool
*/
protected function tokenExists(string $token) : bool
{
return $this->db->table($this->tokenTable)
->where('token', '=', $token)->exists();
}
/**
* Get a token entry for the given token.
* @param string $token
* @return object|null
*/
protected function getEntryByToken(string $token)
{
return $this->db->table($this->tokenTable)
->where('token', '=', $token)
->first();
}
/**
* Check if the given token entry has expired.
* @param stdClass $tokenEntry
* @return bool
*/
protected function entryExpired(stdClass $tokenEntry) : bool
{
return Carbon::now()->subHours($this->expiryTime)
->gt(new Carbon($tokenEntry->created_at));
}
}

View file

@ -3,6 +3,7 @@
use BookStack\Model;
use BookStack\Notifications\ResetPassword;
use BookStack\Uploads\Image;
use Carbon\Carbon;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
@ -10,6 +11,20 @@ use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Notifications\Notifiable;
/**
* Class User
* @package BookStack\Auth
* @property string $id
* @property string $name
* @property string $email
* @property string $password
* @property Carbon $created_at
* @property Carbon $updated_at
* @property bool $email_confirmed
* @property int $image_id
* @property string $external_auth_id
* @property string $system_name
*/
class User extends Model implements AuthenticatableContract, CanResetPasswordContract
{
use Authenticatable, CanResetPassword, Notifiable;

View file

@ -0,0 +1,19 @@
<?php namespace BookStack\Exceptions;
class UserTokenExpiredException extends \Exception {
public $userId;
/**
* UserTokenExpiredException constructor.
* @param string $message
* @param int $userId
*/
public function __construct(string $message, int $userId)
{
$this->userId = $userId;
parent::__construct($message);
}
}

View file

@ -0,0 +1,3 @@
<?php namespace BookStack\Exceptions;
class UserTokenNotFoundException extends \Exception {}

View file

@ -7,10 +7,13 @@ use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\SocialSignInException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Http\Controllers\Controller;
use Exception;
use Illuminate\Foundation\Auth\RegistersUsers;
@ -189,17 +192,38 @@ class RegisterController extends Controller
* Confirms an email via a token and logs the user into the system.
* @param $token
* @return RedirectResponse|Redirector
* @throws UserRegistrationException
* @throws ConfirmationEmailException
* @throws Exception
*/
public function confirmEmail($token)
{
$confirmation = $this->emailConfirmationService->getEmailConfirmationFromToken($token);
$user = $confirmation->user;
try {
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
} catch (Exception $exception) {
if ($exception instanceof UserTokenNotFoundException) {
session()->flash('error', trans('errors.email_confirmation_invalid'));
return redirect('/register');
}
if ($exception instanceof UserTokenExpiredException) {
$user = $this->userRepo->getById($exception->userId);
$this->emailConfirmationService->sendConfirmation($user);
session()->flash('error', trans('errors.email_confirmation_expired'));
return redirect('/register/confirm');
}
throw $exception;
}
$user = $this->userRepo->getById($userId);
$user->email_confirmed = true;
$user->save();
auth()->login($user);
session()->flash('success', trans('auth.email_confirm_success'));
$this->emailConfirmationService->deleteConfirmationsByUser($user);
$this->emailConfirmationService->deleteByUser($user);
return redirect($this->redirectPath);
}

View file

@ -0,0 +1,31 @@
<?php namespace BookStack\Notifications;
class UserInvite extends MailNotification
{
public $token;
/**
* Create a new notification instance.
* @param string $token
*/
public function __construct($token)
{
$this->token = $token;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
$appName = ['appName' => setting('app-name')];
return $this->newMailMessage()
->subject(trans('auth.user_invite_email_subject', $appName))
->greeting(trans('auth.user_invite_email_greeting', $appName))
->line(trans('auth.user_invite_email_text'))
->action(trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
}
}

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddUserInvitesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_invites', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->index();
$table->string('token')->index();
$table->nullableTimestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_invites');
}
}

View file

@ -64,4 +64,10 @@ return [
'email_not_confirmed_click_link' => 'Please click the link in the email that was sent shortly after you registered.',
'email_not_confirmed_resend' => 'If you cannot find the email you can re-send the confirmation email by submitting the form below.',
'email_not_confirmed_resend_button' => 'Resend Confirmation Email',
// User Invite
'user_invite_email_subject' => 'You have been invited to join :appName!',
'user_invite_email_greeting' => 'A user account has been created for you on :appName.',
'user_invite_email_text' => 'Click the button below to set an account password and gain access:',
'user_invite_email_action' => 'Set Account Password',
];

View file

@ -27,6 +27,7 @@ return [
'social_account_register_instructions' => 'If you do not yet have an account, You can register an account using the :socialAccount option.',
'social_driver_not_found' => 'Social driver not found',
'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.',
'invite_token_expired' => 'This invitation link has expired. You can try to reset your account password or request a new invite from an administrator.',
// System
'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.',