Google SignInするSPAとGoサーバー間のセッション管理

書き出したら長くなってしまい4回分になってしまった日記です。

  1. ReactとGoogle Sign-In
  2. ReactRouterを使ってGoogleログイン前と後で画面を変える
  3. ReactRouterを使ってGoogleログイン前と後で画面を変える(続き)

今日は、Google SignInでログインした後、自分のサーバーサイドアプリケーションとのセッション管理について考えてみます。

IDトーク

GoogleSignInと自前サーバーサイドアプリケーションの連携は、ここに書いてある通りやれば良い。

https://developers.google.com/identity/sign-in/web/backend-auth

強調して書かれてるけど、ログイン後のコールバック関数で取得できるユーザー情報をサーバーサイドに渡してはいけない、って書いてある。IDトークンを渡しましょう。

下で取得してるuserIdをサーバーサイドに渡してはダメ。

  onSignIn = (googleUser) => {
    userId = googleUser.getBasicProfile().getId());
  }

代わりにIDトークンを渡す。

onSignIn = (googleUser) => {
    idToken = googleUser.getAuthResponse().id_token;
}

Googleのドキュメントに明記はされてないのだけど、OpenID ConnectのIDトークンと一緒だと思う。

IDトークンの検証

クライアントは、GoogleからもらったIDトークンをサーバーへ送信する。

サーバーアプリケーションは、受け取ったIDトークンの検証をする必要がある。検証方法も先ほどのGoogleのドキュメントに書いてある。自分でやる方法、Googleから提供されてるライブラリを使う方法、Web APIとして公開されてるtokeninfoエンドポイントを使う方法、3つある。

今回は、tokeninfoエンドポイントを使う。

セッション管理

検証がうまくいったらサーバーサイドアプリケーションで、セッションを作成する(初めてログインしてきた場合はユーザーデータの作成もする)。

セッションをどう管理するか?セッション情報をどこに保持するかと、クライアントサーバー間でどうやりとりするかの2つ考えることがある。

セッション情報をどこに保持するか?

  1. サーバー側のデータストアに格納して、そのキーとなる文字列をクライアントとの通信で使う
    • Pros
      • クライアントとやり取りするデータがキーのみなので小さい
    • Cons
      • セッション情報をデータストアに保持するので、アクセスが必要(データストアは、アプリケーションサーバーとは別のサーバーになることが多くサーバー間通信が発生)
      • アプリケーション内メモリに保持するのであれば、(通常複数ある)アプリケーションサーバー間で共有する工夫が必要
        • もしくは、ロードバランスを工夫して同じサーバーにアクセスがいくようにする
  2. JWTを使う
    • Pros
      • セッション情報をJWT自身に保持させることができるので、データストアから取り出す必要がない
        • と思ったけど、本当にデータストアへのアクセスはなくせるのか?例えばサーバー側からセッション切れにしたりしたい場合はどうするのだろう?
    • Cons
      • セッション情報が増えるとJWTのサイズが大きくなる

クライアントサーバー間でどうやりとりするか?

  1. Cookieを使う
    • Pros
      • securehttpOnly をつけておけば、XSS脆弱性があったとしてもJWTやセッションキーが外部に漏れない
    • Cons
      • CSRFの対策が必要
  2. レスポンスボディとリクエストヘッダーで通信して、クライアント側での保存にはlocalStrage ( or sessionStrage )を使う
    • Pros
      • 特になし?
    • Cons
      • XSS脆弱性があると、JWTやセッションキーが外部に漏れる

今回は、サーバーサイドのデータストアにセッション情報を格納、クライアントサーバー間はCookieでやりとりするようにする。

ここまでを実装

実装方針をまとめる。

  1. クライアントは、GoogleSignInに成功したらGoogleからもらったIDトークンをサーバーアプリケーションへ送る
  2. サーバーアプリケーションは、Googleトークンエンドポイントを使ってIDトークンの検証をする
  3. サーバーアプリケーションは、検証に成功したらセッション情報を作成しデータストアに保存する
  4. サーバーアプリケーションは、Set-Cookieヘッダーをセットしたレスポンスを返す
  5. クライアントは、以降サーバーアプリケーションにCookieを送信することで、認証が必要なWebAPIを使うことができる

サーバーサイド

Goでサーバーサイド書いた。サーバーサイドでやることはさっきのステップのうち2,3,4。データストアは map で代用。5555ポートで動かす。

HTML, JavaScriptも同じサーバーから配布する方法もあるけど、今回はCORSの設定をしてクライアントサーバー間で通信できるようにした。

コードは記事の最後に貼っておきます。

クライアントサイド

クライアントサイドの実装。上記の5ステップのうち、1,5がクライアントサイドでやること。

  1. ログインに成功したらGoogleからもらったIDトークンをサーバーサイドのエンドポイント( http://localhost:5555/login )へ送る
  2. サーバーアプリケーションにCookieを送信することで、認証が必要なサーバーサイドのエンドポイント( http://localhost:5555/my )を使ってプライベート情報を取得できる
@@ -54,7 +54,22 @@ class App extends Component {
   }
   onSignIn = (googleUser) => {
     console.log('ID: ' + googleUser.getBasicProfile().getId());
-    this.setState({authenticated: true})
+    fetch("http://localhost:5555/login", {
+      method: "POST",
+      mode: 'cors',
+      credentials: 'include',
+      headers: new Headers({ 'Content-Type': 'application/json' }),
+      body: JSON.stringify({ "IDToken": googleUser.getAuthResponse().id_token }),
+    })
+    .then(response => {
+      return fetch("http://localhost:5555/my", {
+        mode: 'cors',
+        credentials: 'include',
+      })
+    })
+    .then(response => {
+      response.json().then(data => console.log(data))
+      this.setState({authenticated: true})
+    })
   }
   onSignOut = () => {
     const auth2 = this.state.gapi.auth2.getAuthInstance();

ログアウト

この時点でセッションは2つある。

  1. 自分のアプリケーションのセッション
  2. Googleのセッション

Googleサインインは認証を肩代わりしてくれだけとみなして、セッション管理は自分のアプリケーションのセッションだけとした方が管理しやすそう。なので、IDトークンをサーバーサイドに送った以降は、Googleのことは気にしない。

ログアウトボタンの挙動を変える必要がある。

あと、gapiをAppのstateに持たせるのも止める。 そうすれば、1日目の日記で採用しないことにした React-Google-Loginも使える。

まずは、React-Google-Loginを導入して、Appのstateからgapiを消す。

$ npm install react-google-login
@@ -1,5 +1,6 @@
 import React, { Component } from 'react';
 import { BrowserRouter as Router, Switch, Route, Link, Redirect } from 'react-router-dom';
+import { GoogleLogin } from 'react-google-login';
 
 const PrivateRoute = ({authenticated, render, ...rest}) => (
   authenticated ? (
@@ -22,34 +23,20 @@ const PrivateRoute = ({authenticated, render, ...rest}) => (
 class App extends Component {
   state = {
     authenticated: false,
-    gapi: null
+    initialized: null
   }
   componentDidMount() {
-    this.downloadGoogleScript(this.initSignInButton)
-  }
-  downloadGoogleScript = (callback) => {
-    const element = document.getElementsByTagName('script')[0];
-    const js = document.createElement('script');
-    js.id = 'google-platform';
-    js.src = '//apis.google.com/js/platform.js';
-    js.async = true;
-    js.defer = true;
-    element.parentNode.insertBefore(js, element);
-    js.onload = () => callback(window.gapi);
-  }
-  initSignInButton = (gapi) => {
-    gapi.load('auth2', () => {
-      gapi.auth2.init({client_id: "<CLIENT_ID>"})
-        .then(
-          (result) => {
-            if (result.isSignedIn.get()) {
-              this.setState({authenticated: true, gapi})
-            } else {
-              this.setState({authenticated: false, gapi})
-            }
-          },
-          (err) => console.error(err)
-        );
+    fetch("http://localhost:5555/my", {
+      mode: 'cors',
+      credentials: 'include',
+    })
+    .then(response => {
+      if (response.status === 200) {
+        response.json().then(data => console.log(data))
+        this.setState({ authenticated: true, initialized: true })
+      } else {
+        this.setState({ authenticated: false, initialized: true })
+      }
     })
   }
   onSignIn = (googleUser) => {
@@ -78,7 +65,7 @@ class App extends Component {
     this.setState({authenticated: false})
   }
   render() {
-    if (!this.state.gapi) {
+    if (!this.state.initialized) {
       return ('Loading...')
     }
     return (
@@ -88,7 +75,7 @@ class App extends Component {
             <Top {...props} authenticated={this.state.authenticated} onSignOut={this.onSignOut} />
           }/>
           <Route path="/login" render={props =>
-            <Login {...props} onSignIn={this.onSignIn} gapi={this.state.gapi} authenticated={this.state.authenticated} />
+            <Login {...props} onSignIn={this.onSignIn} authenticated={this.state.authenticated} />
           }/>
           <PrivateRoute path="/private" authenticated={this.state.authenticated} render={props =>
             <div>プライベートなページ</div>
@@ -102,14 +89,6 @@ export default App;
 
 
 class Login extends Component {
-  componentDidMount() {
-    if (this.props.gapi) {
-      this.props.gapi.signin2.render('google-signin-button', {
-        'onsuccess': this.props.onSignIn,
-        'onfailure': (err) => console.error(err)
-      });
-    }
-  }
   render() {
     if (this.props.authenticated) {
       const { from } = this.props.location.state || { from: { pathname: "/" } };
@@ -118,7 +97,14 @@ class Login extends Component {
     return (
       <div>
         <h1>ようこそ!</h1>
-        <div id="google-signin-button"></div>
+        <div>
+          <GoogleLogin
+            clientId="<CLIENT_ID>"
+            buttonText="Login"
+            onSuccess={this.props.onSignIn}
+            onFailure={(err) => console.error(err)}
+          />
+        </div>
         <Link to='/'>Topへ</Link>
       </div>
     )

ログアウト時の処理を変えて、サーバーサイドアプリケーションのログアウトAPIを呼ぶようにする。

@@ -60,9 +60,16 @@ class App extends Component {
     })
   }
   onSignOut = () => {
-    const auth2 = this.state.gapi.auth2.getAuthInstance();
-    auth2.signOut().then(() => console.log('sign out'));
-    this.setState({authenticated: false})
+    fetch("http://localhost:5555/logout", {
+      method: "POST",
+      mode: 'cors',
+      credentials: 'include',
+      headers: new Headers({ 'Content-Type': 'application/json' }),
+    })
+    .finally(response => {
+      console.log(response)
+      this.setState({authenticated: false})
+    })
   }
   render() {
     if (!this.state.initialized) {

おしまい

これでできた。セッションタイムアウトや強制ログアウトはサーバーサイドのセッション情報を適切に消してあげれば良い。JWTを使う場合はJWT自身の有効期限でセッションタイムアウトは実現できるけど強制ログアウトさせたい場合に良い方法あるのかな、という点は分かっていない。

書いたコード。

Google SignIn + Server App (Go)