OAuth2.0とGoogle認証の実装

第2回:OAuth2.0とGoogle認証の実装

1. はじめに 🌟

1.1 前回のおさらい

前回はGoogle Authenticatorの基本的な仕組みを学びました。今回は実際のOAuth2.0実装に踏み込んでいきます。

1.2 本記事のゴール

// 最終的に以下のような簡潔なコードで認証を実装
class GoogleAuth {
  async authenticate(req: Request): Promise<AuthResponse> {
    const token = await this.getGoogleToken(req.code);
    const user = await this.verifyAndGetUser(token);
    return this.createSession(user);
  }
}

2. OAuth2.0の基礎理解 📚

2.1 OAuth2.0フローの詳細

file

2.2 認証フロー実装

// auth.service.ts
export class AuthService {
  private readonly oauth2Client: OAuth2Client;

  constructor(
    @Inject('CONFIG')
    private readonly config: ApplicationConfig,
    private readonly userService: UserService,
  ) {
    this.oauth2Client = new OAuth2Client({
      clientId: config.google.clientId,
      clientSecret: config.google.clientSecret,
      redirectUri: config.google.redirectUri,
    });
  }

  generateAuthUrl(): string {
    const state = this.generateSecureState();
    return this.oauth2Client.generateAuthUrl({
      access_type: 'offline',
      scope: [
        'https://www.googleapis.com/auth/userinfo.profile',
        'https://www.googleapis.com/auth/userinfo.email',
      ],
      state,
    });
  }

  private generateSecureState(): string {
    return crypto
      .randomBytes(32)
      .toString('hex');
  }
}

2.3 セキュリティ考慮事項

// security.middleware.ts
export class SecurityMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    // セキュリティヘッダーの設定
    res.set({
      'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
      'X-Frame-Options': 'DENY',
      'X-Content-Type-Options': 'nosniff',
      'Content-Security-Policy': this.getCSPPolicy(),
    });

    // CSRF対策
    this.validateCsrfToken(req);

    next();
  }

  private getCSPPolicy(): string {
    return [
      "default-src 'self'",
      "script-src 'self' https://accounts.google.com",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "connect-src 'self' https://accounts.google.com",
    ].join('; ');
  }
}

3. Google OAuth認証の詳細実装 🔨

3.1 バックエンド実装

// google-auth.controller.ts
@Controller('auth/google')
export class GoogleAuthController {
  constructor(private readonly authService: AuthService) {}

  @Get('login')
  async googleAuth(@Req() req: Request) {
    return { url: this.authService.generateAuthUrl() };
  }

  @Post('callback')
  async googleAuthCallback(
    @Body('code') code: string,
    @Body('state') state: string,
  ) {
    try {
      // ステートの検証
      this.authService.verifyState(state);

      // 認可コードの交換
      const tokens = await this.authService.exchangeCode(code);

      // ユーザー情報の取得
      const userData = await this.authService.getUserInfo(tokens.access_token);

      // ユーザーの作成または更新
      const user = await this.authService.upsertUser(userData);

      // セッショントークンの生成
      const sessionToken = this.authService.generateSessionToken(user);

      return {
        token: sessionToken,
        user: this.sanitizeUser(user),
      };
    } catch (error) {
      this.handleAuthError(error);
    }
  }
}

3.2 データモデルの設計

// user.entity.ts
@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  googleId: string;

  @Column({ nullable: true })
  picture: string;

  @Column('simple-json')
  tokens: {
    accessToken: string;
    refreshToken?: string;
    expiresAt: Date;
  };

  @Column('simple-array')
  scopes: string[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  // リフレッシュトークンの有効性チェック
  isRefreshTokenValid(): boolean {
    return (
      !!this.tokens.refreshToken &&
      new Date() < this.tokens.expiresAt
    );
  }

  // アクセストークンの更新が必要かチェック
  needsTokenRefresh(): boolean {
    const bufferTime = 5 * 60 * 1000; // 5分
    return new Date(Date.now() + bufferTime) >= this.tokens.expiresAt;
  }
}

4. トークン管理とセキュリティ 🔐

4.1 トークンストレージ設計

// token.service.ts
@Injectable()
export class TokenService {
  constructor(
    @Inject(REDIS_CLIENT)
    private readonly redis: Redis,
    private readonly configService: ConfigService,
  ) {}

  async storeToken(userId: string, tokens: OAuth2Tokens): Promise<void> {
    const key = `auth:tokens:${userId}`;
    const encryptedTokens = this.encryptTokens(tokens);

    await this.redis.hmset(key, {
      ...encryptedTokens,
      expiresAt: tokens.expiresAt.getTime(),
    });

    // 有効期限の設定
    await this.redis.expire(
      key,
      this.configService.get('auth.tokenTTL')
    );
  }

  private encryptTokens(tokens: OAuth2Tokens): EncryptedTokens {
    const algorithm = 'aes-256-gcm';
    const key = Buffer.from(
      this.configService.get('auth.encryptionKey'),
      'base64'
    );
    const iv = crypto.randomBytes(16);

    const cipher = crypto.createCipheriv(algorithm, key, iv);
    const encryptedAccess = Buffer.concat([
      cipher.update(tokens.accessToken),
      cipher.final(),
    ]);

    return {
      accessToken: `${iv.toString('hex')}:${encryptedAccess.toString('hex')}`,
      refreshToken: tokens.refreshToken
        ? this.encryptValue(tokens.refreshToken)
        : null,
    };
  }
}

4.2 セッション管理

// session.service.ts
@Injectable()
export class SessionService {
  constructor(
    @Inject(REDIS_CLIENT)
    private readonly redis: Redis,
    private readonly jwtService: JwtService,
  ) {}

  async createSession(user: User): Promise<string> {
    const sessionId = crypto.randomBytes(32).toString('hex');
    const sessionData = {
      userId: user.id,
      email: user.email,
      createdAt: Date.now(),
    };

    // Redisにセッション情報を保存
    await this.redis.hmset(
      `session:${sessionId}`,
      sessionData
    );

    // セッションの有効期限設定
    await this.redis.expire(
      `session:${sessionId}`,
      60 * 60 * 24 * 7 // 1週間
    );

    // JWTトークンの生成
    return this.jwtService.sign({
      sessionId,
      userId: user.id,
    });
  }

  async validateSession(token: string): Promise<SessionData> {
    const decoded = this.jwtService.verify(token);
    const sessionData = await this.redis.hgetall(
      `session:${decoded.sessionId}`
    );

    if (!sessionData) {
      throw new UnauthorizedException('Invalid session');
    }

    return sessionData;
  }
}

4.3 リフレッシュトークンのローテーション

// token-rotation.service.ts
@Injectable()
export class TokenRotationService {
  constructor(
    private readonly tokenService: TokenService,
    private readonly oauth2Client: OAuth2Client,
  ) {}

  @Cron('*/15 * * * *') // 15分ごとに実行
  async rotateExpiredTokens() {
    const expiredTokens = await this.tokenService.findExpiredTokens();

    for (const token of expiredTokens) {
      try {
        const newTokens = await this.oauth2Client.refreshAccessToken(
          token.refreshToken
        );

        await this.tokenService.updateTokens(
          token.userId,
          newTokens
        );

        this.logger.debug(
          `Rotated tokens for user ${token.userId}`
        );
      } catch (error) {
        this.handleRotationError(token, error);
      }
    }
  }

  private handleRotationError(token: TokenData, error: any) {
    if (error.response?.status === 400) {
      // リフレッシュトークンが無効な場合
      this.logger.warn(
        `Invalid refresh token for user ${token.userId}`
      );
      return this.tokenService.invalidateTokens(token.userId);
    }

    this.logger.error(
      `Token rotation failed for user ${token.userId}`,
      error
    );
  }
}

4.4 セキュリティミドルウェア

// security.middleware.ts
export class SecurityMiddleware implements NestMiddleware {
  constructor(
    private readonly configService: ConfigService,
    private readonly rateLimit: RateLimitService,
  ) {}

  async use(req: Request, res: Response, next: NextFunction) {
    try {
      // レート制限のチェック
      await this.rateLimit.checkLimit(req.ip);

      // リクエストの検証
      this.validateRequest(req);

      // セキュリティヘッダーの設定
      this.setSecurityHeaders(res);

      next();
    } catch (error) {
      next(error);
    }
  }

  private validateRequest(req: Request) {
    // オリジンの検証
    const origin = req.get('origin');
    if (origin && !this.isAllowedOrigin(origin)) {
      throw new ForbiddenException('Invalid origin');
    }

    // Content-Typeの検証
    if (req.method !== 'GET') {
      const contentType = req.get('content-type');
      if (!contentType?.includes('application/json')) {
        throw new BadRequestException('Invalid content type');
      }
    }
  }

  private setSecurityHeaders(res: Response) {
    // CSPの設定
    const cspDirectives = {
      'default-src': ["'self'"],
      'script-src': [
        "'self'",
        'https://accounts.google.com',
      ],
      'frame-src': ['https://accounts.google.com'],
      'img-src': ["'self'", 'data:', 'https:'],
      'connect-src': ["'self'", 'https://accounts.google.com'],
    };

    res.set({
      'Content-Security-Policy': this.buildCSP(cspDirectives),
      'X-Content-Type-Options': 'nosniff',
      'X-Frame-Options': 'DENY',
      'X-XSS-Protection': '1; mode=block',
      'Referrer-Policy': 'strict-origin-when-cross-origin',
    });
  }
}

4.5 XSS対策の実装 🛡️

XSS攻撃は現代のWebアプリケーションでも大きな脅威です。

以下のような多層防御アプローチで対策していきましょう。

// xss-prevention.service.ts
@Injectable()
export class XSSPreventionService {
  sanitizeInput(input: string): string {
    return DOMPurify.sanitize(input, {
      ALLOWED_TAGS: ['b', 'i', 'em', 'strong'],
      ALLOWED_ATTR: []
    });
  }
}

// React側での使用例
const UserProfile: React.FC<UserProfileProps> = ({ userData }) => {
  const sanitizedBio = useMemo(() => 
    sanitizeHTML(userData.bio),
    [userData.bio]
  );

  return (
    <div className="profile-container">
      <div 
        className="bio"
        // 安全なHTML文字列の展開
        dangerouslySetInnerHTML={{ __html: sanitizedBio }}
      />
    </div>
  );
};

💡 実装のポイント

  • 入力値の検証と無害化
  • HTTPヘッダーによる防御
  • Content Security Policyの適切な設定

4.6 CSRF対策の実装 🔒

// csrf.middleware.ts
@Injectable()
export class CSRFProtectionMiddleware implements NestMiddleware {
  constructor(
    private readonly configService: ConfigService,
    private readonly redis: Redis
  ) {}

  async use(req: Request, res: Response, next: NextFunction) {
    if (this.isStateChangingMethod(req.method)) {
      const token = req.headers['x-csrf-token'];
      const sessionId = req.session.id;

      // トークンの検証
      const isValid = await this.validateToken(sessionId, token);
      if (!isValid) {
        throw new ForbiddenException('Invalid CSRF token');
      }
    }

    next();
  }

  private isStateChangingMethod(method: string): boolean {
    return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method);
  }
}

5. フロントエンド実装 🎨

5.1 Reactコンポーネント設計

認証フローを扱うコンポーネントを、使いやすく再利用可能な形で実装します。

// GoogleAuthButton.tsx
import React, { useState, useCallback } from 'react';
import { useAuth } from './useAuth';

export const GoogleAuthButton: React.FC = () => {
  const [isLoading, setIsLoading] = useState(false);
  const { initiateGoogleAuth } = useAuth();

  const handleClick = useCallback(async () => {
    try {
      setIsLoading(true);
      await initiateGoogleAuth();
    } catch (error) {
      console.error('認証エラー:', error);
      // エラー表示
    } finally {
      setIsLoading(false);
    }
  }, [initiateGoogleAuth]);

  return (
    <button 
      onClick={handleClick}
      disabled={isLoading}
      className="google-auth-btn"
    >
      {isLoading ? (
        <Spinner size="sm" />
      ) : (
        <>
          <GoogleIcon />
          Googleでログイン
        </>
      )}
    </button>
  );
};

5.2 認証フロー管理

カスタムフックを使用して認証状態を管理します:

// useAuth.ts
export const useAuth = () => {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // 初期認証状態の確認
    checkAuthStatus();
  }, []);

  const checkAuthStatus = async () => {
    try {
      const token = localStorage.getItem('auth_token');
      if (!token) {
        setIsLoading(false);
        return;
      }

      const userData = await verifyToken(token);
      setUser(userData);
    } catch (error) {
      // トークンが無効な場合はクリーンアップ
      localStorage.removeItem('auth_token');
    } finally {
      setIsLoading(false);
    }
  };

  // 認証コンテキストの提供
  return {
    user,
    isLoading,
    isAuthenticated: !!user,
    login: initiateGoogleAuth,
    logout: handleLogout,
  };
};

5.3 エラーハンドリング

ユーザーフレンドリーなエラー処理を実装します:

// ErrorBoundary.tsx
class AuthErrorBoundary extends React.Component<Props, State> {
  state = { error: null };

  static getDerivedStateFromError(error: Error) {
    return { error };
  }

  render() {
    if (this.state.error) {
      return (
        <ErrorDialog
          error={this.state.error}
          onRetry={this.handleRetry}
          onLogout={this.handleLogout}
        />
      );
    }

    return this.props.children;
  }

  private handleRetry = () => {
    this.setState({ error: null });
  };
}

6. エラー処理とログ管理 📝

6.1 集中的なエラー処理

// error.interceptor.ts
@Injectable()
export class ErrorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError(error => {
        // エラーの種類に応じた処理
        if (error instanceof OAuth2Error) {
          return throwError(() => new UnauthorizedException({
            message: 'Authentication failed',
            details: error.message
          }));
        }

        // エラーログの記録
        this.logger.error('Unexpected error', {
          error: error.message,
          stack: error.stack,
          context: context.getClass().name
        });

        return throwError(() => error);
      })
    );
  }
}

7. テストと品質保証 🧪

7.1 ユニットテストの実装

テストは開発の重要な部分です。認証システムでは特に注意深くテストを行う必要があります。

// auth.service.spec.ts
describe('AuthService', () => {
  let authService: AuthService;
  let mockTokenService: MockType<TokenService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      imports: [ConfigModule],
      providers: [
        AuthService,
        {
          provide: TokenService,
          useFactory: () => ({
            storeToken: jest.fn(),
            validateToken: jest.fn(),
          }),
        },
      ],
    }).compile();

    authService = module.get(AuthService);
    mockTokenService = module.get(TokenService);
  });

  describe('validateGoogleAuth', () => {
    it('正常な認証コードで認証が成功すること', async () => {
      // テストデータの準備
      const mockCode = 'valid_auth_code';
      const mockTokens = {
        access_token: 'mock_access_token',
        refresh_token: 'mock_refresh_token',
      };

      // モックの設定
      mockTokenService.validateToken.mockResolvedValue(true);

      // テストの実行
      const result = await authService.validateGoogleAuth(mockCode);

      // アサーション
      expect(result).toBeDefined();
      expect(result.success).toBe(true);
      expect(mockTokenService.storeToken).toHaveBeenCalledWith(
        expect.any(String),
        expect.objectContaining(mockTokens)
      );
    });

    it('無効な認証コードでエラーを投げること', async () => {
      const mockCode = 'invalid_code';
      mockTokenService.validateToken.mockRejectedValue(
        new UnauthorizedException()
      );

      await expect(
        authService.validateGoogleAuth(mockCode)
      ).rejects.toThrow(UnauthorizedException);
    });
  });
});

7.2 統合テスト

// auth.e2e-spec.ts
describe('認証フロー (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    app = await createTestApp();
  });

  it('完全な認証フローが正常に動作すること', async () => {
    // 1. 認証URLの取得
    const authUrlResponse = await request(app.getHttpServer())
      .get('/auth/google/url')
      .expect(200);

    expect(authUrlResponse.body.url).toContain('accounts.google.com');

    // 2. コールバックの処理
    const mockCode = 'test_auth_code';
    const callbackResponse = await request(app.getHttpServer())
      .post('/auth/google/callback')
      .send({ code: mockCode })
      .expect(200);

    expect(callbackResponse.body).toHaveProperty('token');
  });
});

8. パフォーマンス最適化 🚀

8.1 キャッシュ戦略

// cache.service.ts
@Injectable()
export class AuthCacheService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cache: Cache,
  ) {}

  async getCachedUserData(userId: string): Promise<UserData | null> {
    const cacheKey = `user:${userId}:data`;
    let userData = await this.cache.get(cacheKey);

    if (!userData) {
      userData = await this.fetchAndCacheUserData(userId);
    }

    return userData;
  }

  private async fetchAndCacheUserData(userId: string): Promise<UserData> {
    const userData = await this.userService.findById(userId);
    await this.cache.set(
      `user:${userId}:data`,
      userData,
      { ttl: 3600 } // 1時間キャッシュ
    );
    return userData;
  }
}

8.2 パフォーマンスモニタリング

// performance.interceptor.ts
@Injectable()
export class PerformanceInterceptor implements NestInterceptor {
  constructor(private readonly metrics: MetricsService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const startTime = Date.now();
    const req = context.switchToHttp().getRequest();

    return next.handle().pipe(
      tap(() => {
        const duration = Date.now() - startTime;

        // メトリクスの記録
        this.metrics.recordAuthLatency(duration, {
          endpoint: req.path,
          method: req.method,
          status: 'success'
        });
      })
    );
  }
}

9. 実践的なTipsとベストプラクティス 💡

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

✅ 認証フローのセキュリティチェック項目

1. トークン管理
   - [ ] アクセストークンの安全な保存
   - [ ] リフレッシュトークンのローテーション
   - [ ] トークンの有効期限設定

2. 通信セキュリティ
   - [ ] HTTPS使用の強制
   - [ ] 適切なCORSの設定
   - [ ] CSPヘッダーの設定

3. エラー処理
   - [ ] センシティブ情報の非露出
   - [ ] 適切なエラーメッセージ
   - [ ] レート制限の実装

9.2 トラブルシューティングガイド

よくある問題と解決方法をまとめました:

  1. トークン期限切れ

    class TokenExpiredError extends Error {
     constructor() {
       super('Token has expired');
       this.name = 'TokenExpiredError';
     }
    }
    
    // エラーハンドリング
    try {
     await this.validateToken(token);
    } catch (error) {
     if (error instanceof TokenExpiredError) {
       // リフレッシュトークンを使用して再取得
       return this.refreshAuthToken(refreshToken);
     }
     throw error;
    }
  2. 不正なリダイレクト

    // リダイレクトURLのホワイトリスト
    const ALLOWED_REDIRECTS = [
     'https://your-app.com/callback',
     'https://staging.your-app.com/callback'
    ];
    
    function validateRedirectUrl(url: string): boolean {
     return ALLOWED_REDIRECTS.includes(url);
    }

10. まとめ 🎯

本記事では、OAuth2.0とGoogle認証の実装について詳しく解説しました。

実装のキーポイント

  1. セキュリティを最優先に
  2. ユーザー体験の最適化
  3. エラーハンドリングの徹底
  4. パフォーマンスの考慮

次回予告

次回は「AWSでの本番環境構築とCI/CD」について解説します:

  • AWS環境でのデプロイ
  • セキュリティグループの設定
  • 監視とアラートの設定
  • 自動デプロイの構築

ご質問やフィードバックがございましたら、コメント欄にてお待ちしております!

Last modified: 2024-11-24

Author