GoogleAPIを利用するデスクトップアプリのOAuth2.0認証

Posted: , Modified:   GoogleCloudPlatform Go OAuth Qiita

本稿は Qiita 投稿記事 のバックアップです.

概要

Google の API を利用するデスクトップアプリが OAuth 2.0 プロトコルを走らせる方法. Go 標準のoauth2パッケージはほとんどの処理を実行してくれるが, 認証コードを受け取るために一時的な ローカルWebサーバを立てることと, Code verifier の計算は自前で行う.

アプリの登録

まず初めに,コンソールの認証情報ページでアプリのクライアント ID とクライアントシークレットを取得する.

コンソールの API Manager から認証情報を開き,認証情報を作成,OAuth クライアント ID を選択する.

デスクトップアプケーションから使用するため,その他を選び作成する.

クライアント ID とクライアントシークレットが表示されるので控えておく. なお,忘れてしまっても認証情報ページで再確認できる.

Code verifier の計算

サーバから送られてくる認証コードを横取りされる中間者攻撃を防ぐため, Code verifier というものを利用できる. 具体的には,認証コードの要求時にランダム文字列(Code verifier)のハッシュ値を送っておき, アクセストークンを要求する時にはランダム文字列を送る. 認証コードが横取りされてもランダム文字列の方がバレなければ アクセストークンを不正に取得されることを防げる.

Code verifier は,次の文字からなる長さ43から128の文字列と決められている.

まずは,Code verifier の生成関数を作る.

import (
  "crypto/rand"
  "math/big"
)

var (
  CodeVerifierChars []byte
)

func init() {
  for b := byte('a'); b <= byte('z'); b++ {
    CodeVerifierChars = append(CodeVerifierChars, b)
  }
  for b := byte('A'); b <= byte('Z'); b++ {
    CodeVerifierChars = append(CodeVerifierChars, b)
  }
  for b := byte('0'); b <= byte('9'); b++ {
    CodeVerifierChars = append(CodeVerifierChars, b)
  }
  CodeVerifierChars = append(CodeVerifierChars, byte('-'), byte('.'), byte('_'), byte('~'))
}

func GenerateCodeVerifier() (codeVerifier []byte, err error) {

  max := big.NewInt(int64(len(CodeVerifierChars)))
  var n *big.Int
  for i := 0; i != 128; i++ {
    n, err = rand.Int(rand.Reader, max)
    if err != nil {
      return
    }
    codeVerifier = append(codeVerifier, CodeVerifierChars[n.Int64()])
  }
  return

}

(init の中身はもう少し賢くできる気がする)

乱数生成は math/rand パッケージでもできるが,より安全な crypto/rand の方を使ってみた.

先に述べた通り,認証コードの要求時には生成された Code verifier のハッシュ値(Code challenge)を送る. 正確にはパディング無し Base64 エンコードした SHA256 ハッシュ値を送る. 下記のコードはその Code challenge を計算する.

codeVerifier := GenerateCodeVerifier()
sum := sha256.Sum256(codeVerifier)
codeChallenge := strings.TrimRight(base64.URLEncoding.EncodeToString(sum[:]), "=")

認証コード受け取り用サーバ

次に,認証コードを受け取るための Web サーバを用意する. 認証コードは,コードと要求時に指定した State の組みで返ってくるため,構造体を定義しておく.

type authorizationCode struct {
  Code  string
  State string
}

Web サーバの方は goroutine として動かすので,認証コードとエラーを goroutine 呼び出し側が 受け取れるようにチャンネルを作っておく.

type codeReceiver struct {
  Result chan *authorizationCode
  Error  chan error
}

func newCodeReceiver() *codeReceiver {
  return &codeReceiver{
    Result: make(chan *authorizationCode, 1),
    Error:  make(chan error, 1),
  }
}

この Web サーバは,認証コードを受け取る一回のリクエストだけ処理できれば良い. 認証コードあるいはエラー情報はリクエスト URL のクエリとして送られてくるので, 内容に合わせて処理を分ける.

const (
  AuthSucceedURL = "https://jkawamoto.github.io/roadie/auth/succeed/"
  AuthErrorURL = "https://jkawamoto.github.io/roadie/auth/error/"
)

func (r *codeReceiver) ServeHTTP(res http.ResponseWriter, req *http.Request) {

  queries := req.URL.Query()
  if errCode := queries.Get("error"); errCode != "" {
    res.Header().Add("Location", AuthErrorURL)
    r.Error <- fmt.Errorf("Failed authorization: %v", errCode)

  } else if code := queries.Get("code"); code == "" {
    res.Header().Add("Location", AuthErrorURL)
    r.Error <- fmt.Errorf("Failed authorization")

  } else {
    res.Header().Add("Location", AuthSucceedURL)
    r.Result <- &authorizationCode{
      Code:  queries.Get("code"),
      State: queries.Get("state"),
    }

  }
  res.WriteHeader(http.StatusFound)

}

今回は,GitHub Pages に認証成功時と失敗時に表示する Web ページを用意しているので, そちらに転送するレスポンスを返している.

もちろんアプリケーション側で HTML 文書を作成して res.Write(...) のように ResponseWriter 型の res に直接書き込んでも良い.

この Web サーバを起動するには,次のようにする.

port := 18029
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%v", port))
if err != nil {
  return
}
defer listener.Close()

receiver := newCodeReceiver()
go http.Serve(listener, receiver)

アクセストークンの要求

以上を合わせてアクセストークンを要求する. まず,Code verifier, Code challenge を計算し,ローカル Web サーバを起動する.

その後は,oauth2パッケージの Config が提供する AuthCodeURLExchenge を使って アクセストークンを取得する.

Config はクライアント ID, クライアントシークレット,エンドポイント,リダイレクトURL(起動したローカル Web サーバのURL),そしてスコープを取る. なおスコープの一覧は,ここにある.

const (
  authorizeEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
  tokenEndpoint = "https://www.googleapis.com/oauth2/v4/token"
  gcpScope = "https://www.googleapis.com/auth/cloud-platform"
)

cfg := &oauth2.Config{
  ClientID:     ClientID,
  ClientSecret: ClientSecret,
  Endpoint: oauth2.Endpoint{
    AuthURL:  authorizeEndpoint,
    TokenURL: tokenEndpoint,
  },
  RedirectURL: fmt.Sprintf("http://127.0.0.1:%v", port),
  Scopes:      []string{gcpScope},
}

Config を作成したら,AuthCodeURL を使って認証コード要求用 URL を取得する. この時,State と Code challenge を渡す.

state := fmt.Sprintf("%v", time.Now().Unix())
url := cfg.AuthCodeURL(
  state,
  oauth2.SetAuthURLParam("code_challenge_method", "S256"),
  oauth2.SetAuthURLParam("code_challenge", codeChallenge))

ユーザにこの得られた URL にアクセスしてもらい,認証操作を行ってもらう. ユーザがアクセス許可を与えるか,キャンセルするかどちらかのアクションを行うと, 先ほど作成したサーバから認証コードあるいはエラーがチャンネル経由で送られてくる.

認証コードの場合は State 情報が上記で設定したものと同じか確認してから次へ進む.

var code *authorizationCode
select {
case code = <-receiver.Result:
    if code.State != state {
      err = fmt.Errorf("Received state dosen't match")
      return
    }

case err = <-receiver.Error:
    return

case <-ctx.Done():
    return nil, ctx.Err()

}

なお上記のコードでは context のキャンセルも合わせてチェックしている.

最後に,Config の Exchenge を使って認証コードからアクセストークンを取得するのだが, この時 Code verifier を合わせて送るようにしておく.

cfg.Endpoint.TokenURL = fmt.Sprintf(
  "%v?code_verifier=%v", cfg.Endpoint.TokenURL, string(codeVerifier))

token, err := cfg.Exchange(ctx, code.Code)

以上すべてを合わせると次のようになる.

func RequestToken(ctx context.Context) (token *oauth2.Token, err error) {

  // Code verifier, Code challenge の計算
  codeVerifier, err := GenerateCodeVerifier()
  if err != nil {
    return
  }
  sum := sha256.Sum256(codeVerifier)
  codeChallenge := strings.TrimRight(base64.URLEncoding.EncodeToString(sum[:]), "=")

  // Web サーバの起動
  port := 18029
  listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%v", port))
  if err != nil {
    return
  }
  defer listener.Close()

  receiver := newCodeReceiver()
  go http.Serve(listener, receiver)

  // Config の準備
  cfg := &oauth2.Config{
    ClientID:     ClientID,
    ClientSecret: ClientSecret,
    Endpoint: oauth2.Endpoint{
      AuthURL:  authorizeEndpoint,
      TokenURL: tokenEndpoint,
    },
    RedirectURL: fmt.Sprintf("http://127.0.0.1:%v", port),
    Scopes:      []string{gcpScope},
  }

  // URL の取得
  state := fmt.Sprintf("%v", time.Now().Unix())
  url := cfg.AuthCodeURL(
    state,
    oauth2.SetAuthURLParam("code_challenge_method", "S256"),
    oauth2.SetAuthURLParam("code_challenge", codeChallenge))

  // URLを表示してユーザにアクセスさせる
  fmt.Println(url)

  // アクセストークンの取得
  var code *authorizationCode
  select {
  case code = <-receiver.Result:
    if code.State != state {
      err = fmt.Errorf("Received state dosen't match")
      return
    }

  case err = <-receiver.Error:
    return
  case <-ctx.Done():
    return nil, ctx.Err()
  }

  cfg.Endpoint.TokenURL = fmt.Sprintf(
    "%v?code_verifier=%v", cfg.Endpoint.TokenURL, string(codeVerifier))
  return cfg.Exchange(ctx, code.Code)

}

アクセストークンの利用

得られたアクセストークンは,そのトークンを使用する http.Client を作成し他の API クライアントに渡すことで利用できる. トークンを使った http.Client の作成は,oauth2.Config 経由で行う.

ctx := context.Background()
cfg := &oauth2.Config{
  ClientID:     ClientID,
  ClientSecret: ClientSecret,
  Endpoint: oauth2.Endpoint{
    AuthURL:  authorizeEndpoint,
    TokenURL: tokenEndpoint,
  },
  Scopes:      []string{gcpScope},
}

client := cfg.Client(ctx, token)

このクライアントを使うと,アクセストークンが期限切れになった場合に自動で再取得してくれる.

このクライアントを使って,例えば利用可能な Zone 一覧を取得する場合, 次のようになる.

import	(
  "context"
  "fmt"

  "google.golang.org/api/compute/v1"
)

func Zone(ctx context.Context, project string, token *oauth2.Token) (err error){

  cfg := &oauth2.Config{
    ClientID:     ClientID,
    ClientSecret: ClientSecret,
    Endpoint: oauth2.Endpoint{
      AuthURL:  authorizeEndpoint,
      TokenURL: tokenEndpoint,
    },
    Scopes:      []string{gcpScope},
  }

  client := cfg.Client(ctx, token)
  service, err := compute.New(client).newService(ctx)
  if err != nil {
    return
  }

  res, err := service.Zones.List(project).Do()
  if err != nil {
    return
  }

  for _, v := range res.Items {
    fmt.Println(v.Name, v.Status)
  }
  return

}

参考