GolangでGoogleのOpenIDConnectを使った実装をしてみた
概要
Golangを勉強したのでGoogleのOpenIDConnectを使ったサンプルアプリを実装してみました
実装物はこちらに置いてあります
実装物の解説
Google連携するとGoogleアカウントで新規会員登録されるという単純なサンプルアプリです。
認可リクエスト
func AuthGoogleSignUpHandler(w http.ResponseWriter, r *http.Request) {
client := oidc.NewGoogleOidcClient()
// CSRFを防ぐためにstateを保存し、後の処理でstateが一致するか確認する
state, err := oidc.RandomState()
if err != nil {
fmt.Println(err)
return
}
cookie := http.Cookie{Name: "state", Value: state}
http.SetCookie(w, &cookie)
// ユーザーをGoogleのログイン画面にリダイレクト
redirectUrl := client.AuthUrl(
"code",
[]string{"openid", "email", "profile"},
fmt.Sprintf(
"%s://%s:%s/auth/google/sign_up/callback",
os.Getenv("SERVER_PROTO"),
os.Getenv("SERVER_HOST"),
os.Getenv("SERVER_PORT"),
),
state,
)
http.Redirect(w, r, redirectUrl, 301)
}
ランダムな文字列をstateとしてセットして、Googleのログイン画面から返ってきた時に一致するか検証しています。
トークンリクエスト
Googleのログイン画面で認証情報を入力したユーザーはhandler/google.go
で指定したredirectUrl
に帰ってきます。
- stateの検証
- トークンエンドポイントにリクエスト
- 署名の検証
- ペイロードの検証
を行なっていきます
stateの検証
認可リクエストを投げる時に設定したstateが一致しているのか確認することで不正な操作を防ぎます。
cookieState, err := r.Cookie("state")
if err != nil {
fmt.Printf("Cookie get error %s", err)
return
}
queryState := r.URL.Query().Get("state")
if queryState != cookieState.Value {
fmt.Printf("state does not match %s : %s", queryState, cookieState.Value)
return
}
トークンエンドポイントにリクエスト
// 認可コードを取り出しトークンエンドポイントに投げることでid_tokenを取得できる
client := oidc.NewGoogleOidcClient()
tokenResp, err := client.PostTokenEndpoint(
r.URL.Query().Get("code"),
fmt.Sprintf(
"%s://%s:%s/auth/google/sign_up/callback",
os.Getenv("SERVER_PROTO"),
os.Getenv("SERVER_HOST"),
os.Getenv("SERVER_PORT"),
),
"authorization_code",
)
if err != nil {
fmt.Println(err)
return
}
Googleのログイン画面から返ってくるとクエリパラメーターに認可コードが付与されています。この認可コードを取り出し、トークンエンドポイントに投げることでid_tokenやaccess_tokenを取得することができます。
// tokenResponse はトークンエンドポイントのレスポンスをunmarshalするため構造体
type tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
IdToken string `json:"id_token"`
}
トークンエンドポイントのレスポンスはこのような構造になっています。今回はIdToken
のみを扱っていきます。
署名の検証
// JWKsエンドポイントから公開鍵を取得しid_token(JWT)の署名を検証。改竄されていないことを確認する
idToken, err := oidc.NewIdToken(tokenResp.IdToken)
if err != nil {
fmt.Println(err)
return
}
if err := idToken.ValidateSignature(client.JwksEndpoint); err != nil {
fmt.Println(err)
return
}
id_tokenはJWT形式になっており、文字列のままだと扱いにくいので、oidc.NewIdToken(tokenResp.IdToken)
で構造体に焼き直しています。
type idToken struct {
rawToken string
rawHeader string
RawPayload string
rawSignature string
header header
}
type header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
次にid_tokenが改竄されていないかを確かめるために署名を検証します。
- JWKsエンドポイントに公開鍵が公開されているので、鍵を取得
- JWTのヘッダーからkidを取り出し、一致する公開鍵を使う
- 公開鍵を使ってJWTの署名検証を行う
func (token idToken) ValidateSignature(jwksUrl string) error {
resp, err := http.Get(jwksUrl)
if err != nil {
return err
}
// ...
var key jwk
var isFound bool
for _, v := range keys.Keys {
if token.header.Kid == v.Kid {
key = v
isFound = true
}
}
if !isFound {
return errors.New("JWK not found")
}
// ...
headerAndPayload := fmt.Sprintf("%s.%s", token.rawHeader, token.RawPayload)
hasher := sha256.New()
hasher.Write([]byte(headerAndPayload))
decSignature, err := base64.RawURLEncoding.DecodeString(token.rawSignature)
if err != nil {
return err
}
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hasher.Sum(nil), decSignature); err != nil {
return err
}
return nil
}
真面目に実装するなら、JWKsエンドポイントから取得した公開鍵はキャッシュしたほうがいいと思います。
Googleが公開鍵を変更することはめったにないため、HTTP応答のキャッシュディレクティブを使用して公開鍵をキャッシュでき
Rubyでの実装になりますが、このgemの実装が参考になります
ペイロードの検証
// id_tokenのpayload部分をチェックし、期限切れなどしていないか確認する
bytePayload, err := jwt.DecodeSegment(idToken.RawPayload)
if err != nil {
fmt.Println(err)
return
}
payload := &oidc.GoogleIdTokenPayload{}
if err := json.Unmarshal(bytePayload, payload); err != nil {
fmt.Println(err)
return
}
if err = payload.IsValid(client.ClientId); err != nil {
fmt.Println(err)
return
}
JWT自体が改竄されていないことを確認できたら、ペイロードの中身を検証していきます。この辺りは単純な文字列比較などになります。
新規登録
今回のサンプルアプリではOpenIDConnectの部分が実装できれば良かったので、最後は適当にDBにデータを登録しています。
user := &model.User{Email: payload.Email, Sub: payload.Sub, IdProvider: payload.Iss}
db.Create(user)
fmt.Printf("success to create user :%v", user)
Discussion