diff --git a/web/app/Http/Controllers/Auth/EmailVerificationController.php b/web/app/Http/Controllers/Auth/EmailVerificationController.php new file mode 100644 index 0000000..6610666 --- /dev/null +++ b/web/app/Http/Controllers/Auth/EmailVerificationController.php @@ -0,0 +1,67 @@ +userRepository = $userRepository; + $this->event = $event; + $this->emailWasVerifiedEvent = $emailWasVerifiedEvent; + } + + public function verify($token): JsonResponse + { + try { + $user = $this->userRepository->findOneBy(['email_token_confirmation' => $token]); + } catch (Exception $exception) { + $message = __('Invalid token for email verification'); + + return $this->respondWithCustomData(['message' => $message], Response::HTTP_BAD_REQUEST); + } + + if (!$user->hasVerifiedEmail() && $user->markEmailAsVerified()) { + $this->event->dispatch(new EmailWasVerifiedEvent($user)); + + $message = __('Email successfully verified'); + + return $this->respondWithCustomData(['message' => $message], Response::HTTP_OK); + } + + $message = __('Invalid token for email verification'); + + return $this->respondWithCustomData(['message' => $message], Response::HTTP_BAD_REQUEST); + } +} diff --git a/web/app/Http/Controllers/Auth/LoginController.php b/web/app/Http/Controllers/Auth/LoginController.php index ba6ca32..34c011b 100644 --- a/web/app/Http/Controllers/Auth/LoginController.php +++ b/web/app/Http/Controllers/Auth/LoginController.php @@ -12,7 +12,9 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Notification; use TinnyApi\Models\UserModel; +use TinnyApi\Notifications\VerifyEmailNotification; use TinnyApi\Traits\ResponseTrait; class LoginController extends Controller @@ -24,15 +26,22 @@ class LoginController extends Controller */ private $cacheRepository; + /** + * @var VerifyEmailNotification + */ + private $verifyEmailNotification; + /** * Create a new controller instance. * * @param CacheRepository $cacheRepository + * @param VerifyEmailNotification $verifyEmailNotification */ - public function __construct(CacheRepository $cacheRepository) + public function __construct(CacheRepository $cacheRepository, VerifyEmailNotification $verifyEmailNotification) { $this->middleware('guest')->except('logout'); $this->cacheRepository = $cacheRepository; + $this->verifyEmailNotification = $verifyEmailNotification; } /** @@ -55,6 +64,7 @@ protected function sendLoginResponse(Request $request): JsonResponse try { $this->checkUserIfIsActive($user, $request); + $this->checkIfUserHasVerifiedEmail($user, $request); } catch (LockedException $exception) { return $this->respondWithCustomData([ 'message' => $exception->getMessage(), @@ -113,4 +123,24 @@ public function logout(Request $request) return $request->wantsJson() ? $this->respondWithNoContent() : redirect('/'); } + + /** + * @param UserModel $user + * @param Request $request + */ + private function checkIfUserHasVerifiedEmail(UserModel $user, Request $request) + { + if (!$user->hasVerifiedEmail()) { + Notification::send($user, $this->verifyEmailNotification->setToken($user->email_token_confirmation)); + + $this->logout($request); + + $message = __( + 'We sent a confirmation email to :email. Please follow the instructions to complete your registration.', + ['email' => $user->email] + ); + + throw new LockedException($message); + } + } } diff --git a/web/app/Http/Controllers/Auth/RegisterController.php b/web/app/Http/Controllers/Auth/RegisterController.php index 10a2eed..31d587c 100644 --- a/web/app/Http/Controllers/Auth/RegisterController.php +++ b/web/app/Http/Controllers/Auth/RegisterController.php @@ -97,6 +97,7 @@ protected function create(array $data): Model return $this->userRepository->store([ 'id' => Uuid::uuid4()->toString(), 'name' => $data['name'], + 'email_token_confirmation' => Uuid::uuid4()->toString(), 'email' => $data['email'], 'password' => Hash::make($data['password']), 'is_active' => 1, diff --git a/web/app/Providers/AppServiceProvider.php b/web/app/Providers/AppServiceProvider.php index 2bc9bca..d026e36 100644 --- a/web/app/Providers/AppServiceProvider.php +++ b/web/app/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use TinnyApi\DBConnection\MySQLConnectionFactory as MysqlConnection; use TinnyApi\DBConnection\SQLiteConnectionFactory as SQLiteConnection; use TinnyApi\Models\UserModel; +use TinnyApi\Notifications\VerifyEmailNotification; use TinnyApi\Repositories\UserEloquentRepository; use TinnyApi\Requests\PasswordUpdateRequest; use TinnyApi\Requests\UserUpdateRequest; @@ -36,7 +37,12 @@ private function registerAllServices() }); $this->app->singleton(UserRepository::class, function ($app) { - return new UserEloquentRepository(new UserModel(), $app['cache.store']); + return new UserEloquentRepository( + new UserModel(), + $app['cache.store'], + $app['auth.driver'], + $app['events'] + ); }); $this->app->singleton(WeakPasswordRule::class, function ($app) { @@ -48,16 +54,19 @@ private function registerAllServices() }); $this->app->singleton(PasswordUpdateRequest::class, function ($app) { - $passwordUpdateRequest = new PasswordUpdateRequest(); - $passwordUpdateRequest->setCurrentPasswordRuleInstance($app[CurrentPasswordRule::class]); - $passwordUpdateRequest->setWeakPasswordRuleInstance($app[WeakPasswordRule::class]); - - return $passwordUpdateRequest; + return tap(new PasswordUpdateRequest(), function ($passwordUpdateRequest) use ($app) { + $passwordUpdateRequest->setCurrentPasswordRuleInstance($app[CurrentPasswordRule::class]); + $passwordUpdateRequest->setWeakPasswordRuleInstance($app[WeakPasswordRule::class]); + }); }); $this->app->singleton(UserUpdateRequest::class, function () { return new UserUpdateRequest(); }); + + $this->app->singleton(VerifyEmailNotification::class, function ($app) { + return new VerifyEmailNotification($app['config']); + }); } /** diff --git a/web/routes/api.php b/web/routes/api.php index cbcc66d..d9546e1 100644 --- a/web/routes/api.php +++ b/web/routes/api.php @@ -1,5 +1,6 @@ 'api/v1', 'middleware' => 'guest'], function () { + Route::post('email/verify/{token}', [EmailVerificationController::class, 'verify']) + ->middleware('throttle:hard') + ->name('api.email.verify'); Route::post('register', [RegisterController::class, 'register'])->name('api.auth.register'); Route::post('login', [LoginController::class, 'login'])->name('api.auth.login'); }); diff --git a/web/src/Events/EmailWasVerifiedEvent.php b/web/src/Events/EmailWasVerifiedEvent.php new file mode 100644 index 0000000..0a9914d --- /dev/null +++ b/web/src/Events/EmailWasVerifiedEvent.php @@ -0,0 +1,26 @@ +user = $user; + } +} diff --git a/web/src/Factories/UserModelFactory.php b/web/src/Factories/UserModelFactory.php index 194f0d4..623cf7d 100644 --- a/web/src/Factories/UserModelFactory.php +++ b/web/src/Factories/UserModelFactory.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; +use Ramsey\Uuid\Uuid; use TinnyApi\Models\UserModel; class UserModelFactory extends Factory @@ -19,8 +20,9 @@ class UserModelFactory extends Factory public function definition() { return [ - 'id' => 'id', + 'id' => 'user_id', 'name' => $this->faker->name, + 'email_token_confirmation' => 'email_token', 'email' => $this->faker->unique()->safeEmail, 'email_verified_at' => now(), 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', diff --git a/web/src/Models/UserModel.php b/web/src/Models/UserModel.php index 75e8b38..9200ea8 100644 --- a/web/src/Models/UserModel.php +++ b/web/src/Models/UserModel.php @@ -3,13 +3,14 @@ namespace TinnyApi\Models; use Database\Factories\UserModelFactory; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Passport\HasApiTokens; -class UserModel extends Authenticatable +class UserModel extends Authenticatable implements MustVerifyEmail { use HasFactory, Notifiable, HasApiTokens; diff --git a/web/src/Notifications/VerifyEmailNotification.php b/web/src/Notifications/VerifyEmailNotification.php new file mode 100644 index 0000000..fdba75a --- /dev/null +++ b/web/src/Notifications/VerifyEmailNotification.php @@ -0,0 +1,76 @@ +configRepository = $configRepository; + $this->onQueue('notifications'); + } + + public function via(): array + { + return ['mail']; + } + + public function toMail($notifiable): MailMessage + { + return (new MailMessage()) + ->markdown('emails.default') + ->success() + ->subject(__(':app_name - Confirm your registration', ['app_name' => config('app.name')])) + ->greeting(__('Welcome to :app_name', ['app_name' => config('app.name')])) + ->line(__('Click the link below to complete verification:')) + ->action(__('Verify Email'), url('/user/verify/' . $this->token)) + ->line('' . __('5 Security Tips') . '') + ->line('' . __('DO NOT give your password to anyone!') . '
' . + __( + 'DO NOT call any phone number for someone clainming to be :app_name support!', + ['app_name' => config('app.name')] + ) . '
' . + __( + 'DO NOT send any money to anyone clainming to be a member of :app_name!', + ['app_name' => config('app.name')] + ) . '
' . + __('Enable Two Factor Authentication!') . '
' . + __('Make sure you are visiting :app_url', [ + 'app_url' => $this->configRepository->get('app.url') + ]) . '
'); + } + + /** + * @param string $token + * @return $this + */ + public function setToken(string $token): VerifyEmailNotification + { + $this->token = $token; + + return $this; + } +} diff --git a/web/src/Repositories/AbstractEloquentRepository.php b/web/src/Repositories/AbstractEloquentRepository.php index 138ad08..a34573d 100644 --- a/web/src/Repositories/AbstractEloquentRepository.php +++ b/web/src/Repositories/AbstractEloquentRepository.php @@ -2,12 +2,14 @@ namespace TinnyApi\Repositories; +use Illuminate\Contracts\Auth\Guard; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Cache\Repository as CacheRepository; use Ramsey\Uuid\Uuid; use TinnyApi\Contracts\BaseRepository; +use Illuminate\Contracts\Events\Dispatcher as Event; abstract class AbstractEloquentRepository implements BaseRepository { @@ -29,18 +31,32 @@ abstract class AbstractEloquentRepository implements BaseRepository /** * @var CacheRepository */ - private $cacheRepository; + protected $cacheRepository; + + /** + * @var Guard + */ + protected $auth; + + /** + * @var Event + */ + protected $event; /** * EloquentRepository constructor. * * @param Model $model * @param CacheRepository $cacheRepository + * @param Guard $auth + * @param Event $event */ - public function __construct(Model $model, CacheRepository $cacheRepository) + public function __construct(Model $model, CacheRepository $cacheRepository, Guard $auth, Event $event) { $this->model = $model; $this->cacheRepository = $cacheRepository; + $this->auth = $auth; + $this->event = $event; } /** @@ -70,7 +86,7 @@ public function findOneById(string $id): Model throw (new ModelNotFoundException())->setModel(get_class($this->model)); } - if (!empty($this->with) || auth()->check()) { + if (!empty($this->with) || $this->auth->check()) { return $this->findOneBy(['id' => $id]); } diff --git a/web/src/Repositories/UserEloquentRepository.php b/web/src/Repositories/UserEloquentRepository.php index 8fde5c1..38a6e9f 100644 --- a/web/src/Repositories/UserEloquentRepository.php +++ b/web/src/Repositories/UserEloquentRepository.php @@ -57,7 +57,7 @@ public function update(Model $model, array $data): Model if (isset($data['password'])) { $data['password'] = bcrypt($data['password']); - event(new PasswordReset(auth()->user())); + $this->event->dispatch(new PasswordReset($this->auth->user())); } return parent::update($model, $data);