ISUCON8予選で敗退しました...

ISUCON8の予選に @hanhan1978, @trtraki とyokohama-northというチームで参加しましたが、最終スコア30824で敗退しました。無念。。

以前と同様のメンバーでしたが、今回は役回りを変更。過去に参加した時はいつもDB等ミドルウェア・OS周りをやってたけど、今回はアプリケーション改修班に。Goでやりました。担当を変えたことで別の体験ができてすごく良かったです。

今回のお題はイベントの座席の予約サイトという感じ。アプリケーション触ったりコード見たりしてて排他制御があるなー、それ以外は普通かな?と思ってたのですが、実際はやたらと呼ばれてるgetEventという関数の改善で右往左往してるだけでほとんど終わってしまいました。もっとプログラミングの質とスピードを上げたいのと、複数台サーバー使った時に排他制御のところどうやっていこうかなというのをじっくり考えたいなぁという気持ちです。

とにかくすごく楽しかったし、ベンチマーカーやポータルサイトも非常に快適でした。運営の方々、チームメンバーの方々、こんなに楽しい機会をいただき本当にありがとうございます!


以下、雑な思い出タイムライン。

  • 10:00に滞りなく開始!
  • とりあえずGoに切り替える
  • pprofとecho-pprofを導入
    • 使ったことがないgbとかいうパッケージ管理ツールが使われてて一瞬焦る
  • まずはコード読んだりアプリケーション使ってみたり
  • h2oであることに気がつく
    • h2o使ったことある人がいないので、Nginxに置き換えることをチームで決定
  • pprofがうまくいかない
    • 502 BadGatewayだったので、h2oのタイムアウトかなぁ、という感じ
    • Nginxに置き換えられるの待ち
  • 11:00くらい
  • チームで話し合い
  • Nginxへの置き換えをとりあえず終わらせて、ログをalpで分析しよう、ということに
  • alpの結果から GET /GET /api/users/:id とで分担。 GET / を担当することに
  • pprofしたけど、handlerが無名関数で書かれていてプロファイルできない
    • handlerを関数化する。最初の1時間でやっておくべきだった
  • 関数化完了。pprof見ると、ほとんど getEvent
  • 各エンドポイントで N+1 にならないようにしましょう、とメンバーと話す
    • GET / のフロントのコードを読むと、reservationsをそもそも使っていないので、DBから取得する必要すらないだろう、ということで修正する
    • 修正したけど、ベンチが通らない。あー、ベンチマーカーには画面を返してるわけじゃなくJSON返してるんだから、レスポンスの構造は変えちゃダメじゃないかということに気がつく
  • getEventのインタフェースは維持して、中身のロジックを修正する方針に切り替える
    • eventとreservationsのN+1を解消する
    • 最初うまくいかなかったところ
      • OUTER JOINを使ったSQLの結果をstructにマッピングするときにNULLを int64 に代入しようとして実行時エラー
      • sql.NullInt64 を使って対応
    • 次にうまくいかなかったところ
      • 存在しないイベントに対する応答がベンチマーカーが期待してたステータスコードと違う
      • db.QueryRow を使ってレコードがない場合は Scan 時にsql.ErrNoRows が返ってくるが、 db.Query を使うように変更したのでレコードが存在しない場合の挙動が変わってしまった。明示的に sql.ErrNoRows を返すようにして対応
  • getEvent修正終わった
  • 点数が10000点超えた。たしかここで16:00くらいになってた気がする
  • alpすると、 GET /POST /api/events/:id/actions/reserve が遅い
  • pprofすると POST /api/events/:id/actions/reserve は結局 getEvent
  • 引き続き GET / を対応する
    • 公開済みのeventのデータだけをN+1を解消して取得するようにする
  • 修正できたけど GET / 速くならず・・・!
  • 最初のgetEvent改修時のSQLをチューニングしてなかったことに気がついてインデックス作成する
  • 20000-30000くらいをいったり来たり
  • 17:00くらい
  • なんかfailになる時がちょいちょいある
    • しかもサーバー上のコミットがなんか意図したものと違うことに気がつく
    • gitで調整しつつ、バイナリ作る
    • うまくいくコミットが見つかったので、ビルド。バイナリを保存
  • reboot後の動作確認して終了ー

Goでechoサーバーを書いた時のメモ

Goでechoサーバーを書いた時のシステムコールはどうなってるのかなぁ、と思って調べてみた時の日記です。

前にCでechoサーバーを書いた時は以下のような実装をしました。

  1. socket でソケットを作成する
  2. bind でソケットをバインドする
  3. listen でListen状態になる
  4. accept でコネクションを取り出す
  5. read でクライアントから文字列を受け取る
  6. write でクライアントへ文字列を返す

Goで書くとどうなるでしょうか?

環境はVagrantUbuntuで。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04
DISTRIB_CODENAME=xenial
DISTRIB_DESCRIPTION="Ubuntu 16.04.4 LTS"
$ go version
go version go1.10.3 linux/amd64

以下のような無限ループするコードから始めます。

package main
   
import "fmt"
   
func main() {
    fmt.Println("wait...")
    for {
    }
}

実行。

$ go run echo_server.go
$ lsof -i:30000
$ ps -x | grep echo_server
 2200 pts/1    Sl+    0:00 go run echo_server.go
 2232 pts/1    Rl+    0:24 /tmp/go-build303798012/b001/exe/echo_server
 2287 pts/2    S+     0:00 grep --color=auto echo_server
$ ll /proc/2232/fd
total 0
dr-x------ 2 vagrant vagrant  0 Jul  7 05:48 ./
dr-xr-xr-x 9 vagrant vagrant  0 Jul  7 05:48 ../
lrwx------ 1 vagrant vagrant 64 Jul  7 05:48 0 -> /dev/pts/1
lrwx------ 1 vagrant vagrant 64 Jul  7 05:48 1 -> /dev/pts/1
lrwx------ 1 vagrant vagrant 64 Jul  7 05:48 2 -> /dev/pts/1

まだポートのバインドもしていなければソケットのディスクリプタもない。psの結果を見るとstatに l が出力されていてGoのランタイムがマルチスレッドで動いてることが分かる。

Listen

netパッケージのListenを使う。

package main

import (
    "fmt"
    "net"
)

func main() {
    l, err := net.Listen("tcp", "0.0.0.0:30000")
    defer l.Close()
    if err != nil {
        panic(err)
    }
    fmt.Println("wait...")
    for {
    }
}

確認してみる。

$ lsof -i:30000
COMMAND    PID    USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
echo_serv 2330 vagrant    3u  IPv4  19936      0t0  TCP *:30000 (LISTEN)
$ ss -lt4
State       Recv-Q Send-Q                             Local Address:Port                                              Peer Address:Port                
LISTEN      0      128                                            *:30000                                                        *:*

30000ポートでLISTENになってる。つまり、 Listen関数で以下の3ステップが行われた模様。

  1. socket でソケットを作成する
  2. bind でソケットをバインドする
  3. listen でListen状態になる

ディスクリプタも確認する。

$ ll /proc/2330/fd
total 0
dr-x------ 2 vagrant vagrant  0 Jul  7 06:04 ./
dr-xr-xr-x 9 vagrant vagrant  0 Jul  7 06:04 ../
lrwx------ 1 vagrant vagrant 64 Jul  7 06:04 0 -> /dev/pts/1
lrwx------ 1 vagrant vagrant 64 Jul  7 06:04 1 -> /dev/pts/1
lrwx------ 1 vagrant vagrant 64 Jul  7 06:04 2 -> /dev/pts/1
lrwx------ 1 vagrant vagrant 64 Jul  7 06:04 3 -> socket:[19936]
lrwx------ 1 vagrant vagrant 64 Jul  7 06:04 4 -> anon_inode:[eventpoll]

おや、ソケットの他に anon_inode:[eventpoll] が。これはepollを使ったechoサーバー書いたときにもあったやつ。 epoll_createシステムコールで作られるファイルディスクリプタ。あの時は、 sock, bind, listenで準備したソケットのディスクリプタepoll_createで作ったファイルディスクリプタを、epoll_ctlの引数で指定する、という順序でechoサーバーを書いた。

straceしてみると確かにやってる。

$ go build echo_server.go
$ strace ./echo_server
...
epoll_create1(EPOLL_CLOEXEC)            = 4
...
socket(PF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 3
setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(30000), sin_addr=inet_addr("0.0.0.0")}, 16) = 0                                                            
listen(3, 128)                          = 0
epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=880447232, u64=140669649325824}}) = 0
...

通信する

net.ListenerのAcceptメソッドでコネクション(net.Conn)を取得して、net.ConnのReadメソッド, Writeメソッドを使う。

package main

import (
        "io"
        "fmt"
        "net"
)

func main() {
        l, err := net.Listen("tcp4", "0.0.0.0:30000")
        defer l.Close()
        if err != nil {
                panic(err)
        }
        fmt.Println("wait...")
        for {
                conn, err := l.Accept()
                if err != nil {
                        panic(err)
                }
                _, err = conn.Write([]byte("Hello World!\r\n"))
                if err != nil {
                        panic(err)
                }
                buf := make([]byte, 1024)
                n, err := conn.Read(buf)
                if err != nil {
                        if err == io.EOF {
                                break
                        }
                        panic(err)
                }
                _, err = conn.Write(buf[:n])
                if err != nil {
                        panic(err)
                }
                conn.Close()
        }
}

クライアントからつなぐ。

$ telnet 192.168.33.10 30000
Trying 192.168.33.10...
Connected to 192.168.33.10.
Escape character is '^]'.
Hello World!

サーバー側で確認する。

$ lsof -i:30000
COMMAND    PID    USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
echo_serv 2019 vagrant    3u  IPv4  18087      0t0  TCP *:30000 (LISTEN)
echo_serv 2019 vagrant    5u  IPv4  18089      0t0  TCP 192.168.33.10:30000->192.168.33.1:52736 (ESTABLISHED)
$ ll /proc/2019/fd
total 0
dr-x------ 2 vagrant vagrant  0 Jul  7 07:20 ./
dr-xr-xr-x 9 vagrant vagrant  0 Jul  7 07:19 ../
lrwx------ 1 vagrant vagrant 64 Jul  7 07:20 0 -> /dev/pts/1
lrwx------ 1 vagrant vagrant 64 Jul  7 07:20 1 -> /dev/pts/1
lrwx------ 1 vagrant vagrant 64 Jul  7 07:20 2 -> /dev/pts/1
lrwx------ 1 vagrant vagrant 64 Jul  7 07:20 3 -> socket:[18087]
lrwx------ 1 vagrant vagrant 64 Jul  7 07:20 4 -> anon_inode:[eventpoll]
lrwx------ 1 vagrant vagrant 64 Jul  7 07:20 5 -> socket:[18089]

クライアントから適当な文字列を入力するとechoされて接続がサーバーから切られる。

$ telnet 192.168.33.10 30000
Trying 192.168.33.10...
Connected to 192.168.33.10.
Escape character is '^]'.
Hello World!
hoge          
hoge
Connection closed by foreign host.

echoサーバーできた。

複数クライアントから接続してみる

複数のクライアントで同時に接続してみる。

クライアント1

$ telnet 192.168.33.10 30000
Trying 192.168.33.10...
Connected to 192.168.33.10.
Escape character is '^]'.
Hello World!

クライアント2

$ telnet 192.168.33.10 30000
Trying 192.168.33.10...
Connected to 192.168.33.10.
Escape character is '^]'.

最初に接続したクライアント1はサーバーから Hello World! が送られてきてるけど、後に接続したクライアント2には何も送られてきてない。

サーバー側で確認してみる。

$ lsof -i:30000
COMMAND    PID    USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
echo_serv 2019 vagrant    3u  IPv4  18087      0t0  TCP *:30000 (LISTEN)
echo_serv 2019 vagrant    5u  IPv4  18100      0t0  TCP 192.168.33.10:30000->192.168.33.1:54396 (ESTABLISHED)

$ ss -at4 | grep 30000
LISTEN     1      128        *:30000                    *:*                    
ESTAB      0      0      192.168.33.10:30000                192.168.33.1:54396                
ESTAB      0      0      192.168.33.10:30000                192.168.33.1:54405

C言語でechoサーバー書いた時と同じように、1クライアントずつしかさばけていない模様。あの時readブロッキングI/Oで1クライアントずつしか捌けなかった。

今回はどうだろう。straceで確認してみる。

$ sudo strace -p 2019
strace: Process 2019 attached
epoll_pwait(4,

read じゃなくて epoll_pwait でブロックしてる。

straceしながら確認してみる。

$ strace ./echo_server

クライアント1からつなぐ。

[{EPOLLIN, {u32=1587633920, u64=139927327170304}}], 128, -1, NULL, 139927327170304) = 1                                                                 
futex(0x5bcd50, FUTEX_WAKE, 1)          = 1
futex(0x5bcc70, FUTEX_WAKE, 1)          = 1
accept4(3, {sa_family=AF_INET, sin_port=htons(57992), sin_addr=inet_addr("192.168.33.1")}, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5                        
epoll_ctl(4, EPOLL_CTL_ADD, 5, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=1587633712, u64=139927327170096}}) = 0                                        
getsockname(5, {sa_family=AF_INET, sin_port=htons(30000), sin_addr=inet_addr("192.168.33.10")}, [16]) = 0                                               
setsockopt(5, SOL_TCP, TCP_NODELAY, [1], 4) = 0
write(5, "Hello World!\r\n", 14)        = 14
read(5, 0xc4200b2000, 1024)             = -1 EAGAIN (Resource temporarily unavailable)                                                                  
epoll_pwait(4, [{EPOLLOUT, {u32=1587633712, u64=139927327170096}}], 128, 0, NULL, 0) = 1                                                                
epoll_pwait(4,

acceptするときにノンブロッキングの指定をしている。

accept4(3, {sa_family=AF_INET, sin_port=htons(57992), sin_addr=inet_addr("192.168.33.1")}, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5

readしたけどノンブロッキングにしてるのですぐに EAGAIN が返ってきている。つまり、 read ではブロックしていない。

read(5, 0xc4200b2000, 1024)             = -1 EAGAIN (Resource temporarily unavailable)

クライアント2からもつなぐ。

[{EPOLLIN, {u32=1587633920, u64=139927327170304}}], 128, -1, NULL, 0) = 1
epoll_pwait(4,

epollからGoへの通知は発生しているものの、 accept が呼ばれない。

readシステムコールブロッキングはしていないが、GoのReadから先に進んでいない。1つのgoroutineで処理を行っておりReadブロッキングしてるため、クライアントずつしか捌けていない。OSから見るとノンブロッキングI/Oな read だけれども、Goから見るとブロッキングRead という感じ。

複数クライアントを捌く

クライアントから接続が来たらgoroutineを生成して対応する。C言語で書いた時のブロッキングI/Oのままforkを使ってマルチプロセスにしたコードの同じように書ける。

package main

import (
        "io"
        "fmt"
        "net"
)

func main() {
        l, err := net.Listen("tcp4", "0.0.0.0:30000")
        defer l.Close()
        if err != nil {
                panic(err)
        }   
        fmt.Println("wait...")
        for {
                conn, err := l.Accept()
                if err != nil {
                        panic(err)
                }   
                go func(conn net.Conn) {
                        _, err = conn.Write([]byte("Hello World!\r\n"))
                        if err != nil {
                                panic(err)
                        }   
                        buf := make([]byte, 1024)
                        n, err := conn.Read(buf)
                        if err != nil {
                                if err == io.EOF {
                                        return
                                }   
                                panic(err)
                        }   
                        _, err = conn.Write(buf[:n])
                        if err != nil {
                                panic(err)
                        }   
                        conn.Close()
                }(conn)
        }   
}

これで複数クライアント同時に接続しても大丈夫。

$ lsof -i:30000                                                                                                              
COMMAND    PID    USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
echo_serv 2128 vagrant    3u  IPv4  18169      0t0  TCP *:30000 (LISTEN)
echo_serv 2128 vagrant    5u  IPv4  18171      0t0  TCP 192.168.33.10:30000->192.168.33.1:64033 (ESTABLISHED)                                           
echo_serv 2128 vagrant    6u  IPv4  18173      0t0  TCP 192.168.33.10:30000->192.168.33.1:64042 (ESTABLISHED)  

$ ss -at4 | grep 30000
LISTEN     0      128        *:30000                    *:*                    
ESTAB      0      0      192.168.33.10:30000                192.168.33.1:64033                
ESTAB      0      0      192.168.33.10:30000                192.168.33.1:64042

プロセス数とスレッド数を見てみる。

$ ps -L x | grep echo_server
 2128  2128 pts/1    Sl+    0:00 ./echo_server
 2128  2129 pts/1    Sl+    0:00 ./echo_server
 2128  2130 pts/1    Sl+    0:00 ./echo_server
 2128  2131 pts/1    Sl+    0:00 ./echo_server
 2128  2132 pts/1    Sl+    0:00 ./echo_server

プロセスは1個、スレッドは5個ある。Goを実行すると勝手にできるみたい。同時に接続するクライアントを6つにしてみる。

$ lsof -i:30000
COMMAND    PID    USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
echo_serv 2128 vagrant    3u  IPv4  18169      0t0  TCP *:30000 (LISTEN)
echo_serv 2128 vagrant    5u  IPv4  18171      0t0  TCP 192.168.33.10:30000->192.168.33.1:64033 (ESTABLISHED)
echo_serv 2128 vagrant    6u  IPv4  18173      0t0  TCP 192.168.33.10:30000->192.168.33.1:64042 (ESTABLISHED)
echo_serv 2128 vagrant    7u  IPv4  18221      0t0  TCP 192.168.33.10:30000->192.168.33.1:65256 (ESTABLISHED)
echo_serv 2128 vagrant    8u  IPv4  18223      0t0  TCP 192.168.33.10:30000->192.168.33.1:65265 (ESTABLISHED)
echo_serv 2128 vagrant    9u  IPv4  18225      0t0  TCP 192.168.33.10:30000->192.168.33.1:65270 (ESTABLISHED)
echo_serv 2128 vagrant   10u  IPv4  18227      0t0  TCP 192.168.33.10:30000->192.168.33.1:65275 (ESTABLISHED)

$ ps -L x | grep echo_server
 2128  2128 pts/1    Sl+    0:00 ./echo_server
 2128  2129 pts/1    Sl+    0:00 ./echo_server
 2128  2130 pts/1    Sl+    0:00 ./echo_server
 2128  2131 pts/1    Sl+    0:00 ./echo_server
 2128  2132 pts/1    Sl+    0:00 ./echo_server

接続が6個になったにも関わらず、スレッド数は変わってない。接続数を10にしても変わらず。あくまでも1クライアントを1goroutineで捌いてるのであって、1クライアント1スレッドというわけではない。

Goのランタイム

I/OやスレッドをGoのランタイムがOSとの間に入って管理してくれている。

以下の本で、 GoのランタイムはミニOS って書いてあってすごく分かりやすい。

Webの記事はこっち。

ascii.jp

イベント駆動で書いてみる

goroutineとchannelを使ってイベント駆動っぽいのも書いてみた。

package main

import (
        "fmt"
        "io"
        "net"
)

type Event struct {
        Conn net.Conn
        Msg []byte
}

func asyncAccept(l net.Listener) chan net.Conn {
        ch := make(chan net.Conn)
        go func() {
                for {
                        conn, err := l.Accept()
                        if err != nil {
                                panic(err)
                        }   
                        _, err = conn.Write([]byte("Hello World!\r\n"))
                        if err != nil {
                                panic(err)
                        }   
                        ch<-conn
                }   
        }() 
        return ch
}

func asyncRead(ch chan *Event, conn net.Conn) {
        go func() {
                buf := make([]byte, 1024)
                _, err := conn.Read(buf)
                if err != nil {
                        if err == io.EOF {
                                return
                        }   
                        panic(err)
                }   
                event := &Event{ conn, buf }
                ch<-event
        }() 
}

func main() {
        l, err := net.Listen("tcp4", "0.0.0.0:30000")
        defer l.Close()
        if err != nil {
                panic(err)
        }   

        fmt.Println("wait...")
        listener := asyncAccept(l)
        events := make(chan *Event)
        for {
                select {
                case conn := <-listener:
                        asyncRead(events, conn)
                case event := <-events:
                        _, err = event.Conn.Write(event.Msg)
                        if err != nil {
                                panic(err)
                        }
                        event.Conn.Close()
                }
        }
}

goroutineとchannelを使うとGoの世界の中で色々な書き方ができて楽しいですね!

ascii.jp

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)