Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#42 - passwordless login #138

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions app/Actions/PasswordlessCheckAndClearAttemptAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Keating\Actions;

use Carbon\Carbon;
use Keating\Models\PasswordlessAttempt;

class PasswordlessCheckAndClearAttemptAction
{
public function handle(string $email, string $sessionId): bool
{
$passwordlessAttempt = PasswordlessAttempt::query()
->where("email", $email)
->where("can_login", true)
->where("expires_at", ">", Carbon::now())
->where("session_id", $sessionId)
->first();

if ($passwordlessAttempt === null) {
return false;
}
Comment on lines +14 to +23
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would refactor this to ->sole() instead of ->first().


$passwordlessAttempt->delete();

return true;
}
}
30 changes: 30 additions & 0 deletions app/Actions/SendLoginLink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Keating\Actions;

use Carbon\Carbon;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Keating\Mail\LoginLink;

class SendLoginLink
{
public function handle(string $email, Carbon $time): void
{
Mail::to(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you replace facades with dependency injection (as in other actons)?

users: $email,
)->send(
mailable: new LoginLink(
url: URL::temporarySignedRoute(
name: "passwordless.login",
expiration: (int)Carbon::now()->diffInSeconds($time),
parameters: [
"email" => $email,
],
),
),
);
}
}
106 changes: 106 additions & 0 deletions app/Http/Controllers/Public/PasswordlessLoginController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

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 Inertia\Response;
use Keating\Actions\PasswordlessCheckAndClearAttemptAction;
use Keating\Actions\SendLoginLink;
use Keating\Models\PasswordlessAttempt;
use Keating\Models\User;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

class PasswordlessLoginController
{
public function create(): Response
{
return inertia("Public/PasswordlessLogin", [
"universityLogo" => 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.");
}
}
38 changes: 38 additions & 0 deletions app/Mail/LoginLink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Keating\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

final class LoginLink extends Mailable
{
use Queueable;
use SerializesModels;

public function __construct(
public readonly string $url,
) {}

public function envelope(): Envelope
{
return new Envelope(
subject: "Link do logowania w aplikacji Keating",
);
}

public function content(): Content
{
return new Content(
markdown: "emails.auth.login-link",
with: [
"url" => $this->url,
],
);
}
}
30 changes: 30 additions & 0 deletions app/Models/PasswordlessAttempt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Keating\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

/**
* @property string $id
* @property string $email
* @property string $session_id
* @property bool $can_login
* @property Carbon $expires_at
*/
class PasswordlessAttempt extends Model
{
use HasFactory;
use HasUlids;

protected $fillable = [
"email",
"session_id",
"can_login",
"expires_at",
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class() extends Migration {
public function up(): void
{
Schema::create("passwordless_attempts", function (Blueprint $table): void {
$table->ulid("id")->primary();
$table->string("email")->unique();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should make it 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");
}
};
19 changes: 19 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion resources/js/Pages/Public/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const loginForm = useForm({
})

function attemptLogin() {
loginForm.post('/login')
loginForm.post('/passwordless')
}
</script>

Expand Down Expand Up @@ -52,6 +52,11 @@ function attemptLogin() {
</button>
</div>
</form>
<div>
<InertiaLink href="/passwordless" class="mt-3 flex w-full justify-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
Zaloguj się adresem e-mail
</InertiaLink>
</div>
</div>
</div>
</div>
Expand Down
Loading
Loading