shield

認証・認可パターン完全ガイド - セキュアなアクセス制御の設計

2025.12.02

認証(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

セキュリティチェックリスト

  1. パスワード: 適切なハッシュ化 (Argon2id/bcrypt)
  2. トークン: 適切な有効期限と更新
  3. セッション: HTTPOnly, Secure, SameSite Cookie
  4. MFA: 重要な操作には必須
  5. レート制限: ブルートフォース対策
  6. 監査ログ: 認証イベントの記録

適切な認証・認可の実装により、安全なアプリケーションを構築できます。

参考リンク

← 一覧に戻る