お題
以下の組み合わせで作成しているWebアプリケーションにAuth0による認証認可機能を入れてみる。
認証はID(メールアドレス)とパスワードによる方式を採用。
■通信方式
・GraphQL
■フロントエンド
・Vue.js
・Nuxt.js
・TypeScript
・Apollo
■バックエンド
・Golang
・gqlgen
挙動としては以下のようになる。
(2)Auth0のログイン画面(カスタマイズもできるらしい)に飛ばされる。メアドとパスワードを入れて「Continue」ボタンを押下する。
(3)認証が通るとアクセストークン付きでコールバック(あらかじめAuth0に設定しておく)が呼ばれる。
(5)サイドメニューから「Movie」を選択して動画一覧ページに遷移する。
(6)ログインユーザに権限があるので動画一覧ページが表示される。
(8)別の(動画一覧表示権限が与えられていない)ユーザでログインする。
(9)サイドメニューから「Movie」を選択して動画一覧ページに遷移する。
(10)ログインユーザに権限がないので動画一覧ページが表示されない。
画面的には、そっけないエラーページだけど、コンソールログを見ると権限がない旨のエラーログが出ている。
前提
- Auth0のSign in方法から各機能の具体的な操作方法など特に細かくは説明しない。
- Vue.jsやGolangの実装方法について1行単位で詳しく解説はしない。
開発環境
# OS - Linux(Ubuntu)
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.4 LTS (Bionic Beaver)"
# フロントエンド
Nuxt.js
$ cat yarn.lock | grep "@nuxt/vue-app"
"@nuxt/vue-app" "2.12.2"
"@nuxt/vue-app@2.12.2":
resolved "https://registry.yarnpkg.com/@nuxt/vue-app/-/vue-app-2.12.2.tgz#cc4b68356996eb71d398a30f3b9c9d15f7d531bc"
パッケージマネージャ - Yarn
$ yarn -v
1.22.4
IDE - WebStorm
WebStorm 2020.1
Build #WS-201.6668.106, built on April 7, 2020
# バックエンド
# 言語 - Golang
$ go version
go version go1.13.9 linux/amd64
IDE - Goland
GoLand 2020.1.2
Build #GO-201.7223.97, built on May 1, 2020
関連記事索引
- 第12回「GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)」
- 第11回「Dataloadersを使ったN+1問題への対応」
- 第10回「GraphQL(gqlgen)エラーハンドリング」
- 第9回「GraphQLにおける認証認可事例(Auth0 RBAC仕立て)」
- 第8回「GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例」
- 第7回「GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)」
- 第6回「GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)」
- 第5回「DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動」
- 第4回「graphql-codegenでフロントエンドをGraphQLスキーマファースト」
- 第3回「go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)」
- 第2回「NuxtJS(with Apollo)のTypeScript対応」
- 第1回「frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る」
全ソース
バックエンド分
フロントエンド分
実践
Auth0の設定
ApplicationとWebAPIの作成
Auth0は、ドキュメントやチュートリアルが充実してるので、ちゃんと読めば、わりかしハマることは少ないと思う。
フロントエンドの設定について
フロントエンドはモードとしてはSSRなんだけど、形式としてSPAを選択している。
それが正しいのかは、ちょっとわかっていない。
ログインやログアウトの処理でAuth0のSDKを使うわけだけど、その際の接続先に関する情報として以下が必要。
また、Auth0での認証処理が成功した後に再びアプリ側に戻ってくるためのコールバックURLを指定。
あと、ログアウト後の遷移先URLも。
バックエンドの設定について
バックエンドはWebAPIとして機能するのでAPIsで設定。
フロントエンドから渡されたJWTのチェックをするために Identifier を使う。
あと、今回は認可機能の実装のために、Auth0が提供しているRBACの仕組みを使うので、Auth0上で定義したRoleとPermissionがJWTのClaimに積まれるようにしておく。
RoleとPermissionの定義
WebAPIが提供する(予定の)各機能にPermissionを定義していく。
この部分は、WebAPIがどんな機能を提供するかや、どういった粒度でPermission制御が必要かについて設計した上で設定する。
今回は仮に「組織」、「ユーザ」、「コンテンツ(今回だと動画コンテンツ)」といったリソースに対するCRUD操作それぞれに認可制御をする想定で設計。
また、APIを叩いた(認証済み)ユーザが、自分で所有(自分で登録したものや他の人から権限を与えられたもの)するものしか権限がないのか、すべてのリソースに対する権限があるのかという観点でも分けてみた。
まあ、実際のアプリケーションでは、もっといろんな状況があると思うので、このあたりは、要件によりけり。
また、Auth0では、別にAuth0上のWebAPI定義にPermissionを細かく作っていかなくても、実際に動かすアプリケーションの方で機能ごとにPermissionを定義して、それをユーザ作成時にアプリケーションの機能としてAuth0ユーザのMetadataに貼り付けるといったこともできる。
柔軟に制御したいといった場合は、Metadata使う方式の方が後々困らないかもしれない。
ともあれ、とりあえず今回は以下みたいな感じでPermissionを定義。
・
・
・
で、ユーザにはそれぞれのPermissionを1つ1つ付与していくのではなくRoleを付与するので、Roleを作成。
これも、Roleの定義は作りたいアプリケーションの要件によってまちまちなので、今回のは参考程度に見てもらえると。
このRoleに先ほどのPermissionを割り当てていく。
要するに、このRoleはこのWebAPIに関してどれだけの機能を叩くPermissionを有しているかを設定していく。
先ほどのPermissionもそうだけど、こういった作業を画面でポチポチやるのが地味に時間を要する。
1回きりといえばそうなんだけど、例えば、実際の開発現場では、開発環境、ステージング環境、本番環境を用意し、Auth0のテナントもそれぞれに用意するはず。
各環境用にこの画面ポチポチをつどつどやれって言われたら、正直しんどい。
まあ、たぶん、設定のExport/Import機能くらいあるんだろう、きっと。
Auth0の設定画面になくても、Auth0が用意するManagement系のAPIを使うと、わりとさくっとできるかな。
話がズレた。とりあえずRoleごとのPermission割り当てだけど、AdminとNoneの事例を。
・
・
・
Adminは要するに全Permission持ってるということ。
Userを作成
このあたりは実際のサービスなら、まず、1人だけAdmin的な権限のあるユーザを作成。
Adminの人だけが触れる管理画面でも用意して、Auth0のManagement系APIを使って、別のRole別のユーザを作成していくといった流れになるんだろう。
今回は、まだそういう機能は用意してないので、とりあえず適当に3ユーザくらいAuth0の画面から作成。
フロントエンド実装
Auth0ではSPAアプリ用にSDKが用意されている。
でも、今回ぐらいの例だとNuxtが提供しているAuthモジュールを使うことで事足りるので、SDKは使わない。
nuxtjs/auth導入
以下参照。
https://auth.nuxtjs.org/guide/setup.html
今回用の設定は下記。
export default {
mode: 'universal',
〜〜〜
modules: [
〜〜〜
'@nuxtjs/apollo',
'@nuxtjs/auth'
],
〜〜〜
apollo: {
〜〜〜
tokenName: 'auth._token.auth0',
authenticationType: '' // default の Bearer だと「Bearer: Bearer」というように重複が起きるため
},
〜〜〜
auth: {
redirect: {
login: '/login',
logout: '/login',
callback: '/callback',
home: '/'
},
strategies: {
auth0: {
domain: process.env.AUTH0_DOMAIN,
client_id: process.env.AUTH0_CLIENT_ID,
audience: process.env.AUTH0_AUDIENCE
}
},
plugins: ['~/plugins/auth.ts']
},
〜〜〜
Auth0用の設定事例は下記に記載している。
https://auth.nuxtjs.org/providers/auth0.html#usage
domain
とclient_id
はAuth0のフロントエンド分のApplication設定にて表示されていたやつ。
audience
がちょっとわかりづらいけど、バックエンドのWebAPI設定にて表示されていた「Identifier
」の値を指定。
このへんの説明は↓に少しだけ書かれている。
https://auth.nuxtjs.org/providers/auth0.html#obtaining-client-id-domain-and-audience
client_id
とか晒したくないのでnuxtjs/dotenvを使ってる。
あと、GraphQLライブラリとしてApolloを使っていて、
authenticationType
はデフォルトだとBearer
と付くようで、そのままバックエンドに送ると以下のようになってしまう。
なので、明示的にブランクを指定してBearer
が重複しないようにしている。
authorization: Bearer Bearer eyJhbVZn〜〜〜
ログインページ
関連ソース
$ tree
.
├── layouts
│ └── unCertified.vue
├── pages
| ├── callback.vue
│ ├── login.vue
未認証時専用のレイアウト
<template>
<v-app dark>
<v-container>
<nuxt />
</v-container>
</v-app>
</template>
ログインページ
<template>
<v-row justify="center">
<v-col md="auto">
<div>fs-mng-app</div>
</v-col>
<v-col md="auto">
<v-btn @click="login">Log in</v-btn>
</v-col>
</v-row>
</template>
<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'
@Component({ layout: 'unCertified' })
export default class LoginPage extends Vue {
async login() {
try {
await this.$auth.loginWith('auth0')
} catch (err) {
this.$toast.error(err)
}
}
}
</script>
nuxt.config.js
にてauth
の名で設定した内容を踏まえたオブジェクトが$auth
という名前でアクセスできる。
で、$auth.loginWith('auth0')
と書いただけでAuth0へ認証をかけにいくことができる。
つまり、先述の↓に飛ばしてくれる。
なんだかもう、いろいろなことを隠蔽してくれていて、便利なんだけど怖い。
ログイン完了後、Cookieにauth._token.auth0
という名前でトークンが書き込まれている。
たぶん設定の問題だけど、デフォルトでLocalStorageの方にも同じ内容で書き込まれている。
コールバックページ
Auth0上での認証成功後に、アプリケーションに戻ってこないといけないので、そのためのページを用意。
Auth0の設定で用意したページを認証後のリダイレクト先として指定している。
<template>
<v-row justify="center">
<v-col md="auto">
<div>Trying to callback</div>
</v-col>
</v-row>
</template>
<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'
@Component({ layout: 'unCertified' })
export default class LoginPage extends Vue {
created() {
this.$router.push('/')
}
}
</script>
やってることは、単にトップページに遷移するだけ。
動画一覧ページ
ここが今回、認可処理を実装した部分ではあるのだけど、まずは、そもそも認証通ってないと、このページ開けないよの部分から。
バックエンドの方の実装は後ほど示すとして、とりあえずここでは、バックエンドではJWT(要するにCookieに積まれていたトークン)をヘッダに積んでないとエラーを返す実装が入っていることを前提とする。
まずは、先述の通りログイン完了後にCookieにトークンが入っている状態で動画一覧ページの表示を試みると、はい、開く。
このとき、ChromeのDevツールでNetworkタブより「Headers」を見てみると、authorization
ヘッダにCookieに入っていたトークンが指定されている。
つまり、nuxt.config.js
に記述した apollo
モジュールとauth
モジュールの設定だけで、Auth0ログインにて取得したJWTをHTTPヘッダに積んでGraphQL通信する流れが出来上がっている。
さて、試しに、CookieとLocalStorageからトークンを消して、動画一覧ページを開こうとすると、
失敗する。(ただ、これはどちらかというと通信自体に失敗した感じ)
トークンを消すのではなく改ざんっぽく変えてみてから、再度、動画一覧ページを開こうとすると、
画面上は同じエラー画面だけど、通信ログがこのようになる。
「401 Unauthorized
」これは、わかりやすい。
ともかく、適切なトークンを積んでないとGraphQL通信に失敗する仕組みができている。
ログアウト
関連ソース
$ tree
.
├── layouts
│ └── default.vue
ログアウト
「ログアウト」ボタンは、ログイン後のどのページからでも即座に実行できるようメニューの右端に仕込んである。
<template>
<v-app dark>
〜〜〜
<v-navigation-drawer v-model="rightDrawer" :right="right" fixed>
<v-list>
<v-list-item @click="logout">
<v-list-item-action>
<v-icon>
mdi-eject
</v-icon>
</v-list-item-action>
<v-list-item-title>Log out</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
〜〜〜
</v-app>
</template>
<script lang="ts">
import { Component, Provide, Vue } from 'nuxt-property-decorator'
@Component({})
export default class DefaultLayout extends Vue {
〜〜〜
@Provide()
right: boolean = true
@Provide()
rightDrawer: boolean = false
〜〜〜
async logout() {
try {
await this.$auth.logout()
} catch (err) {
this.$toast.error(err)
}
}
}
</script>
例によって$auth
オブジェクトが使えるので、$auth.logout()
とするだけでAuth0的にログアウト処理を実行してくれる。
あ〜、便利。
ログアウトされて、ログインページが表示される。
この状態で、あえて、ログイン後に遷移できるはずのトップページの表示を試みると、
表示できず、ログインページにリダイレクトされる。
バックエンド実装
さて、バックエンド側。
WebAPIサーバ起動動線
〜〜〜
func main() {
// 環境変数からJWTのチェックに使うAuth0のドメインやオーディエンスを取得
e := loadEnv()
〜〜〜
var router *chi.Mux
{
router = chi.NewRouter()
router.Group(func(r chi.Router) {
〜〜〜
// 認証認可チェック用(今はJWTのチェックのみ実装)
a := auth.New(e.Auth0Domain, e.Auth0Audience, e.AuthDebug, e.AuthCredentialsOptional)
r.Use(a.CheckJWTHandlerFunc())
r.Use(a.HoldPermissionsHandler)
// GraphQLリゾルバー
resolver := &graph.Resolver{
DB: db,
〜〜〜
}
// GraphQLエンドポイント
r.Handle("/query", graph.DataLoaderMiddleware(resolver, graphQlServer(resolver)))
})
}
log.Printf("connect to http://localhost:%s/ for GraphQL playground", e.ServerPort)
if err := http.ListenAndServe(":"+e.ServerPort, router); err != nil {
fmt.Println(err)
}
}
WebAPIサーバとしてはライブラリとしてchiを使っている。
で、各リクエストのつどデコレートするためにカスタムミドルウェアを2つ定義している。
・CheckJWTHandlerFunc
・HoldPermissionsHandler
これにより、JWTのチェックと認可チェックをしている。
JWTのチェック
package auth
import(
〜〜〜
jwtMiddleware "github.com/auth0/go-jwt-middleware"
"github.com/dgrijalva/jwt-go"
)
type Auth struct {
domain string
audience string
debug bool
credentialsOptional bool
}
func New(domain, audience string, debug, credentialsOptional bool) *Auth {
return &Auth{domain, audience, debug, credentialsOptional}
}
func (a *Auth) CheckJWTHandlerFunc() func(next http.Handler) http.Handler {
middleware := jwtMiddleware.New(jwtMiddleware.Options{
ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) {
// Verify 'aud' claim
checkAud := token.Claims.(jwt.MapClaims).VerifyAudience(a.audience, false)
if !checkAud {
return token, xerrors.New("Invalid audience.")
}
// Verify 'iss' claim
checkIss := token.Claims.(jwt.MapClaims).VerifyIssuer(fmt.Sprintf("https://%s/", a.domain), false)
if !checkIss {
return token, xerrors.New("Invalid issuer.")
}
cert, err := a.getPemCert(token)
if err != nil {
return token, xerrors.New("Invalid token.")
}
result, _ := jwt.ParseRSAPublicKeyFromPEM([]byte(cert))
return result, nil
},
SigningMethod: jwt.SigningMethodRS256,
Debug: a.debug,
CredentialsOptional: a.credentialsOptional,
})
return middleware.Handler
}
func (a *Auth) getPemCert(token *jwt.Token) (string, error) {
cert := ""
resp, err := http.Get(fmt.Sprintf("https://%s/.well-known/jwks.json", a.domain))
if err != nil {
return cert, xerrors.Errorf("failed to http.Get: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Println(err)
}
}()
var jwks = Jwks{}
err = json.NewDecoder(resp.Body).Decode(&jwks)
if err != nil {
return cert, xerrors.Errorf("failed to decode jwks: %w", err)
}
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 cert, xerrors.New("cert is blank.")
}
return cert, nil
}
type Jwks struct {
Keys []JSONWebKeys `json:"keys"`
}
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"`
}
goにおけるJWTのチェック用ライブラリとしてgo-jwt-middlewareを使う。
チェックの内容については以下を丸コピぐらいの勢いで流用。
https://auth0.com/docs/quickstart/backend/golang/01-authorization#validate-access-tokens
認可チェックのためのPermission取得
さて、JWTのチェックはしたものの、「このログインしたユーザは、この叩かれた機能を実行できていいんだっけ?」の部分は、まだチェックできていない。
それ用のミドルウェアが「HoldPermissionsHandler
」。
下記は先述の「CheckJWTHandlerFunc
」と同じファイルにあるので Auth
構造体やgetPemCert
メソッドは共有している。
package auth
〜〜〜
func (a *Auth) HoldPermissionsHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeaderParts := strings.Split(r.Header.Get("Authorization"), " ")
tokenString := authHeaderParts[1]
parser := func(token *jwt.Token) (interface{}, error) {
cert, err := a.getPemCert(token)
if err != nil {
return nil, xerrors.Errorf("failed to getPemCert: %w", err)
}
result, err := jwt.ParseRSAPublicKeyFromPEM([]byte(cert))
if err != nil {
return nil, xerrors.Errorf("failed to ParseRSAPublicKeyFromPEM: %w", err)
}
return result, nil
}
token, err := jwt.Parse(tokenString, parser)
if err != nil {
log.Printf("failed to ParseWithClaims: %v", err)
next.ServeHTTP(w, r)
return
}
if !token.Valid {
log.Print("invalid token")
next.ServeHTTP(w, r)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
log.Print("not implemented MapClaims")
next.ServeHTTP(w, r)
return
}
authenticatedUser := &AuthenticatedUser{
PermissionSet: util.NewBlankStringSet(),
}
for k, v := range claims {
switch k {
case "sub":
authenticatedUser.ID = v.(string)
case a.audience + "/email":
authenticatedUser.EMail = v.(string)
case "permissions":
if permissionArray, ok := v.([]interface{}); ok {
for _, permission := range permissionArray {
authenticatedUser.PermissionSet.Add(permission.(string))
}
break
}
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), authenticatedUserKey, authenticatedUser)))
})
}
ざっくりと、このメソッドの要点だけ言うと、claims, ok := token.Claims.(jwt.MapClaims)
によってJWTから取り出したクレームの中から、ログインユーザが持つPermissionを取得してリクエストスコープのコンテキストに格納している。
クレームの中身をデバッグしてみると、こんな感じ。
Auth0のAPIのPermission設定で登録していった全てのPermissionが列挙されている。
これは、Adminロールを持つユーザでログインしたため。(例えばこれがNoneロールを持つユーザでログインした場合だと、持ってるPermissionは「user:read:mine
」だけとなる)
認可チェック
実際の認可チェックはGraphQLの各リゾルバーの中で行う。
〜〜〜
func (r *queryResolver) Movies(ctx context.Context) ([]*model.Movie, error) {
user := auth.GetAuthenticatedUser(ctx)
if !user.HasReadMinePermission("content") {
err := xerrors.New("no permissions")
log.Print(err)
return nil, err
}
〜〜〜
return results, nil
}
user := auth.GetAuthenticatedUser(ctx)
によって、リクエストスコープのコンテキストに格納したPermission群を保持する以下構造体を取得できる。
// 認証チェック済みのユーザー情報を保持
type AuthenticatedUser struct {
ID string
EMail string
PermissionSet *util.StringSet
}
そして、user.HasReadMinePermission("content")
により、Permission群の中に”自分の” content
に READ
権限があるかどうかがチェックできる。
このあたりのチェック用に以下のユーティリティメソッド群を用意している。
func (u *AuthenticatedUser) HasPermission(funcName string, crud operation, t target) bool {
if u == nil || u.PermissionSet == nil {
return false
}
return u.PermissionSet.Contains(fmt.Sprintf("%s:%v:%v", funcName, crud, t))
}
func (u *AuthenticatedUser) HasNoTargetPermission(funcName string, crud operation) bool {
if u == nil || u.PermissionSet == nil {
return false
}
return u.PermissionSet.Contains(fmt.Sprintf("%s:%v", funcName, crud))
}
func (u *AuthenticatedUser) HasReadAllPermission(funcName string) bool {
return u.HasPermission(funcName, READ, ALL)
}
func (u *AuthenticatedUser) HasReadMinePermission(funcName string) bool {
return u.HasPermission(funcName, READ, MINE)
}
func (u *AuthenticatedUser) HasCreatePermission(funcName string) bool {
return u.HasNoTargetPermission(funcName, CREATE)
}
type operation string
type target string
const (
READ operation = "read"
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
ALL target = "all"
MINE = "mine"
)
これにより、リゾルバー毎に求めるPermissionを指定して認可チェックすることができる。
まとめ
いつも、まとめようとする頃には力尽きている・・・。
次は、Management系APIを使った機能の実装事例かdataloadenの事例かな。