第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フローの詳細
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 トラブルシューティングガイド
よくある問題と解決方法をまとめました:
-
トークン期限切れ
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; }
-
不正なリダイレクト
// リダイレクト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認証の実装について詳しく解説しました。
実装のキーポイント
- セキュリティを最優先に
- ユーザー体験の最適化
- エラーハンドリングの徹底
- パフォーマンスの考慮
次回予告
次回は「AWSでの本番環境構築とCI/CD」について解説します:
- AWS環境でのデプロイ
- セキュリティグループの設定
- 監視とアラートの設定
- 自動デプロイの構築
ご質問やフィードバックがございましたら、コメント欄にてお待ちしております!