ユーザーの認証と認可を行う方法としてはOpenID Connectがメジャーですよね。ローカルで簡単にテストするために、ローカルでKeycloakをDockerで起動してテストしてみます。外部システムはモックを使う、というのがセオリーですが、気軽に使える本物のサービスを使った方が楽ですよね、ということで。
なお、認証周りのGoのアプリケーションコードは超簡易実装なので、本番実装に入れちゃダメですよ。
- 2020/11/10: コンテナの置き場が変わっていたので更新
- 2020/11/11: Realmについて補足
Keycloakとは
KeycloakはIBM傘下のRedHat傘下のJBossが作成している認証のすごいソフトウェアです。
- 自分自身でユーザーIDとパスワードを管理するID Provider機能を持つ
- OpenID Connect、OAuth2、SAML経由でユーザー認証ができる(OIDCなどのサーバーになれる)
- LDAPやActive Directoryで管理しているユーザー情報を取り込める
- GoogleやTwitterなどの外部のSNSを使った認証ができる(OIDCなどのクライアントになれる)
認証周りのシステムは多種多様で、ひとことOpenID Connect対応といっても、自分がサーバーになるシステムがいたり、クライアントになるシステムがいたり、自分自身でID管理をする機能があったり、なかったり(他のサービスが管理しているユーザー情報を利用するだけ)、機能がいろいろ違っていたりします。Keycloakは、これらのすべての機能を備えていて、いろいろな用途に使うことができます。
今回はGoアプリケーションがOpenID Connectのクライアントになり、KeycloakをOpenID Connectのサーバーにする、という構成になります。
KeycloakをDockerで起動
18080ポートで起動します。ユーザーIDとパスワードは環境変数で設定します。
docker run -d -p 18080:8080 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin --name keycloak quay.io/keycloak/keycloak
ここではDBを指定しないで起動しています。Keyclaok自体はPostgreSQLをはじめ、MySQL、SQL Server、Oracleなどに対応しています。何も指定しないと組み込みのH2が起動します。これはコンテナを落とすとデータが消えてしまいますが、テスト用にはDBのインスタンスを別に起動したりしなくていいのでお手軽で良いかと思います。
Dockerfileの説明によると、ファイルでユーザーを流し込んだり、バッチでユーザーが追加できますので、実運用でも利用するならこちらの方が良いでしょう。今回はあくまでもアプリケーション開発のためのモックなので、ユーザーIDもパスワードも固定値にしています。
ブラウザで http://localhost:18080 にアクセスしてみましょう。Keycloakのログイン画面が出てきますので、先ほどDocker起動時に設定したIDとパスワードでログインします。
管理画面が表示されます。RealmというのはKeycloakのデータの箱です。この箱の中にクライアントが登録されます。デフォルトでMasterというのが作られていますが、これがOpenID Connectのissuerになります。URLは http://localhost:18080/auth/realms/master です。
これから作るアプリケーション(Keycloakから比べるとクライアント)を登録します。左のメニューでClientsを選び、右のCreateボタンをクリックします。クライアントIDを入力します。プロトコルはopenid-connect、URLは http://localhost:8080 にします。
作成したら、一箇所だけ設定を変えておきます。GitHubにしても何にしても、OpenID Connectで接続するときは、クライアントIDとクライアントシークレットを使って繋ぐのが一般的です。それを有効にしましょう。今作ったアプリケーションのAccess Typeをconfidentialにして保存します。そうすると、Credentailsタブにシークレットが表示されています。この2つはあとで使います。
今回は手早く試すのと、Keycloakの全貌をみるために管理画面をいろいろ触って見て設定しましたが、このRealmの情報やらクライアントの情報やらはJSONなどでエクスポートすることができますし、Dockerで起動するときに読み込ませることもできます(し、手作業で作るとクライアントシークレットが毎回変わってしまう)ので、定常的に使うテスト環境などを構築をする場合は管理画面ではなくて、流し込みを使った方が良いでしょう。
Realmについて
Realmはデータの箱と説明しました。異なるRealmを用意すると、それらは完全に独立した状態になります。A社向けとB社向けの認証サービスを1インスタンスで提供するときに、データを完全に分離しておきたいときに使うぐらいのイメージです。シングルサインオンのサービスで利用するアプリケーションとか、同時利用がありえる場合は1つのRealmで対応します。1Keycloakサーバー==1Realmぐらいの感じで使うぐらいが良いかとおもいます。
アプリを作る
Goでアプリケーションを作ります。GoでOpenID Connectを使う場合は、"golang.org/x/oauth2"と、"github.com/coreos/go-oidc"のパッケージを使うのが良いです。OpenID ConnectはOAuth2にIDトークン取得のステップを加えたものなので、途中まではOAuth2です。
まずは、OpenID Connectのサーバー接続設定の部分です。OpenID ConnectはJSON形式で接続に必要な情報を返す機能があります。OpenID Connect Discoveryというのですが、Keycloakでもそれを利用できるので、Keycloakに関する情報はissuerの "http://localhost:18080/auth/realms/master" だけです。具体的な接続先のJSONは/.well-known/openid-configurationにありますので、興味のある方は見てみると良いでしょう。
それ以外にはアプリケーション固有の情報を書いていきます。クライアントID("testapp")、そして先ほど作成したクライアントシークレットは先ほど作ったものを指定します。OpenID Connectのコールバックを受けるURLは"/callback"とします。これも設定に入れます。今回はIDだけとれれば良いのでスコープはopenidだけにしていますが、必要に応じてemailとか入れても良いかと思います。
package main
import (
"context"
"sync"
"github.com/coreos/go-oidc"
"golang.org/x/oauth2"
)
var once sync.Once
var provider *oidc.Provider
var oauth2Config *oauth2.Config
func getConfig() (*oauth2.Config, *oidc.Provider) {
once.Do(func() {
var err error
// ここにissuer情報を設定
provider, err = oidc.NewProvider(context.Background(), "http://localhost:18080/auth/realms/master")
if err != nil {
panic(err)
}
oauth2Config = &oauth2.Config{
// ここにクライアントIDとクライアントシークレットを設定
ClientID: "testapp",
ClientSecret: "3b83efea-cb71-4027-aa31-03639b84277e",
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID},
RedirectURL: "http://localhost:8080/callback",
}
})
return oauth2Config, provider
}
以下はアプリケーションコードです。ログイン成功・失敗はクッキーの存在の有無だけで見ています。コールバックの中はコードを使ってアクセストークンを取得し、今度はそれを使ってIDトークンを取得するという流れになっています。本来はセッションストレージに情報をきちんと入れて、ランダムなセッショントークンを発行したり、クッキーに入っているセショントークンが正しいか確認したりが必要なところですが、とりあえず雑にIDトークンをそのままクッキーに入れて、検証もせずに使っていますが、絶対真似しないでくださいね。
ログイン後のコールバックでも本来は、最初のリダイレクト前に、最初にアクセスのあったページ情報などをセッションストレージに入れておくとか、stateパラメータを使ってなりすましを防いだりとかいろいろ必要ですが、簡易実装ですので、ご容赦ください。
func main() {
// 認証で保護したいページ。ログインしていなければKeycloakのOpenID Connect認証ページに飛ばす
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// クッキーがない時はリダイレクト
if _, err := r.Cookie("Authorization"); err != nil {
config, _ := getConfig()
url := config.AuthCodeURL("")
http.Redirect(w, r, url, http.StatusFound)
return
}
io.WriteString(w, "login success")
})
// OpenID Connectの認証が終わった時に呼ばれるハンドラ
// もろもろトークンを取り出したりした後に、クッキーを設定して元のページに飛ばす
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
config, provider := getConfig()
if err := r.ParseForm(); err != nil {
http.Error(w, "parse form error", http.StatusInternalServerError)
return
}
accessToken, err := config.Exchange(context.Background(), r.Form.Get("code"))
if err != nil {
http.Error(w, "Can't get access token", http.StatusInternalServerError)
return
}
rawIDToken, ok := accessToken.Extra("id_token").(string)
if !ok {
http.Error(w, "missing token", http.StatusInternalServerError)
return
}
oidcConfig := &oidc.Config{
ClientID: "testapp",
}
verifier := provider.Verifier(oidcConfig)
idToken, err := verifier.Verify(context.Background(), rawIDToken)
if err != nil {
http.Error(w, "id token verify error", http.StatusInternalServerError)
return
}
// IDトークンのクレームをとりあえずダンプ
// アプリで必要なものはセッションストレージに入れておくと良いでしょう
idTokenClaims := map[string]interface{}{}
if err := idToken.Claims(&idTokenClaims); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("%#v", idTokenClaims)
http.SetCookie(w, &http.Cookie{
Name: "Authorization",
Value: "Bearer " + rawIDToken, // 行儀が悪いので真似しないねで
Path: "/",
})
http.Redirect(w, r, "/", http.StatusFound)
})
log.Println(http.ListenAndServe(":8080", nil))
}
これで http://localhost:8080 にアクセスすると、未ログイン時は先ほどのKeycloakのログイン画面に飛ばされ、成功すると、"login success"とだけ表示されます。ローカルのテストでもフルセットのOpenID Connectのサービスが使えるようになりました。