Astroをサポートしている認証ライブラリをあまり見かけることがありませんがSessionベース認証のLuciaはAstroにも対応しています。本文書ではusername+password認証に対応したLuciaを利用して動作確認を行います。LuciaはSessionベースの認証ライブラリでデータベースにSessionとCookiesにSessionIdを保存して認証を管理します。データベースにはPrisma経由でSQLiteを利用します。フォームのバリデーションにはZodを利用しています。

Nuxt 3でも同様に動作確認を行っていますが設定+実際のコード上での動作を理解するために一部ソースコードの解説を加えています。コア部分のソースコードはフレームワーク共通なので興味がある人はぜひ参考にしてみてください。

Luciaとは

LuciaはSessionベースの認証ライブラリで、Astro以外にもNext.js, SvelteKitなどを含めさまざまなフレームワークに対応しています。認証方式としてusername+password以外にOAuthにも対応しています。TypeScriptにも対応しているので認証機能の実装を型安全に行うことができます。データベースはAdapter方式を利用しておりデータベースに対するコードがそれぞれ用意されています。認証に関するコアのコードは共通しているので一つのフレームワークで理解ができれば他のフレームワークでもその知識を活用することができます。

認証の流れ

設定を行う前にLuciaでは認証管理をどのように行っているのか簡単に説明を行っておきます。事前にサインアップ画面からユーザの登録は完了していることを前提にしています。

  • ログイン画面に事前に登録したusename+passwordを入力して送信ボタンをクリック。
  • 送信ボタンをクリックすると送信されたusername+passwordに一致するユーザが存在するかサーバ側の処理で確認。
  • ユーザの存在確認後、Sessionデータを新規作成しデータベースに保存、作成したSessionデータに含まれるSessionIdを持つCookiesを作成。ログイン処理完了(認証済)。
  • 各ページにSessionを検証する関数を設定して取得したSessionの値によって各ページに応じた処理を行う。ログイン後はログイン、サインアップページにはアクセスされないなど。
  • Sessionの検証ではCookiesからSessionIdを取り出しデータベースからSessionを取得。
  • 取得したSessionの有効期限をチェック。有効期限のチェックはactivePeriodとidlePeriodの2つで実施。activePeriodの期限が切れてもidlePeriod期限内であればSessionの有効期限の更新。デフォルトではactivePeriodが1日、idlePeriodが2週間+activePeriod有効期間。
  • idlePeriodの有効期限が切れたら認証が終了。Cookieの有効期限切れても認証が終了。Cookieの有効期限はデフォルトではidlePeriodと同じ。
  • 意図的に認証を終了したい場合はAPIエンドポイント(/logout)にPOSTリクエストを送信してSessionの無効化とCookieの削除を実施。Cookiesを手動で削除しても認証が終了。

プロジェクトの作成

Astroのプロジェクトの作成は”npm create astro@latest”コマンドで実行することができます。任意のプロジェクト名を設定することができますがコマンドを実行するとランダムな名前でプロジェクト名が設定されるのでそのままその名前を利用します。今回のプロジェクトの名前はtested-towerです。


 % npm create astro@latest

 astro   Launch sequence initiated.

   dir   Where should we create your new project?
         ./tested-tower

  tmpl   How would you like to start your new project?
         Empty
 ██████  Template copying...

  deps   Install dependencies?
         Yes
 ██████  Installing dependencies with npm...

    ts   Do you plan to write TypeScript?
         Yes

   use   How strict should TypeScript be?
         Strict
 ██████  TypeScript customizing...

   git   Initialize a new git repository?
         No
      ◼  Sounds good! You can always run git init manually.

  next   Liftoff confirmed. Explore your project!

         Enter your project directory using cd ./stale-star 
         Run npm run dev to start the dev server. CTRL+C to stop.
         Add frameworks like react or tailwind using astro add.

         Stuck? Join us at https://astro.build/chat

╭──🎁─╮  Houston:
│ ◠ ◡ ◠ 

プロジェクトを作成後、プロジェクトディレクトリに移動します。


% cd tested-tower                               

Prismaの設定

本文書ではデータベースにはPrismaを利用して接続しますがLuciaがサポートしているデータベースには以下のものがあるので各自の環境にあったものをインストールしてください。

サポートされているデータベース
サポートされているデータベース

Adapterが提供されているのでデータベースのテーブル作成後の処理については大きな違いがありません。テーブルを作成する方法はそれぞれのデータベースによって異なりますがドキュメントに掲載されているのでドキュメント通りに設定することができます。

Prisma のインストール

npm コマンドを利用して Prisma のインストールを行います。


 % npm install prisma --save-dev
コマンド実行後にインストールされた Prisma のバージョンは 5.7.1 でした。

Prisma 用の設定ファイルを作成するために”npx prisma init”コマンドを実行します。実行するとプロジェクトディレクトリ下には prisma ディレクトリと.env ファイルが作成されます。prisma ディレクトリには Prisma の設定ファイルである schema.prisma ファイルが作成されています。.env ファイルはデータベースに接続するために必要となる環境変数を設定するために利用します。

SQLite データベースを利用するのでオプション–datasource-provider に sqlite を設定しています。もし指定しない場合には postgresql データベースが接続データベースとして設定された状態でファイルが作成されます。


 % npx prisma init --datasource-provider sqlite

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

SQLiteデータベースではファイルを利用してデータを保存するため.envファイルにはデータベースファイルを保管するパスとファイル名が環境変数DATABASE_URLに設定されています。

モデルの設定

schema.prismaファイルでは–datasource-providerを指定して実行した場合はSQLiteデータベースに関する設定は完了しているのでスキーマの設定を行います。スキーマについてはドキュメントに掲載されているのでそのまま利用します。


// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id           String    @id @unique
  auth_session Session[]
  key          Key[]
}

model Session {
  id             String @id @unique
  user_id        String
  active_expires BigInt
  idle_expires   BigInt
  user           User   @relation(references: [id], fields: [user_id], onDelete: Cascade)

  @@index([user_id])
}

model Key {
  id              String  @id @unique
  hashed_password String?
  user_id         String
  user            User    @relation(references: [id], fields: [user_id], onDelete: Cascade)
  @@index([user_id])
}

Lusiaで利用するモデルはUser, Session, Keyから構成されています。

Userモデルについてはデフォルトではidのみとなっていますが属性を追加することができます。

Sessionモデルについてはuser_idを介してUserモデルと紐付きログインする度にログインしたユーザに対して新たなSessionデータが作成されます。

Keyモデルについては一人のユーザが複数認証方式で登録した場合に認証方式毎にUserデータを作成するのではなく1つのUserデータに複数のKeyデータを紐づける場合に利用することができます。本文書ではデフォルトのusernameをKeyとして登録するので複数のKeyの動作確認は行なっていません。

テーブルの作成

schema.prismaファイルでのスキーマのの設定が完了したらSQLiteデータベースにテーブルを作成するために”npx prisma db push”コマンドを実行します。


 % npx prisma db push
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"

SQLite database dev.db created at file:./dev.db

🚀  Your database is now in sync with your Prisma schema. Done in 22ms

Running generate... (Use --skip-generate to skip the generators)

added 1 package, and audited 742 packages in 3s

121 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

✔ Generated Prisma Client (v5.7.1) to ./node_modules/@prisma/client in 165ms

コマンドを実行すると.env ファイルの DATABASE_URL で指定した場所に SQLite のデータベースファイル dev.db が作成されます

Prisma Studio からのデータベース接続

Prisma には Prisma Studio という Prisma 専用の GUI ツールを利用してデータベースにアクセスを行うことができます。Prisma Studio を起動するために npx prisma studio コマンドを実行します。


% npx prisma studio
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Prisma Studio is up on http://localhost:5555

設定したスキーマからモデルが作成させていることが確認できます。

作成したモデルの確認
作成したモデルの確認

Luciaの設定

データベースの作成が完了したのでLuciaのインストールを行い、ページの作成や各種設定を行なっていきます。

Luciaのインストール

luciaライブラリのインストールを行います。


 % npm i lucia

Prisma用のAdapterのインストールも行います。接続するデータベースによってインストールするAdapterは変わります。


 % npm i @lucia-auth/adapter-prisma

SSRモードへの変更のを行っておきます。設定はastro.config.mjsファイルで行います。


import { defineConfig } from 'astro/config';

// https://astro.build/config
export default defineConfig({
  output: 'server',
});

設定していない場合には以下のWARNINGがコンソールに表示されます。


11:30:34 [WARN] `Astro.request.headers` is not available in "static" output mode. To enable header access: set `output: "server"` or `output: "hybrid"` in your config file.

lucia.tsファイルの作成

luciaの設定を行うためにsrcディレクトリにlibディレクトリを作成してlucia.tsファイルを作成して以下のコードを記述します。

srcディレクトリにlibディレクトリを作成してlucia.tsファイルを作成します。


import { lucia } from 'lucia';
import { astro } from 'lucia/middleware';
import { prisma } from '@lucia-auth/adapter-prisma';
import { PrismaClient } from '@prisma/client';

const client = new PrismaClient();

export const auth = lucia({
  adapter: prisma(client),
  env: import.meta.env.DEV ? 'DEV' : 'PROD',
  middleware: astro(),
});

export type Auth = typeof auth;

このファイルの内容は接続するデータベースや利用するフレームワーク/ライブラリによって設定が異なります。adapterは利用するデータベース、middlewareは利用するフレームワーク/ライブラリによって変わります。本文書の環境ではadapterにはPrisma, middlewareにはastroを設定します

型定義ファイルの作成

データベースから取得したユーザの情報やテーブルに属性を追加した場合に型の情報を正しく取得できるようにsrcディレクトリのenv.d.tsファイルにluciaの設定情報を追加します。


/// <reference types="astro/client" />
/// <reference types="lucia" />
declare namespace Lucia {
  type Auth = import('./lib/lucia').Auth;
  type DatabaseUserAttributes = {};
  type DatabaseSessionAttributes = {};
}

middlewareの設定

middlewareは各ページにアクセスする前に実行される処理を行うことができます。context.localsを利用することでデータを共有することができます。auth.handleRequestでAuthRequestというクラスがインスタンス化されauthに保存されますがページ内で利用することができます。


import { auth } from './lib/lucia';
import { defineMiddleware } from 'astro/middleware';

export const onRequest = defineMiddleware((context, next) => {
  context.locals.auth = auth.handleRequest(context);
  return next();
});

AuthRequestの型情報を登録するために型定義ファイルの更新を行います。


/// 
/// 
declare namespace Lucia {
  type Auth = import('./lib/lucia').Auth;
  type DatabaseUserAttributes = {};
  type DatabaseSessionAttributes = {};
}
/// 
declare namespace App {
  interface Locals {
    auth: import('lucia').AuthRequest;
  }
}

Layoutsページの作成

layoutsディレクトリを作成してLayout.astroファイルを作成します。これからsignup.astro, login.astro, index.astro, dashboard/index.astroの4つのページを作成するのでLayoutファイルには4つのページを移動できるようにリンクを設定します。


---
import { ViewTransitions } from 'astro:transitions';
---
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Astro</title>
    <ViewTransitions />
  </head>
  <body>
    <nav>
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/login">Login</a></li>
        <li><a href="/signup">Signup</a></li>
        <li><a href="/dashboard">Dashboard</a></li>
      </ul>
    </nav>
    <slot />
  </body>
</html>

pagesディレクトリのindex.astroファイルの更新を行います。


---
import Layout from "../layouts/Layout.astro"
---

<Layout>
  <h1>Home</h1>
</Layout>

pagesディレクトリの下にdashboardディレクトリを作成してindex.astroファイルを作成して以下の内容を記述します。


---
import Layout from "../../layouts/Layout.astro"
---

<Layout>
  <h1>Dashboard</h1>
</Layout>

“npm run dev”コマンドで開発サーバを起動します。ブラウザでアクセスすると以下の画面が表示されます。”/”と”/dashboard”はページが存在するので移動することができます。

Homeページの表示
Homeページの表示

Signupページの作成

pageディレクトリの下にsignup.astroファイルを作成してユーザの登録を行うSignupページの作成を行います。


---
import { LuciaError } from 'lucia';
import Layout from '../layouts/Layout.astro';
import { auth } from '../lib/lucia';
import z from 'zod';

let usernameInput = '';
let errorMessages: z.ZodIssue[] = [];
let errorMessage: string | null = null;

if (Astro.request.method === 'POST') {
  const formData = await Astro.request.formData();
  const username = formData.get('username');
  const password = formData.get('password');
  if (typeof username === 'string') usernameInput = username;

  const result = z
    .object({
      username: z.string().min(4).max(31),
      password: z.string().min(8),
    })
    .safeParse({ username, password });

  if (!result.success) {
    errorMessages = result.error.errors;
    Astro.response.status = 400;
  } else {
    try {
      const user = await auth.createUser({
        key: {
          providerId: 'username',
          providerUserId: result.data.username.toLowerCase(),
          password: result.data.password,
        },
        attributes: {},
      });
      const session = await auth.createSession({
        userId: user.userId,
        attributes: {},
      });
      Astro.locals.auth.setSession(session);
      return Astro.redirect('/dashboard', 302);
    } catch (e) {
      if (e instanceof LuciaError && e.message === 'AUTH_DUPLICATE_KEY_ID') {
        errorMessage = 'Username already taken';
        Astro.response.status = 400;
      } else {
        errorMessage = 'Internal Server Error';
        Astro.response.status = 500;
      }
    }
  }
}
---
<Layout>
  <h1>Sign up</h1>
  <form method="post">
    <label for="username">Username</label>
    <input name="username" id="username" value={usernameInput} />
    <br />
    <label for="password">Password</label>
    <input type="password" name="password" id="password" />
    <br />
    {errorMessages.length > 0 &&
      errorMessages.map((error) => (
        <p>
          {error.path[0]}:{error.message}
        </p>
      ))}
    <p>{errorMessage}</p>
    <input type="submit" />
  </form>
  <a href="/login">Sign in</a>
</Layout>

作成したSignupページには以下のように表示されます。

Signup画面
Signup画面

フォームで入力したバリデーションにはバリデーションライブラリのzodを利用しているのでインストールを行います。Zodを利用してusernameとpasswordの文字列の長さのチェックのみ行っています。バリデーションに失敗するとフォーム上にエラーが表示されるようになっています。


 % npm install zod
バリデーションエラーが発生した場合
バリデーションエラーが発生した場合

signup.astroの中ではcreateUserを利用して入力したユーザ情報で新規ユーザと新規のKeyを作成しています。ユーザとKeyはuserIdを介して紐づいています。keyに設定しているproviderIdとproviderUserIdを利用してkey内で一意となるデータを作成しています。providerIdは今回はusernameですが、email、githubといったように設定するProviderによって値を変更することができます。

ユーザ作成が完了するとauth.createSessionでSessionデータを作成します。Sessionを作成後、middlewareから受け取ったAstro.locals.auth.setSession(AuthRequest.setSession)を利用してCookieの作成を行います。CookieにはSessionで作成したSessionIdが入ります。Cookieを作成後/dashboardにリダイレクトしています。

データベース側でエラーが発生した場合にはLuciaErrorがthrowされるようにデータベースのAdapterで設定が行われています。事前にAdapterで設定されているエラーに対応できるようにtry, catchを利用してエラーをハンドリングしています。

ユーザを作成するとデータベースには下記の情報が保存されます。

ユーザIDはランダムな文字列でSessionとKeyが紐づいています。

Userテーブル
Userテーブル

Keyテーブルにはusername:と入力フォームで入力した文字列の小文字と一緒に保存されます。

Keyテーブル
Keyテーブル

idは40文字のSessionIdでactive_expiresとidle_expiresには有効期限が保存されます。

Sessionテーブル
Sessionテーブル

ブラウザのデベロッパーツールのApplicationのCookiesから作成されたCookiesを確認することができます。

Cookiesの確認
Cookiesの確認

リダイレクトの設定

ログインしたユーザがSignupページにアクセスできないようにリダイレクトの設定を行います。サインアップが完了すると認証が完了しているのでSignupページにアクセスするとDashboardページにリダイレクトされます。


略
const session = await Astro.locals.auth.validate();
if (session) return Astro.redirect("/dashboard", 302); 
---

Loginページの作成

Loginページの作成を行いますがほとんど内容はSignupページと同じで違いはLoginページではユーザの作成が必要ありません。SignUpページで設定したリダイレクトの設定も行っています。


---
import { LuciaError } from 'lucia';
import Layout from '../layouts/Layout.astro';
import { auth } from '../lib/lucia';
import z from 'zod';

let usernameInput = '';
let errorMessages: z.ZodIssue[] = [];
let errorMessage: string | null = null;

if (Astro.request.method === 'POST') {
  const formData = await Astro.request.formData();
  const username = formData.get('username');
  const password = formData.get('password');
  if (typeof username === 'string') usernameInput = username;

  const result = z
    .object({
      username: z.string().min(4).max(31),
      password: z.string().min(8),
    })
    .safeParse({ username, password });

  if (!result.success) {
    errorMessages = result.error.errors;
    Astro.response.status = 400;
  } else {
    try {
      const key = await auth.useKey(
        'username',
        result.data.username.toLowerCase(),
        result.data.password
      );
      const session = await auth.createSession({
        userId: key.userId,
        attributes: {},
      });
      Astro.locals.auth.setSession(session);
      return Astro.redirect('/dashboard', 302);
    } catch (e) {
      if (
        e instanceof LuciaError &&
        (e.message === 'AUTH_INVALID_KEY_ID' ||
          e.message === 'AUTH_INVALID_PASSWORD')
      ) {
        errorMessage = 'Incorrect username or password';
        Astro.response.status = 400;
      } else {
        errorMessage = 'Internal Server Error';
        Astro.response.status = 500;
      }
    }
  }
}
const session = await Astro.locals.auth.validate();
if (session) return Astro.redirect('/dashboard', 302);
---
<Layout>
  <h1>Login</h1>
  <form method="post">
    <label for="username">Username</label>
    <input name="username" id="username" value={usernameInput} />
    <br />
    <label for="password">Password</label>
    <input type="password" name="password" id="password" />
    <br />
    {errorMessages.length > 0 &&
      errorMessages.map((error) => (
        <p>
          {error.path[0]}:{error.message}
        </p>
      ))}
    <p>{errorMessage}</p>
    <input type="submit" />
  </form>
  <a href="/signup">Sign UP</a>
</Layout>
作成したログインページ
作成したログインページ

login.astroページではauth.useKeyを利用してusenameとpasswordの検証を行い、データベース内の情報と一致したら紐づいているkeyデータを取り出します。keyデータにはuserIdが含まれているのでuserIdをauth.createSessionの引数に設定して新しいSessionデータを作成します。Sessionを作成後、middlewareから受け取ったAstro.locals.auth.setSession(AuthRequest.setSession)を利用してCookieの作成を行います。CookieにはSessionで作成したSessionIdが入ります。Cookieを作成後/dashboardにリダイレクトしています。

Dashboardページの設定

Dashboardページではログインが完了していないユーザがアクセスできないようにアクセス制限を行います。ログインが完了している場合はsessionに含まれているuserIdがブラウザ上に表示されるように設定しています。sessionが存在しない場合は/loginにリダイレクトされます。


---
import Layout from "../../layouts/Layout.astro"

const session = await Astro.locals.auth.validate();
if (!session) return Astro.redirect("/login", 302);
---
<Layout>
  <h1>Dashboard</h1>
  <div>{session.user.userId}</div>
  </form> -->
</Layout>

ログアウト機能の追加

DashboardページにLogoutボタンを追加します。ボタンをクリックするとAPIエンドポイントの/logoutにPOSTリクエストが送信されます。


---
import Layout from "../../layouts/Layout.astro"

const session = await Astro.locals.auth.validate();
if (!session) return Astro.redirect("/login", 302);
---
<Layout>
	<h1>Dashboard</h1>
	<div>{session.user.userId}</div>
	<form method="post" action="/logout">
	  <input type="submit" value="Logout" />
	</form>
</Layout>
ログアウトボタンとuserIdが表示されたDashboardページ
ログアウトボタンとuserIdが表示されたDashboardページ

APIエンドポイントを作成するためにpagesディレクトリにlogout.tsファイルを作成します。


import { auth } from '../lib/lucia';

import type { APIRoute } from 'astro';

export const POST: APIRoute = async (context) => {
  const session = await context.locals.auth.validate();
  if (!session) {
    return new Response('Unauthorized', {
      status: 401,
    });
  }
  await auth.invalidateSession(session.sessionId);
  context.locals.auth.setSession(null);
  return context.redirect('/login', 302);
};

POSTリクエストが送信されていたらSessionの検証を行い、Sessionが存在する場合はinvalidSessionでSessionテーブルからSession情報を削除してsetSessionの引数をnullにすることでCookiesを削除しています。

AstroでのLuciaを利用して認証機能を実装することができました。