5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ディップAdvent Calendar 2020

Day 17

google認証のrefresh_tokenの扱いでハマったポイント

Last updated at Posted at 2020-12-16

はじめに

ある案件の要件に「Googleでログインしているユーザーで、スプレッドシートに帳票を出力してほしい」とありましたので、その実現のためにはまったポイントについて、refresh_tokenの「取り方」「保管の仕方」「使い方」の3つの観点で整理しようと思います。
サーバー側のロジックはGoで記述し、Webフレームワークは一部echoを使用しています。

全体概要

解説のためmain.goに全て記述しました。
著書『Go言語によるWebアプリケーション開発』(以下、「教科書」)の内容を元に実装しています。

package main

import (
	"fmt"
	"github.com/gomodule/redigo/redis"
	"github.com/google/uuid"
	"github.com/joho/godotenv"
	"github.com/labstack/echo"
	"github.com/stretchr/gomniauth"
	"github.com/stretchr/gomniauth/providers/google"
	"github.com/stretchr/objx"
	"golang.org/x/oauth2"
	google2 "golang.org/x/oauth2/google"
	"log"
	"net/http"
	"os"
	"strings"
)

// 認証情報はRedisに保存する
var conn redis.Conn

/*
CLIENT_ID プロジェクトのクライアントID
SECRET_VALUE プロジェクトのシークレットValue
REDIRECT_URL プロジェクトで設定したリダイレクトURL
SECURITY_KEY 任意の文字列
*/

func main(){
	log.Println("server start...")

	_ = godotenv.Load()

	var err error
	conn, err = redis.Dial("tcp", os.Getenv("REDIS_CONN"))
	if err != nil{
		panic(err)
	}

	// google認証機能の設定 
	gomniauth.SetSecurityKey(os.Getenv("SECURITY_KEY"))
	gomniauth.WithProviders(
		google.New(os.Getenv("CLIENT_ID"), os.Getenv("SECRET_VALUE"), os.Getenv("REDIRECT_URL")),
	)

	e := echo.New()

	// ハンドラの登録
	e.GET("/login", Login)
	e.GET("/callback", Callback)


	g := e.Group("")
	g.Use(AuthGuard())
	// ログインしていないと見れないページ
	g.GET("/private", Private)

	// echoサーバーの起動
	e.Logger.Fatal(e.Start(":1323"))
}

// Login GoogleログインのURLの発行処理
func Login(c echo.Context) error{
	log.Println("Login is invoked")

    // ! refresh_tokenを取り出すときのポイント
	// provider, err := gomniauth.Provider("google") 教科書ではここでこれを使っているが、それだとScorpesをいじれないし、refresh_tokenも取れない。
	// url, err := provider.GetBeginAuthURL(nil, nil)

	// 代わりにoauth2でconfigを直接いじる。
	config := oauth2.Config{
		ClientID:     os.Getenv("CLIENT_ID"),
		ClientSecret: os.Getenv("SECRET_VALUE"),
		Endpoint:     google2.Endpoint,
		RedirectURL:  os.Getenv("REDIRECT_URL"),
		Scopes: []string{
			"https://www.googleapis.com/auth/userinfo.email",
			"https://www.googleapis.com/auth/spreadsheets", // スプレッドシートの使用権限を追加
		},
	}

	// こうするとログイン後にスプレッドシートの操作権限を渡して良いかの確認ダイアログが出るurlを取得でき、「refreshToken」が取れる。
	url := config.AuthCodeURL(os.Getenv("SECURITY_KEY"), oauth2.AccessTypeOffline, oauth2.ApprovalForce)

	// 生成したurlを返す。フロントはこれにリクエスト、リダイレクトしてもらう。
	return c.String(http.StatusOK, url)
}

/*
googleログイン後はリダイレクトURLに対してクエリ「?code=XXXX」がついた状態でリダイレクトする
教科書では直接GoのCallback関数にリダイレクトさせるように記述されているが、
フロントとAPIでソースコードが別れている場合ではうまくいかなかった。
そのため、一度フロントにURLを返して、改めて再度queryにcodeの値をセットして、Goの/callbackにリクエストを投げてもらっている。
*/

// Callback ログイン後のコールバック処理
func Callback(c echo.Context) error{
	log.Println("Callback is invoked")

	provider, err := gomniauth.Provider("google")
	if err != nil{
		return c.String(http.StatusInternalServerError, err.Error())
	}

	// codeの値を取り出す。Googleの認可処理
	code := c.QueryParam("code")
	cred, err := provider.CompleteAuth(objx.MustFromURLQuery("code=" + code))
	if err != nil {
		return c.String(http.StatusInternalServerError, err.Error())
	}

	// 変数「user」にGoogleで保持しているユーザー情報が取れる。access_token、emailと、アバターの画像、名前、jwtトークンに加えて、上記の対応によりここでrefresh_tokenが含まれるようになる。
	user, err := provider.GetUser(cred)
	if err != nil {
		return c.String(http.StatusInternalServerError, err.Error())
	}


    // refresh_tokenを保管するときのポイント 
	// redisにその情報を保存するため、keyを生成し、cred,userからほしい情報だけ取り出して保存する。
	uuID, err := uuid.NewRandom()
	if err != nil {
		panic(err)
	}	
    uuidStr := uuID.String()
	refreshToken := fmt.Sprintf("%v", cred.Get("refresh_token"))
	jsonStr := fmt.Sprintf(`{"refresh_token":"%s","email":"%s"}`,
		refreshToken, user.Email())

	log.Println(jsonStr)
	_, err = conn.Do("SET", uuidStr, jsonStr, "NX")
	if err != nil{
		return c.String(http.StatusInternalServerError, err.Error())
	}

	// フロントにredisのキーを返す。フロントは今後Header["Authorization"]に入れてリクエストしてもらう。
	return c.String(http.StatusOK, uuidStr)
}

// Private ログインしていない場合、"success"は表示されない。
func Private(c echo.Context) error{
	log.Println("Private is invoked")

	// context からトークンを取り出す
	token := c.Get("token").(string)
	log.Println(token)

	// tokenを表示させる。
	return c.JSON(http.StatusOK, token)
	//return c.String(http.StatusOK, "success")
}

// AuthGuard ログイン状態の確認
func AuthGuard() echo.MiddlewareFunc{
	return func(next echo.HandlerFunc) echo.HandlerFunc{
		return func(c echo.Context) error{
			log.Println("AuthGuard is invoked")

			// Header["Authorization"]からredisのキーを取り出す。
			token := c.Request().Header.Get("Authorization")
			key := strings.ReplaceAll(token, "Bearer ", "")

			// redisから情報を取り出す。
			res, _ := redis.String(conn.Do("GET", key))
			if res == ""{
				// ない場合、ログインが必要だと表示
				return c.String(http.StatusUnauthorized, "login required")
			}

			// contextに値を詰めておく。
			c.Set("token", res)

			// ログインしているので、その関数を実行する。
			if err := next(c); err != nil{
				return c.String(http.StatusInternalServerError, err.Error())
			}
			return nil
		}
	}
}

// ! refresh_tokenの使い方のポイント
// リフレッシュトークンを元にアクセストークンを取得する
func getClient(refreshToken string) *http.Client {
	log.Println(model.IsInvoked())
	urlValue := url.Values{
		"client_id":     {os.Getenv("CLIENT_ID")},
		"client_secret": {os.Getenv("SECRET_VALUE")},
		"refresh_token": {refreshToken},
		"grant_type":    {"refresh_token"},
	}

	resp, err := http.PostForm("https://www.googleapis.com/oauth2/v3/token", urlValue)
	if err != nil {
		log.Fatalf("Error when renew token %v", err)
	}

	body, err := ioutil.ReadAll(resp.Body)
	_ = resp.Body.Close()
	if err != nil {
		log.Fatal(err)
	}
	var token oauth2.Token
	_ = json.Unmarshal(body, &token)
	config := oauth2.Config{}
	return config.Client(context.Background(), &token)
}

refresh_tokenの「取り方」のポイント

にてログイン機能の実装は解説されているのですが、その通りに実装すると、refresh_tokenを変数credの中から取得することができませんでした。(Login関数内のポイント①を参照)
そのため、config.AuthCodeURLのnilを渡している二箇所にそれぞれ、oauth2.AccessTypeOffline, oauth2.ApprovalForceを引数に渡すと、ユーザーがGoogleログインするときに「〇〇 が Google アカウントへのアクセスをリクエストしています」というダイアログを出すようになります。
すると、Callback関数のprovider.GetUser(cred)の返り値のなかに、refresh_tokenが含まれるようになります。

refresh_tokenの「保管の仕方」のポイント

refresh_tokenが流出した場合、その人の権限でスコープで記述した範囲のことができてしまうので、ユーザーに直接見える形で管理するというのは避けなければなりません。
そのため、今回はuuID.String()で生成した値をキーに、メールアドレスと一緒にredisに保管しています。キーをフロントとやり取りするときはAuthorizationヘッダー内に入れます。

refresh_tokenの「使い方」のポイント

いざ使うときにはrefresh_tokenをそのまま使うのではなく、その都度access_tokenを生成しなければなりません。
getClient関数にて、その記述をしました。

さいごに

まだまだ経験の浅いエンジニアです。上記がベストプラクティスなのかは確証がありません・・・。
他にもっと良い方法があった場合、ぜひぜひおしえてください。

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?