Files
opengroupware/lib/auth/session.ts
Ken Yasue 21b9f03e9d feat(m3): auth & user management with session, tests, and e2e
- Custom error classes (lib/errors.ts) and signed-cookie session
  (lib/auth/session.ts, HMAC-SHA256, httpOnly+sameSite=lax)
- UserRepository + AuthService (bcrypt hashing, role/status checks,
  register/login/logout/getCurrentUser/updateProfile)
- Auth helpers (getCurrentUser), user validator, API error handler
- API routes: register (auto-login), login, logout, me, PATCH users/me,
  GET admin/migrations (admin-only guard)
- middleware.ts redirects unauthenticated page access to /login
- Screens: login (login/register toggle), profile, dashboard skeleton,
  home redirect
- vitest: exclude e2e specs, setupFiles for SESSION_SECRET
- playwright: run migrate before dev server, set SESSION_SECRET
- Unit tests (UserRepository, AuthService, session), integration
  auth-flow, e2e auth.spec (register/login/logout/protected/401)
2026-06-25 00:36:42 +02:00

94 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import crypto from 'node:crypto';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { SESSION_COOKIE, SESSION_MAX_AGE_SECONDS } from './constants';
/**
* 署名付きCookieによるステートレスセッション。
* トークン形式: base64url(payloadJson).base64url(hmacSha256(secret, encodedPayload))
* payload = { uid, iat }
*/
function getSecret(): string {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error('SESSION_SECRET is not configured');
}
return secret;
}
function sign(encodedPayload: string): string {
return crypto
.createHmac('sha256', getSecret())
.update(encodedPayload)
.digest('base64url');
}
/**
* ユーザーIDからセッショントークンを生成する
*/
export function createSessionToken(userId: number): string {
const payload = JSON.stringify({ uid: userId, iat: Date.now() });
const encoded = Buffer.from(payload, 'utf-8').toString('base64url');
return `${encoded}.${sign(encoded)}`;
}
/**
* セッショントークンを検証し、ユーザーIDを返す不正時はnull
*/
export function verifySessionToken(token: string): number | null {
const parts = token.split('.');
if (parts.length !== 2) return null;
const [encoded, signature] = parts;
const expected = sign(encoded);
const received = Buffer.from(signature);
const wanted = Buffer.from(expected);
if (received.length !== wanted.length) return null;
if (!crypto.timingSafeEqual(received, wanted)) return null;
try {
const payload = JSON.parse(
Buffer.from(encoded, 'base64url').toString('utf-8')
) as { uid?: unknown };
if (typeof payload.uid !== 'number') return null;
return payload.uid;
} catch {
return null;
}
}
/**
* 現在のリクエスト(Server Component / Route Handler)からセッションユーザーIDを取得する
*/
export async function getSessionUserId(): Promise<number | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
return verifySessionToken(token);
}
/**
* レスポンスにセッションCookieを設定する
*/
export function setSessionCookie(res: NextResponse, token: string): void {
res.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
maxAge: SESSION_MAX_AGE_SECONDS,
});
}
/**
* レスポンスのセッションCookieを削除するログアウト
*/
export function clearSessionCookie(res: NextResponse): void {
res.cookies.set(SESSION_COOKIE, '', {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 0,
});
}