🍻

雰囲気でOAuth2.0を使っているエンジニアがgolangで学んでみる

2021/12/31に公開

はじめに

技術書典で購入していたAuth屋さんの本を読みました。
とてもわかりやすくいい本ですが、チュートリアルを進める時にcurlコマンドとブラウザを行ったり来たりするのがめんどくさくなってきたので、golangで置き換えてみることにしました。

どうしようかと考えてみて🙄、下記のように実装してみます。

  • httpサーバを起動させる
  • アクセスするとgoogleにリダイレクトさせる
  • callbackを受けたら認可コードでトークンリクエストをする
  • トークンを取得したらリソースにアクセスする(GooleのAPIを叩く)

最終的なコードは以下にあります。
https://gist.github.com/sat0ken/cf3b9f7104ea497ab1aa51e65b23849d

準備

以下を行います。
Auth屋さんの本でもチュートリアルを行う前に書かれている内容です。
ローカルのhttpサーバでcallbackを受けるためリダイレクトURIはhttp://localhost:8080/callbackをセットしておきます。

  • GCPのコンソールでGoole Photo Library APIを有効にする
  • 認証画面の作成
  • OAuthクライアントIDの作成
    • シークレット情報のダウンロード
    • ダウンロードしたらjsonファイルをclient_secret.jsonとリネームしてmain.goと同じフォルダに配置する。

実装

net/httpでhttpサーバを立てたらその中に処理を書いていきます。

main関数では/start/callbackの2つのエンドポイントと関数のセットを登録してhttpサーバを起動します。
setup関数は必要なパラメータをセットしてます。

func main() {

	setUp()
	http.HandleFunc("/start", start)
	http.HandleFunc("/callback", callback)
	err := http.ListenAndServe("localhost:8080", nil)
	if err != nil {
		log.Fatal(err)
	}
	log.Println("start server localhost:8080...")

}

リダイレクト

ブラウザでhttp://localhost:8080/startを開くとGoogleの認可エンドポイントにhttpリダイレクトするようにします。
url.Valuesに必要な以下のパラメータをセットします。

  • response_type
  • client_id
  • state
  • scope
  • redirect_uri

PKCEを用いるので以下もセットします。

  • code_challenge_method
  • code_challenge

code_challengeにセットする文字列の値は、SHA256でハッシュ値を計算してBase64URLエンコードしたものをセットします。
RFC7636に書かれているcode_verifierのサンプル値にSHA256+Base64URLエンコードした値をセットします。

最終的にリダイレクトさせるコードは以下のようになりました。

func start(w http.ResponseWriter, req *http.Request) {

	authEndpoint := oauth.authEndpoint

	values := url.Values{}
	values.Add("response_type", response_type)
	values.Add("client_id", oauth.clientId)
	values.Add("state", oauth.state)
	values.Add("scope", oauth.scope)
	values.Add("redirect_uri", redirect_uri)

	// PKCE用パラメータ
	values.Add("code_challenge_method", oauth.code_challenge_method)
	values.Add("code_challenge", oauth.code_challenge)

	// 認可エンドポイントにリダイレクト
	http.Redirect(w, req, authEndpoint+values.Encode(), 302)
}

コールバック

Googleの認可エンドポイントにリダイレクトさせられた後はGoogleにログインをし、アプリが必要な権限の移譲に同意をします。
同意すると認可コードとともにredirect_uriで設定したlocalhostに戻されるのでその時の処理を書きます。

req.URL.Query()で返されるurl.Valuesに認可コードが入っているので、それをそのままアクセストークンをリクエストする関数に渡します。

func callback(w http.ResponseWriter, req *http.Request) {

	query := req.URL.Query()

	// トークンをリクエストする
	result, err := tokenRequest(query)
	if err != nil {
		log.Println(err)
	}

	body, err := apiRequest(req, result["access_token"].(string))
	if err != nil {
		log.Println(err)
	}
	w.Write(body)

}

トークンリクエスト

必要なパラメータをセットしてトークンエンドポイントに送ります。

  • client_id
  • client_secret
  • grant_type
  • redirect_uri
  • code
  • code_verifier

codeにはリダイレクトされた時にもらった認可コードを、code_verifierにはSHA256+BASE64urlエンコードする前の文字列をセットします。
トークン取得に成功したら内容をリターンします。

func tokenRequest(query url.Values) (map[string]interface{}, error) {

	tokenEndpoint := oauth.tokenEndpoint
	values := url.Values{}
	values.Add("client_id", oauth.clientId)
	values.Add("client_secret", oauth.clientSecret)
	values.Add("grant_type", grant_type)

	// 取得した認可コードをトークンのリクエストにセット
	values.Add("code", query.Get("code"))
	values.Add("redirect_uri", redirect_uri)

	// PKCE用パラメータ
	values.Add("code_verifier", verifier)

	req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(values.Encode()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		log.Printf("request err: %s", err)
		return nil, err
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	log.Printf("token response : %s", string(body))
	var data map[string]interface{}
	json.Unmarshal(body, &data)

	return data, nil
}

リソースアクセス

取得したトークンをHTTPリクエストのヘッダーにセットしてGoogleのAPIにアクセスします。
トークンはjsonに入っているのでそれを取り出してリクエスト関数に渡します。

リクエストを送りHTTPレスポンスのbodyを返す。

func apiRequest(req *http.Request, token string) ([]byte, error) {

	photoAPI := "https://photoslibrary.googleapis.com/v1/mediaItems"

	req, err := http.NewRequest("GET", photoAPI, nil)
	if err != nil {
		return nil, err
	}
	// 取得したアクセストークンをHeaderにセットしてリソースサーバにリクエストを送る
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil || resp.StatusCode != 200 {
		log.Printf("http status code is %d, err: %s", resp.StatusCode, err)
		return nil, err
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Println(err)
		return nil, err
	}

	return body, nil

}

Photo Library APIのレスポンスをw.Writeさせているのでブラウザに結果が出力されたらOKです。

Discussion