前回の第2回では、チケットの一覧と状態遷移を作りました。ただ、あの段階のAPIは誰でも状態を変えられる状態でした。今回は、ログインと権限(社員・担当者・管理者)を付けて、「誰が何をしてよいか」をコードで決めます。
(2026年10月29日 追記:記事どおりに再現できるよう、必要なコードと手順を追加しました)
前回の記事はこちらです。

「社内の問い合わせ管理ツールを作ってもらうけど、他の人の申請が見えたり、勝手に完了にされたりしないか心配…ログインって、結局どこまで作ればいいの?」
先に結論です。社内ツールのログインは、「パスワードを元に戻せない形で保存する」「ログイン済みの印を、書き換えられない形で持たせる」「役割ごとの許可を1か所で決める」の3点を押さえれば、小さく堅く作れます。この3つを外部サービスなしで実装し、テストで確かめるところまでを、この回で扱います。
この回で作るのは次の4つです。
- 利用者テーブルと、パスワードのハッシュ化(Web標準の PBKDF2)
- ログイン・ログアウト・本人確認API(署名つきの印を Cookie に入れる)
- 役割(社員/担当者/管理者)による許可の制御
- 利用者の登録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から読めなくする(画面に紛れ込んだ不正なスクリプトに盗まれにくくなる) |
Secure | HTTPS の通信でだけ送る |
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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Honoで作る社内ヘルプデスク 第0回】連載の全体像と、Hono+Cloudflare Workersの雛形を動かす
- 【Honoで作る社内ヘルプデスク 第1回】チケット登録APIを作る:D1のマイグレーションとZodの入力チェック
- 【Honoで作る社内ヘルプデスク 第2回】チケットの一覧と状態遷移を作る:ページングと「戻せない状態」のルール
- 【Honoで作る社内ヘルプデスク 第3回】ログインと権限を付ける:パスワードの保存・Cookie・「社員/担当者/管理者」の線引き(この記事)









コメント
コメント一覧 (2件)
[…] […]
[…] […]