T-CREATOR

Node.js で認証・認可を実装する:JWT と OAuth 入門

Node.js で認証・認可を実装する:JWT と OAuth 入門

現代のWebアプリケーション開発において、ユーザーの身元確認と権限管理は最も重要な要素の一つです。適切な認証・認可システムがなければ、機密情報の漏洩や不正アクセスといった深刻なセキュリティリスクにさらされてしまいます。

本記事では、Node.js環境でのJWT(JSON Web Token)とOAuth 2.0を使った認証・認可の実装方法について、初心者の方にもわかりやすく解説いたします。基本概念から実際のコード実装まで、段階的に学習できる構成となっております。

認証と認可の基礎知識

認証(Authentication)とは

認証とは、「あなたは誰ですか?」という質問に答えるプロセスです。ユーザーが主張する身元が本当に正しいかを確認する仕組みのことを指します。

代表的な認証方式には以下のようなものがあります。

認証方式説明
パスワード認証ユーザー名とパスワードの組み合わせログインフォーム
多要素認証複数の認証要素を組み合わせSMS認証、生体認証
外部認証第三者サービスを利用Google、Facebook認証

認可(Authorization)とは

認可は、「あなたは何ができますか?」という質問に答えるプロセスです。認証されたユーザーが特定のリソースやアクションにアクセスする権限があるかを判断します。

認可の例を見てみましょう。

  • 一般ユーザー:記事の閲覧のみ可能
  • 編集者:記事の作成・編集が可能
  • 管理者:すべての機能にアクセス可能

両者の違いと関係性

認証と認可は密接に関連していますが、それぞれ異なる役割を持っています。以下の図で関係性を理解しましょう。

mermaidflowchart LR
    user[ユーザー] -->|ログイン要求| auth[認証システム]
    auth -->|身元確認| verified[認証完了]
    verified -->|アクセス要求| authz[認可システム]
    authz -->|権限チェック| access[リソースアクセス]

認証が「誰であるか」を確認し、認可が「何ができるか」を決定する流れになります。

重要なポイントは、認証なしに認可は行えないということです。まず身元を確認してから、その人の権限を判断するという順序は必ず守られます。

JWT(JSON Web Token)による認証

JWT の基本概念

JWTは、JSON形式の情報を安全に送信するためのオープンスタンダードです。特にWebアプリケーションにおいて、ユーザーの認証状態を維持するために広く使用されています。

従来のセッション認証とは異なり、JWTはステートレスな認証を実現します。これにより、サーバー側でセッション情報を保持する必要がなくなり、スケーラビリティが大幅に向上します。

JWT の構造と仕組み

JWTは3つの部分から構成されており、それぞれがピリオド(.)で区切られています。

mermaidflowchart LR
    jwt[JWT Token] --> header[Header]
    jwt --> payload[Payload]
    jwt --> signature[Signature]
    header --> |Base64URL| h[ヘッダー情報]
    payload --> |Base64URL| p[ユーザー情報]
    signature --> |暗号化| s[署名]

Header(ヘッダー)

トークンのタイプとハッシュアルゴリズムを指定します。

javascript{
  "alg": "HS256",  // 使用するアルゴリズム
  "typ": "JWT"     // トークンタイプ
}

Payload(ペイロード)

実際のユーザー情報やクレーム(主張)が含まれます。

javascript{
  "sub": "1234567890",    // ユーザーID
  "name": "田中太郎",      // ユーザー名
  "iat": 1516239022,      // 発行時刻
  "exp": 1516242622       // 有効期限
}

Signature(署名)

Header、Payload、および秘密鍵を使って生成される署名です。この署名により、トークンが改ざんされていないことを保証します。

Node.js での JWT 実装手順

まず、必要なパッケージをインストールします。

bashyarn add jsonwebtoken express bcryptjs
yarn add -D @types/jsonwebtoken @types/bcryptjs

JWT生成用のユーティリティ関数

javascriptconst jwt = require('jsonwebtoken');

// JWT生成関数
const generateToken = (userId, email) => {
  const payload = {
    userId: userId,
    email: email
  };
  
  // 24時間有効なトークンを生成
  return jwt.sign(payload, process.env.JWT_SECRET, {
    expiresIn: '24h'
  });
};

JWT検証用ミドルウェア

javascriptconst verifyToken = (req, res, next) => {
  // Authorizationヘッダーからトークンを取得
  const authHeader = req.headers.authorization;
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ 
      error: 'アクセストークンが提供されていません' 
    });
  }
  
  try {
    // トークンを検証
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(403).json({ 
      error: '無効なトークンです' 
    });
  }
};

ログイン処理の実装

javascriptconst bcrypt = require('bcryptjs');

app.post('/api/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // ユーザーをデータベースから検索
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ 
        error: 'ユーザーが見つかりません' 
      });
    }
    
    // パスワードを検証
    const isValidPassword = await bcrypt.compare(password, user.password);
    if (!isValidPassword) {
      return res.status(401).json({ 
        error: 'パスワードが正しくありません' 
      });
    }
    
    // JWTトークンを生成
    const token = generateToken(user._id, user.email);
    
    res.json({
      message: 'ログインに成功しました',
      token: token,
      user: {
        id: user._id,
        email: user.email,
        name: user.name
      }
    });
  } catch (error) {
    res.status(500).json({ 
      error: 'サーバーエラーが発生しました' 
    });
  }
});

保護されたルートの実装

javascript// 認証が必要なエンドポイント
app.get('/api/profile', verifyToken, async (req, res) => {
  try {
    // req.userにはJWTから復号化されたユーザー情報が含まれている
    const user = await User.findById(req.user.userId).select('-password');
    
    if (!user) {
      return res.status(404).json({ 
        error: 'ユーザーが見つかりません' 
      });
    }
    
    res.json({
      user: user
    });
  } catch (error) {
    res.status(500).json({ 
      error: 'サーバーエラーが発生しました' 
    });
  }
});

JWT のメリット・デメリット

メリット

項目説明
ステートレスサーバー側でセッション管理が不要
スケーラブル複数サーバー間での情報共有が容易
自己完結型トークン内に必要な情報がすべて含まれる
標準化業界標準として広く採用されている

デメリット

  • トークンサイズ: セッションIDと比較してサイズが大きい
  • 取り消し困難: 有効期限前のトークン無効化が複雑
  • 機密情報: ペイロードは暗号化されていないため、機密情報は含められない

これらの特性を理解して、適切な場面でJWTを活用することが重要です。

OAuth による認証・認可

OAuth 2.0 の基本概念

OAuth 2.0は、第三者アプリケーションがユーザーのリソースに安全にアクセスするための認可フレームワークです。パスワードを直接共有することなく、限定的なアクセス権限を付与できます。

OAuth 2.0では以下の4つの役割が定義されています。

役割説明
リソースオーナーリソースの所有者(通常はユーザー)Googleアカウントの持ち主
リソースサーバー保護されたリソースを提供Google Drive API
クライアントリソースにアクセスしたいアプリケーションあなたのWebアプリ
認可サーバーアクセストークンを発行Google認証サーバー

OAuth のフロー図解

OAuth 2.0の認可コードフローを図で確認してみましょう。これは最も安全で推奨される方式です。

mermaidsequenceDiagram
    participant User as ユーザー
    participant Client as クライアントアプリ
    participant AuthServer as 認可サーバー
    participant ResourceServer as リソースサーバー

    User->>Client: 1. ログインを要求
    Client->>AuthServer: 2. 認可リクエスト
    AuthServer->>User: 3. 認証ページを表示
    User->>AuthServer: 4. 認証・認可
    AuthServer->>Client: 5. 認可コードを返却
    Client->>AuthServer: 6. アクセストークンを要求
    AuthServer->>Client: 7. アクセストークンを発行
    Client->>ResourceServer: 8. APIリクエスト
    ResourceServer->>Client: 9. リソースを返却

このフローにより、ユーザーのパスワードがクライアントアプリケーションに渡されることなく、安全にリソースアクセスが可能になります。

Node.js での OAuth 実装

必要パッケージのインストール

bashyarn add passport passport-google-oauth20 express-session
yarn add -D @types/passport @types/passport-google-oauth20

Passport.js の設定

javascriptconst passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

// Google OAuth設定
passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  callbackURL: "/auth/google/callback"
}, async (accessToken, refreshToken, profile, done) => {
  try {
    // ユーザー情報をデータベースから検索
    let user = await User.findOne({ googleId: profile.id });
    
    if (user) {
      // 既存ユーザーの場合
      return done(null, user);
    } else {
      // 新規ユーザーの場合
      const newUser = new User({
        googleId: profile.id,
        name: profile.displayName,
        email: profile.emails[0].value,
        avatar: profile.photos[0].value
      });
      
      const savedUser = await newUser.save();
      return done(null, savedUser);
    }
  } catch (error) {
    return done(error, null);
  }
}));

セッション設定

javascriptconst session = require('express-session');

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production', // HTTPS環境でのみtrue
    maxAge: 24 * 60 * 60 * 1000 // 24時間
  }
}));

// Passport初期化
app.use(passport.initialize());
app.use(passport.session());

シリアライゼーション設定

javascript// ユーザー情報をセッションに保存
passport.serializeUser((user, done) => {
  done(null, user._id);
});

// セッションからユーザー情報を復元
passport.deserializeUser(async (id, done) => {
  try {
    const user = await User.findById(id);
    done(null, user);
  } catch (error) {
    done(error, null);
  }
});

主要プロバイダーとの連携

Google OAuth ルートの設定

javascript// Google認証開始
app.get('/auth/google',
  passport.authenticate('google', {
    scope: ['profile', 'email']
  })
);

// Google認証コールバック
app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => {
    // 認証成功時の処理
    res.redirect('/dashboard');
  }
);

Facebook OAuth の追加設定

javascriptconst FacebookStrategy = require('passport-facebook').Strategy;

passport.use(new FacebookStrategy({
  clientID: process.env.FACEBOOK_APP_ID,
  clientSecret: process.env.FACEBOOK_APP_SECRET,
  callbackURL: "/auth/facebook/callback",
  profileFields: ['id', 'displayName', 'photos', 'email']
}, async (accessToken, refreshToken, profile, done) => {
  // Facebook用の処理ロジック
  try {
    let user = await User.findOne({ facebookId: profile.id });
    
    if (!user) {
      user = new User({
        facebookId: profile.id,
        name: profile.displayName,
        email: profile.emails ? profile.emails[0].value : null
      });
      await user.save();
    }
    
    return done(null, user);
  } catch (error) {
    return done(error, null);
  }
}));

GitHub OAuth の設定例

javascriptconst GitHubStrategy = require('passport-github2').Strategy;

passport.use(new GitHubStrategy({
  clientID: process.env.GITHUB_CLIENT_ID,
  clientSecret: process.env.GITHUB_CLIENT_SECRET,
  callbackURL: "/auth/github/callback"
}, async (accessToken, refreshToken, profile, done) => {
  try {
    let user = await User.findOne({ githubId: profile.id });
    
    if (!user) {
      user = new User({
        githubId: profile.id,
        username: profile.username,
        name: profile.displayName,
        email: profile.emails ? profile.emails[0].value : null
      });
      await user.save();
    }
    
    return done(null, user);
  } catch (error) {
    return done(error, null);
  }
}));

実装例とベストプラクティス

Express.js での JWT 実装

完全なJWT認証システムの実装

まず、環境変数の設定を行います。

javascript// .env ファイル
JWT_SECRET=your-super-secret-jwt-key-here
JWT_EXPIRES_IN=24h
REFRESH_TOKEN_SECRET=your-refresh-token-secret
REFRESH_TOKEN_EXPIRES_IN=7d

拡張されたトークン管理システム

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

class TokenService {
  // アクセストークン生成
  static generateAccessToken(payload) {
    return jwt.sign(payload, process.env.JWT_SECRET, {
      expiresIn: process.env.JWT_EXPIRES_IN
    });
  }
  
  // リフレッシュトークン生成
  static generateRefreshToken() {
    return crypto.randomBytes(40).toString('hex');
  }
  
  // トークンセットを生成
  static generateTokens(user) {
    const payload = {
      userId: user._id,
      email: user.email,
      role: user.role
    };
    
    const accessToken = this.generateAccessToken(payload);
    const refreshToken = this.generateRefreshToken();
    
    return { accessToken, refreshToken };
  }
  
  // トークン検証
  static verifyAccessToken(token) {
    try {
      return jwt.verify(token, process.env.JWT_SECRET);
    } catch (error) {
      throw new Error('Invalid access token');
    }
  }
}

高度な認証ミドルウェア

javascriptconst authMiddleware = (requiredRole = null) => {
  return async (req, res, next) => {
    try {
      const authHeader = req.headers.authorization;
      
      if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return res.status(401).json({
          error: 'Authorization header missing or invalid'
        });
      }
      
      const token = authHeader.substring(7);
      const decoded = TokenService.verifyAccessToken(token);
      
      // ユーザー情報を取得
      const user = await User.findById(decoded.userId).select('-password');
      if (!user) {
        return res.status(401).json({
          error: 'User not found'
        });
      }
      
      // ロールベースの認可チェック
      if (requiredRole && user.role !== requiredRole) {
        return res.status(403).json({
          error: 'Insufficient permissions'
        });
      }
      
      req.user = user;
      next();
    } catch (error) {
      res.status(401).json({
        error: 'Invalid or expired token'
      });
    }
  };
};

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

javascriptapp.post('/api/refresh-token', async (req, res) => {
  try {
    const { refreshToken } = req.body;
    
    if (!refreshToken) {
      return res.status(401).json({
        error: 'Refresh token is required'
      });
    }
    
    // データベースでリフレッシュトークンを検証
    const storedToken = await RefreshToken.findOne({
      token: refreshToken,
      expiresAt: { $gt: new Date() }
    });
    
    if (!storedToken) {
      return res.status(401).json({
        error: 'Invalid or expired refresh token'
      });
    }
    
    // 新しいトークンセットを生成
    const user = await User.findById(storedToken.userId);
    const tokens = TokenService.generateTokens(user);
    
    // 古いリフレッシュトークンを削除
    await RefreshToken.deleteOne({ _id: storedToken._id });
    
    // 新しいリフレッシュトークンを保存
    await RefreshToken.create({
      token: tokens.refreshToken,
      userId: user._id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7日後
    });
    
    res.json({
      accessToken: tokens.accessToken,
      refreshToken: tokens.refreshToken
    });
  } catch (error) {
    res.status(500).json({
      error: 'Failed to refresh token'
    });
  }
});

Passport.js を使った OAuth 実装

統合認証戦略の実装

javascriptconst express = require('express');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const GoogleStrategy = require('passport-google-oauth20').Strategy;

class AuthController {
  // ローカル認証戦略
  static configureLocalStrategy() {
    passport.use(new LocalStrategy({
      usernameField: 'email',
      passwordField: 'password'
    }, async (email, password, done) => {
      try {
        const user = await User.findOne({ email });
        
        if (!user) {
          return done(null, false, { 
            message: 'メールアドレスが見つかりません' 
          });
        }
        
        const isValid = await bcrypt.compare(password, user.password);
        if (!isValid) {
          return done(null, false, { 
            message: 'パスワードが正しくありません' 
          });
        }
        
        return done(null, user);
      } catch (error) {
        return done(error);
      }
    }));
  }
  
  // Google認証戦略
  static configureGoogleStrategy() {
    passport.use(new GoogleStrategy({
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: "/auth/google/callback"
    }, async (accessToken, refreshToken, profile, done) => {
      try {
        const existingUser = await User.findOne({
          $or: [
            { googleId: profile.id },
            { email: profile.emails[0].value }
          ]
        });
        
        if (existingUser) {
          // Google IDが未設定の場合は更新
          if (!existingUser.googleId) {
            existingUser.googleId = profile.id;
            await existingUser.save();
          }
          return done(null, existingUser);
        }
        
        // 新規ユーザー作成
        const newUser = await User.create({
          googleId: profile.id,
          email: profile.emails[0].value,
          name: profile.displayName,
          avatar: profile.photos[0].value,
          provider: 'google'
        });
        
        return done(null, newUser);
      } catch (error) {
        return done(error, null);
      }
    }));
  }
}

認証ルートの実装

javascriptconst router = express.Router();

// ローカル認証
router.post('/login', (req, res, next) => {
  passport.authenticate('local', (err, user, info) => {
    if (err) {
      return res.status(500).json({ error: 'Internal server error' });
    }
    
    if (!user) {
      return res.status(401).json({ error: info.message });
    }
    
    req.logIn(user, (err) => {
      if (err) {
        return res.status(500).json({ error: 'Login failed' });
      }
      
      const tokens = TokenService.generateTokens(user);
      
      res.json({
        message: 'Login successful',
        user: {
          id: user._id,
          email: user.email,
          name: user.name
        },
        tokens
      });
    });
  })(req, res, next);
});

// Google認証開始
router.get('/google',
  passport.authenticate('google', {
    scope: ['profile', 'email']
  })
);

// Google認証コールバック
router.get('/google/callback',
  passport.authenticate('google', { session: false }),
  async (req, res) => {
    try {
      const tokens = TokenService.generateTokens(req.user);
      
      // フロントエンドにリダイレクト(トークンをクエリパラメータで渡す)
      const redirectUrl = `${process.env.FRONTEND_URL}/auth/callback?token=${tokens.accessToken}&refresh=${tokens.refreshToken}`;
      res.redirect(redirectUrl);
    } catch (error) {
      res.redirect(`${process.env.FRONTEND_URL}/auth/error`);
    }
  }
);

セキュリティ対策とエラーハンドリング

レート制限の実装

javascriptconst rateLimit = require('express-rate-limit');

// ログイン試行の制限
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分
  max: 5, // 最大5回まで
  message: {
    error: 'ログイン試行回数が上限に達しました。15分後に再試行してください。'
  },
  standardHeaders: true,
  legacyHeaders: false
});

app.use('/api/auth/login', loginLimiter);

セキュリティヘッダーの設定

javascriptconst helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"]
    }
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  }
}));

エラーハンドリングミドルウェア

javascriptconst errorHandler = (err, req, res, next) => {
  console.error('Error:', err);
  
  // JWT関連エラー
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({
      error: 'Invalid token',
      code: 'INVALID_TOKEN'
    });
  }
  
  if (err.name === 'TokenExpiredError') {
    return res.status(401).json({
      error: 'Token expired',
      code: 'TOKEN_EXPIRED'
    });
  }
  
  // バリデーションエラー
  if (err.name === 'ValidationError') {
    const errors = Object.values(err.errors).map(e => e.message);
    return res.status(400).json({
      error: 'Validation failed',
      details: errors
    });
  }
  
  // デフォルトエラー
  res.status(500).json({
    error: 'Internal server error',
    code: 'INTERNAL_ERROR'
  });
};

app.use(errorHandler);

入力値検証の実装

javascriptconst { body, validationResult } = require('express-validator');

const validateLogin = [
  body('email')
    .isEmail()
    .normalizeEmail()
    .withMessage('有効なメールアドレスを入力してください'),
  body('password')
    .isLength({ min: 8 })
    .withMessage('パスワードは8文字以上である必要があります')
    .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
    .withMessage('パスワードは小文字、大文字、数字を含む必要があります'),
  
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({
        error: 'Validation failed',
        details: errors.array()
      });
    }
    next();
  }
];

app.post('/api/auth/login', validateLogin, /* ログイン処理 */);

図で理解できる要点:

  • JWTは3部構成でステートレス認証を実現
  • OAuthは4つの役割で安全な認可フローを提供
  • セキュリティ対策は多層防御が重要

まとめ

本記事では、Node.jsにおけるJWTとOAuth 2.0を使った認証・認可システムの実装について詳しく解説いたしました。

主要なポイント

  • 認証と認可の違い: 認証は「誰か」を確認し、認可は「何ができるか」を決定する
  • JWT の特徴: ステートレスで拡張性が高く、自己完結型のトークン
  • OAuth 2.0 の利点: 安全な第三者認証により、パスワード共有のリスクを回避
  • 実装のベストプラクティス: セキュリティ対策、エラーハンドリング、適切なトークン管理

現代のWebアプリケーション開発において、適切な認証・認可システムの構築は必須です。JWTとOAuthをそれぞれの特性を理解して組み合わせることで、セキュアで使いやすいシステムを構築できます。

実装時は、セキュリティファーストの考え方を持ち、定期的なセキュリティ監査とアップデートを心がけることが重要です。また、ユーザビリティとセキュリティのバランスを取りながら、継続的な改善を行っていきましょう。

関連リンク