MENU

問い合わせ


    【Honoで作る社内ヘルプデスク 第3回】ログインと権限を付ける:パスワードの保存・Cookie・「社員/担当者/管理者」の線引き

    前回の第2回では、チケットの一覧と状態遷移を作りました。ただ、あの段階のAPIは誰でも状態を変えられる状態でした。今回は、ログインと権限(社員・担当者・管理者)を付けて、「誰が何をしてよいか」をコードで決めます。

    (2026年10月29日 追記:記事どおりに再現できるよう、必要なコードと手順を追加しました)

    前回の記事はこちらです。

    「社内の問い合わせ管理ツールを作ってもらうけど、他の人の申請が見えたり、勝手に完了にされたりしないか心配…ログインって、結局どこまで作ればいいの?」

    先に結論です。社内ツールのログインは、「パスワードを元に戻せない形で保存する」「ログイン済みの印を、書き換えられない形で持たせる」「役割ごとの許可を1か所で決める」の3点を押さえれば、小さく堅く作れます。この3つを外部サービスなしで実装し、テストで確かめるところまでを、この回で扱います。

    この回で作るのは次の4つです。

    1. 利用者テーブルと、パスワードのハッシュ化(Web標準の PBKDF2)
    2. ログイン・ログアウト・本人確認API(署名つきの印を Cookie に入れる)
    3. 役割(社員/担当者/管理者)による許可の制御
    4. 利用者の登録API(管理者のみ)と、最初の管理者を作る手順

    ※ 今回のコードは、Node.js 22系・Hono 4・Zod 4・Wrangler 4・TypeScript 6 で、型チェックとテスト(Cloudflare Workers の実行環境 workerd 上で実行)が通ることを確認しています。あわせて wrangler dev でローカルのD1に対してログインを実際に試しました。Cloudflare の実環境へのデプロイは、この回でも未確認です。

    目次

    仕組み:ログインで決めること

    認証と認可は別のもの

    ログインまわりの話は、2つに分けると整理しやすくなります。

    用語意味この回で作るもの
    認証「あなたは誰か」を確かめるメールアドレス+パスワードでのログイン
    認可「あなたに許されているのは何か」を決める社員/担当者/管理者ごとの許可

    この2つが混ざると、「ログインできている=何でもできる」という穴が開きやすくなります。コードも別々の部品にしておきます。

    3つの役割と許可の表

    今回は次のように決めます。発注側で最初に決めておくと、手戻りが減る部分です。

    操作社員担当者管理者
    チケットを申請する○○○
    チケットを見る自分の申請だけ全件全件
    状態を変える×○○
    利用者を登録・一覧する××○

    パスワードは「保存しない」

    パスワードそのものをDBに入れてはいけません。DBが流出したとき、全員のパスワードがそのまま漏れるからです。代わりに、ハッシュ(=元の文字列に戻せない形に変換した値)を保存します。ログインのときは、入力されたパスワードを同じ方法で変換し、保存してある値と一致するかだけを見ます。

    ハッシュ化の方式には、わざと時間がかかる計算を繰り返すもの(PBKDF2、bcrypt、Argon2 など)を選びます。総当たりで試す攻撃を遅くするためです。今回は、Workers に標準で入っている Web Crypto の PBKDF2 を使います。外部ライブラリが要らず、Workers で確実に動くためです。

    📰 出典:Cloudflare Docs「Web Crypto」

    Cloudflare のドキュメントでは、Workers の PBKDF2 は反復回数に上限(100,000回)があることが示されています。今回はその上限いっぱいの 100,000 回を使います。より強い方式(Argon2 など)を使いたい場合は、Workers 以外の構成や外部の認証サービスを検討する、というのが現実的な分かれ道です。

    ログイン済みの印:署名つきの Cookie

    ログインに成功したら、「この人はログイン済み」という印をブラウザに持たせます。今回は、Hono に入っている JWT(=内容に署名をつけた文字列。書き換えると署名が合わなくなり、サーバー側で見破れる)を使い、これを Cookie に入れます。

    📰 出典:Hono Docs「JWT Authentication Helper」

    Cookie には次の3つの設定を付けます。

    設定役割
    HttpOnlyブラウザ上のJavaScriptから読めなくする(画面に紛れ込んだ不正なスクリプトに盗まれにくくなる)
    SecureHTTPS の通信でだけ送る
    SameSite=Lax他のサイトから送られるリクエストには、原則として付けない(なりすましの操作を防ぐ)

    実装

    第2回までのプロジェクトに続けて作業します。新しく作るファイルは全文を、書き換えるファイルは変更箇所(差分)を載せています。新しい部品のインストールはありません(JWT と Cookie の部品は Hono に入っています)。

    1. 利用者テーブル

    migrations/0003_create_users.sql を次の内容で作ります。

    -- migrations/0003_create_users.sql
    -- 第3回: ログインする人。パスワードそのものは保存せず、ハッシュ(元に戻せない値)だけを持つ
    CREATE TABLE IF NOT EXISTS users (
      id            INTEGER PRIMARY KEY AUTOINCREMENT,
      email         TEXT NOT NULL UNIQUE,
      name          TEXT NOT NULL,
      role          TEXT NOT NULL CHECK (role IN ('employee', 'agent', 'admin')),
      password_hash TEXT NOT NULL,
      active        INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
      created_at    TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
    );

    email に UNIQUE を付けて重複登録を防ぎ、role には CHECK で3種類以外が入らないようにしています。active は、退職などで削除せず、使えなくするための列です(過去のチケットの記録を残すため)。

    2. パスワードのハッシュ化

    src/password.ts を次の内容で作ります。

    // src/password.ts
    // パスワードのハッシュ化。Workers 標準の Web Crypto(PBKDF2)だけで行い、外部ライブラリは使わない
    // Workers の PBKDF2 は反復回数が 100000 回までのため、上限いっぱいを使う
    const ITERATIONS = 100_000
    
    const toBase64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf)))
    const fromBase64 = (s: string): Uint8Array<ArrayBuffer> => Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0))
    
    const derive = async (password: string, salt: Uint8Array<ArrayBuffer>, iterations: number) => {
      const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, [
        'deriveBits',
      ])
      return crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, key, 256)
    }
    
    // 保存形式: pbkdf2$反復回数$salt$ハッシュ(salt は人ごとにランダム)
    export const hashPassword = async (password: string): Promise<string> => {
      const salt = crypto.getRandomValues(new Uint8Array(16))
      return `pbkdf2$${ITERATIONS}$${toBase64(salt)}$${toBase64(await derive(password, salt, ITERATIONS))}`
    }
    
    export const verifyPassword = async (password: string, stored: string): Promise<boolean> => {
      const [scheme, iter, salt, hash] = stored.split('$')
      if (scheme !== 'pbkdf2' || !iter || !salt || !hash) return false
      const actual = new Uint8Array(await derive(password, fromBase64(salt), Number(iter)))
      const expected = fromBase64(hash)
      // 先頭から比べて途中で止めない(応答時間から正解を推測されないようにする)
      if (actual.length !== expected.length) return false
      let diff = 0
      for (let i = 0; i < actual.length; i++) diff |= actual[i]! ^ expected[i]!
      return diff === 0
    }

    ポイントは3つです。

    • salt(ソルト)は人ごとにランダムな値です。同じパスワードの人が2人いても、保存される値は別になります。
    • 保存形式に反復回数を含めています。将来、回数や方式を変えても、古い利用者の値を検証できます。
    • 比較は、違う文字が見つかった時点で止めず、最後まで見ています。比較にかかる時間の差から、正解を推測されるのを避けるためです。

    3. ログインして、印を Cookie に入れる

    src/auth.ts に、印の発行と確認をまとめます。次の内容で作ります(後半の requireLogin と requireRole は、手順4で説明します)。

    // src/auth.ts
    import { createMiddleware } from 'hono/factory'
    import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
    import { sign, verify } from 'hono/jwt'
    
    export const roles = ['employee', 'agent', 'admin'] as const
    export type Role = (typeof roles)[number]
    
    export type SessionUser = { id: number; email: string; name: string; role: Role }
    export type AppEnv = { Bindings: Env; Variables: { user: SessionUser } }
    
    const COOKIE = 'session'
    const SESSION_SECONDS = 8 * 60 * 60
    
    // ログイン成功時に呼ぶ。署名つきの印(JWT)を、JavaScript から読めない Cookie に入れる
    export const startSession = async (c: Parameters<typeof setCookie>[0], secret: string, userId: number) => {
      const exp = Math.floor(Date.now() / 1000) + SESSION_SECONDS
      const token = await sign({ sub: String(userId), exp }, secret, 'HS256')
      setCookie(c, COOKIE, token, {
        httpOnly: true,
        secure: true,
        sameSite: 'Lax',
        path: '/',
        maxAge: SESSION_SECONDS,
      })
    }
    
    export const endSession = (c: Parameters<typeof deleteCookie>[0]) => deleteCookie(c, COOKIE, { path: '/' })
    
    // 印を確かめ、そのたびに DB から最新の利用者情報を引く。
    // 退職・停止(active=0)や権限変更が、次のリクエストからすぐ効くようにするため
    export const requireLogin = createMiddleware<AppEnv>(async (c, next) => {
      const token = getCookie(c, COOKIE)
      if (!token) return c.json({ error: 'unauthorized' }, 401)
      let userId: number
      try {
        const payload = await verify(token, c.env.SESSION_SECRET, 'HS256')
        userId = Number(payload.sub)
      } catch {
        return c.json({ error: 'unauthorized' }, 401)
      }
      const user = await c.env.DB.prepare(
        'SELECT id, email, name, role FROM users WHERE id = ? AND active = 1',
      )
        .bind(userId)
        .first<SessionUser>()
      if (!user) return c.json({ error: 'unauthorized' }, 401)
      c.set('user', user)
      await next()
    })
    
    // 許可する役割を絞る。ログイン済み(requireLogin の後)で使う。足りなければ 403
    export const requireRole = (...allowed: Role[]) =>
      createMiddleware<AppEnv>(async (c, next) => {
        if (!allowed.includes(c.get('user').role)) return c.json({ error: 'forbidden' }, 403)
        await next()
      })

    印には、利用者のIDと有効期限(8時間)だけを入れています。役割(role)は印に入れません。印に入れると、役割を変えたり利用者を止めたりしても、発行済みの印が期限まで有効なままになるためです。

    署名に使う秘密の文字列(SESSION_SECRET)は、コードに書かず、ローカルでは .dev.vars(git管理外)、本番では wrangler secret put SESSION_SECRET で渡します。ひな形として .dev.vars.example だけをリポジトリに置いています。

    ひな形 .dev.vars.example を作り、それをコピーして手元用の .dev.vars を用意します。

    # .dev.vars.example
    SESSION_SECRET=ローカル開発用の長いランダム文字列に置き換える
    cp .dev.vars.example .dev.vars
    # .dev.vars の SESSION_SECRET の値を、長いランダムな文字列に書き換える
    # (例:openssl rand -base64 32 で作った文字列)

    SESSION_SECRET の型は、src/env.d.ts を作って足します。

    // src/env.d.ts
    // wrangler secret / .dev.vars で渡す値の型(wrangler types では生成されないため手で足す)
    interface Env {
      SESSION_SECRET: string
    }

    4. 印を確かめる部品(ミドルウェア)

    リクエストが来るたびに、印を確かめ、DBから最新の利用者情報を引きます。手順3で作った src/auth.ts の後半にある requireLogin がこの部品です。印が無い・署名が合わない・期限が切れている・利用者が停止されている、のどれでも 401 を返します。requireRole は、ログイン済みの人の役割を見て、許可していない役割なら 403 を返す部品です。

    401 と 403 の違いも押さえておきましょう。

    ステータス意味例
    401誰か分からない(ログインしていない・印が不正/期限切れ)Cookie なしでチケット一覧を開く
    403誰かは分かるが、許されていない社員が状態を変えようとする

    5. ログインAPI

    src/routes/auth.ts を次の内容で作ります。ログイン・ログアウト・本人確認(/me)の3つです。

    // src/routes/auth.ts
    import { zValidator } from '@hono/zod-validator'
    import { Hono } from 'hono'
    import { z } from 'zod'
    import { endSession, requireLogin, startSession, type AppEnv } from '../auth'
    import { verifyPassword } from '../password'
    
    export const auth = new Hono<AppEnv>()
    
    const loginSchema = z.object({
      email: z.string().trim().toLowerCase().email().max(254),
      password: z.string().min(1).max(200),
    })
    
    // 存在しないメールのときも同じ重さの計算をして、応答時間で「登録の有無」が分からないようにする
    const DUMMY_HASH = 'pbkdf2$100000$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
    
    auth.post(
      '/login',
      zValidator('json', loginSchema, (result, c) => {
        if (!result.success) return c.json({ error: 'invalid_request' }, 400)
      }),
      async (c) => {
        const { email, password } = c.req.valid('json')
        const row = await c.env.DB.prepare(
          'SELECT id, email, name, role, password_hash FROM users WHERE email = ? AND active = 1',
        )
          .bind(email)
          .first<{ id: number; email: string; name: string; role: string; password_hash: string }>()
    
        const ok = await verifyPassword(password, row?.password_hash ?? DUMMY_HASH)
        // 「メールが違う」「パスワードが違う」を区別せず、同じ文言で返す
        if (!row || !ok) return c.json({ error: 'invalid_credentials' }, 401)
    
        await startSession(c, c.env.SESSION_SECRET, row.id)
        return c.json({ id: row.id, email: row.email, name: row.name, role: row.role })
      },
    )
    
    auth.post('/logout', (c) => {
      endSession(c)
      return c.json({ ok: true })
    })
    
    auth.get('/me', requireLogin, (c) => c.json(c.get('user')))

    失敗したときに「このメールアドレスは登録されていません」と教えると、社内の誰が利用者かを外部から調べられてしまいます。そのため、どちらが違っても同じ401と同じ文言にしています。

    6. 既存のチケットAPIに権限をかける

    第2回までのチケットAPIに、requireLogin と役割ごとの制限を足します。src/routes/tickets.ts に次の変更を加えます(+ が追加、- が削除)。

    --- a/src/routes/tickets.ts
    +++ b/src/routes/tickets.ts
    @@ -1,10 +1,17 @@
     // src/routes/tickets.ts
     import { zValidator } from '@hono/zod-validator'
     import { Hono } from 'hono'
    +import { requireLogin, requireRole, type AppEnv } from '../auth'
     import { changeStatusSchema, createTicketSchema, listQuerySchema } from '../schemas'
     import { canTransition, type Status } from '../status'
     
    -export const tickets = new Hono<{ Bindings: Env }>()
    +export const tickets = new Hono<AppEnv>()
    +
    +// このルートはすべてログイン必須。ログインしていなければ 401
    +tickets.use('*', requireLogin)
    +
    +// 社員は自分の申請だけ、担当者・管理者は全件を扱える
    +const canSeeAll = (role: string) => role !== 'employee'
     
     // チケット登録。入力が不正なら 400 を返し、DB には触らない
     tickets.post(
    @@ -22,23 +29,27 @@ tickets.post(
       }),
       async (c) => {
         const input = c.req.valid('json')
    +    const user = c.get('user')
    +    // 申請者は入力ではなくログイン中の本人から決める(他人名義の申請を作れないようにする)
         // 値は必ず bind() で渡す(文字列連結で SQL を組み立てない)
         const ticket = await c.env.DB.prepare(
           `INSERT INTO tickets (title, body, category, priority, requester)
            VALUES (?, ?, ?, ?, ?)
            RETURNING id, title, category, priority, status, requester, created_at`,
         )
    -      .bind(input.title, input.body, input.category, input.priority, input.requester)
    +      .bind(input.title, input.body, input.category, input.priority, user.email)
           .first()
         return c.json(ticket, 201)
       },
     )
     
     tickets.get('/:id{[0-9]+}', async (c) => {
    +  const user = c.get('user')
       const ticket = await c.env.DB.prepare('SELECT * FROM tickets WHERE id = ?')
         .bind(Number(c.req.param('id')))
    -    .first()
    -  if (!ticket) return c.json({ error: 'not_found' }, 404)
    +    .first<{ requester: string }>()
    +  // 他人のチケットは「存在しない」と同じ 404 にする(403 だと番号の存在が分かってしまう)
    +  if (!ticket || (!canSeeAll(user.role) && ticket.requester !== user.email)) return c.json({ error: 'not_found' }, 404)
       return c.json(ticket)
     })
     
    @@ -50,8 +61,19 @@ tickets.get(
       }),
       async (c) => {
         const { status, page, perPage } = c.req.valid('query')
    -    const where = status ? 'WHERE status = ?' : ''
    -    const filter = status ? [status] : []
    +    const user = c.get('user')
    +    // 絞り込み条件は「状態」と「社員なら自分の申請だけ」。条件は組み立てても値は必ず bind する
    +    const conditions: string[] = []
    +    const filter: (string | number)[] = []
    +    if (status) {
    +      conditions.push('status = ?')
    +      filter.push(status)
    +    }
    +    if (!canSeeAll(user.role)) {
    +      conditions.push('requester = ?')
    +      filter.push(user.email)
    +    }
    +    const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
     
         // 件数と一覧を1回の往復でまとめて取る
         const [count, list] = await c.env.DB.batch([
    @@ -68,9 +90,10 @@ tickets.get(
       },
     )
     
    -// 状態の変更。ルールにない遷移は 409、すでに他の人が変えていた場合も 409
    +// 状態の変更は担当者・管理者だけ。ルールにない遷移は 409、すでに他の人が変えていた場合も 409
     tickets.patch(
       '/:id{[0-9]+}/status',
    +  requireRole('agent', 'admin'),
       zValidator('json', changeStatusSchema, (result, c) => {
         if (!result.success) return c.json({ error: 'invalid_request' }, 400)
       }),

    大事なのは、申請者(requester)を入力から外したことです。第1回では、フォームに「申請者のメールアドレス」を入力させていました。これを残すと、他人の名前で申請を作れてしまいます。今回、入力欄(src/schemas.ts の requester)を削除し、ログイン中の本人のメールアドレスをサーバー側で入れるようにしました。

    src/schemas.ts に次の変更を加えます(+ が追加、- が削除)。

    --- a/src/schemas.ts
    +++ b/src/schemas.ts
    @@ -5,13 +5,12 @@ import { statuses } from './status'
     export const categories = ['pc', 'account', 'system', 'other'] as const
     export const priorities = ['low', 'normal', 'high'] as const
     
    -// 受付フォームから届く入力。ここを通ったものだけを DB に入れる
    +// 受付フォームから届く入力。ここを通ったものだけを DB に入れる(申請者はログイン情報から決めるので入力しない)
     export const createTicketSchema = z.object({
       title: z.string().trim().min(1, '件名を入力してください').max(100, '件名は100文字以内です'),
       body: z.string().trim().min(1, '内容を入力してください').max(5000, '内容は5000文字以内です'),
       category: z.enum(categories),
       priority: z.enum(priorities).default('normal'),
    -  requester: z.string().trim().email('メールアドレスの形式で入力してください').max(254),
     })
     
     export type CreateTicketInput = z.infer<typeof createTicketSchema>

    7. 利用者の登録(管理者のみ)と、最初の管理者

    src/routes/users.ts は、ルート全体を requireLogin と requireRole('admin') で守ります。応答にはパスワードのハッシュを含めません。次の内容で作ります。

    // src/routes/users.ts
    import { zValidator } from '@hono/zod-validator'
    import { Hono } from 'hono'
    import { z } from 'zod'
    import { requireLogin, requireRole, roles, type AppEnv } from '../auth'
    import { hashPassword } from '../password'
    
    export const users = new Hono<AppEnv>()
    
    // 利用者の登録・一覧は管理者だけ
    users.use('*', requireLogin, requireRole('admin'))
    
    const createUserSchema = z.object({
      email: z.string().trim().toLowerCase().email().max(254),
      name: z.string().trim().min(1).max(100),
      role: z.enum(roles),
      password: z.string().min(12, 'パスワードは12文字以上にしてください').max(200),
    })
    
    users.post(
      '/',
      zValidator('json', createUserSchema, (result, c) => {
        if (!result.success) return c.json({ error: 'invalid_request' }, 400)
      }),
      async (c) => {
        const input = c.req.valid('json')
        const hash = await hashPassword(input.password)
        try {
          const user = await c.env.DB.prepare(
            `INSERT INTO users (email, name, role, password_hash) VALUES (?, ?, ?, ?)
             RETURNING id, email, name, role`,
          )
            .bind(input.email, input.name, input.role, hash)
            .first()
          return c.json(user, 201)
        } catch {
          // email の UNIQUE 制約に当たった場合(すでに登録済み)
          return c.json({ error: 'email_taken' }, 409)
        }
      },
    )
    
    // password_hash は絶対に返さない
    users.get('/', async (c) => {
      const { results } = await c.env.DB.prepare(
        'SELECT id, email, name, role, active FROM users ORDER BY id',
      ).all()
      return c.json({ items: results })
    })

    「最初の管理者」は、ログインする前なのでAPIでは作れません。そこで、管理者の登録SQLを出力する小さなスクリプト scripts/make-admin-sql.mjs を用意しました。パスワードは引数ではなく環境変数で渡します(シェルの履歴に残さないためです)。スクリプトは次の内容で作ります。

    // scripts/make-admin-sql.mjs
    // 最初の管理者を作る SQL を出力する。実行例:
    //   ADMIN_PASSWORD='12文字以上の長いパスワード' node scripts/make-admin-sql.mjs admin@example.com 管理者 \
    //     | npx wrangler d1 execute helpdesk --local --file=/dev/stdin
    // パスワードは引数ではなく環境変数で渡す(シェルの履歴に残さないため)
    import { webcrypto as crypto } from 'node:crypto'
    
    const [email, name] = process.argv.slice(2)
    const password = process.env.ADMIN_PASSWORD
    if (!email || !name || !password || password.length < 12) {
      console.error('使い方: ADMIN_PASSWORD=<12文字以上> node scripts/make-admin-sql.mjs <email> <name>')
      process.exit(1)
    }
    
    const salt = crypto.getRandomValues(new Uint8Array(16))
    const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits'])
    const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 100000 }, key, 256)
    const b64 = (b) => Buffer.from(b).toString('base64')
    const hash = `pbkdf2$100000$${b64(salt)}$${b64(new Uint8Array(bits))}`
    const q = (s) => `'${s.replaceAll("'", "''")}'`
    
    console.log(
      `INSERT INTO users (email, name, role, password_hash) VALUES (${q(email.toLowerCase())}, ${q(name)}, 'admin', ${q(hash)});`,
    )

    ローカルのデータベースに 0003 の表を作ってから、最初の管理者を登録します。

    npx wrangler d1 migrations apply helpdesk --local
    ADMIN_PASSWORD='12文字以上の長いパスワード' node scripts/make-admin-sql.mjs admin@example.com 管理者 > admin.sql
    npx wrangler d1 execute helpdesk --local --file=admin.sql
    rm admin.sql   # パスワードのハッシュが入ったファイルを残さない

    8. ルートをアプリ本体に取り付ける

    作ったログイン・利用者のルートを、アプリ本体に取り付けます。src/index.ts に次の変更を加えます(+ が追加、- が削除)。

    --- a/src/index.ts
    +++ b/src/index.ts
    @@ -1,8 +1,11 @@
     // src/index.ts
     import { Hono } from 'hono'
    +import type { AppEnv } from './auth'
    +import { auth } from './routes/auth'
    +import { users } from './routes/users'
     import { tickets } from './routes/tickets'
     
    -const app = new Hono<{ Bindings: Env }>()
    +const app = new Hono<AppEnv>()
     
     app.get('/', (c) => c.text(`${c.env.APP_NAME} は動いています`))
     
    @@ -14,6 +17,8 @@ app.get('/api/health', async (c) => {
       return c.json({ status: 'ok', schemaVersion: row?.value ?? null })
     })
     
    +app.route('/api/auth', auth)
    +app.route('/api/users', users)
     app.route('/api/tickets', tickets)
     
     export default app

    動作確認

    テストは、これまでと同じ npm run typecheck と npm test です。今回は 12件から 21件 に増えました。主な観点は次のとおりです。

    • 同じパスワードでもハッシュが毎回違い、正しいパスワードだけが通る
    • ログイン成功時の Cookie に HttpOnly と Secure が付く
    • パスワード違いもメール違いも、同じ401と同じ文言になる
    • Cookie なし・改ざん・期限切れ・別の秘密鍵で署名されたものは401になる
    • 利用を停止された人は、発行済みの Cookie でも次のリクエストから401になる
    • 社員は他人のチケットが404になり、一覧にも自分の分しか出ない。申請者を偽って入力しても無視される
    • 状態の変更は、社員だと403、担当者だと200になる
    • 利用者の登録は管理者だけ。重複は409、短すぎるパスワードは400。応答にハッシュは出ない

    テストでは、署名用の秘密の文字列をテスト専用の値で渡します。vitest.config.ts に次の変更を加えます(+ が追加、- が削除)。

    --- a/vitest.config.ts
    +++ b/vitest.config.ts
    @@ -8,7 +8,7 @@ export default defineConfig(async () => {
         plugins: [
           cloudflareTest({
             wrangler: { configPath: './wrangler.jsonc' },
    -        miniflare: { bindings: { TEST_MIGRATIONS: migrations } },
    +        miniflare: { bindings: { TEST_MIGRATIONS: migrations, SESSION_SECRET: 'test-only-secret-not-for-production' } },
           }),
         ],
         test: { setupFiles: ['./test/apply-migrations.ts'] },

    あわせて、その型を足します。test/env.d.ts に次の変更を加えます(+ が追加、- が削除)。

    --- a/test/env.d.ts
    +++ b/test/env.d.ts
    @@ -1,6 +1,7 @@
     // test/env.d.ts(テスト用に vitest.config.ts から渡すバインディングの型)
     declare namespace Cloudflare {
       interface Env {
    +    SESSION_SECRET: string
         TEST_MIGRATIONS: import('cloudflare:test').D1Migration[]
       }
     }

    テスト用の利用者を作ってログインし、Cookie を受け取る共通の部品 test/helpers.ts を作ります。

    // test/helpers.ts
    import { env, SELF } from 'cloudflare:test'
    import { hashPassword } from '../src/password'
    
    export const PASSWORD = 'correct-horse-battery'
    
    // テスト用の利用者を作り(すでにあれば作り直さない)、ログインして Cookie を返す
    export const loginAs = async (email: string, role: 'employee' | 'agent' | 'admin') => {
      const hash = await hashPassword(PASSWORD)
      await env.DB.prepare(
        'INSERT OR IGNORE INTO users (email, name, role, password_hash) VALUES (?, ?, ?, ?)',
      )
        .bind(email, email.split('@')[0], role, hash)
        .run()
      const res = await SELF.fetch('http://example.com/api/auth/login', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ email, password: PASSWORD }),
      })
      // Set-Cookie の「session=…」部分だけを次のリクエストの cookie ヘッダーに使う
      return res.headers.get('set-cookie')!.split(';')[0]!
    }

    第1〜2回のチケットのテストは、ログインした状態で呼ぶように直します。申請者(requester)を入力から外したので、その検査も消します。test/tickets.test.ts に次の変更を加えます(+ が追加、- が削除)。

    test/tickets.test.ts の変更(差分・102行)
    --- a/test/tickets.test.ts
    +++ b/test/tickets.test.ts
    @@ -1,11 +1,21 @@
     // test/tickets.test.ts
     import { SELF } from 'cloudflare:test'
    -import { describe, expect, it } from 'vitest'
    +import { beforeAll, describe, expect, it } from 'vitest'
    +import { loginAs } from './helpers'
    +
    +let staff = ''
    +let other = ''
    +let admin = ''
    +beforeAll(async () => {
    +  staff = await loginAs('taro@example.com', 'employee')
    +  other = await loginAs('hanako@example.com', 'employee')
    +  admin = await loginAs('agent@example.com', 'agent')
    +})
     
    -const post = (body: unknown) =>
    +const post = (body: unknown, cookie = staff) =>
       SELF.fetch('http://example.com/api/tickets', {
         method: 'POST',
    -    headers: { 'content-type': 'application/json' },
    +    headers: { 'content-type': 'application/json', cookie },
         body: JSON.stringify(body),
       })
     
    @@ -13,7 +23,6 @@ const valid = {
       title: 'プリンターが印刷できない',
       body: '3階の複合機で昨日から印刷が止まります',
       category: 'pc',
    -  requester: 'taro@example.com',
     }
     
     describe('チケット登録 API', () => {
    @@ -24,7 +33,7 @@ describe('チケット登録 API', () => {
         expect(ticket.status).toBe('open')
         expect(ticket.priority).toBe('normal')
     
    -    const got = await SELF.fetch(`http://example.com/api/tickets/${ticket.id}`)
    +    const got = await SELF.fetch(`http://example.com/api/tickets/${ticket.id}`, { headers: { cookie: staff } })
         expect(got.status).toBe(200)
       })
     
    @@ -35,9 +44,8 @@ describe('チケット登録 API', () => {
         expect(json.issues.map((i) => i.path)).toContain('title')
       })
     
    -  it('一覧にない種別・メールアドレスでない連絡先は 400', async () => {
    +  it('一覧にない種別は 400', async () => {
         expect((await post({ ...valid, category: 'unknown' })).status).toBe(400)
    -    expect((await post({ ...valid, requester: 'not-an-email' })).status).toBe(400)
       })
     
       it('SQL を含む文字列もそのまま保存され、テーブルは壊れない', async () => {
    @@ -47,7 +55,7 @@ describe('チケット登録 API', () => {
       })
     
       it('存在しない ID は 404', async () => {
    -    const res = await SELF.fetch('http://example.com/api/tickets/999999')
    +    const res = await SELF.fetch('http://example.com/api/tickets/999999', { headers: { cookie: staff } })
         expect(res.status).toBe(404)
       })
     })
    @@ -56,17 +64,17 @@ const create = async (over: Record<string, unknown> = {}) => {
       const res = await post({ ...valid, ...over })
       return (await res.json<{ id: number }>()).id
     }
    -const patchStatus = (id: number, status: string) =>
    +const patchStatus = (id: number, status: string, cookie = admin) =>
       SELF.fetch(`http://example.com/api/tickets/${id}/status`, {
         method: 'PATCH',
    -    headers: { 'content-type': 'application/json' },
    +    headers: { 'content-type': 'application/json', cookie },
         body: JSON.stringify({ status }),
       })
     
     describe('チケット一覧 API', () => {
       it('総件数・ページ番号つきで返り、perPage で区切れる', async () => {
         for (let i = 0; i < 3; i++) await create({ title: `一覧テスト${i}` })
    -    const res = await SELF.fetch('http://example.com/api/tickets?perPage=2&page=1')
    +    const res = await SELF.fetch('http://example.com/api/tickets?perPage=2&page=1', { headers: { cookie: admin } })
         expect(res.status).toBe(200)
         const json = await res.json<{ items: unknown[]; total: number; page: number }>()
         expect(json.items).toHaveLength(2)
    @@ -77,13 +85,13 @@ describe('チケット一覧 API', () => {
       it('status で絞り込める。不正な値・範囲外の perPage は 400', async () => {
         const id = await create()
         await patchStatus(id, 'in_progress')
    -    const res = await SELF.fetch('http://example.com/api/tickets?status=in_progress')
    +    const res = await SELF.fetch('http://example.com/api/tickets?status=in_progress', { headers: { cookie: admin } })
         const json = await res.json<{ items: { status: string }[] }>()
         expect(json.items.length).toBeGreaterThan(0)
         expect(json.items.every((t) => t.status === 'in_progress')).toBe(true)
     
    -    expect((await SELF.fetch('http://example.com/api/tickets?status=foo')).status).toBe(400)
    -    expect((await SELF.fetch('http://example.com/api/tickets?perPage=1000')).status).toBe(400)
    +    expect((await SELF.fetch('http://example.com/api/tickets?status=foo', { headers: { cookie: admin } })).status).toBe(400)
    +    expect((await SELF.fetch('http://example.com/api/tickets?perPage=1000', { headers: { cookie: admin } })).status).toBe(400)
       })
     })
     

    ログインと権限のテスト test/auth.test.ts を作ります。上に挙げた観点を確かめます。

    test/auth.test.ts の全文(118行)
    // test/auth.test.ts
    import { env, SELF } from 'cloudflare:test'
    import { sign } from 'hono/jwt'
    import { beforeAll, describe, expect, it } from 'vitest'
    import { hashPassword, verifyPassword } from '../src/password'
    import { loginAs, PASSWORD } from './helpers'
    
    const json = (body: unknown, cookie?: string) => ({
      method: 'POST',
      headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) },
      body: JSON.stringify(body),
    })
    const ticket = { title: '権限テスト', body: '本文', category: 'pc' }
    
    let employee = ''
    let agent = ''
    let admin = ''
    beforeAll(async () => {
      employee = await loginAs('emp@example.com', 'employee')
      agent = await loginAs('ag@example.com', 'agent')
      admin = await loginAs('adm@example.com', 'admin')
    })
    
    describe('パスワードのハッシュ', () => {
      it('同じパスワードでも毎回違う値になり、正しいパスワードだけが通る', async () => {
        const a = await hashPassword(PASSWORD)
        const b = await hashPassword(PASSWORD)
        expect(a).not.toBe(b)
        expect(a).not.toContain(PASSWORD)
        expect(await verifyPassword(PASSWORD, a)).toBe(true)
        expect(await verifyPassword('wrong-password', a)).toBe(false)
      })
    })
    
    describe('ログイン', () => {
      it('正しい組み合わせなら HttpOnly の Cookie が付き、/me で本人が分かる', async () => {
        const res = await SELF.fetch('http://example.com/api/auth/login', json({ email: 'EMP@example.com', password: PASSWORD }))
        expect(res.status).toBe(200)
        const setCookie = res.headers.get('set-cookie')!
        expect(setCookie).toMatch(/HttpOnly/i)
        expect(setCookie).toMatch(/Secure/i)
        expect(JSON.stringify(await res.json())).not.toContain('password')
    
        const me = await SELF.fetch('http://example.com/api/auth/me', { headers: { cookie: employee } })
        expect(await me.json()).toMatchObject({ email: 'emp@example.com', role: 'employee' })
      })
    
      it('パスワード違いもメール違いも、同じ 401 と同じ文言', async () => {
        const wrongPw = await SELF.fetch('http://example.com/api/auth/login', json({ email: 'emp@example.com', password: 'x' }))
        const noUser = await SELF.fetch('http://example.com/api/auth/login', json({ email: 'nobody@example.com', password: 'x' }))
        expect(wrongPw.status).toBe(401)
        expect(noUser.status).toBe(401)
        expect(await wrongPw.json()).toEqual(await noUser.json())
      })
    
      it('ログインしていない・改ざんされた・期限切れの Cookie は 401', async () => {
        expect((await SELF.fetch('http://example.com/api/tickets')).status).toBe(401)
        expect((await SELF.fetch('http://example.com/api/tickets', { headers: { cookie: `${employee}x` } })).status).toBe(401)
        const expired = await sign({ sub: '1', exp: Math.floor(Date.now() / 1000) - 10 }, env.SESSION_SECRET, 'HS256')
        expect((await SELF.fetch('http://example.com/api/tickets', { headers: { cookie: `session=${expired}` } })).status).toBe(401)
        const forged = await sign({ sub: '1', exp: Math.floor(Date.now() / 1000) + 600 }, 'another-secret', 'HS256')
        expect((await SELF.fetch('http://example.com/api/tickets', { headers: { cookie: `session=${forged}` } })).status).toBe(401)
      })
    
      it('利用を停止された人は、発行済みの Cookie でも次のリクエストから 401', async () => {
        const cookie = await loginAs('leaver@example.com', 'employee')
        expect((await SELF.fetch('http://example.com/api/auth/me', { headers: { cookie } })).status).toBe(200)
        await env.DB.prepare('UPDATE users SET active = 0 WHERE email = ?').bind('leaver@example.com').run()
        expect((await SELF.fetch('http://example.com/api/auth/me', { headers: { cookie } })).status).toBe(401)
      })
    
      it('ログアウトで Cookie が消える', async () => {
        const res = await SELF.fetch('http://example.com/api/auth/logout', json({}, employee))
        expect(res.headers.get('set-cookie')).toMatch(/session=;/)
      })
    })
    
    describe('役割ごとの権限', () => {
      it('社員は自分の申請だけ見え、他人のチケットは 404。申請者はログイン中の本人になる', async () => {
        const mine = await (await SELF.fetch('http://example.com/api/tickets', json(ticket, employee))).json<{ id: number; requester: string }>()
        expect(mine.requester).toBe('emp@example.com')
        // 申請者を入力で指定しても無視される
        const spoof = await (await SELF.fetch('http://example.com/api/tickets', json({ ...ticket, requester: 'boss@example.com' }, employee))).json<{ requester: string }>()
        expect(spoof.requester).toBe('emp@example.com')
    
        const theirs = await (await SELF.fetch('http://example.com/api/tickets', json(ticket, agent))).json<{ id: number }>()
        expect((await SELF.fetch(`http://example.com/api/tickets/${theirs.id}`, { headers: { cookie: employee } })).status).toBe(404)
        expect((await SELF.fetch(`http://example.com/api/tickets/${mine.id}`, { headers: { cookie: employee } })).status).toBe(200)
    
        const list = await (await SELF.fetch('http://example.com/api/tickets?perPage=100', { headers: { cookie: employee } })).json<{ items: { requester: string }[] }>()
        expect(list.items.every((t) => t.requester === 'emp@example.com')).toBe(true)
        const all = await (await SELF.fetch('http://example.com/api/tickets?perPage=100', { headers: { cookie: agent } })).json<{ items: { requester: string }[] }>()
        expect(new Set(all.items.map((t) => t.requester)).size).toBeGreaterThan(1)
      })
    
      it('状態の変更は担当者・管理者だけ。社員は 403', async () => {
        const t = await (await SELF.fetch('http://example.com/api/tickets', json(ticket, employee))).json<{ id: number }>()
        const patch = (cookie: string) =>
          SELF.fetch(`http://example.com/api/tickets/${t.id}/status`, {
            method: 'PATCH',
            headers: { 'content-type': 'application/json', cookie },
            body: JSON.stringify({ status: 'in_progress' }),
          })
        expect((await patch(employee)).status).toBe(403)
        expect((await patch(agent)).status).toBe(200)
      })
    
      it('利用者の登録は管理者だけ。応答にパスワードのハッシュは含まれない', async () => {
        const body = { email: 'new@example.com', name: '新人', role: 'employee', password: 'long-enough-password' }
        expect((await SELF.fetch('http://example.com/api/users', json(body, agent))).status).toBe(403)
        const created = await SELF.fetch('http://example.com/api/users', json(body, admin))
        expect(created.status).toBe(201)
        expect((await SELF.fetch('http://example.com/api/users', json(body, admin))).status).toBe(409)
        expect((await SELF.fetch('http://example.com/api/users', json({ ...body, email: 'b@example.com', password: 'short' }, admin))).status).toBe(400)
        const list = await SELF.fetch('http://example.com/api/users', { headers: { cookie: admin } })
        expect(JSON.stringify(await list.json())).not.toContain('pbkdf2')
      })
    })

    次のコマンドで、型チェックとテストを行います。

    npx wrangler types
    npm run typecheck
    npm test

    あわせて、npx wrangler dev で起動し、手順7で登録した管理者で、curl で確認しました。

    # ログイン(Cookie を保存)
    curl -s -c cookies.txt -H 'content-type: application/json' \
      -d '{"email":"admin@example.com","password":"..."}' localhost:8787/api/auth/login
    # {"id":1,"email":"admin@example.com","name":"管理者","role":"admin"}
    
    # Cookie なし → 401
    curl -s localhost:8787/api/tickets
    # {"error":"unauthorized"}
    
    # Cookie あり → 一覧が返る
    curl -s -b cookies.txt localhost:8787/api/tickets

    つまずきやすい点・省略したこと

    • ログイン失敗の回数制限がありません:同じアカウントへの総当たりを止める仕組み(一定回数で待たせる、など)は、この連載では省略しています。本番では Cloudflare のレート制限などを検討します。
    • パスワードの変更・再設定の画面はありません:社内ツールでは、管理者が再設定する運用にするか、メールで再設定するかを決める必要があります。
    • CSRF 対策は第8回で扱います:Cookie 方式では、他のサイトから勝手に操作させる攻撃(CSRF)への備えが必要です。今回は SameSite=Lax が最初の防御で、フォームを作る第8回で追加の対策を入れます。
    • 外部の認証サービスという選択肢:Cloudflare Access のように、サービス側でログイン(社内のGoogleアカウントなど)を済ませてから Worker に届ける構成もあります。自前で作るより、パスワード管理の責任を持たずに済みます。社員数や運用体制によっては、こちらのほうが適しています。
    • Cloudflare の実環境は未確認:ローカルのD1(wrangler dev)とテスト環境での確認です。

    発注者向けメモ:ログインと権限は「誰に何を許すか」から決める

    ログインは画面としては小さく見えますが、決めることが多く、後から変えるとテストのやり直しが広い範囲に及びます。開発会社に頼むときは、次の点を確認しておくと手戻りを減らせます。

    発注者がやることチェックリスト

    • ☐ 利用者の種類(社員・担当者・管理者など)と、それぞれにできることを表にして決めた
    • ☐ 利用者の追加・退職時の停止を、誰がどうやって行うか決めた
    • ☐ ログインの方式を決めた(専用のID・パスワード/社内のGoogle・Microsoftアカウントなどとの連携)
    • ☐ パスワードの条件と、忘れたときの再設定の手順を決めた
    • ☐ 「他人の申請は見せない」など、見せてはいけない情報の線引きを決めた
    • ☐ 退職者のデータを残すのか、消すのか、方針を決めた

    開発会社への質問例

    • 「利用者の種類ごとにできる操作を、画面を作る前に一覧表で見せてもらえますか?」
    • 「パスワードはどんな方式で保存しますか?管理者でも元のパスワードを見られない作りですか?」
    • 「ログインを外部のアカウント連携にする場合と、自前で作る場合で、費用や運用はどう変わりますか?」
    • 「退職した人のアカウントを止めたとき、すぐにログインできなくなりますか?」
    • 「ログインの失敗が続いた場合の対策(回数制限・通知)は、見積もりに含まれますか?」

    特に「外部のアカウント連携にするか、自前で作るか」は、初期費用だけでなく、パスワード漏えい時の責任や、ふだんの運用の手間にも関わります。最初に比較してもらうのがおすすめです。

    まとめと次回予告

    今回は、利用者テーブル、パスワードのハッシュ化、署名つき Cookie でのログイン、役割による許可の制御を作りました。ポイントは、パスワードは元に戻せない形で保存する、印には役割を入れずリクエストごとにDBで確かめる、許可の判断を部品にまとめて1か所で管理するの3つです。これで、第2回まで「誰でも変えられる」状態だったAPIが、役割ごとに守られました。

    次回の第4回では、スクリーンショットなどの添付ファイルを Cloudflare R2 に保存します。アップロードできるサイズ・種類の制限と、ダウンロードの権限(今回作った役割を使います)を扱います。

    システムの発注や、社内ツールの構成選びでお悩みの方は、お気軽にご相談ください。

    この連載の記事一覧

    この記事は連載「Honoで作る社内ヘルプデスク」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      目次