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 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 new file mode 100644 index 00000000..50cbe85a --- /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/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/app/Mail/LoginLink.php b/app/Mail/LoginLink.php new file mode 100644 index 00000000..b1ec30fb --- /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..335609d9 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..44683fa8 --- /dev/null +++ b/resources/js/Pages/Public/PasswordlessLogin.vue @@ -0,0 +1,99 @@ + + + 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..7a84c1fd --- /dev/null +++ b/resources/views/emails/auth/login-link.blade.php @@ -0,0 +1,16 @@ + + + + Keating + + + Zaloguj się, klikając w poniższy przycisk: + + Zaloguj + + + + Keating © {{ date('Y') }} + + + diff --git a/routes/web.php b/routes/web.php index e1672477..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,6 +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", [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, + ]); + } +}