Skip to content

Commit

Permalink
#42 - feat: added passwordless login
Browse files Browse the repository at this point in the history
  • Loading branch information
kamilpiech97 committed Aug 28, 2024
1 parent 86b0128 commit b238238
Show file tree
Hide file tree
Showing 10 changed files with 340 additions and 1 deletion.
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;

final class SendLoginLink
{
public function handle(string $email, Carbon $time): void
{
Mail::to(
users: $email,
)->send(
mailable: new LoginLink(
url: URL::temporarySignedRoute(
name: "passwordless.login",
expiration: (int)Carbon::now()->diffInSeconds($time),
parameters: [
"email" => $email,
],
),
),
);
}
}
81 changes: 81 additions & 0 deletions app/Http/Controllers/Public/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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");
}
}
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: "Your Magic Link is here!",
);
}

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();
$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-black 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
94 changes: 94 additions & 0 deletions resources/js/Pages/Public/PasswordlessLogin.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script setup>
import PublicLayout from '@/Layouts/PublicLayout.vue'
import BackgroundGrid from '@/Components/BackgroundGrid.vue'
import { Head, useForm } from '@inertiajs/inertia-vue3'
import { ref } from 'vue'
import axios from 'axios'
import { onBeforeUnmount } from 'vue'
import { Inertia } from '@inertiajs/inertia'
defineProps({
university: String,
universityLogo: String,
})
const loginForm = useForm({
email: '',
})
const interval = ref(0)
function attemptLogin() {
loginForm.post('/passwordless', {
preserveState: true,
onSuccess: () => {
interval.value = setInterval(checkLogin, 2000)
},
})
}
async function checkLogin() {
return axios.get(`/passwordless/check/${loginForm.email}`)
.then(response => {
if (response.status === 200) {
Inertia.visit('/dashboard')
}
})
}
onBeforeUnmount(() => {
if (interval.value) {
clearInterval(interval.value)
}
})
</script>

<template>
<Head title="Logowanie bez hasła" />

<PublicLayout>
<div class="relative isolate bg-white pt-14">
<BackgroundGrid />
<img alt="" class="pointer-events-none absolute right-0 z-0 hidden w-1/2 opacity-10 lg:mt-16 lg:block xl:mt-10 2xl:mt-0"
src="/cwup.png"
>
<div class="mx-auto max-w-7xl px-6 py-24 sm:py-32 lg:flex lg:items-center lg:gap-x-10 lg:px-8 lg:py-40">
<div class="mx-auto max-w-7xl text-center lg:mx-0 lg:flex-auto">
<img :alt="university" :src="universityLogo" class="mx-auto w-[360px]">
<div class="sm:mx-auto sm:w-full sm:max-w-[480px]">
<div class="px-6 py-7 sm:px-12">
<form class="z-10 space-y-6" @submit.prevent="attemptLogin">
<div v-if="loginForm.errors.email"
class="bg-wb-red-10 border-wb-red-20 text-wb-grey-80 rounded-md border px-4 py-3 text-center text-sm"
>
{{ loginForm.errors.email }}
</div>
<div>
<label class="block text-sm font-medium leading-6 text-gray-900" for="email">Email</label>
<div class="mt-2">
<input id="email" v-model="loginForm.email" autocomplete="email" class="top block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-black sm:text-sm sm:leading-6" name="email" required
type="email"
>
</div>
</div>
<div>
<button class="flex w-full justify-center rounded-md bg-black 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"
type="submit"
>
Zaloguj się adresem e-mail
</button>
</div>
</form>
<div>
<InertiaLink class="mt-3 flex w-full justify-center rounded-md bg-black 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"
href="/login"
>
Powrót do zwykłego logowania
</InertiaLink>
</div>
</div>
</div>
</div>
</div>
</div>
</PublicLayout>
</template>
12 changes: 12 additions & 0 deletions resources/views/emails/auth/login-link.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<x-mail::message>
# Login Link

Use the link below to log into the {{ config('app.name') }} application.

<x-mail::button :url="$url">
Login
</x-mail::button>

Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
4 changes: 4 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit b238238

Please sign in to comment.