diff --git a/.env.example b/.env.example index 9b70200..68b12ce 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ DATABASE_URL=postgresql://user:password@host/database?sslmode=require BETTER_AUTH_SECRET=your-secret-key-here BETTER_AUTH_URL=http://localhost:3000 +TRUSTED_ORIGINS=http://localhost:5173 +CORS_ORIGIN=http://localhost:5173 +RESEND_API_KEY=re_xxxxxxxxx +EMAIL_FROM_ADDRESS=no-reply@example.com +EMAIL_FROM_NAME=tascal +EMAIL_SEND_TIMEOUT_MS=3000 +PASSWORD_RESET_MIN_RESPONSE_MS=3000 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index df3d549..abd2327 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -56,8 +56,10 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - # Cloud Run の環境変数(DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, TRUSTED_ORIGINS)は - # Google Cloud コンソールまたは gcloud CLI で事前に設定してください + # Cloud Run の環境変数(DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, + # TRUSTED_ORIGINS, EMAIL_FROM_ADDRESS, EMAIL_FROM_NAME)は Google Cloud + # コンソールまたは gcloud CLI で事前に設定してください。 + # RESEND_API_KEY は Secret Manager から Cloud Run に割り当てます。 # GitHub Secrets にも DATABASE_URL の設定が必要です(マイグレーション実行用) - name: Deploy to Cloud Run run: | diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 4133fb4..af11757 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -98,6 +98,8 @@ jobs: - name: Deploy to Cloud Run run: | + # PR preview には意図的に RESEND_API_KEY / EMAIL_FROM_* を渡さない。 + # 未レビューコードからメール送信資格情報を隔離する。 IMAGE=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }} gcloud run deploy ${{ env.SERVICE_NAME }} \ --image $IMAGE \ diff --git a/apps/api/.env.example b/apps/api/.env.example index ee1233a..38292fb 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,4 +1,10 @@ DATABASE_URL=postgresql://tascal:tascal@localhost:5432/tascal BETTER_AUTH_SECRET=your-secret-key-here BETTER_AUTH_URL=http://localhost:3000 +TRUSTED_ORIGINS=http://localhost:5173 CORS_ORIGIN=http://localhost:5173 +RESEND_API_KEY=re_xxxxxxxxx +EMAIL_FROM_ADDRESS=no-reply@example.com +EMAIL_FROM_NAME=tascal +EMAIL_SEND_TIMEOUT_MS=3000 +PASSWORD_RESET_MIN_RESPONSE_MS=3000 diff --git a/apps/api/package.json b/apps/api/package.json index 3c94e7d..067b9c1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -31,6 +31,7 @@ "hono": "^4.12.34", "pg": "^8.20.0", "pino": "^10.3.1", + "resend": "^6.20.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index cb1621a..34b4c33 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,6 +5,7 @@ import { HTTPException } from "hono/http-exception"; import { cors } from "hono/cors"; import { getAuth } from "./auth.js"; import type { Auth } from "./auth.js"; +import { handleAuthRequest } from "./auth-handler.js"; import { getDb } from "./db/index.js"; import logger from "./logger.js"; import categoriesApp from "./routes/categories.js"; @@ -14,12 +15,20 @@ import usersApp from "./routes/users.js"; type AuthVariables = { user: Auth["$Infer"]["Session"]["user"] | null; session: Auth["$Infer"]["Session"]["session"] | null; + requestId: string; }; const SKIP_LOG_PATHS = new Set(["/healthz"]); const app = new Hono<{ Variables: AuthVariables }>(); +app.use("*", async (c, next) => { + const requestId = crypto.randomUUID(); + c.set("requestId", requestId); + c.header("X-Request-ID", requestId); + await next(); +}); + // リクエストログミドルウェア app.use("*", async (c, next) => { if (SKIP_LOG_PATHS.has(c.req.path)) { @@ -41,6 +50,7 @@ app.use("*", async (c, next) => { path: c.req.path, status, duration, + requestId: c.get("requestId"), }, `${c.req.method} ${c.req.path} ${status} ${duration}ms`, ); @@ -55,6 +65,7 @@ app.onError((err, c) => { method: c.req.method, path: c.req.path, status: err.status, + requestId: c.get("requestId"), }, `HTTP error: ${err.message}`, ); @@ -66,6 +77,7 @@ app.onError((err, c) => { err, method: c.req.method, path: c.req.path, + requestId: c.get("requestId"), }, `Unhandled error: ${err.message}`, ); @@ -111,8 +123,17 @@ app.use("/api/*", async (c, next) => { await next(); }); -app.on(["POST", "GET"], "/api/auth/**", (c) => { - return getAuth().handler(c.req.raw); +app.on(["POST", "GET"], "/api/auth/**", async (c) => { + const configuredMinimum = Number(process.env.PASSWORD_RESET_MIN_RESPONSE_MS); + return handleAuthRequest({ + request: c.req.raw, + requestId: c.get("requestId"), + handler: (request) => getAuth().handler(request), + passwordResetMinimumMs: + Number.isFinite(configuredMinimum) && configuredMinimum >= 0 + ? configuredMinimum + : undefined, + }); }); // RPC 型推論のためチェイン形式でルートをマウント diff --git a/apps/api/src/auth-email.test.ts b/apps/api/src/auth-email.test.ts new file mode 100644 index 0000000..136a477 --- /dev/null +++ b/apps/api/src/auth-email.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import { getTestInstance } from "better-auth/test"; +import { createAuthOptions } from "./auth.js"; +import type { TransactionalEmail, TransactionalEmailSender } from "./email.js"; + +function createCollectingSender() { + const messages: TransactionalEmail[] = []; + const sender: TransactionalEmailSender = { + send(message) { + messages.push(message); + return Promise.resolve(); + }, + }; + return { messages, sender }; +} + +async function postJson( + instance: { + customFetchImpl: (url: string, init?: RequestInit) => Promise; + }, + path: string, + body: unknown, +) { + return instance.customFetchImpl(`http://localhost:3000/api/auth${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: JSON.stringify(body), + }); +} + +describe("email verification", () => { + it("新規登録では session を作らず、確認後にだけログインでき、再送も sign-in に限定する", async () => { + const { messages, sender } = createCollectingSender(); + const instance = await getTestInstance(createAuthOptions(sender)); + messages.length = 0; + const email = "new-user@example.com"; + const password = "password123"; + + const signUp = await postJson(instance, "/sign-up/email", { + name: "New User", + email, + password, + }); + expect(signUp.status).toBe(200); + expect(await signUp.json()).toMatchObject({ token: null }); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + purpose: "email_verification", + to: email, + }); + + const blockedSignIn = await postJson(instance, "/sign-in/email", { + email, + password, + }); + expect(blockedSignIn.status).toBe(403); + expect(await blockedSignIn.json()).toMatchObject({ + code: "EMAIL_NOT_VERIFIED", + }); + expect(messages).toHaveLength(2); + + const publicResend = await postJson(instance, "/send-verification-email", { + email, + callbackURL: "/login?verified=true", + }); + expect(publicResend.status).toBe(404); + + const verify = await instance.customFetchImpl( + messages[1].text.match(/https?:\/\/\S+/)![0], + { + redirect: "manual", + }, + ); + expect(verify.status).toBe(302); + expect(verify.headers.get("location")).toContain("/login?verified=true"); + + const signedIn = await postJson(instance, "/sign-in/email", { + email, + password, + }); + expect(signedIn.status).toBe(200); + const signedInBody = (await signedIn.json()) as { token?: unknown }; + expect(typeof signedInBody.token).toBe("string"); + }); + + it("不正・期限切れの確認 token を処理せず callback にエラーを返す", async () => { + const { messages, sender } = createCollectingSender(); + const options = createAuthOptions(sender); + const instance = await getTestInstance({ + ...options, + emailVerification: { ...options.emailVerification, expiresIn: -1 }, + }); + messages.length = 0; + + await postJson(instance, "/sign-up/email", { + name: "Expired User", + email: "expired@example.com", + password: "password123", + callbackURL: "/login?verified=true", + }); + const expired = await instance.customFetchImpl( + messages[0].text.match(/https?:\/\/\S+/)![0], + { redirect: "manual" }, + ); + expect(expired.status).toBe(302); + expect(expired.headers.get("location")).toContain("error=TOKEN_EXPIRED"); + + const invalid = await instance.customFetchImpl( + "http://localhost:3000/api/auth/verify-email?token=invalid&callbackURL=%2Flogin%3Fverified%3Dtrue", + { redirect: "manual" }, + ); + expect(invalid.status).toBe(302); + expect(invalid.headers.get("location")).toContain("error=INVALID_TOKEN"); + }); +}); + +describe("password reset", () => { + it("登録有無を同じ応答にし、再設定後は既存 session を失効して新 password だけを受け付ける", async () => { + const { messages, sender } = createCollectingSender(); + const instance = await getTestInstance(createAuthOptions(sender)); + messages.length = 0; + const email = "reset-user@example.com"; + const oldPassword = "password123"; + const newPassword = "new-password123"; + + await postJson(instance, "/sign-up/email", { + name: "Reset User", + email, + password: oldPassword, + callbackURL: "/login?verified=true", + }); + await instance.customFetchImpl( + messages[0].text.match(/https?:\/\/\S+/)![0], + { + redirect: "manual", + }, + ); + + const signIn = await postJson(instance, "/sign-in/email", { + email, + password: oldPassword, + }); + const cookie = signIn.headers.get("set-cookie")!; + + const known = await postJson(instance, "/request-password-reset", { + email, + redirectTo: "/reset-password", + }); + const unknown = await postJson(instance, "/request-password-reset", { + email: "unknown@example.com", + redirectTo: "/reset-password", + }); + expect(await known.json()).toEqual(await unknown.json()); + + const resetMessage = messages.find( + (message) => message.purpose === "password_reset", + )!; + const callback = await instance.customFetchImpl( + resetMessage.text.match(/https?:\/\/\S+/)![0], + { redirect: "manual" }, + ); + const location = callback.headers.get("location")!; + const token = new URL(location, "http://localhost:3000").searchParams.get( + "token", + )!; + + const reset = await postJson(instance, "/reset-password", { + token, + newPassword, + }); + expect(reset.status).toBe(200); + + const oldSession = await instance.customFetchImpl( + "http://localhost:3000/api/auth/get-session", + { headers: { Cookie: cookie } }, + ); + expect(await oldSession.json()).toBeNull(); + expect( + ( + await postJson(instance, "/sign-in/email", { + email, + password: oldPassword, + }) + ).status, + ).toBe(401); + expect( + ( + await postJson(instance, "/sign-in/email", { + email, + password: newPassword, + }) + ).status, + ).toBe(200); + + expect( + ( + await postJson(instance, "/reset-password", { + token, + newPassword: "another-password123", + }) + ).status, + ).toBe(400); + expect( + ( + await postJson(instance, "/reset-password", { + token: "invalid", + newPassword: "another-password123", + }) + ).status, + ).toBe(400); + }); +}); diff --git a/apps/api/src/auth-handler.ts b/apps/api/src/auth-handler.ts new file mode 100644 index 0000000..3dea04c --- /dev/null +++ b/apps/api/src/auth-handler.ts @@ -0,0 +1,58 @@ +import { withEmailDeliveryContext } from "./email-delivery-context.js"; + +const GENERIC_RESET_RESPONSE = { + status: true, + message: + "If this email exists in our system, check your email for the reset link", +}; + +type AuthRequestHandlerOptions = { + request: Request; + requestId: string; + handler: (request: Request) => Promise; + passwordResetMinimumMs?: number; +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export async function handleAuthRequest({ + request, + requestId, + handler, + passwordResetMinimumMs = 3_000, +}: AuthRequestHandlerOptions): Promise { + const startedAt = Date.now(); + const requestHeaders = new Headers(request.headers); + requestHeaders.set("x-request-id", requestId); + const requestWithId = new Request(request, { headers: requestHeaders }); + const { result, failures } = await withEmailDeliveryContext(() => + handler(requestWithId), + ); + + if (new URL(request.url).pathname.endsWith("/request-password-reset")) { + const remaining = passwordResetMinimumMs - (Date.now() - startedAt); + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining)); + } + + return result.ok ? jsonResponse(GENERIC_RESET_RESPONSE) : result; + } + + if (failures.length > 0) { + return jsonResponse( + { + code: "EMAIL_DELIVERY_UNAVAILABLE", + message: + "メールを送信できませんでした。時間をおいて再度お試しください。", + }, + 503, + ); + } + + return result; +} diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 5d3bc7d..c63b1e6 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -4,6 +4,14 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { bearer, customSession } from "better-auth/plugins"; import { getDb } from "./db/index.js"; import * as schema from "./db/schema.js"; +import { + createEmailSenderFromEnv, + createPasswordResetEmail, + createVerificationEmail, + deliverAuthEmail, + getEmailRequestId, + type TransactionalEmailSender, +} from "./email.js"; import logger from "./logger.js"; function maskEmail(email: string): string { @@ -82,16 +90,57 @@ export function createPublicSessionListPlugin() { } satisfies BetterAuthPlugin; } +export function createAuthOptions(sender: TransactionalEmailSender) { + return { + disabledPaths: ["/send-verification-email"] as string[], + emailVerification: { + expiresIn: 60 * 60, + sendOnSignUp: true, + sendOnSignIn: true, + autoSignInAfterVerification: false, + sendVerificationEmail: async ( + { user, url }: { user: { email: string }; url: string }, + request?: Request, + ) => { + const verificationUrl = new URL(url); + verificationUrl.searchParams.set("callbackURL", "/login?verified=true"); + await deliverAuthEmail( + sender, + createVerificationEmail( + user.email, + verificationUrl.toString(), + getEmailRequestId(request), + ), + ); + }, + }, + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + resetPasswordTokenExpiresIn: 60 * 60, + revokeSessionsOnPasswordReset: true, + sendResetPassword: async ( + { user, url }: { user: { email: string }; url: string }, + request?: Request, + ) => { + await deliverAuthEmail( + sender, + createPasswordResetEmail(user.email, url, getEmailRequestId(request)), + ); + }, + }, + } as const; +} + function createAuth() { + const emailSender = createEmailSenderFromEnv(); return betterAuth({ database: drizzleAdapter(getDb(), { provider: "pg", schema, usePlural: true, }), - emailAndPassword: { - enabled: true, - }, + ...createAuthOptions(emailSender), plugins: [ bearer(), createPublicSessionPlugin(), diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 5e3b57a..f821a09 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -90,6 +90,11 @@ async function seed() { console.log(`✅ Created seed user (id: ${userId})`); } + await db + .update(users) + .set({ emailVerified: true }) + .where(eq(users.id, userId)); + // Insert sample tasks (skip if tasks already exist for this user) const existingTasks = await db .select() diff --git a/apps/api/src/email-delivery-context.ts b/apps/api/src/email-delivery-context.ts new file mode 100644 index 0000000..1d9664e --- /dev/null +++ b/apps/api/src/email-delivery-context.ts @@ -0,0 +1,36 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export type EmailPurpose = "email_verification" | "password_reset"; + +export type EmailFailureType = + | "configuration" + | "rejected" + | "timeout" + | "unreachable" + | "unknown"; + +type EmailDeliveryFailure = { + purpose: EmailPurpose; + failureType: EmailFailureType; + requestId: string; +}; + +type DeliveryContext = { + failures: EmailDeliveryFailure[]; +}; + +const deliveryStorage = new AsyncLocalStorage(); + +export function recordEmailDeliveryFailure( + failure: EmailDeliveryFailure, +): void { + deliveryStorage.getStore()?.failures.push(failure); +} + +export async function withEmailDeliveryContext( + callback: () => Promise, +): Promise<{ result: T; failures: EmailDeliveryFailure[] }> { + const context: DeliveryContext = { failures: [] }; + const result = await deliveryStorage.run(context, callback); + return { result, failures: context.failures }; +} diff --git a/apps/api/src/email.test.ts b/apps/api/src/email.test.ts new file mode 100644 index 0000000..d39bd07 --- /dev/null +++ b/apps/api/src/email.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { warn } = vi.hoisted(() => ({ warn: vi.fn() })); + +vi.mock("./logger.js", () => ({ + default: { warn }, +})); + +import { handleAuthRequest } from "./auth-handler.js"; +import { + createPasswordResetEmail, + createResendEmailSender, + createVerificationEmail, + deliverAuthEmail, + EmailDeliveryError, + type TransactionalEmail, + type TransactionalEmailSender, +} from "./email.js"; + +const baseMessage: TransactionalEmail = { + purpose: "email_verification", + to: "user@example.com", + subject: "subject", + text: "text", + html: "

html

", + requestId: "request-123", +}; + +describe("Resend transactional email sender", () => { + beforeEach(() => { + warn.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("text / HTML の両方を渡し、Resend の受付成功を完了として扱う", async () => { + const send = vi.fn().mockResolvedValue({ + data: { id: "email-id" }, + error: null, + headers: {}, + }); + const sender = createResendEmailSender({ + apiKey: "test-key", + fromEmail: "no-reply@example.com", + fromName: "tascal", + client: { emails: { send } }, + }); + + await sender.send(baseMessage); + + expect(send).toHaveBeenCalledOnce(); + expect(send.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + from: "tascal ", + to: "user@example.com", + text: "text", + html: "

html

", + }), + ); + const requestOptions = send.mock.calls[0]?.[1] as + | { signal?: unknown } + | undefined; + expect(requestOptions?.signal).toBeInstanceOf(AbortSignal); + }); + + it("Resend が返す error を安全な拒否分類へ変換する", async () => { + const sender = createResendEmailSender({ + apiKey: "test-key", + fromEmail: "no-reply@example.com", + fromName: "tascal", + client: { + emails: { + send: vi.fn().mockResolvedValue({ + data: null, + error: { + name: "rate_limit_exceeded", + statusCode: 429, + message: "provider detail must stay private", + }, + headers: {}, + }), + }, + } as never, + }); + + await expect(sender.send(baseMessage)).rejects.toMatchObject({ + failureType: "rejected", + }); + }); + + it("SDK の provider response detail を console へ出力しない", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + name: "validation_error", + statusCode: 403, + message: "private provider response body", + }), + { status: 403 }, + ), + ), + ); + const sender = createResendEmailSender({ + apiKey: "test-key", + fromEmail: "no-reply@example.com", + fromName: "tascal", + }); + + await expect(sender.send(baseMessage)).rejects.toMatchObject({ + failureType: "rejected", + }); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it("送信 timeout を中断し、安全な timeout 分類へ変換する", async () => { + const sender = createResendEmailSender({ + apiKey: "test-key", + fromEmail: "no-reply@example.com", + fromName: "tascal", + timeoutMs: 5, + client: { + emails: { + send: vi.fn((_payload, options) => { + const signal = (options as unknown as { signal: AbortSignal }) + .signal; + return new Promise((resolve) => { + signal.addEventListener("abort", () => + resolve({ + data: null, + error: { + name: "application_error", + statusCode: null, + message: "aborted", + }, + headers: null, + }), + ); + }); + }), + }, + } as never, + }); + + await expect(sender.send(baseMessage)).rejects.toMatchObject({ + failureType: "timeout", + }); + }); + + it("HTML に埋め込む URL を escape する", () => { + const verification = createVerificationEmail( + "user@example.com", + 'https://example.com/verify?a=1&value=">