From b238238ebc9942852745baaf787da0ac5335808d Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 28 Aug 2024 13:49:41 +0200 Subject: [PATCH 1/4] #42 - feat: added passwordless login --- app/Actions/SendLoginLink.php | 30 ++++++ .../Controllers/Public/LoginController.php | 81 ++++++++++++++++ app/Mail/LoginLink.php | 38 ++++++++ app/Models/PasswordlessAttempt.php | 30 ++++++ ...906_create_passwordless_attempts_table.php | 26 +++++ docker-compose.yaml | 19 ++++ resources/js/Pages/Public/Login.vue | 7 +- .../js/Pages/Public/PasswordlessLogin.vue | 94 +++++++++++++++++++ .../views/emails/auth/login-link.blade.php | 12 +++ routes/web.php | 4 + 10 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 app/Actions/SendLoginLink.php create mode 100644 app/Mail/LoginLink.php create mode 100644 app/Models/PasswordlessAttempt.php create mode 100644 database/migrations/2024_08_28_123906_create_passwordless_attempts_table.php create mode 100644 resources/js/Pages/Public/PasswordlessLogin.vue create mode 100644 resources/views/emails/auth/login-link.blade.php diff --git a/app/Actions/SendLoginLink.php b/app/Actions/SendLoginLink.php new file mode 100644 index 00000000..3fac86f4 --- /dev/null +++ b/app/Actions/SendLoginLink.php @@ -0,0 +1,30 @@ +send( + mailable: new LoginLink( + url: URL::temporarySignedRoute( + name: "passwordless.login", + expiration: (int)Carbon::now()->diffInSeconds($time), + parameters: [ + "email" => $email, + ], + ), + ), + ); + } +} diff --git a/app/Http/Controllers/Public/LoginController.php b/app/Http/Controllers/Public/LoginController.php index eaa53fa9..d2570f2c 100644 --- a/app/Http/Controllers/Public/LoginController.php +++ b/app/Http/Controllers/Public/LoginController.php @@ -4,10 +4,17 @@ 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 { @@ -32,4 +39,78 @@ 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/Mail/LoginLink.php b/app/Mail/LoginLink.php new file mode 100644 index 00000000..3ccb2aea --- /dev/null +++ b/app/Mail/LoginLink.php @@ -0,0 +1,38 @@ + $this->url, + ], + ); + } +} diff --git a/app/Models/PasswordlessAttempt.php b/app/Models/PasswordlessAttempt.php new file mode 100644 index 00000000..74d482c7 --- /dev/null +++ b/app/Models/PasswordlessAttempt.php @@ -0,0 +1,30 @@ +ulid("id")->primary(); + $table->string("email")->unique(); + $table->string("session_id"); + $table->boolean("can_login")->default(false); + $table->timestamp("expires_at"); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists("passwordless_attempts"); + } +}; diff --git a/docker-compose.yaml b/docker-compose.yaml index 12473143..200c22a2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -70,6 +70,25 @@ services: - keating-dev restart: unless-stopped + mailpit: + image: axllent/mailpit:v1.19.3@sha256:8fbe10f22c09c769ad678bd14e2f9858e41d5e199ea043efe207f85f279fd594 + container_name: keating-mailpit-dev + labels: + - "traefik.enable=true" + - "traefik.blumilk.environment=true" + - "traefik.http.routers.keating-mailpit-http-router.rule=Host(`keating-mailpit.blumilk.localhost`)" + - "traefik.http.routers.keating-mailpit-http-router.entrypoints=web" + - "traefik.http.routers.keating-mailpit-https-router.rule=Host(`keating-mailpit.blumilk.localhost`)" + - "traefik.http.routers.keating-mailpit-https-router.entrypoints=websecure" + - "traefik.http.routers.keating-mailpit-https-router.tls=true" + - "traefik.http.services.keating-mailpit.loadbalancer.server.port=8025" + networks: + - keating-dev + - traefik-proxy-blumilk-local + ports: + - ${DOCKER_MAILPIT_DASHBOARD_HOST_PORT:-3854}:8025 + restart: unless-stopped + redis: image: redis:7.0.11-alpine3.17@sha256:cbcf5bfbc3eaa232b1fa99e539459f46915a41334d46b54bf894f8837a7f071e container_name: keating-redis-dev diff --git a/resources/js/Pages/Public/Login.vue b/resources/js/Pages/Public/Login.vue index 8dfa7d25..3b548848 100644 --- a/resources/js/Pages/Public/Login.vue +++ b/resources/js/Pages/Public/Login.vue @@ -14,7 +14,7 @@ const loginForm = useForm({ }) function attemptLogin() { - loginForm.post('/login') + loginForm.post('/passwordless') } @@ -52,6 +52,11 @@ function attemptLogin() { +
+ + Zaloguj się adresem e-mail + +
diff --git a/resources/js/Pages/Public/PasswordlessLogin.vue b/resources/js/Pages/Public/PasswordlessLogin.vue new file mode 100644 index 00000000..4a967494 --- /dev/null +++ b/resources/js/Pages/Public/PasswordlessLogin.vue @@ -0,0 +1,94 @@ + + + diff --git a/resources/views/emails/auth/login-link.blade.php b/resources/views/emails/auth/login-link.blade.php new file mode 100644 index 00000000..6c6b7b45 --- /dev/null +++ b/resources/views/emails/auth/login-link.blade.php @@ -0,0 +1,12 @@ + + # Login Link + + Use the link below to log into the {{ config('app.name') }} application. + + + Login + + + Thanks,
+ {{ config('app.name') }} +
diff --git a/routes/web.php b/routes/web.php index e1672477..fcfd5fe4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -36,6 +36,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::middleware("auth")->prefix("dashboard")->group(function (): void { From 4f6413e8f4dd6b3b69905fd659c1ce8d78cb91dd Mon Sep 17 00:00:00 2001 From: Kamil Date: Thu, 29 Aug 2024 12:21:14 +0200 Subject: [PATCH 2/4] #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 00000000..77f53262 --- /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 3fac86f4..50cbe85a 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 d2570f2c..eaa53fa9 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 00000000..286a5023 --- /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 4a967494..5f256494 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 6c6b7b45..ffb2fac4 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 fcfd5fe4..2ac7525f 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 00000000..800f43e9 --- /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, + ]); + } +} From 27e91b83cf4bffb7e83be874867690f37eaa7e19 Mon Sep 17 00:00:00 2001 From: Kamil Date: Thu, 29 Aug 2024 14:24:34 +0200 Subject: [PATCH 3/4] #42 - fix: updated .env.example --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index 0e7f2758..7483a01e 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ MAIL_ENCRYPTION=null DOCKER_APP_HOST_PORT=53851 DOCKER_DATABASE_HOST_PORT=53853 +DOCKER_MAILPIT_DASHBOARD_HOST_PORT=53854 DOCKER_REDIS_HOST_PORT=53852 DOCKER_INSTALL_XDEBUG=true DOCKER_HOST_USER_ID=1000 From ed5ca14a38c91556664968186455f63595c78f7d Mon Sep 17 00:00:00 2001 From: Kamil Date: Fri, 30 Aug 2024 10:41:48 +0200 Subject: [PATCH 4/4] #42 - fix: code review fixes --- app/Mail/LoginLink.php | 2 +- resources/js/Pages/Public/Login.vue | 2 +- resources/js/Pages/Public/PasswordlessLogin.vue | 2 +- resources/views/emails/auth/login-link.blade.php | 7 +------ 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/app/Mail/LoginLink.php b/app/Mail/LoginLink.php index 3ccb2aea..b1ec30fb 100644 --- a/app/Mail/LoginLink.php +++ b/app/Mail/LoginLink.php @@ -22,7 +22,7 @@ public function __construct( public function envelope(): Envelope { return new Envelope( - subject: "Your Magic Link is here!", + subject: "Link do logowania w aplikacji Keating", ); } diff --git a/resources/js/Pages/Public/Login.vue b/resources/js/Pages/Public/Login.vue index 3b548848..335609d9 100644 --- a/resources/js/Pages/Public/Login.vue +++ b/resources/js/Pages/Public/Login.vue @@ -53,7 +53,7 @@ function attemptLogin() {
- + Zaloguj się adresem e-mail
diff --git a/resources/js/Pages/Public/PasswordlessLogin.vue b/resources/js/Pages/Public/PasswordlessLogin.vue index 5f256494..44683fa8 100644 --- a/resources/js/Pages/Public/PasswordlessLogin.vue +++ b/resources/js/Pages/Public/PasswordlessLogin.vue @@ -84,7 +84,7 @@ onBeforeUnmount(() => {
- Powrót do zwykłego logowania diff --git a/resources/views/emails/auth/login-link.blade.php b/resources/views/emails/auth/login-link.blade.php index ffb2fac4..7a84c1fd 100644 --- a/resources/views/emails/auth/login-link.blade.php +++ b/resources/views/emails/auth/login-link.blade.php @@ -1,21 +1,16 @@ - Keating - Zaloguj się, klikając w poniższy przycisk: - - + Zaloguj - Keating © {{ date('Y') }} -