🙃

OAuth 2.0によるソーシャルログインがまだわからない人へ

に公開

OAuth 2.0によるソーシャルログイン

💡 OAuth 2.0とは?
Google、Facebook などの外部サービスのアカウントでログインできる仕組み。パスワード管理が不要になる。

こんな感じのことらしいよ

フロントエンド実装(React/TypeScript):

// OAuth設定
const GOOGLE_CLIENT_ID = process.env.REACT_APP_GOOGLE_CLIENT_ID;
const REDIRECT_URI = `${window.location.origin}/auth/callback`;

// Googleログインコンポーネント
export function GoogleLoginButton() {
  const navigate = useNavigate();
  
  // Googleログイン開始
  const handleGoogleLogin = () => {
    // OAuth認証URLを構築
    const params = new URLSearchParams({
      client_id: GOOGLE_CLIENT_ID!,
      redirect_uri: REDIRECT_URI,
      response_type: 'code',
      scope: 'openid email profile',
      access_type: 'offline', // Refresh Tokenも取得
      prompt: 'consent',      // 毎回同意画面を表示
    });
    
    // Google認証ページへリダイレクト
    window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
  };
  
  return (
    <button
      onClick={handleGoogleLogin}
      className="flex items-center gap-2 px-4 py-2 border rounded-lg"
    >
      <img src="/google-icon.svg" alt="Google" className="w-5 h-5" />
      Googleでログイン
    </button>
  );
}

// コールバック処理コンポーネント
export function OAuthCallback() {
  const navigate = useNavigate();
  const location = useLocation();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  
  useEffect(() => {
    const handleCallback = async () => {
      try {
        // URLパラメータから認可コードを取得
        const params = new URLSearchParams(location.search);
        const code = params.get('code');
        const error = params.get('error');
        
        if (error) {
          throw new Error(`OAuth error: ${error}`);
        }
        
        if (!code) {
          throw new Error('認可コードが見つかりません');
        }
        
        // バックエンドに認可コードを送信
        const response = await fetch('/api/auth/oauth/google', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            code,
            redirectUri: REDIRECT_URI,
          }),
          credentials: 'include',
        });
        
        if (!response.ok) {
          throw new Error('認証に失敗しました');
        }
        
        const { accessToken, expiresIn, user } = await response.json();
        
        // トークンマネージャーに保存
        authManager.setTokens(accessToken, expiresIn);
        
        // ユーザー情報を状態管理に保存
        store.dispatch(setUser(user));
        
        // ダッシュボードへリダイレクト
        navigate('/dashboard');
      } catch (err) {
        console.error('OAuth callback error:', err);
        setError(err instanceof Error ? err.message : 'エラーが発生しました');
      } finally {
        setLoading(false);
      }
    };
    
    handleCallback();
  }, [location, navigate]);
  
  if (loading) {
    return (
      <div className="flex items-center justify-center h-screen">
        <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
        <p className="ml-4">認証処理中...</p>
      </div>
    );
  }
  
  if (error) {
    return (
      <div className="flex flex-col items-center justify-center h-screen">
        <p className="text-red-500 mb-4">{error}</p>
        <button
          onClick={() => navigate('/login')}
          className="px-4 py-2 bg-blue-500 text-white rounded"
        >
          ログイン画面に戻る
        </button>
      </div>
    );
  }
  
  return null;
}

バックエンド実装(Go):

package oauth

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "time"
    
    "github.com/gin-gonic/gin"
    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
)

// OAuth設定
var googleOAuthConfig = &oauth2.Config{
    ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
    ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
    RedirectURL:  "", // リクエストごとに設定
    Scopes: []string{
        "openid",
        "email",
        "profile",
    },
    Endpoint: google.Endpoint,
}

// Googleユーザー情報の構造体
type GoogleUserInfo struct {
    ID            string `json:"id"`
    Email         string `json:"email"`
    VerifiedEmail bool   `json:"verified_email"`
    Name          string `json:"name"`
    Picture       string `json:"picture"`
}

// OAuthハンドラー
func GoogleOAuthHandler(c *gin.Context) {
    var req struct {
        Code        string `json:"code"`
        RedirectURI string `json:"redirectUri"`
    }
    
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": "Invalid request"})
        return
    }
    
    // RedirectURIを設定
    config := *googleOAuthConfig
    config.RedirectURL = req.RedirectURI
    
    // 認可コードをアクセストークンに交換
    ctx := context.Background()
    token, err := config.Exchange(ctx, req.Code)
    if err != nil {
        c.JSON(400, gin.H{"error": "Failed to exchange token"})
        return
    }
    
    // Googleのユーザー情報を取得
    client := config.Client(ctx, token)
    resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to get user info"})
        return
    }
    defer resp.Body.Close()
    
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to read response"})
        return
    }
    
    var googleUser GoogleUserInfo
    if err := json.Unmarshal(body, &googleUser); err != nil {
        c.JSON(500, gin.H{"error": "Failed to parse user info"})
        return
    }
    
    // メールが確認済みかチェック
    if !googleUser.VerifiedEmail {
        c.JSON(400, gin.H{"error": "Email not verified"})
        return
    }
    
    // ユーザーの存在確認または新規作成
    user, err := findOrCreateUser(googleUser)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to process user"})
        return
    }
    
    // JWTトークンを生成
    tokenService := &TokenService{secret: []byte(JWTSecret)}
    accessToken, err := tokenService.GenerateAccessToken(user)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to generate token"})
        return
    }
    
    refreshToken, err := tokenService.GenerateRefreshToken(user.ID)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to generate refresh token"})
        return
    }
    
    // Refresh TokenをHttpOnly Cookieとして設定
    c.SetCookie(
        "refreshToken",
        refreshToken,
        int(RefreshTokenDuration.Seconds()),
        "/api/auth/refresh",
        "",
        true,
        true,
    )
    
    // レスポンス
    c.JSON(200, gin.H{
        "accessToken": accessToken,
        "expiresIn":   int(AccessTokenDuration.Seconds()),
        "user": gin.H{
            "id":      user.ID,
            "email":   user.Email,
            "name":    user.Name,
            "picture": user.Picture,
            "role":    user.Role,
        },
    })
}

// ユーザーの検索または作成
func findOrCreateUser(googleUser GoogleUserInfo) (*User, error) {
    // メールアドレスで既存ユーザーを検索
    user, err := getUserByEmail(googleUser.Email)
    if err == nil {
        // 既存ユーザーが見つかった場合
        // Google IDとプロフィール画像を更新
        user.GoogleID = googleUser.ID
        user.Picture = googleUser.Picture
        if err := updateUser(user); err != nil {
            return nil, err
        }
        return user, nil
    }
    
    // 新規ユーザーを作成
    newUser := &User{
        ID:       generateUUID(),
        Email:    googleUser.Email,
        Name:     googleUser.Name,
        GoogleID: googleUser.ID,
        Picture:  googleUser.Picture,
        Role:     "candidate", // デフォルトは求職者
        Provider: "google",
        CreatedAt: time.Now(),
    }
    
    if err := createUser(newUser); err != nil {
        return nil, err
    }
    
    // ウェルカムメールを送信(非同期)
    go sendWelcomeEmail(newUser)
    
    return newUser, nil
}

// 複数プロバイダー対応の例
type OAuthProvider interface {
    GetAuthURL(state string) string
    ExchangeToken(code string) (*oauth2.Token, error)
    GetUserInfo(token *oauth2.Token) (*UserInfo, error)
}

// プロバイダーファクトリー
func GetOAuthProvider(provider string) (OAuthProvider, error) {
    switch provider {
    case "google":
        return &GoogleProvider{config: googleOAuthConfig}, nil
    case "github":
        return &GitHubProvider{config: githubOAuthConfig}, nil
    case "linkedin":
        return &LinkedInProvider{config: linkedinOAuthConfig}, nil
    default:
        return nil, fmt.Errorf("unsupported provider: %s", provider)
    }
}

Discussion