本文書ではWebフレームワークのAstro上でTokenとCookiesを利用したシンプルな認証機能をライブラリを利用せず実装していきます。Astroを利用する場合はブログなどのStaticなサイトとして利用する場合が多いと思いますがCookiesを利用するためにはSSR(Server Side Rendering)モードに設定する必要があるのでSSRモードでの実装となります。設定を通してAstroでのCookiesやFormなどの設定方法の基礎を学ぶことができます。

ライブラリを利用した認証については下記のコードで公開しています。

認証の流れ

ログインを行うとTokenが作成され、Cookiesに保存されます。Cookiesに保存されたTokenの検証をページを移動する度に実行してページ毎にアクセス制限を設定します。ログイン済みかどうかに関わらずアクセスできるページ、ログイン済みの場合のみアクセスできるページ、ログイン済みでない場合のみアクセスできるページと3つに分けて設定を行います。Tokenには有効期限を設定し、有効期限が切れるとログアウトとなります。Tokenにはユーザ情報も含まれており、ログイン後はユーザ情報にアクセスすることが可能になります。

プロジェクトの作成

Astroプロジェクトを作成するために”npm crate astro@latest”コマンドを実行します。実行するとプロジェクト名を入力する必要がありますが自動でランダムな名前がつけられるのでそのままその名前を利用します。プロジェクト名の設定以外にも質問がありますがテンプレートの選択では”Empy”, Install Dependencies,TypeScript(Strict), Git Repositoryは”Y”を選択して進めます。


% npm create astro@latest
Need to install the following packages:
create-astro@4.6.0
Ok to proceed? (y) y

 astro   Launch sequence initiated.

   dir   Where should we create your new project?
         ./extra-equinox

  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?
         Yes
 ██████  Git initializing...

  next   Liftoff confirmed. Explore your project!

         Enter your project directory using cd ./extra-equinox 
         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:
│ ◠ ◡ ◠  Good luck out there, astronaut! 🚀

本文書ではプロジェクト名がextra-equinoxなのでプロジェクトの作成が完了したらプロジェクトディレクトリに移動します。


% cd extra-equinox

“npm run dev”コマンドで開発サーバを起動します。


% npm run dev

> extra-equinox@0.0.1 dev
> astro dev


 astro  v4.0.8 ready in 228 ms

┃ Local    http://localhost:4321/
┃ Network  use --host to expose

10:10:07 watching for file changes...

ポートは4321で起動するのでブラウザからhttp://localhost:4321/にアクセスするとEmptyテンプレートの初期ページが表示されます。

Emptyテンプレートの初期画面
Emptyテンプレートの初期画面

package.jsonファイルでインストールを行なったAstroのバージョンを確認しておきます。4.0.8であることが確認できます。

{
  "name": "extra-equinox",
  "type": "module",
  "version": "0.0.1",
  "scripts": {
    "dev": "astro dev",
    "start": "astro dev",
    "build": "astro check && astro build",
    "preview": "astro preview",
    "astro": "astro"
  },
  "dependencies": {
    "@astrojs/check": "^0.3.4",
    "astro": "^4.0.8",
    "typescript": "^5.3.3"
  }
}

Cookieの設定

本文書の認証ではCookieを利用するのでAstroでのCookieの設定方法について最初に確認しておきます。

Cookieの動作確認

Cookieの動作確認を行うためにアクセスするとcounterが増えるコードをpagesディレクトリのindex.astroファイルに記述します。


---
let counter = 0;

if (Astro.cookies.has("counter")) {
  const cookie = Astro.cookies.get("counter");
  if (cookie) counter = cookie.number() + 1;
}

Astro.cookies.set("counter", String(counter));
---

<html>
  <h1>Counter = {counter}</h1>
</html>

index.astroファイルを更新すると開発サーバを起動したターミナルにはWARNINGが表示されます。


10:42:12 [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.

メッセージからoutput modeがstaticではAstro.request.headersが利用できないのでastroの設定ファイルであるastro.config.mjsファイルでoutputの値をserverまたはhybridに設定する必要があります。

AstroはデフォルトではStatic Modeとして動作するためビルド時にページが作成されます。astro.config.mjsでoutputの値をserverまたはhybridにするとSSR(Server Side Rendering)として動作することができアクセスがあった時にページが作成されます。
fukidashi

ブラウザからアクセスするとエラーが表示されることはありませんが。ページをリロードしてもcounterの値が増えることはありません。ブラウザのデベロッパーツールのアプリケーションのCookiesを確認するとcounterという名前で作成されてはいます。

Counterの初期値とCookieの確認
Counterの初期値とCookieの確認

astro.config.mjsファイルのoutputの値を’server’に設定します。


import { defineConfig } from 'astro/config';

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

設定後ページにアクセスを行い、ページをリロードするとcounterの値が増えていきます。デベロッパーツールのApplicationタブで確認できるCookieのcounterの値も増えていることが確認できます。

Counterの増加の確認
Counterの増加の確認

outputの値を’hybrid’に変更しても同様に動作します。

AstroでCookieを利用するためにはastro.config.mjsでoutputの値を’server’または’hybrid’に変更する必要があることがわかりました。

HttpOnly属性の設定

デフォルトの設定ではHttpOnlyが設定されていないのでJavaScriptからCookieにアクセスすることができます。Astroではscriptタグを追加することでクライアント側で実行可能なJavaScriptのコードを記述することができるのでCookieにアクセスできるか確認します。


---
let counter = 0

if (Astro.cookies.has("counter")) {
  const cookie = Astro.cookies.get("counter");
  if(cookie) counter = cookie.number() + 1
}

Astro.cookies.set("counter",String(counter))
---
<html>
  <h1>Counter = {counter}</h1>
</html>

<script>
  console.log(document.cookie);
</script>

ブラウザのデベロッパーツールのコンソールにはCookieの値が表示されます。


counter=2

JavaScriptからのアクセスできないようにAstro.cookies.setのオプションのhttpOnlyの値をtrueに設定します。


Astro.cookies.set("counter", String(counter), {
  httpOnly: true,
});

設定後はブラウザのデベロッパーツールのコンソールにはCookiesの値が表示されなくなります。デベロッパーツールのApplicationタブでCookiesを確認するとHttpOnlyにチェックが入っていることも確認できます。

httpOnlyにチェックが入る
httpOnlyにチェックが入る

secure属性の設定

HTTPS通信の場合のみCookieが送信されるようにsecure属性を設定することができます。設定するとSecureにチェックが付きます。


Astro.cookies.set("counter",String(counter), {
  httpOnly: true,
  secure: true,
})
secureの確認
secureの確認
Chromeでは開発環境のlocalhostの場合はhttpの通信でもCookieが利用できます
fukidashi

有効期限

有効期限はデベロッパーツールのExpires/Max-Ageで確認することができます。デフォルトではSessionに設定されているのでブラウザを閉じるまでCookieが有効になります。ブラウザのタブを閉じただけ、ブラウザを完全に停止しない場合はSessionが有効のままです。

secureの確認
Expires/Max-Ageの確認

有効期限を設定したい場合にはMax-Age, Expiresを設定することで期限を設定することができます。1週間後に設定した場合は下記の通りです。


const maxAge = 60 * 60 * 24 * 7;

Astro.cookies.set("counter", String(counter), {
  httpOnly: true,
  secure: true,
  maxAge,
});

デベロッパーツールでも設定した値を確認することができます。

Max-ageの値の反映を確認
Max-ageの値の反映を確認

middlewareからのCookiesへのアクセス

各ページやエンドポイントにアクセスがあった場合にAstroのmiddleware機能を利用することでページの表示前に実行できる処理を追加することができます。middlewareからはCookiesにアクセスすることもできます。

middlewareを利用するためにsrcディレクトリ直下にmiddleware.tsファイルを作成して以下のコードを記述します。


import { defineMiddleware } from 'astro/middleware';

export const onRequest = defineMiddleware((context, next) => {
  console.log('counter', context.cookies.get('counter'));
  return next();
});

ブラウザからアクセスを行うと開発サーバを起動したターミナルにCookiesの情報が表示されます。表示されない場合は開発サーバを再起動してみてください。


counter AstroCookie { value: '5' }

middlewareからもCookiesにアクセスできることがわかりました。認証設定の中でもmiddlewareからCookiesのアクセスを行います。

Prismaの設定

ユーザ情報を保存するためにデータベースを利用します。データベースはSQLiteを利用しますがPrisma経由でアクセスを行います。

Prisma のインストール

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


 % npm install prisma -D

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データベースに関する設定は完了しているのでスキーマの設定を行います。ユーザ情報をデータベースに保存するのでUserモデルのみ作成します。emailとpasswordを利用した認証なのでemail, passwordが必須となります。


// 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        Int     @id @default(autoincrement())
  email     String  @unique
  name      String?
  password  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

テーブルの作成

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

http://localhost:5555で起動するのでブラウザからアクセスすると設定したスキーマからモデルが作成させていることが確認できます。

Userテーブルの表示
Userテーブルの表示

ページの作成

それぞれ役割の異なるindex.astro, signup.astro, login.astro, dashboard/index.astroの4つのページを作成します。

  • index.astroは”/”にアクセスすると表示され、ログイン済みかどうかに関わらずアクセスできるページ
  • signup.astroは”/signup”にアクセスすると表示され、ユーザの登録画面を表示するページ。ログインが完了していない場合のみ表示。
  • login.astroは”/login”にアクセスすると表示され、ユーザのログイン画面を表示するページ。ログインが完了していない場合のみ表示。
  • dashboard/index.astroは”/dashboard”にアクセスすると表示され、ログイン済みの場合のみ表示。

Layoutファイルの作成

ページ間の移動をリンクから行えるようにLayoutファイルの作成を行います。srcディレクトリ直下にlayoutsディレクトリを作成してLayout.astroファイルを作成します。


---
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="/dashboard">Dashboard</a></li>
        <li><a href="/signup">Signup</a></li>
        <li><a href="/login">Login</a></li>
      </ul>
    </nav>
    <slot />
  </body>
</html>

ページファイルの作成

Layoutファイルの作成が完了したらindex.astro, dashboard.astroファイルでLayoutファイルのimportを行います。


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

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

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

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

ページの上部にはリンクが表示されます。リンクをクリックするとHomeとDashboard間のみページを移動することができます。SignupとLoginページはまだ存在しないのでアクセスすると404 Not Foundページが表示されます。

Home Pageの表示
Home Pageの表示

認証の設定

Prisma用のファイル作成

Prismaを利用するたにlibディレクトリにprisma.tsファイルを作成して以下のコードを記述します。


import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient();

各種ライブラリのインストール

パスワードをデータベースに保存する際にハッシュ化を行うのでbcryptjsライブラリのインストールを行います。型も一緒にインストールします。


 % npm install bcryptjs
 % npm install @types/bcryptjs -D

Tokenの作成にはjsonwebtokenを利用するのでjsonwebtokenライブラリのインストールを行います。型も一緒にインストールします。


 % npm install jsonwebtoken
 % npm install @types/jsonwebtoken -D

必須ではありませんがフォームに入力したデータに対してバリデーションを行う際に利用するバリデーションライブラリのZodをインストールします。


 % npm install zod

本文書で利用するライブラリのインストールが完了したのでpackage.jsonファイルを確認しておきます。利用したバージョンは下記の通りです。


{
  "name": "extra-equinox",
  "type": "module",
  "version": "0.0.1",
  "scripts": {
    "dev": "astro dev",
    "start": "astro dev",
    "build": "astro check && astro build",
    "preview": "astro preview",
    "astro": "astro"
  },
  "dependencies": {
    "@astrojs/check": "^0.3.4",
    "@prisma/client": "^5.7.1",
    "astro": "^4.0.8",
    "bcryptjs": "^2.4.3",
    "jsonwebtoken": "^9.0.2",
    "typescript": "^5.3.3",
    "zod": "^3.22.4"
  },
  "devDependencies": {
    "@types/bcryptjs": "^2.4.6",
    "@types/jsonwebtoken": "^9.0.5",
    "prisma": "^5.7.1"
  }
}

サインアップ画面の作成

ユーザ登録を行うためのサインアップページの作成を行うためpagesディレクトリにsignup.astroファイルを作成します。

Astroでフォームを作成したことがない人もいるといるのでSignUpページについてはできるだけ詳細に説明を行なっていきます。AstroのフォームについてはドキュメントのBuild HTML forms in Astro pagesを参考にしています。

フォームの作成

作成したsignup.astroファイルにフォームを追加します。


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

<Layout>
  <h1>Sign up</h1>
  <form method="post">
    <div>
      <label for="name">Name</label>
      <input type="text" name="name" id="name" />
    </div>
    <div>
      <label for="email">Email</label>
      <input type="email" name="email" id="email" required />
    </div>
    <div>
      <label for="password">Password</label>
      <input type="password" name="password" id="password" required />
    </div>
    <button>Sign Up</button>
  </form>
  <a href="/login">Login</a>
</Layout>

ブラウザから/signupにアクセスすると設定した入力フォーム画面が表示されます。

SignUp画面の表示
SignUp画面の表示

Email, Passwordに文字列を入力して”Sign Up”ボタンを押しても画面には変化はありません。

input要素にはブラウザの機能であるrequiredやtypeを設定しているのでその要件に満たされない場合はエラーが表示されます。
fukidashi

フォームデータの取得

入力した文字列をサーバ側で取得できるように設定を行います。

Astro.request.methodの中にはリクエストのメソッドが入っています。通常のアクセスでは”GET”、フォームからはPOSTリクエストで送信を行うので”POST”が入っています。Astro.request.methodで分岐を行うことでリクエストのメソッドによって処理する内容を変えています。


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

if (Astro.request.method === "POST") {
  const formData = await Astro.request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  console.log(name, email, password);
}
---
//略

フォームに文字列を入力して”Sign Up”ボタンをクリックすると開発サーバを起動したターミナルには入力した文字列が表示されます。サーバ側でのフォームデータの取得方法が確認できました。


John john@example.com password

バリデーションの設定

フォームデータのバリデーションにはインストール済みのバリデーションライブラリのZodを利用します。

Zodではemail, name, passwordにどのような値が含まれるべきなのかをSchemaで定義してsafeParseメソッドでバリデーションを行います。safeParseの引数にはバリデーションを行うデータを指定します。


const result = z
  .object({
    email: z.string().email(),
    name: z.string(),
    password: z.string().min(8),
  })
  .safeParse({ email, name, password });

上記のコードではemailは文字列でEmailの形式であること、nameは必須ではないので文字列であること、passwordは文字列で8文字以上あることを条件にしています。

スキーマの定義を下記のように分けて記述することもできます。


const schema = z.object({
  email: z.string().email(),
  name: z.string(),
  password: z.string().min(8),
});

const result = schema.safeParse({ email, name, password });

バリデーションに成功した場合と失敗した場合にはどのような値が戻り値に含まれてるの確認しておきます。


if (Astro.request.method === "POST") {
  const formData = await Astro.request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const result = z
    .object({
      email: z.string().email(),
      name: z.string(),
      password: z.string().min(8),
    })
    .safeParse({ email, name, password });

  console.log("result", result);
}

成功した場合にはsuccessプロパティにtrue, dataプロパティにバリデーションを行った値が含まれます。


result {
  success: true,
  data: { email: 'john@example.com', name: 'John', password: 'password' }
}

失敗した場合にはsuccessプロパティにfalse, dataプロパティではなくerrorプロパティが戻されます。


result { success: false, error: [Getter] }

失敗した場合のerrorの中身を確認するためにsuccessプロパティを利用します。errorの中のissuesプロパティでエラーの内容を確認できます。


 if (!result.success) console.log(result.error.issues);

passwordの文字列が8文字以上ではない値を入れたの以下のような形をしたメッセージが表示されます。


[
  {
    code: 'too_small',
    minimum: 8,
    type: 'string',
    inclusive: true,
    exact: false,
    message: 'String must contain at least 8 character(s)',
    path: [ 'password' ]
  }
]

バリデーションに失敗した場合には変数issuesに保存してブラウザ上に表示されるように設定を行います。またバリデーションに失敗した場合はAstro.response.statusでステータスコード400(Bad Request)を戻すように設定します。設定しなければエラーが発生してもステータスコード200が戻されます。


---
import Layout from "../layouts/Layout.astro";
import z from "zod";

let issues: z.ZodIssue[] = [];

if (Astro.request.method === "POST") {
  const formData = await Astro.request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const result = z
    .object({
      email: z.string().email(),
      name: z.string(),
      password: z.string().min(8),
    })
    .safeParse({ email, name, password });

  if (!result.success) {
    issues = result.error.errors;
    Astro.response.status = 400;
  }
}
---

<Layout>
  <h1>Sign up</h1>
  <form method="post">
    <div>
      <label for="name">Name</label>
      <input type="text" name="name" id="name" />
    </div>
    <div>
      <label for="email">Email</label>
      <input type="email" name="email" id="email" required />
    </div>
    <div>
      <label for="password">Password</label>
      <input type="password" name="password" id="password" required />
    </div>
    {
      issues.length > 0 &&
        issues.map((issue) => (
          <p>
            {issue.path[0]}:{issue.message}
          </p>
        ))
    }
    <button>Sign Up</button>
  </form>
  <a href="/login">Login</a>
</Layout>

バリデーションに失敗するデータをフォームに入力した場合にはブラウザ上に下記のようにバリデーションのエラーが表示されます。

バリデーションエラーの表示
バリデーションエラーの表示

バリデーションエラーは表示されるようになりましたがName, Email, Passwordに入力した値がすべて消えています。入力した値がエラーが発生した場合にも保存できるように設定します。

入力した値の保持

nameInput, emailInput, passwordInputを定義して入力した値を定義した変数に入れてinput要素のvalueに指定します。


---
import Layout from "../layouts/Layout.astro";
import z from "zod";

let issues: z.ZodIssue[] = [];
let nameInput = "";
let emailInput = "";
let passwordInput = "";

if (Astro.request.method === "POST") {
  const formData = await Astro.request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  nameInput = name;
  emailInput = email;
  passwordInput = password;

  const result = z
    .object({
      email: z.string().email(),
      name: z.string(),
      password: z.string().min(8),
    })
    .safeParse({ email, name, password });

  if (!result.success) {
    issues = result.error.errors;
    Astro.response.status = 400;
  }
}
---

<Layout>
  <h1>Sign up</h1>
  <form method="post">
    <div>
      <label for="name">Name</label>
      <input type="text" name="name" id="name" value={nameInput} />
    </div>
    <div>
      <label for="email">Email</label>
      <input type="email" name="email" id="email" value={emailInput} required />
    </div>
    <div>
      <label for="password">Password</label>
      <input
        type="password"
        name="password"
        id="password"
        value={passwordInput}
        required
      />
    </div>
    {
      issues.length > 0 &&
        issues.map((issue) => (
          <p>
            {issue.path[0]}:{issue.message}
          </p>
        ))
    }
    <button>Sign Up</button>
  </form>
  <a href="/login">Login</a>
</Layout>

設定後は、バリデーションエラーが発生した後も入力したデータが保持できるようになりました。

入力した値の保持
入力した値の保持

ユーザの作成

バリデーションにパスした後にユーザの作成を行います。lib/prismaからimportしたprismaを利用してcreateメソッドでユーザの作成を行います。バリデーションのresultのdataにはバリデーションにパスしたデータが保存されるのでその値を利用しています。


---
import Layout from "../layouts/Layout.astro";
import z from "zod";
import { prisma } from "../lib/prisma";
//略
if (!result.success) {
  issues = result.error.errors;
  Astro.response.status = 400;
} else {
  await prisma.user.create({
    data: {
      name: result.data.name,
      email: result.data.email,
      password: result.data.password,
    },
  });
}

設定後、入力フォームにバリデーションにパスする値を入力して”Sign Up”ボタンをクリックします。入力値が保持されているので画面に変化がありませんがPrisma StudioでUserテーブルを確認するとユーザ情報が追加されています。

作成したユーザ情報の確認
作成したユーザ情報の確認

表示されたpasswordの列を見ると入力した値によって表示される内容は異なりますが入力したままの平文で入っていることがわかります。bcryptjsを利用してハッシュ化したパスワードが保存されるように設定します。


---
import Layout from "../layouts/Layout.astro";
import z from "zod";
import { prisma } from "../lib/prisma";
import bcrypt from "bcryptjs";
//略
const salt = bcrypt.genSaltSync(10);
const hashedPassword = bcrypt.hashSync(result.data.password, salt);
await prisma.user.create({
    data: {
    name: result.data.name,
    email: result.data.email,
    password: hashedPassword,
    },
});

bcryptjsによるハッシュ化の設定を行った後ユーザの登録を再度行います。先ほどとは異なるemailを利用して入力してください。

Userテーブルを確認するとパスワードがハッシュ化されていることがわかります。

パスワードのハッシュ化の確認
パスワードのハッシュ化の確認

データベースのエラー

ブラウザの画面には先ほど登録を行ったメールアドレスが設定されている場合は再度”Sign Up”ボタンをクリックしてください。フォームに何も入っていない場合は登録済みのメールアドレスを入れて”Sign Up”ボタンをクリックしてください。

エラー画面の表示
エラー画面の表示

Prismaの中でエラーがthrowされるため画面にはエラー画面が表示されます。原因はemailの”Unique constraint failed on the filds”です。emailはUnique keyを設定しているので同じメールアドレスを登録することはできません。

エラー画面を表示させるのではなくtry, catchを利用してバリデーションエラーのようにブラウザ上に表示させるように変更を行います。

エラーの内容をissuesに入れていましたが新たに変数errorsに変更してPrismaのエラーもブラウザ上に表示させるように設定を行っています。errorsに保存するエラーの内容の型ErroMessageも設定しています。この形でerrors変数の中にエラーを追加します。


---
import Layout from "../layouts/Layout.astro";
import z from "zod";
import { prisma } from "../lib/prisma";
import bcrypt from "bcryptjs";
import { Prisma } from "@prisma/client";

interface ErrorMessage {
  name: string | number;
  message: string;
}

let errors: ErrorMessage[] = [];
let nameInput = "";
let emailInput = "";
let passwordInput = "";

if (Astro.request.method === "POST") {
  const formData = await Astro.request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  nameInput = name;
  emailInput = email;
  passwordInput = password;

  const result = z
    .object({
      email: z.string().email(),
      name: z.string(),
      password: z.string().min(8),
    })
    .safeParse({ email, name, password });

  if (!result.success) {
    errors = result.error.errors.map((error) => {
      return {
        name: error.path[0],
        message: error.message,
      };
    });
    Astro.response.status = 400;
  } else {
    try {
      const salt = bcrypt.genSaltSync(10);
      const hashedPassword = bcrypt.hashSync(result.data.password, salt);
      await prisma.user.create({
        data: {
          name: result.data.name,
          email: result.data.email,
          password: hashedPassword,
        },
      });
    } catch (e) {
      if (e instanceof Prisma.PrismaClientKnownRequestError) {
        if (e.code === "P2002") {
          errors = [{ name: "email", message: "email already registered" }];
        }
        Astro.response.status = 400;
      } else {
        console.log(e);
        Astro.response.status = 500;
      }
    }
  }
}
---

<Layout>
  <h1>Sign up</h1>
  <form method="post">
    <div>
      <label for="name">Name</label>
      <input type="text" name="name" id="name" value={nameInput} />
    </div>
    <div>
      <label for="email">Email</label>
      <input type="email" name="email" id="email" value={emailInput} required />
    </div>
    <div>
      <label for="password">Password</label>
      <input
        type="password"
        name="password"
        id="password"
        value={passwordInput}
        required
      />
    </div>
    {
      errors.length > 0 &&
        errors.map((error) => (
          <p>
            {error.name}:{error.message}
          </p>
        ))
    }
    <button>Sign Up</button>
  </form>
  <a href="/login">Login</a>
</Layout>

設定後はバリデーションと同様にPrismaからthrowされたエラーもブラウザ上に表示されるようになりました。

データベースからのエラーの表示
データベースからのエラーの表示

Tokenの作成

ユーザの作成が完了したらTokenの作成を行います。Tokenの作成にはインストール済みのjsonwebtokenを利用します。

Tokenの作成にはシークレットキーを利用しますが設定は.envファイルで行います。expiresInで有効期限を1日に設定しています。Tokenにはユーザ情報を含めています。


const token = jwt.sign(
  {
    id: user.id,
    name: user.name,
    email: user.email,
  },
  import.meta.env.SECRET,
  { expiresIn: "1d" },
);

.envファイルに環境変数のSECRETを追加します。


DATABASE_URL="file:./dev.db"
SECRET=secret

設定したSECRETの型設定はenv.d.tsファイルで行うことができます。この設定を追加することでimport.meta.env.SECRETの型がanyから指定したstringに変わります。


/// >reference types="astro/client" />

interface ImportMetaEnv {
  readonly SECRET: string;
}
interface ImportMeta {
  readonly env: ImportMetaEnv;
}

作成したTokenを表示できるようにconsole.logを設定します。


---
import Layout from "../layouts/Layout.astro";
import z from "zod";
import { prisma } from "../lib/prisma";
import bcrypt from "bcryptjs";
import { Prisma } from "@prisma/client";
import jwt from "jsonwebtoken";

interface ErrorMessage {
  name: string | number;
  message: string;
}

let errors: ErrorMessage[] = [];
let nameInput = "";
let emailInput = "";
let passwordInput = "";

if (Astro.request.method === "POST") {
  const formData = await Astro.request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  nameInput = name;
  emailInput = email;
  passwordInput = password;

  const result = z
    .object({
      email: z.string().email(),
      name: z.string(),
      password: z.string().min(8),
    })
    .safeParse({ email, name, password });

  if (!result.success) {
    errors = result.error.errors.map((error) => {
      return {
        name: error.path[0],
        message: error.message,
      };
    });
    Astro.response.status = 400;
  } else {
    try {
      const salt = bcrypt.genSaltSync(10);
      const hashedPassword = bcrypt.hashSync(result.data.password, salt);
      const user = await prisma.user.create({
        data: {
          name: result.data.name,
          email: result.data.email,
          password: hashedPassword,
        },
      });
      const token = jwt.sign(
        {
          id: user.id,
          name: user.name,
          email: user.email,
        },
        import.meta.env.SECRET,
        { expiresIn: "1d" },
      );
      console.log(token);
    } catch (e) {
      if (e instanceof Prisma.PrismaClientKnownRequestError) {
        if (e.code === "P2002") {
          errors = [{ name: "email", message: "email already registered" }];
        }
        Astro.response.status = 400;
      } else {
        console.log(e);
        Astro.response.status = 500;
      }
    }
  }
}
//流役

Prisma Studioからユーザを削除するかこれまで登録した別のEmailアドレスを利用してユーザ登録を行ってください。

ユーザの登録が完了すると作成したTokenが開発サーバを起動したターミナルに表示されます。


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywibmFtZSI6IkpvaG4iLCJlbWFpbCI6ImpvaG5AZXhhbXBsZS5jb20iLCJpYXQiOjE3MDM4MTgzNDIsImV4cCI6MTcwMzkwNDc0Mn0.K4NTb0OJVwUruYES5fvFa4FdSRToZsyfuMvCRKRKfoU

Tokenに保存したユーザの情報はTokenの値さえわかれば誰でも中を確認することができるので重要な情報は含めないようにします。https://jwt.io/のサイトでTokenに含まれている情報を確認することができます。 Tokenを作成する際のアルゴリズムもデフォルトの”HS256″であることもわかります。

Tokenの中身の確認
Tokenの中身の確認

Cookiesの作成

Tokenの作成が完了したらCookiesの作成を行います。tokenという名前で作成したTokenを値に持つCookiesを作成します。有効期限はTokenと同じ1日としています。コードはTokenの作成の後に追加します。


Astro.cookies.set("token", token, {
  httpOnly: true,
  secure: true,
  maxAge: 60 * 60 * 24,
});

入力フォームから新しいユーザを登録してください。登録後にブラウザのデベロッパーツールのApplicationを確認するとtokenという名前でTokenの値を持つCookiesを確認することができます。

保存されたCookiesの確認
保存されたCookiesの確認

Cookies作成後は/dashboardにリダイレクトする設定を行っておきます。設定後はユーザ登録が完了すると/dashboardへリダイレクトされます。


Astro.cookies.set("token", token, {
  httpOnly: true,
  secure: true,
  maxAge: 60 * 60 * 24,
});

return Astro.redirect("/dashboard", 302);

SignUpページの作成が完了しました。ここまでに記述したコードは下記の通りです。


---
import Layout from "../layouts/Layout.astro";
import z from "zod";
import { prisma } from "../lib/prisma";
import bcrypt from "bcryptjs";
import { Prisma } from "@prisma/client";
import jwt from "jsonwebtoken";

interface ErrorMessage {
  name: string | number;
  message: string;
}

let errors: ErrorMessage[] = [];
let nameInput = "";
let emailInput = "";
let passwordInput = "";

if (Astro.request.method === "POST") {
  const formData = await Astro.request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  nameInput = name;
  emailInput = email;
  passwordInput = password;

  const result = z
    .object({
      email: z.string().email(),
      name: z.string(),
      password: z.string().min(8),
    })
    .safeParse({ email, name, password });

  if (!result.success) {
    errors = result.error.errors.map((error) => {
      return {
        name: error.path[0],
        message: error.message,
      };
    });
    Astro.response.status = 400;
  } else {
    try {
      const salt = bcrypt.genSaltSync(10);
      const hashedPassword = bcrypt.hashSync(result.data.password, salt);
      const user = await prisma.user.create({
        data: {
          name: result.data.name,
          email: result.data.email,
          password: hashedPassword,
        },
      });
      const token = jwt.sign(
        {
          id: user.id,
          name: user.name,
          email: user.email,
        },
        import.meta.env.SECRET,
        { expiresIn: "1d" },
      );

      Astro.cookies.set("token", token, {
        httpOnly: true,
        secure: true,
        maxAge: 60 * 60 * 24,
      });

      return Astro.redirect("/dashboard", 302);
    } catch (e) {
      if (e instanceof Prisma.PrismaClientKnownRequestError) {
        if (e.code === "P2002") {
          errors = [{ name: "email", message: "email already registered" }];
        }
        Astro.response.status = 400;
      } else {
        console.log(e);
        Astro.response.status = 500;
      }
    }
  }
}
---

<Layout>
  <h1>Sign up</h1>
  <form method="post">
    <div>
      <label for="name">Name</label>
      <input type="text" name="name" id="name" value={nameInput} />
    </div>
    <div>
      <label for="email">Email</label>
      <input type="email" name="email" id="email" value={emailInput} required />
    </div>
    <div>
      <label for="password">Password</label>
      <input
        type="password"
        name="password"
        id="password"
        value={passwordInput}
        required
      />
    </div>
    {
      errors.length > 0 &&
        errors.map((error) => (
          <p>
            {error.name}:{error.message}
          </p>
        ))
    }
    <button>Sign Up</button>
  </form>
  <a href="/login">Login</a>
</Layout>

ログイン画面の作成

ログイン画面の作成を行いますが内容はほとんどサインアップ画面と変わりません。ユーザを作成する代わりにフォームから送信するemailを使ってデータベースを検索してbcryptjsを利用してユーザパスワードの検証を行います。その後のTokenの作成、Cookiesの作成処理は同じです。

pagesディレクトリにlogin.astroファイルを作成して以下のコードを記述します。


---
import Layout from "../layouts/Layout.astro";
import z from "zod";
import { prisma } from "../lib/prisma";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";

interface ErrorMessage {
  name: string | number;
  message: string;
}

let errors: ErrorMessage[] = [];
let emailInput = "";
let passwordInput = "";

if (Astro.request.method === "POST") {
  const formData = await Astro.request.formData();
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  emailInput = email;
  passwordInput = password;

  const result = z
    .object({
      email: z.string().email(),
      password: z.string().min(8),
    })
    .safeParse({ email, password });

  if (!result.success) {
    errors = result.error.errors.map((error) => {
      return {
        name: error.path[0],
        message: error.message,
      };
    });
    Astro.response.status = 400;
  } else {
    try {
      const user = await prisma.user.findUnique({
        where: {
          email: result.data.email,
        },
      });

      if (!user) {
        errors = [{ name: "email", message: "Invalid credentials" }];
        Astro.response.status = 400;
      } else {
        const valid = bcrypt.compareSync(password, user.password);

        if (!valid) {
          errors = [{ name: "password", message: "Invalid credentials" }];
          Astro.response.status = 400;
        } else {
          const token = jwt.sign(
            {
              id: user.id,
              name: user.name,
              email: user.email,
            },
            import.meta.env.SECRET,
            { expiresIn: "1d" },
          );

          Astro.cookies.set("token", token, {
            httpOnly: true,
            secure: true,
            maxAge: 60 * 60 * 24,
          });

          return Astro.redirect("/dashboard", 302);
        }
      }
    } catch (e) {
      console.log(e);
      Astro.response.status = 500;
    }
  }
}
---

<Layout>
  <h1>Login</h1>
  <form method="post">
    <div>
      <label for="email">Email</label>
      <input type="email" name="email" id="email" value={emailInput} required />
    </div>
    <div>
      <label for="password">Password</label>
      <input
        type="password"
        name="password"
        id="password"
        value={passwordInput}
        required
      />
    </div>
    {
      errors.length > 0 &&
        errors.map((error) => (
          <p>
            {error.name}:{error.message}
          </p>
        ))
    }
    <button>Login</button>
  </form>
  <a href="/signup">SigUp</a>
</Layout>

login.astroは作成したコードを見ながら内容を確認していきます。login.astroではemailとpasswordを利用してログインを行うためnameは利用しません。

バリデーションをパスした後はemailを利用してPrisma経由でデータベースにアクセスしてユーザ情報を取得します。


const user = await prisma.user.findUnique({
  where: {
    email: result.data.email,
  },
});

ユーザが見つからない場合にはnullが戻されるのでif文で分岐をしています。nullの場合はエラーを設定します。


if (!user) {
  errors = [{ name: "email", message: "Invalid credentials" }];
  Astro.response.status = 400;
} else {
//略

ユーザが見つかった場合はbcryptjsを利用して送信されてきたpasswordがデータベースに保存されているパスワードと一致するかチェックしています。ハッシュ化しているためbcrypt.compareSyncを利用しています。


const valid = bcrypt.compareSync(password, user.password);

validにはbcrypt.compareSyncの戻り値が入りますがboolean値のtrueかfalseです。validを利用して分岐を行い、falseの場合はエラーを設定します。


if (!valid) {
 errors = [{ name: "password", message: "Invalid credentials" }];
 Astro.response.status = 400;
} else {

trueの場合はToken、Cookiesを作成して/dashboardにリダイレクトします。


const token = jwt.sign(
  {
    id: user.id,
    name: user.name,
    email: user.email,
  },
  import.meta.env.SECRET,
  { expiresIn: "1d" },
);

Astro.cookies.set("token", token, {
  httpOnly: true,
  secure: true,
  maxAge: 60 * 60 * 24,
});

return Astro.redirect("/dashboard", 302);

アクセス制限の設定

すべてのページの作成が完了したので認証を利用してページ毎に異なるアクセスの制限を行なっていきます。

middlewareの設定

middlware.tsファイルが存在しない場合はsrcの下に作成してください。middlewareを設定することでページを移動する前にCookiesに保存されているTokenが有効かどうかチェックを行います。

“/”ページのようにTokenが有効化どうかは関係ないページについてはallowedPathsの配列に設定を行なっていきます。allowedPathsに含まれるURLはCookiesのチェックは行わずそのまま処理が完了して次の処理に進みます。


import { defineMiddleware } from "astro/middleware";
import jwt from "jsonwebtoken";

const allowedPaths = ["/"];

export const onRequest = defineMiddleware((context, next) => {
  if (allowedPaths.includes(context.url.pathname)) return next();

  context.locals.user = null;

  const token = context.cookies.get("token");

  if (token?.value) {
    jwt.verify(token.value, import.meta.env.SECRET, (err, decoded: any) => {
      if (!err) context.locals.user = decoded;
    });
  }
  return next();
});

allowedPathに含まれないページにアクセスした場合は、Cookiesに含まれているTokenを取り出し、Tokenが存在する場合に検証が行われます。検証後のデータはcontext.localsにuserとして設定していますがcontext.localsを利用することでページファイルなどアプリケーション内でデータ共有することができます。Cookiesが存在しない場合にはuserにはnullが入ります。

context.localsに設定したuserについてはenv.d.tsファイルで型の設定を行う必要があります。


/// <reference types="astro/client" />
interface ImportMetaEnv {
  readonly SECRET: string;
}
interface ImportMeta {
  readonly env: ImportMetaEnv;
}
declare namespace App {
  interface Locals {
    user: null | {
      id: number;
      name?: string;
      email: string;
    };
  }
}

dashboardファイルでの設定

dashboardファイルについては認証済みの場合のみアクセスが可能なのでmiddlewareから渡されたcontext.locals.userを利用して制限を行います。

userの値がnullの場合は認証済みではないので/loginにリダイレクトをさせます。userにデータが含まれている場合はブラウザ上にTokenに含まれていたユーザ情報を表示させるように設定を行っています。


---
import Layout from "../../layouts/Layout.astro";
const user = await Astro.locals.user;
if (!user) return Astro.redirect("/login", 302);
---

<Layout>
  <h1>Dashboard</h1>
  <p>{user.name}</p>
  <p>{user.email}</p>
</Layout>

ログインした状態でアクセスするとNameとEmailが表示されます。

ログイン状態でのDashboardへのアクセス
ログイン状態でのDashboardへのアクセス

ログインしていない状態でアクセスすると/loginにリダイレクトされます。ログアウト機能をまだ実装していないのでブラウザのデベロッパーツールからCookiesを削除するとログインしていない状態に変更することができます。

ログアウト機能の追加

ログアウトができるようにlogout機能の追加を行います。APIエンドポイント/logoutを追加してPOSTリクエストを受信したらCookiesの削除を行い、/loginページにリダイレクトさせます。pagesディレクトリの下にlogout.tsファイルを作成します。


import type { APIRoute } from "astro";

export const POST: APIRoute = async (context) => {
  context.cookies.delete("token");
  return context.redirect("/login", 302);
};

ログアウトはdashboardページから行えるようにlogoutボタンを追加します。


---
import Layout from "../../layouts/Layout.astro";
const user = Astro.locals.user;
if (!user) return Astro.redirect("/login", 302);
---

<Layout>
  <h1>Dashboard</h1>
  <p>Name: {user.name}</p>
  <p>Email: {user.email}</p>
  <form method="post" action="/logout">
    <input type="submit" value="Logout" />
  </form>
</Layout>

Dashboardページにアクセスできるのはログイン時のみなのでDashboardページに表示させている”logout”ボタンをクリックしてください。/loginページにリダイレクトされデベロッパーツールを確認するとCookiesが削除されていることが確認できます。その後はDashboardページにアクセスすることはできません。

ログイン、サインアップページでの設定

ログイン、サインアップページはログインができていない場合にはアクセスできる必要がありますがログイン完了後はアクセスする必要はありません。ログインが完了している場合にはDashboardページにリダイレクトされるように設定を行います。


const user = Astro.locals.user;
if (user) return Astro.redirect("/dashboard", 302);
---

ログイン後に/loginにアクセスすると/dashboardへリダイレクトされます。

signup.astroファイルで同様の設定を行います。ログイン後に/signupにアクセスすると/dashboardへリダイレクトされます。


const user = Astro.locals.user;
if (user) return Astro.redirect("/dashboard", 302);
---

シンプルな機能ですが、Cookiesを利用して認証機能を実装することができました。

ビルドの実行

astro.config.mjsファイルのoutputの値が’server’または’hybrid’の場合はAdapterのインストールが必要になることがわかります。


 % npm run build
//略
[NoAdapterInstalled] Cannot use `output: 'server'` or `output: 'hybrid'` without an adapter. Please install and configure the appropriate server adapter for your final deployment.
  Hint:
    See https://docs.astro.build/en/guides/server-side-rendering/ for more information.
  Error reference:
    https://docs.astro.build/en/reference/errors/no-adapter-installed/

ビルドを行うためにnodeのadapterをインストールします。DeployするサービスによってインストールするAdapterは変わります。


 % npx astro add node  
✔ Resolving packages...
14:12:08 
  Astro will run the following command:
  If you skip this step, you can always run it yourself later

 ╭───────────────────────────────────╮
 │ npm install @astrojs/node@^7.0.3  │
 ╰───────────────────────────────────╯

✔ Continue? … yes
✔ Installing dependencies...
14:12:14 
  Astro will make the following changes to your config file:

 ╭ astro.config.mjs ─────────────────────────────╮
 │ import { defineConfig } from "astro/config";  │
 │                                               │
 │ import node from "@astrojs/node";             │
 │                                               │
 │ // https://astro.build/config                 │
 │ export default defineConfig({                 │
 │   output: "server",                           │
 │   adapter: node({                             │
 │     mode: "standalone"                        │
 │   })                                          │
 │ });                                           │
 ╰───────────────────────────────────────────────╯

14:12:14   For complete deployment options, visit
  https://docs.astro.build/en/guides/deploy/

✔ Continue? … yes
14:12:22   
   success  Added the following integration to your project:
  - @astrojs/node

nodeのadpterを追加後に再度ビルドを実行するとビルドが完了します。