<?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)
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;
- }
}
--- /dev/null
+<?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));
+ }
+
+}
--- /dev/null
+<?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));
+ }
+
+}
\ No newline at end of file
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;
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;
--- /dev/null
+<?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);
+ }
+
+
+}
\ No newline at end of file
--- /dev/null
+<?php namespace BookStack\Exceptions;
+
+class UserTokenNotFoundException extends \Exception {}
\ No newline at end of file
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;
* 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);
}
--- /dev/null
+<?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));
+ }
+}
--- /dev/null
+<?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');
+ }
+}
'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',
];
\ No newline at end of file
'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.',