Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Redis

Authentication ที่ปลอดภัยและ Type-Safe ใน SvelteKit ด้วย Lucia, PlanetScale และ Upstash Redis

หลัง Guide ก่อนหน้าบน Upstash Blog ได้ขึ้น Bytes Newsletter ผมคิดว่าเล่น SvelteKit ต่อไปดี

身為 Svelte Super Fan ผมเห็นคนกระโดดขึ้นมามากขึ้นทุกวัน ทำให้ตื่นเต้นกับอนาคตมาก

เครื่องมือที่ยัง Under Radar หนึ่งคือ Lucia

Guide นี้จะแสดงวิธี Setup Authentication ด้วย Lucia ใช้ PlanetScale สำหรับ Database และ Upstash Redis จัดการ Sessions

Screenshot เป้าหมายสุดท้าย:

Authentication ที่ปลอดภัยและ Type-Safe ใน SvelteKit ด้วย Lucia, PlanetScale และ Upstash Redis

Guide นี้เป็น SvelteKit แต่ Lucia รองรับ Framework ไหนก็ได้ ส่วนใหญ่ Apply ได้หมด

What is Lucia? - Lucia คืออะไร?

Simple: Lucia คือ Library TypeScript ทำให้จัดการ Users และ Sessions ง่ายขึ้น ต้นทางสร้างสำหรับ SvelteKit แต่ Evolve จนรองรับ Framework เกือบทุกตัว

จุดเด่น Lucia: ให้ Primitives ครบครันจัดการ Complexity ของ Auth โดยไม่犧牲 UX คุณตัดสินใจ Structure Code และ Handle UX เอง Lucia มี Key Parts:

Middleware ให้ Lucia อ่าน Request/Response สำหรับ Framework/Runtime ต่างๆ

ตัวอย่าง Config Middleware:

import { lucia } from "lucia";
import { node } from "lucia/middleware";
// import { nextjs } from "lucia/middleware";
// import { h3 } from "lucia/middleware";
export const auth = lucia({
  env: "DEV", // "PROD" ถ้า Deploy HTTPS
  middleware: node(),
});

Database Adapters ให้ Lucia Store/Retrieve Users และ Sessions มี 2 ประเภท: Regular Adapters และ Session Adapters Guide นี้ใช้ MySQL บน PlanetScale เก็บ Users และ Redis บน Upstash จัดการ Sessions

ตัวอย่าง Config Database Adapter:

import { prisma } from "@lucia-auth/adapter-prisma";
import { PrismaClient } from "@prisma/client";
import { lucia } from "lucia";
const client = new PrismaClient();
const auth = lucia({
  env: "DEV",
  adapter: prisma(client),
});

Prerequisites - เงื่อนไขก่อนเริ่ม

  • เข้าใจ SvelteKit พื้นฐาน Forms และ Routing เป็นประโยชน์
  • คุ้นเคย Drizzle ORM พื้นฐาน
  • Account และ Database บน PlanetScale
  • เข้าถึง Redis Instance เช่น Upstash Redis

Getting Started - เริ่มต้น

เพื่อประสิทธิภาพ ไม่สร้าง App จากศูนย์ Clone Directory sveltekit-lucia-redis จาก Upstash Examples Repo

หลัง Download cd เข้า App Install Dependencies และ Set .env โดย Duplicate .env.example

Understanding the Key Parts - เข้าใจส่วนสำคัญ

  • src/lib/server/auth/index.ts - Config Lucia
  • src/lib/server/drizzle - Drizzle สร้าง MySQL Schema Push ไป PlanetScale ด้วย Drizzle Kit
  • src/lib/server/planetscale - Export Upstash Client ใช้ใน Lucia Adapter Config จัดการ Users
  • src/lib/server/upstash - Export Upstash Client ใช้ใน Lucia Adapter Config จัดการ Sessions

Breaking Down the Code - แยกวิเคราะห์โค้ด

Configuring Lucia - กำหนดค่า Lucia

สร้างไฟล์ src/lib/server/auth/index.ts:

import { planetscale } from "@lucia-auth/adapter-mysql";
import { dev } from "$app/environment";
import { lucia } from "lucia";
import { sveltekit } from "lucia/middleware";
import { ps } from "../planetscale";
export enum PROVIDER_ID { EMAIL = "email", }
export const auth = lucia({
  adapter: {
    user: planetscale(ps, {
      user: "users",
      key: "keys",
      session: null, // Sessions จัดการโดย Upstash Redis
    }),
  },
  middleware: sveltekit(),
  env: dev ? "DEV" : "PROD",
  getUserAttributes: (data) => {
    return { userId: data.id, email: data.email };
  },
});
export type Auth = typeof auth;

สิ่งที่เกิดขึ้น:

Import lucia Function Setup Config Lucia

Config adapter Property บอก Lucia จัดการ Users/Sessions ยังไง

Set session เป็น null เพราะใช้ Redis จัดการ Sessions หากใช้ String 'session' Lucia จะใช้ Adapter เดียวกันทั้ง Users/Sessions (Strings ตรงกับ Tables ใน Database)

Session Adapter รอ會 später

middleware Property บอก Lucia ใช้ SvelteKit อ่าน Request/Response Objects

สังเกต Exported Auth Type เป็น Type ของ auth Object จำเป็นสำหรับ Setup SvelteKit Locals

Getting Great Type Inference with Lucia - Type Inference ที่ดี

Lucia เขียน TypeScript Type Inference ดี Out-of-the-box ให้ SvelteKit รู้ Auth Type ที่สร้าง

เปิด app.d.ts เพิ่ม:

import type { Auth as LuciaAuth } from "$lib/server/auth";
import type { AuthRequest, Session, User } from "lucia";
declare global {
  namespace App {
    interface Locals {
      auth: AuthRequest;
      session: Session | null;
    }
    interface PageData {
      user?: User;
    }
  }
}
/// <reference types="lucia" />
declare global {
  namespace Lucia {
    type Auth = LuciaAuth;
    type DatabaseUserAttributes = { email: string; };
    type DatabaseSessionAttributes = {};
  }
}
export {};

เพิ่ม Auth Type ใน Lucia Namespace เข้าถึง auth Object จาก locals ใน SvelteKit Routes ได้ ทุกอย่าง Import จาก Lucia จะมี Types ถูกต้อง

Setup hooks.server.ts Bind AuthRequest และ Session Object กับ Request ปัจจุบัน เข้าถึงง่ายผ่าน locals:

import type { Handle } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import { auth } from "$lib/server";
const auth_handle: Handle = async ({ event, resolve }) => {
  event.locals.auth = auth.handleRequest(event);
  event.locals.session = await event.locals.auth.validate();
  return resolve(event);
};
export const handle = sequence(auth_handle);

Import sequence Helper รัน Multiple Hooks เรียงลำดับ จะใช้ตอน Protect Routes

Creating the User Model - สร้าง User Model

Lucia Configured แล้ว สร้าง User Model ใช้ Drizzle ORM (Hot Right Now)

Authentication ที่ปลอดภัยและ Type-Safe ใน SvelteKit ด้วย Lucia, PlanetScale และ Upstash Redis

Meme ของพวกเขาจุดเปลี่ยน ดูตัวนี้

ก่อนต่อ ต้องสร้าง Database บน PlanetScale และ Setup Drizzle Config File ช่วย Drizzle CLI Connect Database:

import dotenv from "dotenv";
import type { Config } from "drizzle-kit";
dotenv.process();
const username = process.env.DATABASE_USERNAME;
const password = process.env.DATABASE_PASSWORD;
const host = process.env.DATABASE_HOST;
const db = process.env.DATABASE_NAME;
const connectionString = `mysql://${username}:${password}@${host}/${db}?ssl={"rejectUnauthorized":true}`;
export default {
  schema: "./src/lib/server/drizzle/schema/index.ts",
  driver: "mysql2",
  dbCredentials: { connectionString: connectionString },
} satisfies Config;

ใช้ MySQL Adapter Lucia คาดหวัง User Model Structure เฉพาะ รายละเอียดใน Docs ใส่ใน src/lib/server/drizzle/schema/index.ts:

import { relations } from "drizzle-orm";
import { bigint, datetime, index, int, mysqlEnum, mysqlTable, timestamp, unique, varchar } from "drizzle-orm/mysql-core";
export const users = mysqlTable(
  "users",
  {
    id: varchar("id", { length: 255 }).primaryKey(),
    createdAt: timestamp("createdAt").defaultNow().onUpdateNow().notNull(),
    updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
    email: varchar("email", { length: 191 }).notNull(),
  },
  (table) => {
    return {
      idIdx: index("users_id_idx").on(table.id),
      userIdKey: unique("users_id_key").on(table.id),
    };
  },
);
export const keys = mysqlTable(
  "keys",
  {
    id: varchar("id", { length: 255 }).primaryKey(),
    hashedPassword: varchar("hashed_password", { length: 255 }),
    userId: varchar("user_id", { length: 255 }).notNull(),
  },
  (table) => {
    return {
      userIdIdx: index("keys_user_id_idx").on(table.userId),
      keyIdKey: unique("keys_id_key").on(table.id),
    };
  },
);

รัน pnpm drizzle-kit push:mysql Push Schema ไป PlanetScale

Voila! User Model พร้อมให้ Lucia จัดการ Users

Setting Up Session Management - ตั้งค่า Session Management

User Model พร้อม ตั้งค่า Session Management ใช้ Upstash Redis จัดการ Sessions สมัคร Free Account ได้ที่นี่

Authentication ที่ปลอดภัยและ Type-Safe ใน SvelteKit ด้วย Lucia, PlanetScale และ Upstash Redis

ใน Dashboard สร้าง Database ใหม่ Copy Env Variables

Authentication ที่ปลอดภัยและ Type-Safe ใน SvelteKit ด้วย Lucia, PlanetScale และ Upstash Redis

เพิ่ม Variables ใน .env และเพิ่มใน src/lib/server/upstash/index.ts:

import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$env/static/private";
export const upstashClient = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });

จำได้ไหมตอน Config Lucia Set session เป็น null เพราะใช้ Redis จัดการ Sessions Config ตอนนี้:

import { planetscale } from "@lucia-auth/adapter-mysql";
import { upstash } from "@lucia-auth/adapter-session-redis";
import { dev } from "$app/environment";
import { lucia } from "lucia";
import { sveltekit } from "lucia/middleware";
import { ps } from "../planetscale";
import { upstashClient } from "../upstash";
export enum PROVIDER_ID { EMAIL = "email", }
export const auth = lucia({
  adapter: {
    user: planetscale(ps, { user: "users", key: "keys", session: null }),
    session: upstash(upstashClient), // ใช้ Upstash Redis สำหรับ Sessions
  },
  middleware: sveltekit(),
  env: dev ? "DEV" : "PROD",
  getUserAttributes: (data) => { return { userId: data.id, email: data.email }; },
});
export type Auth = typeof auth;

โชคดี Lucia มี Upstash Redis Adapter Built-in Import และส่ง Upstash Client เข้าไปง่ายมาก!

Creating the Routes - สร้าง Routes

Lucia Configured แล้ว สร้าง Routes สร้างโฟลเดอร์ใน src/routes:

  • src/routes/auth → signin, signup
  • src/routes/app

Tip: Group Features ใต้ Folder เดียวกัน จัดการ Redirects และ Protect Routes ง่าย

Creating the Signin Page - สร้างหน้า Sign In

สร้าง src/routes/auth/signin/+page.svelte (ข้าม Styling เน้น Functionality Full Code ใน Repo):

<script lang="ts">
import { enhance } from '$app/forms';
import { Button, Input, Label, PasswordInput } from '$lib/components/common';
import type { ActionData } from './$types';
export let form: ActionData;
let loading = false;
let email = '';
let password = '';
</script>
<form method="POST" use:enhance={() => { loading = true; return async ({ update }) => { loading = false; update(); }; }} >
  {#if form && form.error}
    <div class="p-2 mb-4 text-sm text-center text-red-900 bg-red-200 rounded-sm"> Error: {form.error} </div>
  {/if}
  <div class="grid gap-2.5">
    <div class="grid gap-1">
      <Label for="email">Email</Label>
      <Input bind:value={email} name="email" placeholder="Email" type="email" autoCapitalize="none" autoComplete="email" autoCorrect="off" required={true} />
    </div>
    <div class="grid gap-1">
      <Label for="password">Password</Label>
      <PasswordInput name="password" placeholder="Password" bind:value={password} />
    </div>
    <div class="mt-6 col-span-full">
      <Button type="submit" class="w-full" disabled={loading}> {#if loading} Loading... {:else} Sign in {/if} </Button>
    </div>
  </div>
</form>

use:enhance Progressive Enhance Form แสดง Loading State Submit Form

Handling the Signin Request - จัดการ Request Sign In

SvelteKit จัดการ POST Requests ง่าย สร้าง +page.server.ts Directory เดียวกับ +page.svelte Export actions Object มี default Property:

import { fail, redirect } from "@sveltejs/kit";
import { auth, PROVIDER_ID } from "$lib/server";
import { LuciaError } from "lucia";
import type { Actions, PageServerLoad } from "./$types";
export const actions = { /* actions here */ };

Import PROVIDER_ID Enum และ auth จาก src/lib/server/auth มี Methods จัดการ Users/Sessions

ดู actions Object ดึง Form Data จาก Request Housekeeping:

actions = {
  default: async ({ request, locals }) => {
    const formData = await request.formData();
    const email = formData.get("email") as string;
    const password = formData.get("password") as string;
    const fields = [email, password];
    if (fields.some(field => !field)) {
      return fail(400, { error: "All fields are required" });
    }

พยายาม Sign In User หากไม่มี User หรือ Password ผิด Lucia Throw Error Catch Error Return 400 พร้อม Error Message:

try {
  const user = await auth.useKey(PROVIDER_ID.EMAIL, email.toLowerCase(), password);
  const session = await auth.createSession({ userId: user.userId, attributes: {} });
  locals.auth.setSession(session);
} catch (err) {
  if (err instanceof LuciaError && (err.message === 'AUTH_INVALID_KEY_ID' || err.message === 'AUTH_INVALID_PASSWORD')) {
    return fail(400, { error: 'Incorrect username of password' });
  }
  return fail(400, { error: 'An unknown error occurred' });
}

User Exists + Password Correct → Create New Session

Redirect ไป Dashboard:

return redirect('/app');

Lucia Abstract ความซับซ้อนของ Auth เรียก Methods ให้ถูกต้อง Lucia จัดการหมด Hash Passwords, Create Sessions, Manage Cookies Type-safe!

Authentication ที่ปลอดภัยและ Type-Safe ใน SvelteKit ด้วย Lucia, PlanetScale และ Upstash Redis

Creating the Signup Page - สร้างหน้า Sign Up

สร้าง src/routes/auth/signup/+page.svelte คล้าย Signin แต่ขอ Password Confirmation

<script lang="ts">
import { enhance } from '$app/forms';
import { Button, Input, Label, PasswordInput } from '$lib/components/common';
import type { ActionData } from './$types';
export let form: ActionData;
let loading = false;
let email = '';
let password = '';
let passwordConfirmation = '';
</script>
<form method="POST" use:enhance={() => { loading = true; return async ({ update }) => { loading = false; update(); }; }} >
  {#if form && form.error} <div class="p-2 mb-4 text-sm text-center text-red-900 bg-red-200 rounded-sm"> Error: {form.error} </div> {/if}
  <div class="grid gap-2.5">
    <div class="grid gap-1"> <Label for="email">Email</Label> <Input bind:value={email} name="email" placeholder="Email" type="email" autoCapitalize="none" autoComplete="email" autoCorrect="off" required={true} /> </div>
    <div class="grid gap-1"> <Label for="password">Password</Label> <PasswordInput name="password" placeholder="Password" bind:value={password} /> </div>
    <div class="grid gap-1"> <Label for="passwordConfirmation">Confirm password</Label> <PasswordInput name="passwordConfirmation" placeholder="Repeat password" bind:value={passwordConfirmation} /> </div>
    <div class="mt-6 col-span-full"> <Button type="submit" class="w-full" disabled={loading}> {#if loading} Loading... {:else} Sign in {/if} </Button> </div>
  </div>
</form>

Handling the Signup Request - จัดการ Request Sign Up

สร้าง src/routes/auth/signup/+page.server.ts Pattern คล้าย Signup แต่ต่างกันที่ Data Retrieval, Validation, Create User, Handle Existing Users

Imports เหมือน Signin ข้าม

ดึง Form Data Housekeeping Check Passwords Match:

const formData = await request.formData();
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const passwordConfirmation = formData.get('passwordConfirmation') as string;
const fields = [email, password, passwordConfirmation];
if (fields.some((field) => !field)) { return fail(400, { error: 'All fields are required' }); }
if (password !== passwordConfirmation) { return fail(400, { error: 'Passwords do not match' }); }

หา User โดย Email ด้วย Drizzle ORM หากมี User Return 400 Error:

try {
  const user = await db.query.users.findFirst({ where: eq(schema.users.email, email.toLowerCase()) });
  if (user) { return fail(400, { error: "User with this email already exists" }); }

ไม่มี User → Create User ใหม่ด้วย Lucia:

const newUser = await auth.createUser({
  key: { providerId: PROVIDER_ID.EMAIL, providerUserId: email.toLowerCase(), password: password },
  attributes: { email },
});

Create Session Redirect Dashboard:

const session = await auth.createSession({ userId: newUser.userId, attributes: {} });
locals.auth.setSession(session);

Easy Peasy!

Bonus: Creating the Signout Page - สร้างหน้า Sign Out

สร้าง Endpoint Sign Out src/routes/auth/signout/+server.ts:

import { auth } from "$lib/server";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals }) => {
  const session = await locals.auth.validate();
  if (!session) { return new Response(null, { status: 400 }); }
  await auth.invalidateAllUserSessions(session.userId);
  await auth.invalidateSession(session.sessionId);
  locals.auth.setSession(null);
  return new Response(null, { status: 200 });
};

Lucia ทำ Invalidate Sessions ง่าย เรียก Endpoint ตอน User กด Sign Out

<script lang="ts">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/common';
async function handleSignOut() {
  const response = await fetch('/auth/signout', { method: 'POST', headers: { 'Content-Type': 'application/json' } });
  if (response.ok) { goto('/auth/signin', { replaceState: true, invalidateAll: true }); }
}
</script>
<Button on:click={handleSignOut}>Sign out</Button>

That's a Wrap - จบแล้ว

สร้าง Type-safe Authentication ด้วย Lucia, PlanetScale, Upstash Redis สำเร็จ เพียงแค่ผิวหน้าของความสามารถ Lucia

Repo สำหรับ Guide ที่นี่ อยากเรียนรู้ Lucia เพิ่มเติม อ่าน Docs แนะนำ

มาร่วม Upstash Discord Community กำลังสนุกอยู่ อยาก SvelteKit Content เพิ่ม หาได้ที่ Blog ของผม