認証(Authentication)と認可(Authorization)は、アプリケーションセキュリティの基盤です。本記事では、モダンなWebアプリケーションにおける認証・認可パターンの設計と実装を解説します。
認証と認可の基本
概念の違い
認証 (Authentication) - “あなたは誰ですか?”
sequenceDiagram
participant U as ユーザー
participant S as システム
U->>S: 資格情報提示(ID/パスワード)
S->>U: 本人確認結果(認証トークン)
認可 (Authorization) - “何ができますか?“
sequenceDiagram
participant U as ユーザー
participant S as システム
U->>S: リソース要求(+ 認証トークン)
S->>S: 権限確認
S->>U: 許可/拒否
認証方式の比較
| 方式 | 特徴 | ユースケース |
|---|---|---|
| セッションベース | サーバー側で状態管理 | 従来型Webアプリ |
| JWT (ステートレス) | トークンに情報を含む | SPA、API |
| OAuth 2.0 / OIDC | 外部認証プロバイダ連携 | ソーシャルログイン |
| API Key | 静的なキー | サーバー間通信 |
| mTLS | 証明書ベース | マイクロサービス間 |
セッションベース認証
実装例
// session-auth.ts
import { randomBytes, createHash } from 'crypto';
import Redis from 'ioredis';
interface Session {
userId: string;
email: string;
role: string;
createdAt: number;
lastAccessedAt: number;
userAgent: string;
ip: string;
}
class SessionManager {
private redis: Redis;
private readonly SESSION_TTL = 24 * 60 * 60; // 24時間
private readonly SESSION_PREFIX = 'session:';
constructor(redisUrl: string) {
this.redis = new Redis(redisUrl);
}
async createSession(userId: string, userData: Partial<Session>, req: Request): Promise<string> {
const sessionId = this.generateSessionId();
const hashedId = this.hashSessionId(sessionId);
const session: Session = {
userId,
email: userData.email || '',
role: userData.role || 'user',
createdAt: Date.now(),
lastAccessedAt: Date.now(),
userAgent: req.headers.get('user-agent') || '',
ip: req.headers.get('x-forwarded-for') || '',
};
await this.redis.setex(
`${this.SESSION_PREFIX}${hashedId}`,
this.SESSION_TTL,
JSON.stringify(session)
);
// ユーザーのセッション一覧を管理
await this.redis.sadd(`user_sessions:${userId}`, hashedId);
return sessionId;
}
async getSession(sessionId: string): Promise<Session | null> {
const hashedId = this.hashSessionId(sessionId);
const data = await this.redis.get(`${this.SESSION_PREFIX}${hashedId}`);
if (!data) return null;
const session: Session = JSON.parse(data);
// 最終アクセス時刻を更新
session.lastAccessedAt = Date.now();
await this.redis.setex(
`${this.SESSION_PREFIX}${hashedId}`,
this.SESSION_TTL,
JSON.stringify(session)
);
return session;
}
async destroySession(sessionId: string): Promise<void> {
const hashedId = this.hashSessionId(sessionId);
const data = await this.redis.get(`${this.SESSION_PREFIX}${hashedId}`);
if (data) {
const session: Session = JSON.parse(data);
await this.redis.srem(`user_sessions:${session.userId}`, hashedId);
}
await this.redis.del(`${this.SESSION_PREFIX}${hashedId}`);
}
async destroyAllUserSessions(userId: string): Promise<void> {
const sessionIds = await this.redis.smembers(`user_sessions:${userId}`);
for (const hashedId of sessionIds) {
await this.redis.del(`${this.SESSION_PREFIX}${hashedId}`);
}
await this.redis.del(`user_sessions:${userId}`);
}
private generateSessionId(): string {
return randomBytes(32).toString('hex');
}
private hashSessionId(sessionId: string): string {
return createHash('sha256').update(sessionId).digest('hex');
}
}
// Cookieの設定
function setSessionCookie(response: Response, sessionId: string): Response {
const cookie = [
`session=${sessionId}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
'Path=/',
`Max-Age=${24 * 60 * 60}`,
].join('; ');
response.headers.set('Set-Cookie', cookie);
return response;
}
JWTベース認証
JWT実装
// jwt-auth.ts
import { SignJWT, jwtVerify, JWTPayload } from 'jose';
interface TokenPayload extends JWTPayload {
sub: string; // userId
email: string;
role: string;
type: 'access' | 'refresh';
}
interface TokenPair {
accessToken: string;
refreshToken: string;
}
class JWTManager {
private readonly accessSecret: Uint8Array;
private readonly refreshSecret: Uint8Array;
private readonly issuer = 'myapp';
private readonly audience = 'myapp-users';
private readonly ACCESS_TOKEN_TTL = '15m';
private readonly REFRESH_TOKEN_TTL = '7d';
constructor(accessSecret: string, refreshSecret: string) {
this.accessSecret = new TextEncoder().encode(accessSecret);
this.refreshSecret = new TextEncoder().encode(refreshSecret);
}
async generateTokenPair(userId: string, userData: { email: string; role: string }): Promise<TokenPair> {
const now = new Date();
const accessToken = await new SignJWT({
sub: userId,
email: userData.email,
role: userData.role,
type: 'access',
})
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
.setIssuer(this.issuer)
.setAudience(this.audience)
.setExpirationTime(this.ACCESS_TOKEN_TTL)
.sign(this.accessSecret);
const refreshToken = await new SignJWT({
sub: userId,
type: 'refresh',
})
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
.setIssuer(this.issuer)
.setAudience(this.audience)
.setExpirationTime(this.REFRESH_TOKEN_TTL)
.sign(this.refreshSecret);
return { accessToken, refreshToken };
}
async verifyAccessToken(token: string): Promise<TokenPayload | null> {
try {
const { payload } = await jwtVerify(token, this.accessSecret, {
issuer: this.issuer,
audience: this.audience,
});
if (payload.type !== 'access') {
return null;
}
return payload as TokenPayload;
} catch (error) {
return null;
}
}
async verifyRefreshToken(token: string): Promise<TokenPayload | null> {
try {
const { payload } = await jwtVerify(token, this.refreshSecret, {
issuer: this.issuer,
audience: this.audience,
});
if (payload.type !== 'refresh') {
return null;
}
return payload as TokenPayload;
} catch (error) {
return null;
}
}
async refreshTokens(
refreshToken: string,
getUserData: (userId: string) => Promise<{ email: string; role: string } | null>
): Promise<TokenPair | null> {
const payload = await this.verifyRefreshToken(refreshToken);
if (!payload || !payload.sub) {
return null;
}
const userData = await getUserData(payload.sub);
if (!userData) {
return null;
}
return this.generateTokenPair(payload.sub, userData);
}
}
// ミドルウェア
async function authMiddleware(
request: Request,
jwtManager: JWTManager
): Promise<TokenPayload | Response> {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
const token = authHeader.slice(7);
const payload = await jwtManager.verifyAccessToken(token);
if (!payload) {
return new Response('Invalid token', { status: 401 });
}
return payload;
}
トークンのリフレッシュフロー
sequenceDiagram
participant C as Client
participant S as Server
rect rgb(240, 248, 255)
Note over C,S: 1. 初回ログイン
C->>S: POST /auth/login<br/>{email, password}
S->>C: 200 OK<br/>{accessToken, refreshToken}
end
rect rgb(245, 255, 245)
Note over C,S: 2. API呼び出し
C->>S: GET /api/resource<br/>Authorization: Bearer {accessToken}
S->>C: 200 OK / 401 Unauthorized
end
rect rgb(255, 250, 240)
Note over C,S: 3. トークンリフレッシュ(accessToken期限切れ時)
C->>S: POST /auth/refresh<br/>{refreshToken}
S->>C: 200 OK<br/>{accessToken, refreshToken}(両方新しいトークン)
end
認可パターン
RBAC (Role-Based Access Control)
// rbac.ts
type Permission =
| 'read:users' | 'write:users' | 'delete:users'
| 'read:posts' | 'write:posts' | 'delete:posts'
| 'admin:*';
type Role = 'guest' | 'user' | 'moderator' | 'admin';
const rolePermissions: Record<Role, Permission[]> = {
guest: ['read:posts'],
user: ['read:posts', 'write:posts', 'read:users'],
moderator: ['read:posts', 'write:posts', 'delete:posts', 'read:users'],
admin: ['admin:*'],
};
class RBACAuthorizer {
hasPermission(role: Role, permission: Permission): boolean {
const permissions = rolePermissions[role];
if (permissions.includes('admin:*')) {
return true;
}
return permissions.includes(permission);
}
hasAnyPermission(role: Role, permissions: Permission[]): boolean {
return permissions.some(p => this.hasPermission(role, p));
}
hasAllPermissions(role: Role, permissions: Permission[]): boolean {
return permissions.every(p => this.hasPermission(role, p));
}
}
// ミドルウェア
function requirePermission(...permissions: Permission[]) {
return async function (request: Request, user: TokenPayload): Promise<Response | null> {
const authorizer = new RBACAuthorizer();
if (!authorizer.hasAllPermissions(user.role as Role, permissions)) {
return new Response('Forbidden', { status: 403 });
}
return null; // 許可
};
}
// 使用例
app.delete('/api/posts/:id', async (req) => {
const user = await authMiddleware(req, jwtManager);
if (user instanceof Response) return user;
const forbidden = await requirePermission('delete:posts')(req, user);
if (forbidden) return forbidden;
// 削除処理...
});
ABAC (Attribute-Based Access Control)
// abac.ts
interface Resource {
type: string;
ownerId: string;
attributes: Record<string, any>;
}
interface Subject {
id: string;
role: string;
department: string;
attributes: Record<string, any>;
}
interface Context {
time: Date;
ip: string;
location?: string;
}
type PolicyCondition = (subject: Subject, resource: Resource, context: Context) => boolean;
interface Policy {
name: string;
effect: 'allow' | 'deny';
actions: string[];
resources: string[];
condition: PolicyCondition;
}
class ABACAuthorizer {
private policies: Policy[] = [];
addPolicy(policy: Policy): void {
this.policies.push(policy);
}
evaluate(
action: string,
subject: Subject,
resource: Resource,
context: Context
): boolean {
// マッチするポリシーを検索
const matchingPolicies = this.policies.filter(policy => {
const actionMatch = policy.actions.includes(action) || policy.actions.includes('*');
const resourceMatch = policy.resources.includes(resource.type) || policy.resources.includes('*');
return actionMatch && resourceMatch;
});
// デフォルト拒否
if (matchingPolicies.length === 0) {
return false;
}
// 条件を評価
for (const policy of matchingPolicies) {
const conditionResult = policy.condition(subject, resource, context);
if (policy.effect === 'deny' && conditionResult) {
return false;
}
if (policy.effect === 'allow' && conditionResult) {
return true;
}
}
return false;
}
}
// ポリシー定義
const authorizer = new ABACAuthorizer();
// 自分のリソースは編集可能
authorizer.addPolicy({
name: 'owner-can-edit',
effect: 'allow',
actions: ['update', 'delete'],
resources: ['post', 'comment'],
condition: (subject, resource) => subject.id === resource.ownerId,
});
// 管理者はすべて可能
authorizer.addPolicy({
name: 'admin-full-access',
effect: 'allow',
actions: ['*'],
resources: ['*'],
condition: (subject) => subject.role === 'admin',
});
// 同じ部署のリソースは閲覧可能
authorizer.addPolicy({
name: 'same-department-read',
effect: 'allow',
actions: ['read'],
resources: ['document'],
condition: (subject, resource) =>
subject.department === resource.attributes.department,
});
// 営業時間外はアクセス制限
authorizer.addPolicy({
name: 'business-hours-only',
effect: 'deny',
actions: ['*'],
resources: ['sensitive-data'],
condition: (_, __, context) => {
const hour = context.time.getHours();
return hour < 9 || hour >= 18; // 9-18時以外は拒否
},
});
リソースベースのアクセス制御
// resource-based-auth.ts
interface Post {
id: string;
authorId: string;
status: 'draft' | 'published' | 'archived';
visibility: 'public' | 'private' | 'members-only';
}
class PostAuthorizer {
canRead(user: Subject | null, post: Post): boolean {
// 公開投稿は誰でも閲覧可能
if (post.status === 'published' && post.visibility === 'public') {
return true;
}
// 未ログインユーザーは公開投稿のみ
if (!user) {
return false;
}
// 自分の投稿は常に閲覧可能
if (post.authorId === user.id) {
return true;
}
// 管理者はすべて閲覧可能
if (user.role === 'admin' || user.role === 'moderator') {
return true;
}
// メンバー限定投稿
if (post.visibility === 'members-only' && post.status === 'published') {
return true;
}
return false;
}
canUpdate(user: Subject, post: Post): boolean {
// 著者のみ編集可能
if (post.authorId === user.id) {
return true;
}
// 管理者も編集可能
if (user.role === 'admin') {
return true;
}
return false;
}
canDelete(user: Subject, post: Post): boolean {
// 著者のみ削除可能(下書きのみ)
if (post.authorId === user.id && post.status === 'draft') {
return true;
}
// 管理者はすべて削除可能
if (user.role === 'admin') {
return true;
}
return false;
}
canPublish(user: Subject, post: Post): boolean {
// 著者のみ公開可能
if (post.authorId === user.id) {
return true;
}
// 管理者も公開可能
if (user.role === 'admin') {
return true;
}
return false;
}
}
// 使用例
const postAuth = new PostAuthorizer();
app.get('/api/posts/:id', async (req, { params }) => {
const user = await getOptionalUser(req); // nullの可能性あり
const post = await getPost(params.id);
if (!post) {
return new Response('Not Found', { status: 404 });
}
if (!postAuth.canRead(user, post)) {
return new Response('Forbidden', { status: 403 });
}
return Response.json(post);
});
多要素認証 (MFA)
TOTP実装
// mfa.ts
import { createHmac, randomBytes } from 'crypto';
class TOTPManager {
private readonly DIGITS = 6;
private readonly PERIOD = 30; // 秒
private readonly ALGORITHM = 'sha1';
generateSecret(): string {
return randomBytes(20).toString('base32').slice(0, 16);
}
generateOTP(secret: string, counter?: number): string {
const time = counter ?? Math.floor(Date.now() / 1000 / this.PERIOD);
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(time));
const decodedSecret = this.base32Decode(secret);
const hmac = createHmac(this.ALGORITHM, decodedSecret)
.update(timeBuffer)
.digest();
const offset = hmac[hmac.length - 1] & 0xf;
const binary =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
const otp = binary % Math.pow(10, this.DIGITS);
return otp.toString().padStart(this.DIGITS, '0');
}
verifyOTP(secret: string, otp: string, window: number = 1): boolean {
const currentCounter = Math.floor(Date.now() / 1000 / this.PERIOD);
for (let i = -window; i <= window; i++) {
const expectedOTP = this.generateOTP(secret, currentCounter + i);
if (this.timingSafeEqual(otp, expectedOTP)) {
return true;
}
}
return false;
}
generateQRCodeURL(secret: string, email: string, issuer: string): string {
const encodedIssuer = encodeURIComponent(issuer);
const encodedEmail = encodeURIComponent(email);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=${this.DIGITS}&period=${this.PERIOD}`;
}
private base32Decode(input: string): Buffer {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const char of input.toUpperCase()) {
const val = alphabet.indexOf(char);
if (val === -1) continue;
bits += val.toString(2).padStart(5, '0');
}
const bytes: number[] = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
bytes.push(parseInt(bits.slice(i, i + 8), 2));
}
return Buffer.from(bytes);
}
private timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
}
// バックアップコード
class BackupCodeManager {
generateCodes(count: number = 10): string[] {
const codes: string[] = [];
for (let i = 0; i < count; i++) {
const code = randomBytes(4).toString('hex').toUpperCase();
codes.push(`${code.slice(0, 4)}-${code.slice(4)}`);
}
return codes;
}
hashCode(code: string): string {
return createHash('sha256')
.update(code.replace('-', ''))
.digest('hex');
}
}
セキュリティベストプラクティス
パスワードポリシー
// password-policy.ts
interface PasswordValidationResult {
valid: boolean;
errors: string[];
strength: 'weak' | 'medium' | 'strong';
}
function validatePassword(password: string): PasswordValidationResult {
const errors: string[] = [];
// 長さチェック
if (password.length < 8) {
errors.push('パスワードは8文字以上必要です');
}
if (password.length > 128) {
errors.push('パスワードは128文字以下にしてください');
}
// 複雑性チェック
if (!/[a-z]/.test(password)) {
errors.push('小文字を含める必要があります');
}
if (!/[A-Z]/.test(password)) {
errors.push('大文字を含める必要があります');
}
if (!/[0-9]/.test(password)) {
errors.push('数字を含める必要があります');
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
errors.push('記号を含める必要があります');
}
// 一般的なパスワードチェック
const commonPasswords = ['password', '123456', 'qwerty', 'admin'];
if (commonPasswords.some(p => password.toLowerCase().includes(p))) {
errors.push('一般的すぎるパスワードは使用できません');
}
// 強度計算
let strength: 'weak' | 'medium' | 'strong' = 'weak';
if (errors.length === 0) {
if (password.length >= 12 && /[!@#$%^&*(),.?":{}|<>]/.test(password)) {
strength = 'strong';
} else {
strength = 'medium';
}
}
return {
valid: errors.length === 0,
errors,
strength,
};
}
レート制限
// rate-limiter.ts
class RateLimiter {
private requests: Map<string, number[]> = new Map();
constructor(
private maxRequests: number,
private windowMs: number
) {}
isAllowed(key: string): boolean {
const now = Date.now();
const windowStart = now - this.windowMs;
let timestamps = this.requests.get(key) || [];
// 古いリクエストを除去
timestamps = timestamps.filter(t => t > windowStart);
if (timestamps.length >= this.maxRequests) {
return false;
}
timestamps.push(now);
this.requests.set(key, timestamps);
return true;
}
getRemainingRequests(key: string): number {
const now = Date.now();
const windowStart = now - this.windowMs;
const timestamps = (this.requests.get(key) || []).filter(t => t > windowStart);
return Math.max(0, this.maxRequests - timestamps.length);
}
}
// ログイン試行制限
const loginLimiter = new RateLimiter(5, 15 * 60 * 1000); // 15分で5回
app.post('/auth/login', async (req) => {
const ip = req.headers.get('x-forwarded-for') || 'unknown';
if (!loginLimiter.isAllowed(ip)) {
return new Response('Too many login attempts', {
status: 429,
headers: {
'Retry-After': '900', // 15分
},
});
}
// ログイン処理...
});
まとめ
認証・認可は、アプリケーションセキュリティの根幹です。
選択ガイドライン
| 要件 | 推奨パターン |
|---|---|
| 従来型Webアプリ | セッション + RBAC |
| SPA/モバイル | JWT + OAuth 2.0 |
| マイクロサービス | JWT + mTLS |
| 複雑な権限 | ABAC |
| シンプルな権限 | RBAC |
セキュリティチェックリスト
- パスワード: 適切なハッシュ化 (Argon2id/bcrypt)
- トークン: 適切な有効期限と更新
- セッション: HTTPOnly, Secure, SameSite Cookie
- MFA: 重要な操作には必須
- レート制限: ブルートフォース対策
- 監査ログ: 認証イベントの記録
適切な認証・認可の実装により、安全なアプリケーションを構築できます。