TechHub

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

← 記事一覧に戻る

バックエンドの認証・認可とは?JWTとOAuth2.0の実装方法

公開日: 2024年2月14日 著者: mogura
バックエンドの認証・認可とは?JWTとOAuth2.0の実装方法

疑問

バックエンドで認証・認可を実装するには、どのような方法があるのでしょうか?JWTとOAuth2.0の実装方法について一緒に学んでいきましょう。

導入

認証・認可は、Webアプリケーションのセキュリティにおいて最も重要な要素の一つです。適切な認証・認可を実装することで、ユーザーのデータを保護し、不正アクセスを防ぐことができます。

本記事では、認証・認可の基本概念から、JWT(JSON Web Token)とOAuth2.0の実装方法、セキュリティのベストプラクティスまで、実践的なコード例とともに詳しく解説していきます。

認証・認可のイメージ

解説

1. 認証と認可の違い

認証と認可は、セキュリティにおいて重要な概念ですが、それぞれ異なる目的を持っています。認証は「ユーザーが誰であるかを確認する」プロセスであり、認可は「ユーザーが何をできるかを決定する」プロセスです。

認証(Authentication)

認証は、「ユーザーが誰であるかを確認する」プロセスです。

  • ログイン: ユーザー名とパスワードで本人確認を行います。最も一般的な認証方式です。
  • 多要素認証: パスワードに加えて、SMSやアプリで確認コードを送信し、より高いセキュリティを実現します。
  • 生体認証: 指紋や顔認証などの生体情報を使用して認証を行います。

認可(Authorization)

認可は、「ユーザーが何をできるかを決定する」プロセスです。

  • ロールベース: 管理者、一般ユーザーなどの役割に基づいてアクセス権限を制御します。
  • 権限ベース: 特定の操作に対する権限に基づいてアクセス権限を制御します。
  • リソースベース: 特定のリソースへのアクセス権限に基づいて制御します。

2. JWT(JSON Web Token)とは

JWTは、認証情報を安全に伝送するためのトークンベースの認証方式です。ヘッダー、ペイロード、署名の3つの部分で構成され、署名により改ざんを検出できます。

JWTは、認証情報を安全に伝送するためのトークンベースの認証方式です。ステートレスな認証を実現し、スケーラブルなアプリケーションに適しています。

JWTの構造

JWTは、ヘッダー(Header)、ペイロード(Payload)、署名(Signature)の3つの部分で構成されています。各部分はBase64URLエンコードされ、ピリオド(.)で区切られています。

JWTの構成要素

ヘッダーには、トークンのタイプ(JWT)と署名アルゴリズム(例:HS256、RS256)が含まれます。ペイロードには、クレーム(ユーザーID、有効期限など)が含まれます。署名は、ヘッダーとペイロードを秘密鍵で署名したもので、改ざんを検出できます。

JWTの構造例

この例では、JWTの構造を示しています。ヘッダー、ペイロード、署名がピリオドで区切られています。

# JWTの構造
# Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywiZXhwIjoxNjQzMzY2NDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

# デコードすると
# Header: {"alg":"HS256","typ":"JWT"}
# Payload: {"userId":123,"exp":1643366400}
# Signature: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

3. JWTの実装(Node.js)

Node.jsでJWTを使用した認証を実装します。jsonwebtokenパッケージを使用して、トークンの生成と検証を行います。

基本的な実装

JWTを使用した認証では、ログイン時にトークンを生成し、以降のリクエストでトークンを検証します。トークンには有効期限を設定し、適切に管理することが重要です。

JWTを使用した認証の基本的な実装方法を説明します。

JWT認証の実装例

この例では、JWTを使用した認証を実装しています。ログイン時にトークンを生成し、認証ミドルウェアでトークンを検証します。

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

// トークンの生成
const generateToken = (userId) => {
  return jwt.sign(
    { userId },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }
  );
};

// トークンの検証
const verifyToken = (token) => {
  try {
    return jwt.verify(token, process.env.JWT_SECRET);
  } catch (error) {
    throw new Error('Invalid token');
  }
};

// ログイン
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  
  // ユーザーの検証
  const user = await User.findOne({ email });
  if (!user || !await bcrypt.compare(password, user.password)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // トークンの生成
  const token = generateToken(user._id);
  
  res.json({ token, user: { id: user._id, email: user.email } });
});

// 認証ミドルウェア
const authenticateToken = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  
  try {
    const decoded = verifyToken(token);
    req.userId = decoded.userId;
    next();
  } catch (error) {
    return res.status(403).json({ error: 'Invalid token' });
  }
};

// 保護されたルート
app.get('/users/me', authenticateToken, async (req, res) => {
  const user = await User.findById(req.userId);
  res.json(user);
});

4. リフレッシュトークン

リフレッシュトークンは、アクセストークンの有効期限が切れた際に、新しいアクセストークンを取得するためのトークンです。セキュリティを向上させるために、アクセストークンとリフレッシュトークンを分離します。

リフレッシュトークンの実装

リフレッシュトークンは、アクセストークンよりも長い有効期限を持ち、データベースに保存して管理します。アクセストークンの有効期限が切れた際に、リフレッシュトークンを使用して新しいアクセストークンを取得します。

リフレッシュトークンの実装例

この例では、リフレッシュトークンを使用してアクセストークンを更新しています。アクセストークンは短い有効期限、リフレッシュトークンは長い有効期限を持ちます。

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

// リフレッシュトークンの生成
const generateRefreshToken = () => {
  return crypto.randomBytes(40).toString('hex');
};

// ログイン時にアクセストークンとリフレッシュトークンを発行
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await User.findOne({ email });
  
  if (!user || !await bcrypt.compare(password, user.password)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // アクセストークン(短い有効期限)
  const accessToken = jwt.sign(
    { userId: user._id },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  
  // リフレッシュトークン(長い有効期限)
  const refreshToken = generateRefreshToken();
  
  // リフレッシュトークンをデータベースに保存
  await RefreshToken.create({
    userId: user._id,
    token: refreshToken,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7日
  });
  
  res.json({ accessToken, refreshToken });
});

// アクセストークンの更新
app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  
  const tokenDoc = await RefreshToken.findOne({ token: refreshToken });
  if (!tokenDoc || tokenDoc.expiresAt < new Date()) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
  
  // 新しいアクセストークンを生成
  const accessToken = jwt.sign(
    { userId: tokenDoc.userId },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  
  res.json({ accessToken });
});

5. OAuth2.0とは

OAuth2.0は、サードパーティアプリケーションがユーザーのリソースにアクセスするための認可フレームワークです。認証コードフロー、インプリシットフロー、クライアントクレデンシャルフローなどのフローがあります。

OAuth2.0のフロー

OAuth2.0には、認証コードフロー、インプリシットフロー、クライアントクレデンシャルフロー、リソースオーナーパスワードクレデンシャルフローなどのフローがあります。最も一般的なのは認証コードフローで、Webアプリケーションに適しています。

OAuth2.0認証コードフロー

この例では、OAuth2.0の認証コードフローの流れを示しています。

# OAuth2.0認証コードフロー

1. ユーザーがクライアントアプリケーションにアクセス
2. クライアントが認証サーバーにリダイレクト
3. ユーザーが認証サーバーで認証
4. 認証サーバーが認証コードをクライアントに返す
5. クライアントが認証コードをアクセストークンと交換
6. クライアントがアクセストークンを使用してリソースサーバーにアクセス

6. OAuth2.0の実装(認証サーバー)

OAuth2.0認証サーバーを実装します。認証コードの生成、アクセストークンの発行、リフレッシュトークンの管理を行います。

認証サーバーの実装

OAuth2.0認証サーバーでは、認証コードの生成、アクセストークンの発行、リフレッシュトークンの管理を行います。認証コードは短い有効期限を持ち、一度使用すると無効になります。

OAuth2.0認証サーバーの実装例

この例では、OAuth2.0認証サーバーの基本的な実装を示しています。認証コードの生成とアクセストークンの発行を行います。

const express = require('express');
const crypto = require('crypto');
const app = express();

// 認証コードの生成
app.get('/oauth/authorize', (req, res) => {
  const { client_id, redirect_uri, response_type, state } = req.query;
  
  // クライアントの検証
  if (client_id !== 'your-client-id') {
    return res.status(400).json({ error: 'Invalid client_id' });
  }
  
  // ユーザーがログインしている場合、認証コードを生成
  if (req.session.userId) {
    const authCode = crypto.randomBytes(32).toString('hex');
    
    // 認証コードを一時的に保存(実際にはデータベースに保存)
    authCodes[authCode] = {
      client_id,
      redirect_uri,
      userId: req.session.userId,
      expiresAt: Date.now() + 10 * 60 * 1000 // 10分
    };
    
    // リダイレクト
    res.redirect(`${redirect_uri}?code=${authCode}&state=${state}`);
  } else {
    // ログインページにリダイレクト
    res.redirect('/login');
  }
});

// アクセストークンの発行
app.post('/oauth/token', async (req, res) => {
  const { code, client_id, client_secret, redirect_uri } = req.body;
  
  // 認証コードの検証
  const authCode = authCodes[code];
  if (!authCode || authCode.expiresAt < Date.now()) {
    return res.status(400).json({ error: 'Invalid or expired code' });
  }
  
  // クライアントの検証
  if (authCode.client_id !== client_id || client_secret !== 'your-client-secret') {
    return res.status(400).json({ error: 'Invalid client credentials' });
  }
  
  // アクセストークンの生成
  const accessToken = jwt.sign(
    { userId: authCode.userId },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }
  );
  
  // 認証コードを削除
  delete authCodes[code];
  
  res.json({
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 3600
  });
});

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

RBACは、ユーザーの役割に基づいてアクセス権限を制御する方式です。管理者、一般ユーザーなどの役割を定義し、各役割に権限を割り当てます。

RBACの実装

RBACでは、ユーザーに役割を割り当て、各役割に権限を定義します。ミドルウェアでユーザーの役割を確認し、必要な権限がある場合のみアクセスを許可します。

RBACの実装例

この例では、RBACを実装しています。役割と権限を定義し、ミドルウェアでアクセス権限をチェックします。

// 役割の定義
const ROLES = {
  ADMIN: 'admin',
  USER: 'user',
  MODERATOR: 'moderator'
};

// 権限の定義
const PERMISSIONS = {
  CREATE_USER: 'create:user',
  UPDATE_USER: 'update:user',
  DELETE_USER: 'delete:user',
  VIEW_USER: 'view:user'
};

// 役割と権限のマッピング
const rolePermissions = {
  [ROLES.ADMIN]: [
    PERMISSIONS.CREATE_USER,
    PERMISSIONS.UPDATE_USER,
    PERMISSIONS.DELETE_USER,
    PERMISSIONS.VIEW_USER
  ],
  [ROLES.MODERATOR]: [
    PERMISSIONS.UPDATE_USER,
    PERMISSIONS.VIEW_USER
  ],
  [ROLES.USER]: [
    PERMISSIONS.VIEW_USER
  ]
};

// 権限チェックミドルウェア
const checkPermission = (permission) => {
  return async (req, res, next) => {
    const user = await User.findById(req.userId);
    const userPermissions = rolePermissions[user.role] || [];
    
    if (!userPermissions.includes(permission)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    
    next();
  };
};

// 役割チェックミドルウェア
const checkRole = (...roles) => {
  return async (req, res, next) => {
    const user = await User.findById(req.userId);
    
    if (!roles.includes(user.role)) {
      return res.status(403).json({ error: 'Insufficient role' });
    }
    
    next();
  };
};

// ルートでの使用
app.delete('/users/:id', authenticateToken, checkPermission(PERMISSIONS.DELETE_USER), async (req, res) => {
  await User.findByIdAndDelete(req.params.id);
  res.status(204).send();
});

app.get('/admin/users', authenticateToken, checkRole(ROLES.ADMIN), async (req, res) => {
  const users = await User.find();
  res.json(users);
});

8. パスワードのハッシュ化

パスワードのハッシュ化は、セキュリティにおいて重要な要素です。bcryptなどのハッシュ関数を使用して、パスワードを安全に保存します。

bcryptを使用したパスワードハッシュ化

bcryptは、パスワードハッシュ化のための安全なアルゴリズムです。ソルトを自動的に生成し、レインボーテーブル攻撃を防ぎます。ハッシュ化のラウンド数を設定することで、セキュリティとパフォーマンスのバランスを調整できます。

パスワードハッシュ化の実装例

この例では、bcryptを使用してパスワードのハッシュ化と検証を実装しています。パスワードは平文で保存せず、必ずハッシュ化して保存します。

const bcrypt = require('bcrypt');

// パスワードのハッシュ化
const hashPassword = async (password) => {
  const saltRounds = 10;
  return await bcrypt.hash(password, saltRounds);
};

// パスワードの検証
const comparePassword = async (password, hash) => {
  return await bcrypt.compare(password, hash);
};

// ユーザー登録
app.post('/auth/register', async (req, res) => {
  const { email, password } = req.body;
  
  // パスワードのハッシュ化
  const hashedPassword = await hashPassword(password);
  
  // ユーザーの作成
  const user = await User.create({
    email,
    password: hashedPassword
  });
  
  res.status(201).json({ id: user._id, email: user.email });
});

// ログイン
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  
  const user = await User.findOne({ email });
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // パスワードの検証
  const isValid = await comparePassword(password, user.password);
  if (!isValid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // トークンの生成
  const token = generateToken(user._id);
  res.json({ token });
});

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

セキュリティのベストプラクティスを実装することで、アプリケーションのセキュリティを向上させます。強力なパスワードポリシー、レート制限、HTTPSの強制、セキュアなCookie設定などを実装します。

1. 強力なパスワードポリシー

パスワードは、最低8文字以上、大文字・小文字・数字・記号を含むように要求します。また、一般的なパスワードやユーザー名を含むパスワードを禁止します。

2. レート制限

ログイン試行回数にレート制限を設定し、ブルートフォース攻撃を防ぎます。一定回数の失敗後は、アカウントを一時的にロックします。

3. HTTPSの強制

本番環境では、HTTPSを強制し、HTTPリクエストをHTTPSにリダイレクトします。これにより、通信の盗聴や改ざんを防ぎます。

4. セキュアなCookie設定

Cookieには、HttpOnly、Secure、SameSite属性を設定します。HttpOnlyにより、JavaScriptからのアクセスを防ぎ、SecureによりHTTPSでのみ送信され、SameSiteによりCSRF攻撃を防ぎます。

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

この例では、パスワードポリシーの検証とセキュアなCookie設定を実装しています。

// パスワードポリシーの検証
const validatePassword = (password) => {
  const minLength = 8;
  const hasUpperCase = /[A-Z]/.test(password);
  const hasLowerCase = /[a-z]/.test(password);
  const hasNumber = /[0-9]/.test(password);
  const hasSpecialChar = /[!@#$%^&*]/.test(password);
  
  if (password.length < minLength) {
    return { valid: false, message: 'パスワードは8文字以上である必要があります' };
  }
  if (!hasUpperCase || !hasLowerCase) {
    return { valid: false, message: 'パスワードは大文字と小文字を含む必要があります' };
  }
  if (!hasNumber) {
    return { valid: false, message: 'パスワードは数字を含む必要があります' };
  }
  if (!hasSpecialChar) {
    return { valid: false, message: 'パスワードは記号を含む必要があります' };
  }
  
  return { valid: true };
};

// セキュアなCookie設定
app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 24 * 60 * 60 * 1000 // 24時間
  }
}));

10. 多要素認証(MFA)

MFAは、パスワードに加えて、追加の認証要素を使用する認証方式です。TOTP(Time-based One-Time Password)を使用して、MFAを実装します。

TOTP(Time-based One-Time Password)の実装

TOTPは、時間ベースのワンタイムパスワードを生成する方式です。speakeasyやotplibなどのパッケージを使用して、TOTPを実装します。ユーザーは、Google AuthenticatorなどのアプリでQRコードをスキャンし、認証コードを生成します。

MFAの実装例

この例では、TOTPを使用したMFAを実装しています。QRコードを生成し、ユーザーが認証アプリでスキャンして認証コードを生成します。

const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// MFAのセットアップ
app.post('/auth/mfa/setup', authenticateToken, async (req, res) => {
  const secret = speakeasy.generateSecret({
    name: `MyApp (${req.user.email})`
  });
  
  // シークレットをユーザーに保存
  await User.findByIdAndUpdate(req.userId, {
    mfaSecret: secret.base32
  });
  
  // QRコードを生成
  const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
  
  res.json({ qrCode: qrCodeUrl, secret: secret.base32 });
});

// MFAの検証
app.post('/auth/mfa/verify', authenticateToken, async (req, res) => {
  const { token } = req.body;
  const user = await User.findById(req.userId);
  
  const verified = speakeasy.totp.verify({
    secret: user.mfaSecret,
    encoding: 'base32',
    token: token,
    window: 2 // 前後2ステップの許容範囲
  });
  
  if (!verified) {
    return res.status(401).json({ error: 'Invalid MFA token' });
  }
  
  // MFAが有効になったことを記録
  await User.findByIdAndUpdate(req.userId, { mfaEnabled: true });
  
  res.json({ success: true });
});

// MFAを使用したログイン
app.post('/auth/login', async (req, res) => {
  const { email, password, mfaToken } = req.body;
  
  const user = await User.findOne({ email });
  if (!user || !await bcrypt.compare(password, user.password)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // MFAが有効な場合、MFAトークンを検証
  if (user.mfaEnabled) {
    if (!mfaToken) {
      return res.status(401).json({ error: 'MFA token required' });
    }
    
    const verified = speakeasy.totp.verify({
      secret: user.mfaSecret,
      encoding: 'base32',
      token: mfaToken,
      window: 2
    });
    
    if (!verified) {
      return res.status(401).json({ error: 'Invalid MFA token' });
    }
  }
  
  const accessToken = generateToken(user._id);
  res.json({ token: accessToken });
});

11. ベストプラクティス

認証・認可の実装におけるベストプラクティスをまとめます。これらの原則に従うことで、セキュアで使いやすい認証・認可システムを構築できます。

  • パスワードのハッシュ化: パスワードは必ずハッシュ化して保存し、平文で保存しないようにします。bcryptなどの安全なハッシュ関数を使用します。
  • 強力なパスワードポリシー: 強力なパスワードポリシーを実装し、ユーザーに安全なパスワードを要求します。
  • レート制限: ログイン試行回数にレート制限を設定し、ブルートフォース攻撃を防ぎます。
  • HTTPSの強制: 本番環境では、HTTPSを強制し、通信を暗号化します。
  • セキュアなCookie設定: Cookieには、HttpOnly、Secure、SameSite属性を設定し、セキュリティを向上させます。
  • トークンの適切な管理: JWTトークンには適切な有効期限を設定し、リフレッシュトークンを使用してセキュリティを向上させます。
  • 多要素認証: 重要な操作には、多要素認証を追加し、セキュリティを強化します。
  • ロールベースアクセス制御: RBACを実装し、ユーザーの役割に基づいてアクセス権限を制御します。
  • セッション管理: セッションを適切に管理し、セッションタイムアウトやセッション固定攻撃を防ぎます。
  • ログと監視: 認証イベントをログに記録し、不正なアクセスを監視します。

まとめ

バックエンドの認証・認可は、Webアプリケーションのセキュリティにおいて最も重要な要素です。JWTを使用したトークンベース認証、OAuth2.0によるサードパーティ認証、ロールベースアクセス制御などを適切に実装することで、セキュアなアプリケーションを構築できます。

パスワードのハッシュ化、レート制限、HTTPSの強制、セキュアなCookie設定など、セキュリティのベストプラクティスを実装することが重要です。多要素認証を追加することで、さらなるセキュリティ強化が可能です。

実践的なプロジェクトで認証・認可を実装し、継続的にセキュリティを監視・改善することで、より安全なWebアプリケーションを構築できます。

バックエンドのデータベース設計とは?正規化とパフォーマンス最適化 GraphQLとは?RESTful APIとの違いと実装方法