こちらは GAOGAO Advent Calendar 2021 ことしもGAOGAOまつりです の2日目の記事です。昨日の記事は ますみんさん の リバースプロキシをDocker Compose環境で実現する でした。
こんにちは、GAOGAO の案件に携わらさせていただいている こうりん と申します。 よろしくお願いいたします。
この記事では、GoのAPIでAuth0のJWT認証をする方法をハンズオン形式で説明します。今回実装するコードは以下のリポジトリに公開します。
実行環境
maxOS Big Sur version 11.6
Node.js: v16.13.0
npm: 8.1.0
go: 1.16.3
Auth0とは
Auth0 は IDaaS (Identity as a Service) に分類されるもので、クラウドでユーザー認証基盤を提供しているサービスです。ユーザーID/パスワード認証だけでなく Google や Facebook などのソーシャルアカウントを使ったサインイン/サインアップやMFA (Multi-Factor Authentication)の設定も可能です。Auth0では多くのプログラミング言語向けSDKを提供しており、Webだけでなくスマートフォンアプリでも使用できます。
Auth0を使ったユーザー認証とAPIアクセスのフロー
WebアプリでAuth0を使う場合、以下のような関係になります。
ブラウザ側でAuth0にアクセスしログインします。この時、結果としてブラウザへJWT(JSON Web Token)が渡されます。そして、このJWTをブラウザがAPIを呼ぶ時にトークンとして渡します。APIではJWTを検証を行い正しいユーザーからのアクセスであることを確認するとともに、リクエストがあったユーザーを識別します。
JWTとは
RFC7519 で標準化されたトークンの仕様です。JWTの中身は以下のように、ヘッダ・ペイロード・署名の3つがピリオド区切りの構成になっています。
<ヘッダ>
.
<ペイロード>
.
<署名>
ヘッダ はJWT作成・検証で必要なトークンタイプや署名アルゴリズムの種類などを格納します。この値をBase64エンコードしてJWTの一番目に配置します。
{
"typ": "JWT",
"alg": "RS256"
}
ペイロード は具体的な認証情報を格納します。ヘッダと同様Base64エンコードしたものがJWTに配置されます。
Auth0では以下のような情報が含まれています。
- iss: トークンの発行者
- azp: クライアントID (Auth0で使用しているサービスの識別子)
- sub: Auth0でのユーザーの識別子
- aud: トークンを使用する対象 (APIなど)
- gty: トークンを取得した方法
- iat: トークンの発行日時
- exp: トークンの失効日時
- scope: 利用できるユーザーのリソースのリスト
- permissions: ユーザーが属するロールが持つ許可のリスト
{
"iss": "https://example.us.auth0.com",
"azp": "xxxxxxxxx",
"sub": "auth0|....",
"aud": [
"http://localhost:8080",
"https://example.us.auth0.com"
],
"gty": "password",
"iat": 1637954372,
"exp": 1638040772,
"scope": "openid profile email",
"permissions": [
"sys:admin"
]
}
署名 は<Base64エンコードしたヘッダ>,<Base64エンコードしたペイロード>
を署名アルゴリズムで署名したものです。
JWTを検証する際は、JWTに含まれるヘッダとペイロードを元にダイジェストを作成します。そして、署名を復号した結果に含まれるダイジェストと一致するか確認して、JWTが改ざんされていないかチェックします。
Auth0の初期設定
まず、Auth0を使用するための初期設定を行います。Auth0.com へアクセスし、サインインをすると以下のようなコンソール画面に移動します。
左のメニューから Applications > APIs を選択します。
APIs で右上の Create API を押すと、次のようなモーダルが表示されます。
Name は好きな名前を設定します。Identifier はAPIの識別子として使われますが、通常はAPIのURLを指定します。 Signing Algorithm では RS256, HS256 のどちらかを選択できます。RS256 が公開鍵/秘密鍵を使った署名方法、HS256 が共通鍵を使った署名方法です。特に問題が無ければ、RS256で良いと思います。
Create を押して作成が完了すると、以下のようなAPI設定画面へ遷移します。
次にフロントエンドからAuth0へアクセスするためにApplicationを作成します。同様に左メニューから Applications > Applications を選択します。
APIに対するApplicationが作られていますが、Create Application を押して新しく作成します。Name には任意の名前を入力してください。Application Type はフロントエンドにReactを使うので、Single Page Web Application を選択します。
Applicationの作成が完了すると設定画面に遷移します。Settingsタブをクリックします。Domain と ClientID は後ほどフロントエンドで使用するのでメモしておきます。
Settingsで Allowed Callback URLs, Allowed Logout URLs, Allowed Web Origins, Allowed Origins (CORS) にフロントエンドのURLを設定します。とりあえず、ローカルで動かすのでそれぞれ http://localhost:3000
を設定します。
Save Changesを押して作成すると、同様に設定画面へ遷移します。ひとまずAuth0のコンソールで必要な作業は以上です。
APIのコード実装
プロジェクト構築
GoでAPIのコードを書いていきます。API用のプロジェクトのディレクトリを作り、go mod init
でGoのプロジェクトをセットアップします。(GOPATHの外でgo mod init
をする場合は、プロジェクトのルートパスを指定する必要があります)
$ mkdir server
# server以下に移動する
$ cd server
# Goプロジェクトの初期化
$ go mod init
# GOPATHの外で実行する場合 (例)
$ go mod init github.com/Kourin1996/go-auth0-example/server
プロジェクトの構成は以下のようにします。handlers
以下にAPIのハンドラ、middlewares
以下にAuth0のJWT検証部分をそれぞれ実装します。
server
├── handlers
│ └── v1
│ ├── v1.go (/v1へのリクエストのハンドラ定義)
│ └── users
│ └── me (/v1/users/meへのリクエストのハンドラ定義)
│ └── me.go
├── middlewares
│ └── auth0 (Auth0向けの処理を定義)
├── main.go (エントリポイント)
└── go.mod
まず、APIの動作確認のためにシンプルなエンドポイントを実装します。server/main.go
と server/handlers/v1/v1.go
を作成し、それぞれ以下を記述します。
// server/main.go
// APIのエントリポイント
package main
import (
"fmt"
"log"
"net/http"
v1 "github.com/Kourin1996/go-auth0-example/server/handlers/v1"
)
const (
port = 8000
)
func main() {
mux := http.NewServeMux()
// /v1へのリクエストが来た場合のハンドラを追加
mux.HandleFunc("/v1", v1.HandleIndex)
addr := fmt.Sprintf(":%d", port)
// localhost:8000 でサーバーを立ち上げる
log.Printf("Listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}
// server/handlers/v1/v1.go
// /v1向けのハンドラ
package v1
import (
"net/http"
)
func HandleIndex(w http.ResponseWriter, r *http.Request) {
// "Hello!" とだけ返します
w.Write([]byte("Hello!"))
}
ファイルを保存しgo run main.go
でサーバーを起動します。
$ go run main.go
2021/12/02 12:47:27 Listening on :8000
別のシェルからcurlを使ってAPIにリクエストを投げると、正しくメッセージが返ってくることを確認できます。
$ curl http://localhost:8000/v1
Hello!
JWT認証用の処理を追加
APIでAuth0向けのJWT認証を行うには大きく分けて3つのステップがあります
- 署名を検証するための公開鍵をAPI起動時に取得する
- auth0/go-jwt-middlewareを初期化する
- 認証が必要なリクエストが来た場合にJWTの検証をする
それぞれ順番に実装していきます
公開鍵の取得
まず公開鍵を取得します。JWTは秘密鍵を使って署名を生成しているので、検証には対になる公開鍵が必要となります。これはhttps://<DOMAIN>/.well-known/jwks.json
から取得できます。ドメインはAuth0のApplicationの設定画面などに記載されています。
ドメインを元に公開鍵を取得する関数を実装します。server/middlewares/auth0/key.go
を作成し以下を記述します。取得するデータは、JWKS (JSON Web Key Set)というJSONに公開鍵などが入ってる形式なので、取得したJSONをGoの構造体にマッピングします。
// server/middlewares/auth0/key.go
package auth0
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// JKWS向けの構造体定義
type JSONWebKeys struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
X5c []string `json:"x5c"`
}
type JWKS struct {
Keys []JSONWebKeys `json:"keys"`
}
func FetchJWKS(auth0Domain string) (*JWKS, error) {
// ドメインを指定して公開鍵が入ったJWKSを取得する
resp, err := http.Get(fmt.Sprintf("https://%s/.well-known/jwks.json", auth0Domain))
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 取得したJSONデータを構造体にマッピングする
jwks := &JWKS{}
err = json.NewDecoder(resp.Body).Decode(jwks)
return jwks, err
}
auth0/go-jwt-middlewareの初期化
auth0/go-jwt-middlewareはAuth0向けのJWTを検証するためのパッケージです。まず、パッケージをgo get
で追加します。
$ go get github.com/auth0/go-jwt-middleware@v1.0.1
次にserver/middlewares/auth0/auth0.go
を作成し、以下を記述します。
// server/middlewares/auth0/auth0.go
package auth0
import (
"errors"
"fmt"
"net/http"
jwtmiddleware "github.com/auth0/go-jwt-middleware"
"github.com/form3tech-oss/jwt-go"
)
func NewMiddleware(domain, clientID string, jwks *JWKS) (*jwtmiddleware.JWTMiddleware, error) {
return jwtmiddleware.New(jwtmiddleware.Options{
ValidationKeyGetter: newValidationKeyGetter(domain, clientID, jwks),
// JWTで使われている署名アルゴリズムを指定する
SigningMethod: jwt.SigningMethodRS256,
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err string) {},
}), nil
}
func newValidationKeyGetter(domain, clientID string, jwks *JWKS) func(*jwt.Token) (interface{}, error) {
return func(token *jwt.Token) (interface{}, error) {
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return token, errors.New("invalid claims type")
}
// azpフィールドを見て、適切なClientIDのJWTかチェックする
azp, ok := claims["azp"].(string)
if !ok {
return nil, errors.New("authorized parties are required")
}
if azp != clientID {
return nil, errors.New("invalid authorized parties")
}
// issフィールドを見て、正しいトークン発行者か確認する
iss := fmt.Sprintf("https://%s/", domain)
ok = token.Claims.(jwt.MapClaims).VerifyIssuer(iss, true)
if !checkIss {
return nil, errors.New("invalid issuer")
}
// JWTの検証に必要な鍵を生成する
cert, err := getPemCert(jwks, token)
if err != nil {
return nil, err
}
return jwt.ParseRSAPublicKeyFromPEM([]byte(cert))
}
}
// JWKSからJWTで使われているキーをPEM形式で返す
func getPemCert(jwks *JWKS, token *jwt.Token) (string, error) {
cert := ""
for k := range jwks.Keys {
if token.Header["kid"] == jwks.Keys[k].Kid {
cert = "-----BEGIN CERTIFICATE-----\n" + jwks.Keys[k].X5c[0] + "\n-----END CERTIFICATE-----"
}
}
if cert == "" {
return "", errors.New("unable to find appropriate key")
}
return cert, nil
}
Middlewareを作成
任意のハンドラでJWT認証を行えるように、Middlewareの形で関数を作成します。server/middlewares/auth0/middleware.go
を作成し以下を記述します。
// server/middlewares/auth0/middleware.go
package auth0
import (
"context"
"net/http"
jwtmiddleware "github.com/auth0/go-jwt-middleware"
"github.com/form3tech-oss/jwt-go"
)
// jwtmiddleware.JWTMiddlewareをContextに格納するためのキー
type JWTMiddlewareKey struct{}
// JWTをContextに保存するためのキー
type JWTKey struct{}
// jwtmiddleware.JWTMiddlewareをリクエストのContextに格納するためのMiddleware
func WithJWTMiddleware(m *jwtmiddleware.JWTMiddleware) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// リクエストのContextにJWTMiddlewareを格納する
ctx := context.WithValue(r.Context(), JWTMiddlewareKey{}, m)
// 新しいContextを入れて次の処理に渡す
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// JWT検証を行うためのmiddleware
func UseJWT(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ContextからJWTMiddlewareを取得
jwtm := r.Context().Value(JWTMiddlewareKey{}).(*jwtmiddleware.JWTMiddleware)
// リクエスト中のJWTを検証
if err := jwtm.CheckJWT(w, r); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// JWT検証後に、Contextのjwtm.Options.UserPropertyからパース済みのトークンを取得する
if val := r.Context().Value(jwtm.Options.UserProperty); val != nil {
token, ok := val.(*jwt.Token)
if ok {
// リクエストのContextにJWTを保存する
ctx := context.WithValue(r.Context(), JWTKey{}, token)
// 新しいContextを入れて次の処理に渡す
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
next.ServeHTTP(w, r)
})
}
// Contextに埋め込まれたJWTを取得する
func GetJWT(ctx context.Context) *jwt.Token {
rawJWT, ok := ctx.Value(JWTKey{}).(*jwt.Token)
if !ok {
return nil
}
return rawJWT
}
Middlewareとはリクエストのハンドラを呼ぶ前後に任意の処理を差し込む機能です。ここでは2つのMiddlewareを実装しています。
- 2番のMiddlewareがJWTMiddlewareを参照できるようにContext中に埋め込む
- リクエストに対応したハンドラの処理が呼ばれる前に、リクエスト中に含まれるJWTを検証する
1番のWithJWTMiddleware
では、JWTMiddleware
を受け取り新しいMiddlewareを作成します。このMiddlewareでは、リクエストのContextにJWTMiddleware
を埋め込み、そのContextとともに次の処理へ渡します。
2番のUseJWT
では、Contextの中にあるJWTMiddleware
を使ってリクエスト中のJWTを検証します。もし検証に失敗したらエラーを書き込んで返します。エラーの場合はnext.ServeHTTP(w, r)
が呼ばれないため、後続の処理は呼ばれずハンドラの処理は実行されません。JWTの検証に成功した場合は、パースしたJWTをContextに埋め込んで次の処理へ渡します。
GetJWT
はContextにあるJWT
を取得するヘルパー関数です。他のMiddlewareやハンドラでJWTを参照したい場合に使用します。
JWT認証が必要なエンドポイントの実装
JWT認証に必要なコードは実装できたので、実際にJWT認証が必要なエンドポイントを実装していきます。/v1/users/me
にアクセスした時にJWT認証をするようにします。
まず、server/handlers/v1/users/me/me.go
を作成します。ここでは/v1/users/me
へのリクエストに対するハンドラを実装し、JWTに応じてユーザーの情報を返します。
このハンドラは事前にJWT認証をしているため、Contextに検証済みのJWTが含まれています。JWTのClaims
にはペイロード情報が入っており、たとえばsub
はAuth0におけるユーザーの識別子が入っています。このsub
に紐づくユーザーを返します。ここではインメモリのデータを参照していますが、実際のAPIではDBにクエリを投げてsub
が一致するユーザーレコードを取得するなどします。
そして、取得したユーザーをレスポンスとして返します。
// server/handlers/v1/users/me/me.go
package me
import (
"encoding/json"
"fmt"
"net/http"
"github.com/Kourin1996/go-auth0-example/server/middlewares/auth0"
"github.com/form3tech-oss/jwt-go"
)
type User struct {
Name string `json:"name"`
Age int `json:"age"`
}
var (
subToUsers = map[string]User{
"auth0|61a8178b21127500715968e2": {
Name: "kourin",
Age: 15,
},
}
)
// subを元にUserを取得する関数
// 実際のAPIではDBなどに照会し、subに紐づくUserを取得するなどをする
func getUser(sub string) *User {
user, ok := subToUsers[sub]
if !ok {
return nil
}
return &user
}
// /v1/users/me のハンドラ
func HandleIndex(w http.ResponseWriter, r *http.Request) {
token := auth0.GetJWT(r.Context())
fmt.Printf("jwt %+v\n", token)
// token.Claimsをjwt.MapClaimsへ変換
claims := token.Claims.(jwt.MapClaims)
// claimsの中にペイロードの情報が入っている
sub := claims["sub"].(string)
// userを取得する
user := getUser(sub)
if user == nil {
http.Error(w, "user not found", http.StatusNotFound)
return
}
// レスポンスを返す
res, err := json.Marshal(user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(res)
}
初期化とMiddlewareの設定
// server/main.go
package main
import (
"fmt"
"log"
"net/http"
v1 "github.com/Kourin1996/go-auth0-example/server/handlers/v1"
"github.com/Kourin1996/go-auth0-example/server/handlers/v1/users/me"
"github.com/Kourin1996/go-auth0-example/server/middlewares/auth0"
"github.com/rs/cors"
)
const (
port = 8000
domain = "<AUTH0_DOMAIN>"
clientID = "<AUTH0_CLIENT_ID>"
)
func main() {
// 公開鍵を取得する
jwks, err := auth0.FetchJWKS(domain)
if err != nil {
log.Fatal(err)
}
// domain, clientID, 公開鍵を元にJWTMiddlewareを作成する
jwtMiddleware, err := auth0.NewMiddleware(domain, clientID, jwks)
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
// /v1へのリクエストの場合のハンドラを登録
mux.HandleFunc("/v1", v1.HandleIndex)
// /v1/users/meへのリクエストの場合のハンドラを登録
// auth0.UseJWTでラップし、ハンドラを呼ぶ前にJWT認証を行う
mux.Handle("/v1/users/me", auth0.UseJWT(http.HandlerFunc(me.HandleIndex)))
// フロントエンドからアクセスできるようにCORSの設定をする
c := cors.New(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
AllowCredentials: true,
Debug: true,
})
// リクエスト前にJWTMiddlewareをContextに埋め込むためのMiddlewareを追加
wrappedMux := auth0.WithJWTMiddleware(jwtMiddleware)(mux)
wrappedMux = c.Handler(wrappedMux)
addr := fmt.Sprintf(":%d", port)
log.Printf("Listening on %s", addr)
if err := http.ListenAndServe(addr, wrappedMux); err != nil {
log.Fatal(err)
}
}
これで一通りのAPIのコードは実装が完了しました。go run main.go
でAPIを起動して、サーバーが立ち上がったら完了です。
$ go run main.go
2021/12/02 14:50:50 Listening on :8000
フロントエンドサンプルアプリ作成
ログイン部分実装
最後にフロントエンドからアクセスするための簡単なReactアプリを作成します。今回はCreate React Appを使い、Reactアプリの雛形を作成します。プロジェクト構築が完了した後、Auth0認証に必要な @auth0/auth0-react
を追加でインストールします。
# client以下にReactのプロジェクトを作成する
$ npx create-react-app client
# プロジェクトの中に移動する
$ cd client
# Auth0認証に必要なパッケージをインストールする
$ npm install --save @auth0/auth0-react
まず、src/index.js
を開き、Auth0Provider
をApp
コンポーネントの上にラップします。この時, domain
, audience
, clientId
, redirectUri
を渡します。audience
にはAPIのIdentifierを指定します。
// src/index.js
...
import App from "./App";
import { Auth0Provider } from "@auth0/auth0-react";
ReactDOM.render(
<React.StrictMode>
<Auth0Provider
domain="<DOMAIN>"
audience="<API_IDENTIFIER>"
clientId="<CLIENT_ID>"
redirectUri="http://localhost:3000"
>
<App />
</Auth0Provider>
</React.StrictMode>,
document.getElementById("root")
);
...
次に, src/App.js
を開き、ここにログインボタンを設置します。ボタンが押されたら、useAuth0
フックの中にあるloginWithRedirect
関数を呼びます。
// src/App.js
// 分かりやすい用にオリジナルのコードは全て消してあります
function App() {
const { loginWithRedirect, isAuthenticated } = useAuth0();
const onClickLogin = () => {
loginWithRedirect();
};
return (
<div className="App">
<div
style={{ display: "flex", justifyContent: "center", marginTop: "64px" }}
>
<button onClick={onClickLogin} disabled={isAuthenticated}>
{isAuthenticated ? "ログイン済み" : "ログイン"}
</button>
</div>
</div>
);
}
export default App;
npm run start
で開発サーバーを立ち上げて、ブラウザからアクセスします。
$ npm run start
ブラウザからアクセスしログインボタンを押すと、Auth0のログインページにリダイレクトをします。
サインインまたはサインアップをすると、元のページに戻ります。そして、useAuth0()
フックに含まれる isAuthenticated
が true
になりログイン済みであることを取得できます。
API呼び出し部分実装
API呼び出し部分を実装します。まず、JWTを取得する必要があります。useAuth0()
フックの中にgetAccessTokenSilently
という関数があり、これを呼ぶとJWTを取得できます。そして、APIを呼ぶ際にJWTをヘッダーのAuthorization
にセットします。
// client/src/App.js
import { useState, useEffect } from "react";
import "./App.css";
import { useAuth0 } from "@auth0/auth0-react";
const API_URL = "http://localhost:8000";
// Auth0のJWTを取得するフック
const useAuth0Token = () => {
const { isAuthenticated, user, getAccessTokenSilently } = useAuth0();
const [accessToken, setAccessToken] = useState(null);
useEffect(() => {
const fetchToken = async () => {
// JWTを取得して状態に保存する
setAccessToken(await getAccessTokenSilently());
};
// ログイン済みの場合のみJWTを取得する
if (isAuthenticated) {
fetchToken();
}
}, [isAuthenticated, user?.sub]);
return accessToken;
};
function App() {
const { loginWithRedirect, isAuthenticated } = useAuth0();
const token = useAuth0Token();
// ユーザー情報を保持する状態
const [me, setMe] = useState(null);
// APIコールのエラーを保持する状態
const [error, setError] = useState(null);
const onClickLogin = () => {
loginWithRedirect();
};
const onClickCall = async () => {
try {
// APIを呼ぶ
const res = await fetch(`${API_URL}/v1/users/me`, {
method: "GET",
mode: "cors",
headers: {
// JWTをAuthorizationヘッダにセットする
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error(res.statusText);
}
const me = await res.json();
setError(null);
setMe(me);
} catch (error) {
console.log("error", error);
setError(error);
}
};
return (
<div className="App">
<div
style={{ display: "flex", justifyContent: "center", marginTop: "64px" }}
>
<button onClick={onClickLogin} disabled={isAuthenticated}>
{isAuthenticated ? "ログイン済み" : "ログイン"}
</button>
</div>
<div
style={{ display: "flex", justifyContent: "center", marginTop: "64px" }}
>
<div
style={{
display: "flex",
alignItems: "center",
marginRight: "32px",
}}
>
<button onClick={onClickCall}>ユーザー情報を取得</button>
</div>
<div style={{ width: "300px" }}>
<p>ユーザー: {JSON.stringify(me)}</p>
<p>エラー: {error ? error.toString() : ""}</p>
</div>
</div>
</div>
);
}
export default App;
ログインしていない時の呼び出し | ログイン済みの場合の呼び出し |
---|---|
まとめ
この記事ではSPA+Go APIの構成で、ユーザーのJWTをAPIから認証する方法について紹介しました。auth0/go-jwt-middlewareのレポジトリを覗いたらver2.0 betaが上がってたので、時間があれば試してみます。