AdonisJS v6入門 認証編

Aug 12, 2024

はじめに

TypeScriptファーストなフルスタックWebアプリケーションであるAdonisJSに入門してみました。 まずは前回記事で実装したTODOアプリの続きで今回はこれに認証周りを追加実装していきます。

前回の記事

サポートされていない機能

注意する点としてAdonisJSのAuthパッケージ(@adonisjs/auth)はあくまで認証にフォーカスしているそうで、以下機能は対応していないとのことです。

  • ユーザー登録(サインアップフォーム、メール認証、アカウントアクティベーション)
  • アカウント管理(パスワードリセット、メール更新)
  • ロールの割り当て(@adonisjs/bouncer で権限チェックは実装できる)

今回はログイン機能まで実装してみます。

Model/Migration

Starter KitではすでにUserテーブルのマイグレーションやモデルファイルが作成されており、マイグレーションも実行済みなので省略可能です。

Starter Kitでは以下のようなファイルが生成されていました。

[マイグレーションファイル]

// database/migrations/xxxxxx_create_users_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'users'

  async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id').notNullable()
      table.string('full_name').nullable()
      table.string('email', 254).notNullable().unique()
      table.string('password').notNullable()

      table.timestamp('created_at').notNullable()
      table.timestamp('updated_at').nullable()
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}

[モデルファイル]

// app/models/user.ts
import { DateTime } from 'luxon'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'

const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
  uids: ['email'],
  passwordColumnName: 'password',
})

export default class User extends compose(BaseModel, AuthFinder) {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare fullName: string | null

  @column()
  declare email: string

  @column({ serializeAs: null })
  declare password: string

  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime | null
}

フルネーム、メールアドレス、パスワードだけと割とシンプルでした。 マイグレーションを実行していない方は先に実行してUserテーブルを作成しておいてください。

Guard

認証機能を実装するためにはガードの設定が必要になります。 ガードにはいくつかタイプがあり、

  • Session
  • Access Tokens
  • Basic auth
  • Custom

の4つが使えます。 今回はSessionを選択しました。

以下がガードの設定ファイルになります。

// config/auth.ts:
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'

const authConfig = defineConfig({
  default: 'web',
  guards: {
    web: sessionGuard({
      useRememberMeTokens: false,
      provider: sessionUserProvider({
        model: () => import('#models/user'),
      }),
    }),
  },
})

Starter Kitではすでに設定済みでした。 今回はすでにインストールされていましたが、@adonisjs/auth@adonisjs/sessionのパッケージが必要になるようです。

Controller

ここから自分で実装していくことになります。 まずはログイン処理を行うSessionコントローラを作成します。

node ace make:controller users/session
// app/controllers/users/session_controller.ts
import User from '#models/user'
import { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
	async create({ inertia }: HttpContext) {
    return inertia.render('users/login')
  }

  async store({ request, auth, response }: HttpContext) {
    /**
     * Step 1: Get credentials from the request body
     */
    const { email, password } = request.only(['email', 'password'])

    /**
     * Step 2: Verify credentials
     */
    const user = await User.verifyCredentials(email, password)

    /**
     * Step 3: Login user
     */
    await auth.use('web').login(user)

    /**
     * Step 4: Send them to a protected route
     */
    response.redirect('/todos') // <- 変更
  }

  async destroy({ auth, response }: HttpContext) {
    await auth.use('web').logout()
    response.redirect('/login')
  }
}

基本的にはドキュメントにあったコードのままで良いですが、1点ログイン後のリダイレクト先はTodo一覧にリダイレクトするよう変更しました。

Middleware

次に、Sessionコントローラのルーティング設定を行います。また、ミドルウェアを使用して、認証されたユーザーだけがTodoリソースの画面にアクセスできるように設定します。

まずはミドルウェアを用意しなければならないのですが、こちらもすでにStarter Kitでは作成されており、先ほどのSessionガードを使ってリクエストを通すか判定しています。

// app/middleware/auth_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'

/**
 * Auth middleware is used authenticate HTTP requests and deny
 * access to unauthenticated users.
 */
export default class AuthMiddleware {
  /**
   * The URL to redirect to, when authentication fails
   */
  redirectTo = '/login'

  async handle(
    ctx: HttpContext,
    next: NextFn,
    options: {
      guards?: (keyof Authenticators)[]
    } = {}
  ) {
    await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })
    return next()
  }
}

次にミドルウェアの登録です。

// start/kernel.ts
import router from '@adonisjs/core/services/router'

export const middleware = router.named({
  guest: () => import('#middleware/guest_middleware'),
  auth: () => import('#middleware/auth_middleware'),
})

こちらもStarter Kitではすでに設定済みでした。

最後はルーティングでミドルウェアを設定します。

// start/routes.ts
import router from '@adonisjs/core/services/router'
import { middleware } from './kernel.js'

const TodosController = () => import('#controllers/todos_controller')

router.resource('todos', TodosController).use('*', middleware.auth())

const UsersSessionController = () => import('#controllers/users/session_controller')

router.get('login', [UsersSessionController, 'create'])
router.post('login', [UsersSessionController, 'store'])
router.post('logout', [UsersSessionController, 'destroy']).use(middleware.auth())

Todoリソースとログアウトに認証処理を設定しました。

CSRF

前回の記事では触れませんでしたが、Starter KitではCSRF保護が有効になっています。

公式ドキュメント によるとInertiaでは追加設定は特に必要ないとのことでした。

XSRF-TOKENクッキーが毎回クライアント側にセットされて、リクエスト時に返すという仕組みのようです。

Seeder

動作確認のためのユーザーデータを準備します。

やり方は基本前回と同じなため、特に説明は不要です。

node ace make:seeder User
// database/seeders/user_seeder.ts
import User from '#models/user'
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
  async run() {
    await User.createMany([
      {
        email: 'john.doe@example.com',
        fullName: 'John Doe',
        password: 'password',
      },
    ])
  }
}

ここでpasswordはハッシュ化して保存しないといけないと思ったんですが、どうやらUserモデルでAuthFinder mixinを適用しているため、DB保存前にハッシュ化される処理が追加されているようです。

あとはシードを流します。

今回はuser_seeder.tsのみ実行したいので以下のようなコマンドになります。

node ace db:seed --files "./database/seeders/user_seeder.ts"

View

ログイン画面

次はログイン画面を作ります。

// inertia/pages/users/login.tsx
import { router } from '@inertiajs/react'
import { useState } from 'react'

export default function Login() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSubmit = (event: React.FormEvent) => {
    event.preventDefault()
    router.post(`/login`, {
      password,
      email,
    })
  }
  return (
    <div style={{ padding: '6rem' }}>
      <h2>Login</h2>
      <form method="post" action="/login" onSubmit={handleSubmit}>
        <div>
          <label>Email</label>
          <input
            type="email"
            name="email"
            onChange={(e) => setEmail(e.target.value)}
            value={email}
          />
        </div>
        <div>
          <label>Password</label>
          <input
            type="password"
            name="password"
            onChange={(e) => setPassword(e.target.value)}
            value={password}
          />
        </div>
        <button type="submit">Login</button>
      </form>
    </div>
  )
}

ユーザー情報表示/ログアウト

次にログイン後のユーザー情報取得表示とログアウトボタンを実装します。

Todoコントローラを編集して、ログイン済みユーザーの情報を返すように変更します。

export default class TodosController {
  /**
   * Display a list of resource
   */
  async index({ inertia, auth }: HttpContext) {
    const todos = await Todo.all()
    const user = auth.getUserOrFail()
    return inertia.render('todos/index', {
      todos: todos.map((t) => new TodoDto(t).toJson()),
      user: { fullName: user.fullName },
    })
  }

}

auth.userでもユーザー情報は取れますが、getUserOrFailメソッドを使うことで万が一未認証ユーザーがこのindexアクションに到達できた場合でも401のレスポンスが返されるようになります。

そして、これだけではInertia側のpropsで受け取る際、型に問題が出てきます。

今回ユーザー情報はHttpContext型のauthから取得しています。 これは@adonisjs/auth/initialize_auth_middlewareをインポートしている場合のみ、この型が使えます。

Inertia側ではこれをインポートしていないため、idもfullNameもany型になってしまいます。

これを解決するためにはreferenceディレクティブを使用して、Inertia側で型を認識させます。 Inertiaのエントリファイルに以下を追記しました。

// inertia/app/app.tsx
/// <reference path="../../adonisrc.ts" />
/// <reference path="../../config/inertia.ts" />
// ↓追加
/// <reference path="../../config/auth.ts" />

これでバックエンド側からユーザー情報が返ってくるようになったので、Todo一覧でユーザー情報を表示させます。 ログインボタンもここに配置しましょう。

// inertia/pages/todos/index.tsx
import { Link } from '@inertiajs/react'
import TodosController from '#controllers/todos_controller'
import { InferPageProps } from '@adonisjs/inertia/types'

export default function Index(props: InferPageProps<TodosController, 'index'>) {
  const { todos, user } = props
  return (
    <div style={{ padding: '6rem' }}>
      {/* ↓追加 */}
      <Link href="/logout" method="post" as="button">
        Logout
      </Link>
      {/* ↓変更 */}
      <h2>Hi, {user?.fullName}!</h2>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <Link href={`/todos/${todo.id}`}>{todo.title}</Link> | {todo.description} |{' '}
            {todo.isCompleted ? 'Completed' : 'Not completed'}
          </li>
        ))}
      </ul>
      <Link href="/todos/create">Create a new todo</Link>
    </div>
  )
}

動作確認

画面ができたので、あとは動かしてみます。

開発サーバーを起動して、/login へアクセスして、メールとパスワードを入力してみましょう。

ログイン成功後は以下のような画面が出てくれば成功です。

Login

ログイン後は以下画面になります。

Logged-in todos

ログアウトボタンも押してみて、ログアウトしてログイン画面へ遷移されれば成功です。

ログアウト後は /todos へアクセスしても /login へリダイレクトされるはずです。

所感

今回はログイン機能の実装をやってみましたが、特につまずくことなく簡単に実装できました。 サインアップ、メール認証、パスワードリセットなどを実装する場合もう少し手を加える必要がありそうですが、ここまでの所感としては以下になります。

  • 認証周りの機能やドキュメントが少し不足と感じた。サインアップやパスワードリセットがあっても良いかも!?
  • セッション認証がInertia(SPA)でも設定不要で簡単に導入できたのが良い
  • パスワードのハッシュ化、CSRF対策など特に意識せずとも対応できたのも良い

ソースコード

ソースコードを公開していますので、気になる方は参考にしてみてください。