TechHub

エンジニアの成長をサポートする技術情報サイト

← 記事一覧に戻る

Web APIの認証と認可とは?JWTとOAuth 2.0の実装方法

公開日: 2024年2月14日 著者: mogura
Web APIの認証と認可とは?JWTとOAuth 2.0の実装方法

疑問

Web APIの認証と認可を実装するには、どのような方法があるのでしょうか?JWTとOAuth 2.0の使い方について一緒に学んでいきましょう。

導入

Web APIの認証と認可は、セキュアなアプリケーションを構築するための重要な要素です。適切な認証・認可メカニズムを実装することで、不正アクセスを防ぎ、ユーザーデータを保護できます。

本記事では、認証と認可の基本概念から、JWT、OAuth 2.0の実装方法、セキュリティのベストプラクティスまで、詳しく解説していきます。

Web API認証のイメージ

解説

1. 認証と認可の違い

認証と認可は、セキュリティの重要な概念ですが、しばしば混同されます。認証は「あなたは誰ですか?」という質問に答えるプロセスで、ユーザーの身元を確認します。一方、認可は「あなたは何ができますか?」という質問に答えるプロセスで、ユーザーの権限を確認します。この2つの違いを理解することで、適切なセキュリティ実装が可能になります。

認証(Authentication)

認証は、「あなたは誰ですか?」という質問に答えるプロセスです。ユーザーの身元を確認します。
- ログイン: ユーザー名とパスワードで認証
- 多要素認証: パスワード + SMS/メール認証
- 生体認証: 指紋、顔認証など

認可(Authorization)

認可は、「あなたは何ができますか?」という質問に答えるプロセスです。ユーザーの権限を確認します。
- ロールベース: 管理者、一般ユーザーなど
- 権限ベース: 特定のリソースへのアクセス権限
- スコープベース: 特定の操作への権限

2. JWT(JSON Web Token)

JWT(JSON Web Token)は、認証情報を安全に伝達するためのトークンベースの認証方式です。JWTは、ヘッダー、ペイロード、署名の3つの部分で構成される自己完結型のトークンで、サーバー側でセッションを管理する必要がありません。これにより、スケーラブルで分散システムに適した認証方式を実現できます。

JWTの構造

JWTは、3つの部分で構成されます:

1. ヘッダー(Header): トークンのタイプと署名アルゴリズム

{
  "alg": "HS256",
  "typ": "JWT"
}


2. ペイロード(Payload): クレーム(情報)
{
  "sub": "1234567890",
  "name": "太郎",
  "iat": 1516239022,
  "exp": 1516242622
}


3. 署名(Signature): ヘッダーとペイロードを署名
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)


JWTの形式: header.payload.signature

: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JWTの実装(Node.js)

Node.jsでJWTを実装する方法:

必要なパッケージ: jsonwebtoken

npm install jsonwebtoken


トークンの生成: ログイン時にトークンを発行
- ユーザーID、ロール、有効期限を含める
- 秘密鍵で署名

トークンの検証: リクエスト時にトークンを検証
- 署名を確認
- 有効期限を確認
- ユーザー情報を抽出

実装のポイント
- 秘密鍵は環境変数で管理
- 有効期限は適切に設定(通常15分〜1時間)
- トークンには機密情報を含めない

JWTのリフレッシュトークン

リフレッシュトークンの仕組み:

問題: アクセストークンの有効期限が短いと、ユーザーが頻繁にログインし直す必要がある

解決策: リフレッシュトークンを使用
- アクセストークン: 短い有効期限(15分〜1時間)
- リフレッシュトークン: 長い有効期限(7日〜30日)

フロー
1. ログイン時にアクセストークンとリフレッシュトークンを発行
2. アクセストークンでAPIにアクセス
3. アクセストークンが期限切れになったら、リフレッシュトークンで新しいアクセストークンを取得
4. リフレッシュトークンも期限切れになったら、再ログイン

セキュリティ
- リフレッシュトークンはデータベースに保存
- トークンの無効化機能を実装
- トークンのローテーションを検討

JWTの実装例(Node.js)

JWTの生成、検証、リフレッシュトークンの実装例です。

const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

// 秘密鍵(環境変数から取得)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'your-refresh-secret';

// 1. ログイン(トークン発行)
async function login(email, password) {
  // ユーザーを検証
  const user = await findUserByEmail(email);
  if (!user) {
    throw new Error('ユーザーが見つかりません');
  }
  
  // パスワードを検証
  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) {
    throw new Error('パスワードが正しくありません');
  }
  
  // アクセストークンを生成
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email, role: user.role },
    JWT_SECRET,
    { expiresIn: '15m' }
  );
  
  // リフレッシュトークンを生成
  const refreshToken = jwt.sign(
    { userId: user.id },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  
  // リフレッシュトークンをデータベースに保存
  await saveRefreshToken(user.id, refreshToken);
  
  return {
    accessToken,
    refreshToken,
    expiresIn: 900 // 15分(秒)
  };
}

// 2. トークンの検証(ミドルウェア)
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer <token>
  
  if (!token) {
    return res.status(401).json({ error: 'トークンが提供されていません' });
  }
  
  jwt.verify(token, JWT_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'トークンが無効です' });
    }
    req.user = user;
    next();
  });
}

// 3. リフレッシュトークンで新しいアクセストークンを取得
async function refreshAccessToken(refreshToken) {
  // リフレッシュトークンを検証
  const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
  
  // データベースでリフレッシュトークンを確認
  const storedToken = await findRefreshToken(refreshToken);
  if (!storedToken || storedToken.userId !== decoded.userId) {
    throw new Error('リフレッシュトークンが無効です');
  }
  
  // ユーザー情報を取得
  const user = await findUserById(decoded.userId);
  
  // 新しいアクセストークンを生成
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email, role: user.role },
    JWT_SECRET,
    { expiresIn: '15m' }
  );
  
  return { accessToken, expiresIn: 900 };
}

// 4. 使用例
app.post('/api/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    const tokens = await login(email, password);
    res.json(tokens);
  } catch (error) {
    res.status(401).json({ error: error.message });
  }
});

app.get('/api/users/me', authenticateToken, (req, res) => {
  res.json({ user: req.user });
});

app.post('/api/auth/refresh', async (req, res) => {
  try {
    const { refreshToken } = req.body;
    const tokens = await refreshAccessToken(refreshToken);
    res.json(tokens);
  } catch (error) {
    res.status(403).json({ error: error.message });
  }
});

3. OAuth 2.0

OAuth 2.0は、サードパーティアプリケーションがユーザーのリソースにアクセスするための認可フレームワークです。ユーザーがパスワードを共有することなく、アプリケーションに権限を付与できます。Google、GitHub、Facebookなどの主要なサービスで使用されており、現代のWebアプリケーションでは標準的な認証方式となっています。

OAuth 2.0のフロー

OAuth 2.0の認可コードフロー:

主要な役割
- リソースオーナー: ユーザー
- クライアント: アプリケーション
- 認可サーバー: 認証・認可を行うサーバー
- リソースサーバー: 保護されたリソースを提供するサーバー

フロー
1. 認可リクエスト: クライアントがユーザーを認可サーバーにリダイレクト
2. 認可: ユーザーがログインし、権限を付与
3. 認可コード: 認可サーバーが認可コードをクライアントに返す
4. トークンリクエスト: クライアントが認可コードをトークンと交換
5. アクセストークン: クライアントがアクセストークンを受け取る
6. リソースアクセス: クライアントがアクセストークンでリソースにアクセス

スコープ: アクセスの範囲を定義(例: read:user, write:post

OAuth 2.0の実装(簡易版)

OAuth 2.0の簡易実装例:

実装のポイント
- 認可コードを一時的に保存(通常5〜10分の有効期限)
- 認可コードは一度だけ使用可能
- アクセストークンとリフレッシュトークンを発行
- スコープを検証

セキュリティ
- リダイレクトURIを検証
- クライアントIDとシークレットを検証
- PKCE(Proof Key for Code Exchange)を使用(推奨)
- HTTPSを使用(必須)

OAuth 2.0の実装例

OAuth 2.0の認可コードフローの実装例です。

// OAuth 2.0の簡易実装例

// 1. 認可エンドポイント(ユーザーをリダイレクト)
app.get('/oauth/authorize', (req, res) => {
  const { client_id, redirect_uri, response_type, scope, state } = req.query;
  
  // クライアントを検証
  const client = findClientById(client_id);
  if (!client || client.redirect_uri !== redirect_uri) {
    return res.status(400).json({ error: 'invalid_client' });
  }
  
  // ログインページにリダイレクト(実際の実装では認証が必要)
  // ここでは簡易的に認可ページを表示
  res.render('authorize', {
    client_id,
    redirect_uri,
    scope,
    state
  });
});

// 2. 認可の承認
app.post('/oauth/authorize', (req, res) => {
  const { client_id, redirect_uri, scope, state } = req.body;
  const user = req.user; // 認証済みユーザー
  
  // 認可コードを生成
  const authCode = generateAuthCode();
  
  // 認可コードを一時的に保存(5分の有効期限)
  authCodes.set(authCode, {
    client_id,
    redirect_uri,
    scope,
    userId: user.id,
    expiresAt: Date.now() + 5 * 60 * 1000
  });
  
  // リダイレクトURIに認可コードを付けてリダイレクト
  const redirectUrl = new URL(redirect_uri);
  redirectUrl.searchParams.set('code', authCode);
  if (state) {
    redirectUrl.searchParams.set('state', state);
  }
  
  res.redirect(redirectUrl.toString());
});

// 3. トークンエンドポイント(認可コードをトークンと交換)
app.post('/oauth/token', (req, res) => {
  const { grant_type, code, redirect_uri, client_id, client_secret } = req.body;
  
  if (grant_type !== 'authorization_code') {
    return res.status(400).json({ error: 'unsupported_grant_type' });
  }
  
  // クライアントを検証
  const client = findClientById(client_id);
  if (!client || client.secret !== client_secret) {
    return res.status(401).json({ error: 'invalid_client' });
  }
  
  // 認可コードを検証
  const authCodeData = authCodes.get(code);
  if (!authCodeData) {
    return res.status(400).json({ error: 'invalid_grant' });
  }
  
  if (authCodeData.expiresAt < Date.now()) {
    authCodes.delete(code);
    return res.status(400).json({ error: 'invalid_grant' });
  }
  
  if (authCodeData.redirect_uri !== redirect_uri) {
    return res.status(400).json({ error: 'invalid_grant' });
  }
  
  // 認可コードを削除(一度だけ使用可能)
  authCodes.delete(code);
  
  // アクセストークンとリフレッシュトークンを生成
  const accessToken = generateAccessToken(authCodeData.userId, authCodeData.scope);
  const refreshToken = generateRefreshToken(authCodeData.userId);
  
  res.json({
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 3600,
    refresh_token: refreshToken,
    scope: authCodeData.scope
  });
});

// 4. リソースエンドポイント(アクセストークンで保護)
app.get('/api/user', authenticateToken, (req, res) => {
  // req.user にはトークンから抽出したユーザー情報が含まれる
  res.json({ user: req.user });
});

// 5. トークン検証ミドルウェア
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'unauthorized' });
  }
  
  // トークンを検証
  const tokenData = verifyAccessToken(token);
  if (!tokenData) {
    return res.status(403).json({ error: 'invalid_token' });
  }
  
  req.user = tokenData;
  next();
}

4. セッションベース認証

セッションベース認証は、サーバー側でセッションを管理する伝統的な認証方式です。ユーザーがログインすると、サーバーはセッションIDを生成し、クッキーでクライアントに送信します。以降のリクエストでは、このセッションIDを使用してユーザーを識別します。セッション情報はサーバー側(メモリ、データベース、Redisなど)に保存されます。

Express Session

Express.jsでセッションベース認証を実装する方法:

必要なパッケージ: express-session

npm install express-session


セッションストア: セッション情報の保存先
- メモリストア: 開発環境用(本番環境では使用しない)
- Redis: 本番環境で推奨
- MongoDB: データベースに保存

セキュリティ設定
- httpOnly: true: JavaScriptからアクセス不可
- secure: true: HTTPSのみで送信(本番環境)
- sameSite: 'strict': CSRF攻撃を防ぐ
- secret: セッションIDの署名に使用(環境変数で管理)

メリット
- サーバー側でセッションを完全に制御
- セッションの無効化が容易
- 機密情報をクライアントに送信しない

デメリット
- サーバー側でセッションを管理する必要がある
- スケーラビリティの課題(複数サーバー間でセッションを共有)

5. ロールベースアクセス制御(RBAC)

ロールベースアクセス制御(RBAC: Role-Based Access Control)は、ユーザーにロールを割り当て、ロールに基づいてアクセス権限を制御する方式です。管理者、編集者、閲覧者などのロールを定義し、各ロールに適切な権限を付与します。これにより、ユーザーごとに個別に権限を設定する必要がなくなり、管理が容易になります。

RBACの基本概念

RBACの基本概念:

ロール: ユーザーの役割(例: admin, editor, viewer)
権限: 実行可能な操作(例: read:user, write:post, delete:comment)
リソース: アクセス対象(例: users, posts, comments)

ロールの階層:
- 管理者(admin): すべての権限
- 編集者(editor): 作成・編集・削除の権限
- 閲覧者(viewer): 読み取りのみの権限

実装方法
- データベースでロールと権限を管理
- ミドルウェアでロールを検証
- エンドポイントごとに必要なロールを指定

RBACの実装例

RBACの実装のポイント:

データモデル
- ユーザーテーブル: ユーザー情報
- ロールテーブル: ロール定義
- 権限テーブル: 権限定義
- ユーザーロールテーブル: ユーザーとロールの関連
- ロール権限テーブル: ロールと権限の関連

ミドルウェア
- 認証ミドルウェア: ユーザーを認証
- 認可ミドルウェア: ロールと権限を検証

エンドポイント保護
- 必要なロールを指定
- 必要な権限を指定

RBACの実装例

ロールベースアクセス制御の実装例です。

// RBACの実装例

// 1. ロール定義
const ROLES = {
  ADMIN: 'admin',
  EDITOR: 'editor',
  VIEWER: 'viewer'
};

// 2. 権限定義
const PERMISSIONS = {
  READ_USER: 'read:user',
  WRITE_USER: 'write:user',
  DELETE_USER: 'delete:user',
  READ_POST: 'read:post',
  WRITE_POST: 'write:post',
  DELETE_POST: 'delete:post'
};

// 3. ロールと権限のマッピング
const rolePermissions = {
  [ROLES.ADMIN]: [
    PERMISSIONS.READ_USER,
    PERMISSIONS.WRITE_USER,
    PERMISSIONS.DELETE_USER,
    PERMISSIONS.READ_POST,
    PERMISSIONS.WRITE_POST,
    PERMISSIONS.DELETE_POST
  ],
  [ROLES.EDITOR]: [
    PERMISSIONS.READ_USER,
    PERMISSIONS.READ_POST,
    PERMISSIONS.WRITE_POST
  ],
  [ROLES.VIEWER]: [
    PERMISSIONS.READ_USER,
    PERMISSIONS.READ_POST
  ]
};

// 4. ロールチェックミドルウェア
function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: '認証が必要です' });
    }
    
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: '権限がありません' });
    }
    
    next();
  };
}

// 5. 権限チェックミドルウェア
function requirePermission(permission) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: '認証が必要です' });
    }
    
    const userRole = req.user.role;
    const permissions = rolePermissions[userRole] || [];
    
    if (!permissions.includes(permission)) {
      return res.status(403).json({ error: '権限がありません' });
    }
    
    next();
  };
}

// 6. エンドポイントの保護
// 管理者のみアクセス可能
app.delete('/api/users/:id', 
  authenticateToken,
  requireRole(ROLES.ADMIN),
  async (req, res) => {
    // ユーザー削除の処理
  }
);

// 編集者以上がアクセス可能
app.post('/api/posts',
  authenticateToken,
  requireRole(ROLES.ADMIN, ROLES.EDITOR),
  async (req, res) => {
    // 投稿作成の処理
  }
);

// 権限ベースの保護
app.delete('/api/posts/:id',
  authenticateToken,
  requirePermission(PERMISSIONS.DELETE_POST),
  async (req, res) => {
    // 投稿削除の処理
  }
);

// 7. ユーザーのロールを取得する関数(データベースから)
async function getUserRole(userId) {
  const user = await findUserById(userId);
  return user.role;
}

// 8. ユーザーに権限があるかチェックする関数
function hasPermission(userRole, permission) {
  const permissions = rolePermissions[userRole] || [];
  return permissions.includes(permission);
}

6. セキュリティのベストプラクティス

認証・認可システムのセキュリティは、アプリケーション全体の安全性に直結します。パスワードのハッシュ化、HTTPSの使用、レート制限、適切なCORS設定など、多層的なセキュリティ対策を実装することで、安全な認証システムを構築できます。

パスワードのハッシュ化

パスワードのハッシュ化:

重要性: パスワードを平文で保存してはいけません。データベースが漏洩した場合、すべてのユーザーのパスワードが危険にさらされます。

推奨アルゴリズム
- bcrypt: 最も一般的(Node.jsではbcryptパッケージ)
- Argon2: より新しいアルゴリズム(推奨)
- PBKDF2: 標準的なアルゴリズム

実装のポイント
- ソルト(Salt)を使用(自動生成)
- コストファクターを適切に設定(10〜12が推奨)
- パスワードの検証時もハッシュ化して比較

パスワードポリシー
- 最小長(8文字以上)
- 複雑さの要件(大文字、小文字、数字、記号)
- 定期的な変更(オプション)

HTTPSの使用

HTTPSの使用:

重要性: 認証情報やトークンは機密情報です。HTTPで送信すると、中間者攻撃で盗聴される可能性があります。

実装
- TLS 1.2以上を使用
- 有効なSSL証明書を使用
- HSTS(HTTP Strict Transport Security)ヘッダーを設定

HSTSヘッダー

Strict-Transport-Security: max-age=31536000; includeSubDomains


注意点
- 本番環境では必ずHTTPSを使用
- 開発環境でもHTTPSの使用を推奨
- 証明書の有効期限を監視

レート制限

レート制限:

目的: ブルートフォース攻撃やアカウント列挙攻撃を防ぐ

実装
- ログイン試行回数を制限(例: 5回/15分)
- IPアドレスベースの制限
- アカウントベースの制限

レスポンス
- レート制限超過時は429 Too Many Requestsを返す
- Retry-Afterヘッダーで再試行可能な時間を通知
- アカウントロックアウト機能(オプション)

実装例
- Redisを使用したレート制限
- express-rate-limitパッケージの使用

CORS設定

CORS設定:

問題: ブラウザの同一オリジンポリシーにより、異なるオリジンからのリクエストが制限される

適切な設定
- 必要なオリジンのみを許可
- 認証情報を含むリクエストにはcredentials: trueを設定
- プリフライトリクエストを適切に処理

設定例

Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true


注意点
- Access-Control-Allow-Origin: *は認証が必要なAPIでは使用しない
- 必要なメソッドとヘッダーのみを許可

7. ベストプラクティスまとめ

本記事で学んだ認証・認可システムの実装におけるベストプラクティスをまとめます。実践的なプロジェクトで認証・認可を実装する際のチェックリストとして活用してください。

認証方式の選択

認証方式の選択ガイド:

JWT:
- ✅ スケーラブルな分散システム
- ✅ サーバー側でセッション管理が不要
- ✅ モバイルアプリとの連携
- ❌ トークンの無効化が困難
- ❌ トークンサイズが大きくなる可能性

セッションベース認証:
- ✅ サーバー側で完全に制御可能
- ✅ セッションの無効化が容易
- ✅ 機密情報をクライアントに送信しない
- ❌ サーバー側でセッション管理が必要
- ❌ 複数サーバー間でセッション共有が必要

OAuth 2.0:
- ✅ サードパーティアプリとの連携
- ✅ ユーザーがパスワードを共有しない
- ✅ 標準的なプロトコル
- ❌ 実装が複雑
- ❌ 追加のインフラが必要

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

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

認証
- [ ] パスワードをハッシュ化して保存
- [ ] 強力なパスワードポリシーを実装
- [ ] レート制限を実装
- [ ] アカウントロックアウト機能を実装
- [ ] 多要素認証を検討

トークン管理
- [ ] トークンに有効期限を設定
- [ ] リフレッシュトークンを実装
- [ ] トークンの無効化機能を実装
- [ ] トークンを安全に保存(クライアント側)

通信
- [ ] HTTPSを使用(本番環境)
- [ ] HSTSヘッダーを設定
- [ ] 適切なCORS設定
- [ ] セキュリティヘッダーを設定

実装のベストプラクティス

実装のベストプラクティス:

コード品質
- 認証ロジックをミドルウェアとして分離
- エラーメッセージは情報漏洩を防ぐ
- ログに機密情報を出力しない
- 環境変数で機密情報を管理

テスト
- 認証・認可の単体テスト
- 統合テスト
- セキュリティテスト(ペネトレーションテスト)
- レート制限のテスト

監視
- ログイン試行の監視
- 異常なアクセスパターンの検出
- トークンの使用状況の監視
- エラー率の監視

継続的な改善

継続的な改善:

セキュリティアップデート:
- 依存パッケージの定期的な更新
- セキュリティパッチの適用
- 脆弱性の監視

ユーザー体験:
- ログイン体験の改善
- エラーメッセージの明確化
- パスワードリセット機能の改善

パフォーマンス:
- 認証処理の最適化
- セッション管理の最適化
- キャッシングの活用

まとめ

Web APIの認証と認可は、セキュアなアプリケーションを構築するための重要な要素です。JWTはトークンベースの認証に適し、OAuth 2.0はサードパーティアプリケーションとの連携に適しています。

適切なセキュリティ対策(パスワードのハッシュ化、HTTPS、レート制限など)を実装することで、安全なAPIを構築できます。プロジェクトの要件に応じて適切な認証方式を選択し、継続的にセキュリティを改善していくことが重要です。

実践的なプロジェクトで認証・認可を実装し、経験を積むことで、より安全なWebアプリケーションを構築できるようになります。

モダンなフロントエンドフレームワークとは?React、Vue、Angularの比較 RESTful APIの設計原則とは?実践的なガイド