JSON WebトークンでGridDB REST APIを保護する 後編

GridDBのJSON Web Tokenシリーズのパート2です。前回の記事はこちらからお読みいただけます: JSON Web Tokens Part I. この記事では、JSON Web Tokenとは何か、その発行と確認方法、GridDBのエンドポイントを保護する方法について説明しました。その記事の Web トークンは非常にシンプルで、適切なユーザ名とパスワードの組み合わせでユーザに発行され、ホームページと /data ページへのアクセスを許可するものでした。

後編では、GridDB のエンドポイントに対して、より詳細な認証チェックを追加します。JSON Web トークンは単純にハッシュ化されたオブジェクトであり、追加したいあらゆる種類の情報を含めることができるため、任意の粒度の認証を追加することができます。今回の例では、4つの異なるGridDBコンテナを用意し、JSON Web Tokenのきめ細かなトークンで保護します。つまり、あるGridDBコンテナ/エンドポイントにはアクセスできても、別のGridDBコンテナ/エンドポイントにはアクセスできないということです。パート1のトークンでは、オール・オア・ナッシングでした。

GridDBに保存されたユーザ名とパスワードを使ってサインインします。トークンの有効期限、トークンの名前、そしてこの新しいトークンが許可する様々なコンテナ/クリアランス・レベルを選択する必要がある。トークンを手にしたユーザは、保護されたルートへのすべてのリクエストで、認可ベアラートークンとしてトークンをアタッチする必要があります。もしユーザがアクセス権のないルートにベアラートークンをアタッチしようとすると、401認証httpエラーが表示されます。

前提条件

GridDBとGridDB Go Connectorがインストールされている必要があります。本記事では、HTMLのテンプレートファイルを除き、すべてのコードをGoで記述します。

上記で説明したように、以下のものが必要になります:

現在のところ、GridDB Go Connectorの動作方法のため、ビルドと使用プロセスの一部として、go 1.11モジュール機能をオフにする必要があります(go env -w GO111MODULE=off)。この機能をオフにすると、プロジェクトのソースコードを $GOPATH 内にビルドすることになります。また、このプロジェクトに関連するすべての Go ライブラリを手動で go get することになります。このような指示はすべて次のセクション プロジェクトのビルド に記載します。

プロジェクトのビルド

このプロジェクトを実行するには、このプロジェクトを $GOPATH の中に入れておく必要があります。これが、goモジュールを利用しない通常のGoプロジェクトの動作方法です。私の場合、プロジェクトの構成はこのようになっています:

/home/israel/go/
                └─ src
                    └─ github.com
                        └─ griddbnet
                            └─ Blogs
                                └─ [all source code]

その前に、GridDB Goクライアントを作ってみましょう。

GridDB Go Connector v0.8.4

まず、GoクライアントのREADMEの指示に従って、SWIGをダウンロードしてインストールしてください:

$ wget https://prdownloads.sourceforge.net/swig/swig-4.0.2.tar.gz
$ tar xvfz swig-4.0.2.tar.gz
$ cd swig-4.0.2
$ ./autogen.sh
$ ./configure
$ make
$ sudo make install

そして、GridDB Go Client のインストールを開始します。まず、環境変数 GOPATH を設定しておくとよい。ターミナルにgo envと入力すると、Goのインストールが既に使用しているものが表示されます。それをコピーしてください:

$ export GOPATH=/home/israel/go

そして

1. $ go env -w GO111MODULE=off
2. $ go get -d github.com/griddb/go_client
3. $ cd $GOPATH/src/github.com/griddb/go_client
4. $ ./run_swig.sh
5. $ go install

これで完了です!これでGoクライアントが使えるようになりました。では、このプロジェクトのソースコードと残りの必要なライブラリを入手しましょう:

$ cd $GOPATH/src/github.com
$ mkdir griddbnet
$ cd griddbnet
$ git clone https://github.com/griddbnet/Blogs.git --branch jwtII
$ cd Blogs
$ go env -w GO111MODULE=on
$ go get
$ go env -w GO111MODULE=off

それから走るのは簡単です

$ source key.env
$ go build
$ ./Blogs

注:このブロックの最初のコマンドは、単に秘密鍵を環境変数に追加するだけです。これを入れ忘れると、サーバーは実行されますが、Web トークンを発行したりチェックしたりすることはできません。

JSON ウェブトークンときめ細かな制御

上で説明したように、私たちが追加したい主な機能は、ウェブトークン、ひいてはエンドポイントをよりきめ細かく制御することです。つまり、Web トークンを発行する関数を修正する必要があり、さらに Web トークンが認証されるプロセスを修正して、トークンのユーザーに付与された特定のロールをチェックする必要があります。そして最後に、ユーザがヘッダにウェブトークンを含むリクエストを送信していることを確認するために、認可ミドルウェアを修正する必要があります。

ウェブトークンクレーム

トークンを作成するときとトークンを認証するときの両方で、私たちは claims を提供します。これらの「クレーム」は、サブジェクトについて主張される情報の断片です。たとえば、IDトークン(これは常にJWTである)には、認証するユーザの名前が「John Doe」であることを主張するnameというクレームを含めることができます。JWTでは、クレームはnameとvalueのペアとして表示され、nameは常に文字列で、valueは任意のJSON値です。一般的に、JWTの文脈でクレームについて話すときは、名前(またはキー)を指しています。” (ソース)[https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims]。

私たちが使用しているJWTライブラリには、最も一般的に使用されるデフォルトのクレーム(issued-by、expiryなど)がいくつか付属しており、本連載の第1回ではこれらを使用しました。このパートでは、これに独自のカスタムクレームを追加します。具体的には、roles構造体を追加し、ユーザが特定のGridDBコンテナ(特定のエンドポイントから読み込まれる)にアクセスできるかどうかを指定できるようにします。また、トークンの有効期限やnameというフィールドを変更できるようにします。

カスタムクレームの追加

カスタムクレームを追加するには、MyClaims という新しい構造体を作成し、以前に使用したデフォルトクレームをインポートします。

//specialRoleEndpoints.go
type Roles struct {
    Basic   bool   `json:"basic"`
    Advisor bool   `json:"advisor"`
    Admin   bool   `json:"admin"`
    Owner   bool   `json:"owner"`
    Value   string `json:"value"`
    Name    string `json:"name"`
}

//issueTokens.go
type MyCustomClaims struct {
    Role Roles `json:"roles"`
    jwt.RegisteredClaims
}

var claims = MyCustomClaims{
    Roles{},
    jwt.RegisteredClaims{
        ExpiresAt: jwt.NewNumericDate(time.Unix(time.Now().Unix()*time.Hour.Milliseconds(), 0)),
        IssuedAt:  jwt.NewNumericDate(time.Now()),
        NotBefore: jwt.NewNumericDate(time.Now()),
        Issuer:    "griddb-auth-server",
    },
}

そして、実際にトークンを発行するときに、ユーザが特定のロールを持っているかどうかを(boolで)指定できるようになりました。また、ユーザが有効期限を指定できるようにしたので、有効期限 time.Time をパラメータの1つとして受け取るように IssueTokens 関数を変更しました(Roles 構造体と一緒にします)。

//issueTokens.go
func IssueToken(roles Roles, expiry time.Time) (string, error) {

    claims.Role = roles
    claims.ExpiresAt = jwt.NewNumericDate(expiry)

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    key := []byte(os.Getenv("SigningKey"))
    if string(key) != "" {
        if len(key) <= 0 {
            return "", errors.New("Key is less than length 0")
        }
        s, err := token.SignedString(key)
        if err != nil {
            fmt.Printf("Error, couldn't read os environment: %q", err)
            return "", errors.New("could not read environment var")
        }
        return s, nil
    }

    return "", errors.New("No environment variable set")
}

つまり、以前と比較すると、この関数はRoles構造体を受け取り、Webトークン自体を作成するときにその情報を使用するようになりました。認可の部分に入る前に、ユーザーからこれらのオプションを収集するhtmlテンプレートファイルを見てみましょう。

ユーザー指定のクレームの受け入れ

これを達成するために、ユーザが自分の好みを選択できるように入力を追加するだけです。次に、POSTリクエストを使用してこの情報をサーバーに送り返します。サーバーはこの情報を使用してRoles構造体を形成し、有効期限を設定します。

//home.tmpl
 <h3> Select your level of clearance </h3>
 <form action="/getToken" method="post">
  <fieldset>
    <legend>JSON Web Token Choices</legend>

    <label for="Owner">Expiration in Days</label> 
    <input type="number" id="expiration" name="expiration" min="1" max="365" />
    </br>

    <label for="Owner">Token Name</label> 
    <input type="text" id="name" name="name" required />
    </br>

    <input type="checkbox" id="role" name="basic" value="true" />
    <label for="basic">Basic</label><br />

    <input type="checkbox" id="advisor" name="advisor" value="true" />
    <label for="advisor">Advisor</label><br />

    <input type="checkbox" id="admin" name="admin" value="true" />
    <label for="admin">Admin</label><br />

    <input type="checkbox" id="owner" name="owner" value="true" />
    <label for="Owner">Owner</label>
  </fieldset>
      <div>
    <button type="submit">Get Token</button>
    </div>
</form>

そしてサーバーサイドでは、httpハンドラでこれらの入力をキャプチャします。

 roles := &Roles{}
    if r.Method == "POST" {

        roles.Basic, _ = strconv.ParseBool(r.FormValue("basic"))
        roles.Advisor, _ = strconv.ParseBool(r.FormValue("advisor"))
        roles.Admin, _ = strconv.ParseBool(r.FormValue("admin"))
        roles.Owner, _ = strconv.ParseBool(r.FormValue("owner"))
        roles.Name = r.FormValue("name")
        fmt.Println("Received form values", roles)

        expiryTime, _ := strconv.ParseInt(r.FormValue("expiration"), 10, 64)
        expiryTimeInDays := expiryTime * 24
        expirationTime := time.Now().Add(time.Duration(expiryTimeInDays) * time.Hour)

        token, err := IssueToken(*roles, expirationTime)
        if err != nil {
            fmt.Println("issue getting token", err)
            fmt.Fprintf(w, "Issue getting token, possible no environment variable set")
            return
        }
        // We send the roles data struct to the template file. 
        // We need the value to share with the user on the frontend
        roles.Value = token 

エンドポイントとクレームの認証

粒状に設定された新しいトークンを手に入れたので、ルートが適切に保護されていることを確認するために、新しい認証ミドルウェアを使わなければなりません。そのために、granularAuthという新しい関数を作ります。

上で説明したように、この関数はこのルートへのリクエストがヘッダーにauthorizationセクションを持っているかどうかをチェックします。もしあれば、トークンが私たちの主張と秘密の署名鍵に対して有効かどうかを検証します。これがすべてチェックアウトされると、この関数はこのトークンの仕様に従って付与されたロールのチェックを開始します。例えば、トークンがbasicのロールがTrueであると主張すれば、ルート/basicがこのリクエストに対して許可されます。もしbasicがFalseであれば、このコンテンツを見るためのパーミッションが不十分であることをユーザーに伝えます。

リクエストが許可された場合、同じ名前(basic)のGridDBコンテナを読み込み、レスポンスとしてリクエスト元に返します。まず、認可ミドルウェアをアタッチした新しいエンドポイントを見てみましょう。

//main.go
func main() {
    createUsersContainer()

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        http.Redirect(w, r, "/signIn", http.StatusSeeOther)
    })

    http.HandleFunc("/signUp", SignUp)
    http.HandleFunc("/signIn", SignIn)

    http.HandleFunc("/getToken", isAuthorized(GetToken))
    http.HandleFunc("/auth", isAuthorized(AuthPage))
    http.HandleFunc("/data", isAuthorized(DataEndPoints))

    http.HandleFunc("/basic", granularAuth(Basic))
    http.HandleFunc("/admin", granularAuth(Admin))
    http.HandleFunc("/advisor", granularAuth(Advisor))
    http.HandleFunc("/owner", granularAuth(Owner))

    fmt.Println("Listening on port :2828....")
    log.Fatal(http.ListenAndServe(":2828", nil))

}

前回の記事で紹介した古いルートは isAuthorized 関数で保護されているが、新しいルートは granularAuth で保護することができます。

繰り返しますが、フローは次のようになります。ユーザがサインインして login トークンが付与されます。次に、ユーザは /auth にリダイレクトされ、そこで新しい json web トークンを作成します。今のところ、ユーザは granularAuth で保護されたルートにアクセスすることはできません。提示されたオプションを使用して新しいトークンを作成すると、 /getToken ページで新しいトークンが提示されます。このトークンを使って granularAuth で保護されたルートに HTTP リクエストを行うことができますが、トークンを認可ベアラートークンとしてアタッチする必要があります。

このページを見てわかるように、各エンドポイントにはそれぞれ入力フィールドがあります。これにより、ユーザはエンドポイントに関連付けられた特定のコンテナにデータ行を追加することができます(たとえば、/basic は basic というコンテナに関連付けられています)。そして、このエンドポイントは認証によって保護されている(つまり、先ほど作成した新しいjsonウェブトークンが必要)ので、このコードは、データを特定のコンテナに追加するために、postリクエストに直接ベアラートークンを追加しています。以下はそのコードの一部です:

function send(e,form) {
    console.log("Authorization: 'Bearer {{ .Value }}'")

    const str = form.action;
    const n = str.lastIndexOf('/');
    const containerName = str.substring(n + 1);

    fetch(form.action, {
      method:'post', 
      headers: {
        Authorization: 'Bearer {{ .Value }}'
      },
      body: new URLSearchParams(new FormData(form))
    });

  console.log('sent data');
  alert("Added row of data to container: " + containerName)
  //document.getElementById(result).reset();
  form.reset()
  e.preventDefault();
}

この {{ .Value }} は Go のバックエンドから来たもので、json web トークンの文字列です。しかし、GETリクエストでテストしたり内容を読んだりするときには、ベアラートークンを手動で入力する必要があります。

次に、実際に認証を処理するバックエンド(golang)のコードを見てみましょう:

func granularAuth(endpoint func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {

        if r.Header["Authorization"] != nil {
            authorization := r.Header.Get("Authorization")
            tokenString := strings.TrimSpace(strings.Replace(authorization, "Bearer", "", 1))

            token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
                return MySigningKey, nil
            }, jwt.WithLeeway(5*time.Second))

            if err != nil {
                log.Fatal(err)
            } else if claims, ok := token.Claims.(*MyCustomClaims); ok {
                urlPath := strings.TrimLeft(r.URL.Path, "/")

                gridstore := ConnectGridDB()
                defer griddb.DeleteStore(gridstore)

                col := GetContainer(gridstore, urlPath)
                defer griddb.DeleteContainer(col)

                rs, err := QueryContainer(gridstore, col, "select *")
                if err != nil {
                    fmt.Println("Error getting container", err)
                    return
                }
                defer griddb.DeleteRowSet(rs)

                var b strings.Builder

                switch urlPath {
                case "basic":
                    if claims.Role.Basic {
                        for rs.HasNext() {
                            rrow, err := rs.NextRow()
                            if err != nil {
                                fmt.Println("GetNextRow err:", err)
                                panic("err GetNextRow")
                            }

                            str := rrow[0].(string)
                            fmt.Fprintln(&b, str)
                        }
                        fmt.Fprintf(w, b.String())
                        endpoint(w, r)
                        return
                    } else {
                        w.WriteHeader(http.StatusUnauthorized)
                        fmt.Fprintln(w, "Insufficient Role to view this content")
                        fmt.Fprintln(w, "Please make sure you have the role of BASIC")
                    }
                case "admin":
                    if claims.Role.Admin {
                        for rs.HasNext() {
                            rrow, err := rs.NextRow()
                            if err != nil {
                                fmt.Println("GetNextRow err:", err)
                                panic("err GetNextRow")
                            }

                            str := rrow[0].(string)
                            fmt.Fprintln(&b, str)
                        }
                        fmt.Fprintf(w, b.String())
                        endpoint(w, r)
                        return
                    } else {
                        w.WriteHeader(http.StatusUnauthorized)
                        fmt.Fprintln(w, "Insufficient Role to view this content")
                        fmt.Fprintln(w, "Please make sure you have the role of ADMIN")
                    }
                case "advisor":
                    if claims.Role.Advisor {
                        for rs.HasNext() {
                            rrow, err := rs.NextRow()
                            if err != nil {
                                fmt.Println("GetNextRow err:", err)
                                panic("err GetNextRow")
                            }

                            str := rrow[0].(string)
                            fmt.Fprintln(&b, str)
                        }
                        fmt.Fprintf(w, b.String())
                        endpoint(w, r)
                        return
                    } else {
                        w.WriteHeader(http.StatusUnauthorized)
                        fmt.Fprintln(w, "Insufficient Role to view this content")
                        fmt.Fprintln(w, "Please make sure you have the role of ADVISOR")
                    }
                case "owner":
                    if claims.Role.Owner {
                        for rs.HasNext() {
                            rrow, err := rs.NextRow()
                            if err != nil {
                                fmt.Println("GetNextRow err:", err)
                                panic("err GetNextRow")
                            }

                            str := rrow[0].(string)
                            fmt.Fprintln(&b, str)
                        }
                        fmt.Fprintf(w, b.String())
                        endpoint(w, r)
                        return
                    } else {
                        w.WriteHeader(http.StatusUnauthorized)
                        fmt.Fprintln(w, "Insufficient Role to view this content")
                        fmt.Fprintln(w, "Please make sure you have the role of OWNER")
                    }

                default:
                    w.WriteHeader(http.StatusUnauthorized)
                    fmt.Fprintln(w, "Authorization Signature Invalid")
                }

            } else {
                log.Fatal("unknown claims type, cannot proceed")
            }

        } else {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }

    }
}

すでに説明したが、ここでの大前提は、この関数が呼ばれると、リクエストヘッダ内にauthorizationセクションがあるかどうかをチェックすることです。次に、ベアラートークンヘッダーのトークンが有効かどうかを確認します。そして最後に、このトークンによって付与された特定のロールをチェックします。

保護されたルートのテスト

Postmanのリクエストを見て、ルートが期待通りに動いていることを確認しましょう。

まず、成功したルートを示します

適切なロール、適切なエンドポイントを用意し、リクエストヘッダの bearer auth の位置にトークンを入れます。すると、GridDBコンテナの中身が返ってきます。

次に、ベアラ・トークンを含めなかった場合にどうなるかを見てみましょう。

ここでは、401 httpステータス・エラーが表示されていることがわかります。

最後に、トークンはあるが、特定のエンドポイントに対するパーミッションが不十分な場合に何が起こるかを見てみましょう。

結論

例えば、特定のコンテナやコンテナのサブセットに対して、ロールを読み取り専用や読み取り/書き込み専用にすることができます。本当に、JSON ウェブ トークンの優れた点は、開発者の手にコントロールが委ねられることです。

If you have any questions about the blog, please create a Stack Overflow post here https://stackoverflow.com/questions/ask?tags=griddb .
Make sure that you use the “griddb” tag so our engineers can quickly reply to your questions.

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください