GAE と Angular で普通の SPA の作り方 〜 認証編

# はじめに

* 前回までで GAE と Angular を使った SPA のひな形を作り、GAE にデプロイするところまでできるようになった。
* 今回は OAuth を使ってユーザー認証を実装する。

# 実装方針

* ユーザー認証には省力化のため OAuth 認証を利用する。
* OAuth プロバイダは Google を利用するが、他のプロバイダも利用できるようにできるだけ汎用的に実装する。

# ユーザー認証の流れ

![ZP91Iy9m68Vl-nHZTxz0HEWXkWH1q0SGYZgO1vjt_-zBCDH88oO5OTPOIqihGIpDFiolVzk-HNl9F8Y5bvtUv_azOqiVsTc2NJpC6OO5Ti7t81yqiCpKcfd9xnTpsOAvOe8VGKyW3zm7NuB7e25KDse5eVUsYDt8h2VSLgPe7vXH-oGAnh5RqNSLS1lK0mMWLx0pJwwc_Y5dkqEnS0dIyo0wTXOrM3yIS4FwkGQvBrbpTjrj.png](https://qiita-image-store.s3.amazonaws.com/0/17778/3fba1cae-5cb3-68c2-0800-f9b128839407.png)

* ログイン画面を Angular から提供する部分以外は基本的に通常の Web サービスでの OAuth 認証の流れと同じ。
* クライアントサイドに認証情報や認証処理を出してしまうのは安全面でのデメリットが大きいと判断し、SPA ではあるがあえて認証処理はサーバーサイドで直接提供するようにした。

# OAuth プロバイダの準備

* GCP Console で OAuth 用のクライアント ID などの認証情報を発行する。
* 左上メニューから "API とサービス" > "認証情報" を選択
* "認証情報を作成" を押下し、"ウェブアプリケーション" を選択
* "承認済みのリダイレクト URI" に "http://localhost:8080/oauth/callback" を登録
* "作成" ボタン押下で "クライアント ID" と "クライアントシークレット" が表示されるのでメモしておく。

# OAuth プロバイダ認証情報を環境変数から取得できるように

* 取得した "クライアント ID" と "クライアントシークレット" を GAE の設定ファイルに追記して環境変数として取得できるようにする
* 認証情報は git リポジトリにアップロードしたくないので、別ファイルに分けて .gitignore に追加し、本ファイルから読み込むようにする

```yaml:server/gae/app.yaml
includes:
- secret.yaml
```

```yaml:server/gae/secret.yaml
env_variables:
  GOOGLE_CLIENT_ID: "クライアント ID"
  GOOGLE_CLIENT_SECRET: "クライアントシークレット"
```

# ログイン画面の表示

* app.component.ts へ OAuth ログイン開始画面へリダイレクトするメソッドを実装

```ts:client/src/app/app.component.ts
  onClickLogin() {
    location.href = '/oauth/start';
  }
```

* app.component.html でリダイレクトメソッドを実行するボタンを実装

```html:client/src/app/app.component.html
<button (click)="onClickLogin()">LOGIN</button>
```

# OAuth ログイン開始ハンドラの実装

* OAuth プロバイダの認証情報を使ってプロバイダの OAuth 認証画面の URL を作成してリダイレクトする。
* ここから GAE の設定ファイルとアプリケーションの実装ファイルをわけるために .go ファイルは `server/app` 以下に置いていく。

```go:server/app/handler.go
var cookieNameState = "STATE"

// Google の OAuth 認証画面へリダイレクトさせるためのハンドラ
func OauthStartHandler(e echo.Context) error {
	// CSRF 対策にランダムな文字列を付与してコールバックの際に検証する
	// ハンドラ間での値の引き回しは Cookie を利用する
	state := uuid.NewV4().String()
	e.SetCookie(&http.Cookie{
		Name:  cookieNameState,
		Value: state,
		Path:  "/",
	})

	c := createOauth2Config()
	url := c.AuthCodeURL(state, oauth2.AccessTypeOnline)

	http.Redirect(e.Response(), e.Request(), url, 302)
	return nil
}

func createOauth2Config() oauth2.Config {
	return oauth2.Config{
		ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
		ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
		Scopes:       []string{"openid", "profile"},
		Endpoint: oauth2.Endpoint{
			AuthURL:  google.Endpoint.AuthURL,
			TokenURL: google.Endpoint.TokenURL,
		},
		RedirectURL: "http://localhost:8080/oauth/callback",
	}
}
```

# OAuth コールバックハンドラの実装

* OAuth 認証が成功した場合にリダイレクトされてくるページのハンドラを実装する。
* コールバックには認証コードが付与されてくるので、これからアクセストークンを取得し、アクセストークンでプロバイダのユーザー情報を取得し、アプリケーションのユーザーの取得または登録を行う。

```go:server/app/handler.go
// Google の OAuth 認証が成功した場合にリダイレクトされてくるハンドラ
// 認証情報を使ってユーザーの参照または登録を行う
func OauthCallbackHandler(e echo.Context) error {
	ctx := appengine.NewContext(e.Request())

	// state が同一であるかチェック
	state := e.QueryParam("state")
	if cookie, err := e.Cookie(cookieNameState); err != nil {
		if cookie.Value != state {
			return errors.New("state is not valid")
		}
	}

	// 認証コードを使ってアクセストークンを取得する
	c := createOauth2Config()
	code := e.QueryParam("code")
	tok, err := c.Exchange(ctx, code)
	if err != nil {
		panic(err)
	}
	log.Debugf(ctx, "token: %v", tok)

	// アクセストークンを使って Google のユーザー情報を取得する
	client := c.Client(ctx, tok)
	ret, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
	if err != nil {
		return err
	}
	defer ret.Body.Close()
	data, err := ioutil.ReadAll(ret.Body)
	if err != nil {
		return err
	}
	log.Debugf(ctx, "userinfo: %v", string(data))
	userinfo := struct {
		Sub string `json:"sub"`
	}{}
	if err := json.Unmarshal(data, &userinfo); err != nil {
		return err
	}
	log.Debugf(ctx, "sub: %v", userinfo.Sub)

	// 取得した Google のユーザー情報でアプリケーションのユーザーがいなければ作成する
	key := datastore.NewKey(ctx, "User", userinfo.Sub, 0, nil)
	u := &User{ID: userinfo.Sub}
	err = datastore.RunInTransaction(ctx, func(ctx context.Context) error {
		err := datastore.Get(ctx, key, u)
		if err != nil && err != datastore.ErrNoSuchEntity {
			log.Debugf(ctx, "user exists: %v", u)
			return err
		}
		_, err = datastore.Put(ctx, key, u)
		return err
	}, nil)
	if err != nil {
		log.Errorf(ctx, "Transaction failed: %v", err)
		return err
	}
	log.Debugf(ctx, "user created: %v", u)

	return e.JSON(http.StatusOK, struct{ Message string }{"ok"})
}
```

```go:server/app/user.go
package app

type User struct {
	ID string
}
```

# 実装したハンドラをルートに追加する

```go:server/gae/app.go
	e.GET("/oauth/start", app.OauthStartHandler)
	e.GET("/oauth/callback", app.OauthCallbackHandler)
```

# 依存パッケージのインストール

* 今回 GAE や OAuth 関連の依存パッケージが新たにいくつも追加された。
* ひとつづつ `go get` してもよいが `dep` コマンドを使うと依存パッケージを探して自動でインストールしてくれる。

```
# dep のインストール
go get -u github.com/golang/dep/cmd/dep

# 依存パッケージのインストール
dep init
```

# 動作確認

* クライアントのビルド後に開発サーバーを起動し `http://localhost:8080/client/` でログイン画面を表示する。
* OAuth ログインを行いユーザーを作成する。
* Datastore の中身は `http://localhost:8000` から確認することができるので、正常にユーザーが作成できたかどうかを確認する。

# デプロイ

* デプロイして実際の GAE 環境で動作確認をする場合は GCP Console の認証情報のコールバック URL に `https://$PROJECT_NAME.appsopt.com/oauth/callback` を追加する。

# 実コードについて

* この記事の時点のコードは https://github.com/nirasan/gae-angular-sample/tree/qiita.com/nirasan/items/85b635ec2ccb580643a6 を参照のこと

# おわりに

* 次回はユーザー認可編で、アプリ専用のアクセストークンを発行して Angular, GAE 間の通信で利用する方法についてまとめる。