From 4f6413e8f4dd6b3b69905fd659c1ce8d78cb91dd Mon Sep 17 00:00:00 2001 From: Kamil Date: Thu, 29 Aug 2024 12:21:14 +0200 Subject: [PATCH] #42 - fix: code refactor --- ...PasswordlessCheckAndClearAttemptAction.php | 29 +++++ app/Actions/SendLoginLink.php | 2 +- .../Controllers/Public/LoginController.php | 81 ------------- .../Public/PasswordlessLoginController.php | 106 ++++++++++++++++++ .../js/Pages/Public/PasswordlessLogin.vue | 9 +- .../views/emails/auth/login-link.blade.php | 23 ++-- routes/web.php | 9 +- tests/Feature/PasswordlessLoginTest.php | 78 +++++++++++++ 8 files changed, 242 insertions(+), 95 deletions(-) create mode 100644 app/Actions/PasswordlessCheckAndClearAttemptAction.php create mode 100644 app/Http/Controllers/Public/PasswordlessLoginController.php create mode 100644 tests/Feature/PasswordlessLoginTest.php diff --git a/app/Actions/PasswordlessCheckAndClearAttemptAction.php b/app/Actions/PasswordlessCheckAndClearAttemptAction.php new file mode 100644 index 0000000..77f5326 --- /dev/null +++ b/app/Actions/PasswordlessCheckAndClearAttemptAction.php @@ -0,0 +1,29 @@ +where("email", $email) + ->where("can_login", true) + ->where("expires_at", ">", Carbon::now()) + ->where("session_id", $sessionId) + ->first(); + + if ($passwordlessAttempt === null) { + return false; + } + + $passwordlessAttempt->delete(); + + return true; + } +} diff --git a/app/Actions/SendLoginLink.php b/app/Actions/SendLoginLink.php index 3fac86f..50cbe85 100644 --- a/app/Actions/SendLoginLink.php +++ b/app/Actions/SendLoginLink.php @@ -9,7 +9,7 @@ use Illuminate\Support\Facades\URL; use Keating\Mail\LoginLink; -final class SendLoginLink +class SendLoginLink { public function handle(string $email, Carbon $time): void { diff --git a/app/Http/Controllers/Public/LoginController.php b/app/Http/Controllers/Public/LoginController.php index d2570f2..eaa53fa 100644 --- a/app/Http/Controllers/Public/LoginController.php +++ b/app/Http/Controllers/Public/LoginController.php @@ -4,17 +4,10 @@ namespace Keating\Http\Controllers\Public; -use Carbon\Carbon; use Illuminate\Auth\AuthManager; -use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Inertia\Response; -use Keating\Actions\SendLoginLink; -use Keating\Models\PasswordlessAttempt; -use Keating\Models\User; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class LoginController { @@ -39,78 +32,4 @@ public function store(Request $request, AuthManager $auth): RedirectResponse "email" => "Niepoprawne dane logowania", ]); } - - public function passwordlessCreate(): Response - { - return inertia("Public/PasswordlessLogin", [ - "universityLogo" => asset("cwup-full.png"), - ]); - } - - public function passwordlessStore(Request $request, SendLoginLink $action): RedirectResponse - { - $time = Carbon::now()->addMinutes(5); - PasswordlessAttempt::query() - ->updateOrCreate( - attributes: [ - "email" => $request->email, - ], - values: [ - "email" => $request->email, - "session_id" => $request->session()->getId(), - "can_login" => false, - "expires_at" => $time, - ], - ); - - $action->handle( - email: $request->email, - time: $time, - ); - - return back(); - } - - public function passwordlessCheck(Request $request): JsonResponse - { - $passwordlessAttempt = PasswordlessAttempt::query() - ->where("email", $request->email) - ->where("can_login", true) - ->where("expires_at", ">", Carbon::now()) - ->where("session_id", $request->session()->getId()) - ->first(); - - if ($passwordlessAttempt === null) { - return new JsonResponse([ - "can_login" => false, - ], SymfonyResponse::HTTP_UNAUTHORIZED); - } - - $user = User::query() - ->where("email", $request->email) - ->first(); - - Auth::login($user); - - return new JsonResponse([ - "can_login" => true, - ], SymfonyResponse::HTTP_OK); - } - - public function passwordlessLogin(Request $request, string $email): RedirectResponse - { - if (!$request->hasValidSignature()) { - abort(SymfonyResponse::HTTP_UNAUTHORIZED); - } - - PasswordlessAttempt::query() - ->where("email", $email) - ->where("can_login", false) - ->where("expires_at", ">", Carbon::now()) - ->update([ - "can_login" => true, - ]); - - return redirect()->route("passwordless.create"); - } } diff --git a/app/Http/Controllers/Public/PasswordlessLoginController.php b/app/Http/Controllers/Public/PasswordlessLoginController.php new file mode 100644 index 0000000..286a502 --- /dev/null +++ b/app/Http/Controllers/Public/PasswordlessLoginController.php @@ -0,0 +1,106 @@ + asset("cwup-full.png"), + ]); + } + + public function store(Request $request, SendLoginLink $action): RedirectResponse + { + $user = User::query()->where("email", $request->get("email"))->first(); + + if ($user === null) { + return $this->redirectToPasswordlessCreate(); + } + + $time = Carbon::now()->addMinutes(5); + PasswordlessAttempt::query() + ->updateOrCreate( + attributes: [ + "email" => $request->get("email"), + ], + values: [ + "email" => $request->get("email"), + "session_id" => $request->session()->getId(), + "can_login" => false, + "expires_at" => $time, + ], + ); + + $action->handle( + email: $request->email, + time: $time, + ); + + return $this->redirectToPasswordlessCreate(); + } + + public function check(Request $request, string $email, PasswordlessCheckAndClearAttemptAction $action, AuthManager $auth): JsonResponse + { + $canLogin = $action->handle( + email: $email, + sessionId: $request->session()->getId(), + ); + + if (!$canLogin) { + return new JsonResponse([ + "can_login" => false, + ], SymfonyResponse::HTTP_UNAUTHORIZED); + } + + $user = User::query() + ->where("email", $email) + ->first(); + + $auth->login($user); + $request->session()->regenerate(); + + return new JsonResponse([ + "can_login" => true, + ], SymfonyResponse::HTTP_OK); + } + + public function login(Request $request, string $email): RedirectResponse + { + if (!$request->hasValidSignature()) { + abort(SymfonyResponse::HTTP_UNAUTHORIZED); + } + + PasswordlessAttempt::query() + ->where("email", $email) + ->where("can_login", false) + ->where("expires_at", ">", Carbon::now()) + ->update([ + "can_login" => true, + ]); + + return redirect()->route("passwordless.create") + ->with("success", "Potwierdzono logowanie."); + } + + private function redirectToPasswordlessCreate(): RedirectResponse + { + return redirect()->route("passwordless.create") + ->with("success", "Jeśli podany adres e-mail istnieje w naszej bazie, otrzymasz link do logowania."); + } +} diff --git a/resources/js/Pages/Public/PasswordlessLogin.vue b/resources/js/Pages/Public/PasswordlessLogin.vue index 4a96749..5f25649 100644 --- a/resources/js/Pages/Public/PasswordlessLogin.vue +++ b/resources/js/Pages/Public/PasswordlessLogin.vue @@ -21,13 +21,13 @@ function attemptLogin() { loginForm.post('/passwordless', { preserveState: true, onSuccess: () => { - interval.value = setInterval(checkLogin, 2000) + interval.value = setInterval(checkLogin, 5000) }, }) } async function checkLogin() { - return axios.get(`/passwordless/check/${loginForm.email}`) + return axios.post(`/passwordless/check/${loginForm.email}`) .then(response => { if (response.status === 200) { Inertia.visit('/dashboard') @@ -57,6 +57,11 @@ onBeforeUnmount(() => {
+
+ {{ $page.props.flash.success }} +
diff --git a/resources/views/emails/auth/login-link.blade.php b/resources/views/emails/auth/login-link.blade.php index 6c6b7b4..ffb2fac 100644 --- a/resources/views/emails/auth/login-link.blade.php +++ b/resources/views/emails/auth/login-link.blade.php @@ -1,12 +1,21 @@ - - # Login Link + - Use the link below to log into the {{ config('app.name') }} application. + + + Keating + + + + Zaloguj się, klikając w poniższy przycisk: - Login + Zaloguj - Thanks,
- {{ config('app.name') }} -
+ + + Keating © {{ date('Y') }} + + + + diff --git a/routes/web.php b/routes/web.php index fcfd5fe..2ac7525 100644 --- a/routes/web.php +++ b/routes/web.php @@ -25,6 +25,7 @@ use Keating\Http\Controllers\Public\HomeController; use Keating\Http\Controllers\Public\LoginController; use Keating\Http\Controllers\Public\NewsController; +use Keating\Http\Controllers\Public\PasswordlessLoginController; Route::get("/", HomeController::class)->name("main"); Route::get("/aktualnosci", [NewsController::class, "index"]); @@ -36,10 +37,10 @@ Route::middleware("guest")->group(function (): void { Route::get("/login", [LoginController::class, "create"])->name("login"); Route::post("/login", [LoginController::class, "store"]); - Route::get("/passwordless", [LoginController::class, "passwordlessCreate"])->name("passwordless.create"); - Route::post("/passwordless", [LoginController::class, "passwordlessStore"])->name("passwordless.store"); - Route::get("/passwordless/{email}", [LoginController::class, "passwordlessLogin"])->name("passwordless.login"); - Route::get("/passwordless/check/{email}", [LoginController::class, "passwordlessCheck"])->name("passwordless.check"); + Route::get("/passwordless", [PasswordlessLoginController::class, "create"])->name("passwordless.create"); + Route::post("/passwordless", [PasswordlessLoginController::class, "store"])->name("passwordless.store"); + Route::get("/passwordless/{email}", [PasswordlessLoginController::class, "login"])->name("passwordless.login"); + Route::post("/passwordless/check/{email}", [PasswordlessLoginController::class, "check"])->name("passwordless.check"); }); Route::middleware("auth")->prefix("dashboard")->group(function (): void { diff --git a/tests/Feature/PasswordlessLoginTest.php b/tests/Feature/PasswordlessLoginTest.php new file mode 100644 index 0000000..800f43e --- /dev/null +++ b/tests/Feature/PasswordlessLoginTest.php @@ -0,0 +1,78 @@ + "test@example.com"])->create(); + } + + public function testUserCanPasswordLessLogin(): void + { + $this->assertDatabaseMissing("passwordless_attempts", [ + "email" => "test@example.com", + ]); + + $this->post("/passwordless", [ + "email" => "test@example.com", + ])->assertRedirect("/passwordless"); + + Mail::assertSentCount(1); + $link = Mail::sent(LoginLink::class)->first()->url; + + $this->assertDatabaseHas("passwordless_attempts", [ + "email" => "test@example.com", + "can_login" => false, + ]); + + $this->get($link)->assertRedirect("/passwordless"); + + $this->assertDatabaseHas("passwordless_attempts", [ + "email" => "test@example.com", + "can_login" => true, + ]); + } + + public function testUserCannotLoginWithExpiredLink(): void + { + $this->assertDatabaseMissing("passwordless_attempts", [ + "email" => "test@example.com", + ]); + + $this->post("/passwordless", [ + "email" => "test@example.com", + ])->assertRedirect("/passwordless"); + + Mail::assertSentCount(1); + $link = Mail::sent(LoginLink::class)->first()->url; + + $this->travel(6)->minutes(); + + $this->assertDatabaseHas("passwordless_attempts", [ + "email" => "test@example.com", + "can_login" => false, + ]); + + $this->get($link)->assertStatus(401); + + $this->assertDatabaseHas("passwordless_attempts", [ + "email" => "test@example.com", + "can_login" => false, + ]); + } +}