From 44330bdd24a7dca1c85ab2068d335f089d4cff6d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 17 Aug 2019 15:52:33 +0100 Subject: [PATCH] Start user invite system --- app/Auth/Access/EmailConfirmationService.php | 93 +----------- app/Auth/Access/UserInviteService.php | 23 +++ app/Auth/Access/UserTokenService.php | 134 ++++++++++++++++++ app/Auth/User.php | 15 ++ app/Exceptions/UserTokenExpiredException.php | 19 +++ app/Exceptions/UserTokenNotFoundException.php | 3 + .../Controllers/Auth/RegisterController.php | 32 ++++- app/Notifications/UserInvite.php | 31 ++++ ...19_08_17_140214_add_user_invites_table.php | 33 +++++ resources/lang/en/auth.php | 6 + resources/lang/en/errors.php | 1 + 11 files changed, 299 insertions(+), 91 deletions(-) create mode 100644 app/Auth/Access/UserInviteService.php create mode 100644 app/Auth/Access/UserTokenService.php create mode 100644 app/Exceptions/UserTokenExpiredException.php create mode 100644 app/Exceptions/UserTokenNotFoundException.php create mode 100644 app/Notifications/UserInvite.php create mode 100644 database/migrations/2019_08_17_140214_add_user_invites_table.php diff --git a/app/Auth/Access/EmailConfirmationService.php b/app/Auth/Access/EmailConfirmationService.php index 4df014116..a9aecaf22 100644 --- a/app/Auth/Access/EmailConfirmationService.php +++ b/app/Auth/Access/EmailConfirmationService.php @@ -1,33 +1,18 @@ 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; - } } diff --git a/app/Auth/Access/UserInviteService.php b/app/Auth/Access/UserInviteService.php new file mode 100644 index 000000000..8e04d7b22 --- /dev/null +++ b/app/Auth/Access/UserInviteService.php @@ -0,0 +1,23 @@ +deleteByUser($user); + $token = $this->createTokenForUser($user); + $user->notify(new UserInvite($token)); + } + +} diff --git a/app/Auth/Access/UserTokenService.php b/app/Auth/Access/UserTokenService.php new file mode 100644 index 000000000..40f363ee1 --- /dev/null +++ b/app/Auth/Access/UserTokenService.php @@ -0,0 +1,134 @@ +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 diff --git a/app/Auth/User.php b/app/Auth/User.php index e5a8a3931..7ad14d9f0 100644 --- a/app/Auth/User.php +++ b/app/Auth/User.php @@ -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; diff --git a/app/Exceptions/UserTokenExpiredException.php b/app/Exceptions/UserTokenExpiredException.php new file mode 100644 index 000000000..203e08c85 --- /dev/null +++ b/app/Exceptions/UserTokenExpiredException.php @@ -0,0 +1,19 @@ +userId = $userId; + parent::__construct($message); + } + + +} \ No newline at end of file diff --git a/app/Exceptions/UserTokenNotFoundException.php b/app/Exceptions/UserTokenNotFoundException.php new file mode 100644 index 000000000..08c1fd830 --- /dev/null +++ b/app/Exceptions/UserTokenNotFoundException.php @@ -0,0 +1,3 @@ +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); } diff --git a/app/Notifications/UserInvite.php b/app/Notifications/UserInvite.php new file mode 100644 index 000000000..b01911bcd --- /dev/null +++ b/app/Notifications/UserInvite.php @@ -0,0 +1,31 @@ +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)); + } +} diff --git a/database/migrations/2019_08_17_140214_add_user_invites_table.php b/database/migrations/2019_08_17_140214_add_user_invites_table.php new file mode 100644 index 000000000..23bd6988c --- /dev/null +++ b/database/migrations/2019_08_17_140214_add_user_invites_table.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index 1065945c0..4474fb7ee 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -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', ]; \ No newline at end of file diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index b91a0c3e1..d66dcc92d 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -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.',