JSON Web Tokens で GridDB REST API を保護する

これまでの記事で、GridDBとJavaNode.jsGoといった様々な技術を使ってREST APIを作る方法を取り上げてきました。また、以前簡単には触れることはありましたが、Webベースの認証でデータやエンドポイントを保護する方法について具体的な詳細には触れてきませんでした。

今回から2回に分けて、JSON Web Tokensとは何か、どのように実装するのか、GridDBのREST APIを保護するためにどのように利用するのかについて解説します。後編では、JSON Web Tokens を利用することで得られる詳細な情報を紹介します。記事が公開されたら、ぜひご覧ください。

この記事で紹介されている内容を実行するには、あなたのマシンにGoと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

そして、以下のコマンドを実行します。

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

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

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

それから実行するのはとても簡単です。

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

JSON Web Tokens 入門

JSON Web Tokens (JWT) が何であるかを最もよく理解するためには、ここにあるJWTのドキュメントを直接読むのが一番でしょう。JWTは、当事者間で情報をJSONオブジェクトとして安全に送信するための、コンパクトで自己完結的な方法を定義するオープンスタンダード(RFC 7519)です。

平たく言えば、署名されたトークン(JSONオブジェクト)を作成すれば、共有された当事者(そして共有された当事者だけ!)にデータを安全に送信できるはず、ということです。この記事では、GridDBデータ・エンドポイントをトークン・チェッカーの後ろに隠蔽します。有効なJSON Web Tokensがあれば、データへのアクセスが許可されます。そうでない場合は、HTTP 402 エラーが表示されます。

JWT の実装

JWTを実装し、適切な使い方を紹介するために、ある種の認証システムを設定したいと思います。そこでは、ユーザは、選択したユーザ名とパスワードを使ってサインアップし、ユーザ名とパスワードをハッシュ化されたパスワードとともに永続ストレージに保存し、ユーザ名とパスワードを使ってサインインし、認証情報が検証されたときにJSON Web Tokensを割り当て、JWTをユーザのブラウザにクッキーとして保存し、このクッキーを使って保護されたエンドポイント/ウェブページにアクセスします。

今回の仕様では、まずGridDBとある種のパスワード・ハッシュ・システムを使用して、シンプルなサインアップ/サインイン・システムをセットアップする必要があります。

サインアップとサインイン・ウェブページの作成

まず、ユーザ名とパスワードの2つの入力を持つ非常にシンプルなhtmlページを使って、ユーザがサインアップできるようにしましょう。ユーザ名がまだ入力されていない場合は、ユーザ名をそのまま保存し、パスワードを bcrypt を使ってハッシュ化し、GridDBに保存します。その後、サインイン用の同様のページを用意し、パスワードをbcryptでハッシュ化し、その結果を比較し、許可された場合にユーザにはJSON Web Tokensが発行されます。

サインアップページ

ユーザに公開するページには、標準ライブラリのテンプレート機能を使います。これによって、Goコンパイラによって解析され、net/httpライブラリによって提供されるHTMLページを簡単に作成することができます。まず、サインアップのページを見てみましょう。次に、レスポンスとリクエストを処理する関数を作成し、フォームデータを受け取るhtmlテンプレートファイルを提供し、パスワードを暗号化し、GridDBサーバとの接続を行い、最後にUserとPassの組み合わせをGridDBに保存します。

まず、GridDBのヘルパー関数をいくつか紹介しましょう。

// Connect to GridDB
func ConnectGridDB() griddb.Store {
    factory := griddb.StoreFactoryGetInstance()

    // Get GridStore object
    gridstore, err := factory.GetStore(map[string]interface{}{
        "notification_member": "127.0.0.1:10001",
        "cluster_name":        "myCluster",
        "username":            "admin",
        "password":            "admin"})
    if err != nil {
        fmt.Println(err)
        panic("err get store")
    }

    return gridstore
}

// helper function to "get" container as type griddb.Container
func GetContainer(gridstore griddb.Store, cont_name string) griddb.Container {

    col, err := gridstore.GetContainer(cont_name)
    if err != nil {
        fmt.Println("getting failed, err:", err)
        panic("err create query")
    }
    col.SetAutoCommit(false)

    return col
}

func QueryContainer(gridstore griddb.Store, col griddb.Container, query_string string) (griddb.RowSet, error) {
    fmt.Println("querying: ", query_string)
    query, err := col.Query(query_string)
    if err != nil {
        fmt.Println("create query failed, err:", err)
        return nil, err
    }

    rs, err := query.Fetch(true)
    if err != nil {
        fmt.Println("fetch failed, err:", err)
        return nil, err
    }
    return rs, nil
}

// simple function used in signup.go in our SignIn function
func saveUser(username, hashedPassword string) {
    gridstore := ConnectGridDB()
    defer griddb.DeleteStore(gridstore)

    userCol := GetContainer(gridstore, "users")
    err := userCol.Put([]interface{}{username, hashedPassword})
    if err != nil {
        fmt.Println("error putting new user into GridDB", err)
    }

    fmt.Println("Saving user into GridDB")
    userCol.Commit()

}

上記のコードボックス内の関数名は、その主な役割/義務を適切に説明しているはずです。これらの関数はすべて Sign Up http ハンドラ関数で使用します。つまり、main 関数で宣言したルートがリクエストを受信したときに実行されます。以下はメイン関数です。

//main.go
func main() {

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

    log.Fatal(http.ListenAndServe(":2828", nil))

}

それでは SignUp 関数を見てみましょう(繰り返しますが、サーバを実行して /signUp にリクエストを行うと、以下の関数が実行され、リクエストとレスポンスが処理されます)。

//signup.go
func SignUp(w http.ResponseWriter, r *http.Request) {

    tmpl, err := template.ParseFiles("signUp.tmpl")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    if r.Method == http.MethodPost {

        // type Credentials of a struct with two fields User & Password
        creds := &Credentials{}
        creds.Username = r.FormValue("username")
        creds.Password = r.FormValue("password")

        // Converting our user Password to a hashed password to save for security
        hashedPassword, err := bcrypt.GenerateFromPassword([]byte(creds.Password), 8)
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
        }

        saveUser(creds.Username, string(hashedPassword))
        http.Redirect(w, r, "/signIn", http.StatusFound) // redirect to sign in once you successfully sign up
        return
    }

    data := struct {
        Message string
    }{
        Message: "Please enter create a username and password combo",
    }

    err = tmpl.Execute(w, data)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
}

そしてこれがテンプレート・ファイルです。

<!DOCTYPE html>
<html>
<head>
    <title>Sign In Page</title>
</head>
<body>
    <h1>Sign In</h1>
    {{ if .Message }}
        <p>{{ .Message }}</p>
    {{ end }}
    <form action="/signIn" method="post">
        <div>
            <label for="username">Username:</label>
            <input type="text" id="username" name="username" required>
        </div>
        <div>
            <label for="password">Password:</label>
            <input type="password" id="password" name="password" required>
        </div>
        <div>
            <input type="submit" value="Sign In">
        </div>
    </form>
</body>
</html>

これをロードすると、htmlページがロードされ、ユーザに固有のユーザ名とパスワードの入力を求められます。Goコードから、2つの値を受け取り、bcryptライブラリを使ってパスワードをハッシュ化し、その値を Users コレクション・コンテナに保存します。このコンテナは、最初にバイナリファイルを実行したときに作成されたものです。

func createUsersContainer() {
    gridstore := ConnectGridDB()
    defer griddb.DeleteStore(gridstore)
    conInfo, err := griddb.CreateContainerInfo(map[string]interface{}{
        "name": "users",
        "column_info_list": [][]interface{}{
            {"username", griddb.TYPE_STRING},
            {"password", griddb.TYPE_STRING}},
        "type":    griddb.CONTAINER_COLLECTION,
        "row_key": true})
    if err != nil {
        fmt.Println("Create containerInfo failed, err:", err)
        panic("err CreateContainerInfo")
    }
    _, e := gridstore.PutContainer(conInfo)
    if e != nil {
        fmt.Println("put container failed, err:", e)
        panic("err PutContainer")
    }

}

GridDB CLI を使用すると、users コンテナの showcontainer を簡単に表示することができます!

gs[public]> showcontainer users
Database    : public
Name        : users
Type        : COLLECTION
Partition ID: 105
DataAffinity: -

Columns:
No  Name                  Type            CSTR  RowKey
------------------------------------------------------------------------------
 0  username              STRING          NN    [RowKey]
 1  password              STRING                

Indexes:
Name        : 
Type        : TREE
Columns:
No  Name                  
--------------------------
 0  username

サインインページ

サインインページはサインアップページと非常によく似ています。しかしもちろん、GridDBデータベースに「入力」するのではなく、ユーザが入力した内容をデータベースに保存されている暗号化されたパスワードと照合します。もし一致すれば、トークンを発行することができます。

func SignIn(w http.ResponseWriter, r *http.Request) {

    tmpl, err := template.ParseFiles("signIn.tmpl")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    if r.Method == "POST" {
        creds := &Credentials{}
        creds.Username = r.FormValue("username")
        creds.Password = r.FormValue("password")

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

        userCol := GetContainer(gridstore, "users")
        defer griddb.DeleteContainer(userCol)
        userCol.SetAutoCommit(false)

        // Grab the hashed password of the username from the form frontend
        queryStr := fmt.Sprintf("select * FROM users where username = '%s'", creds.Username)
        rs, err := QueryContainer(gridstore, userCol, queryStr)
        defer griddb.DeleteRowSet(rs)
        if err != nil {
            fmt.Println("Issue with querying container")
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        storedCreds := &Credentials{}
        for rs.HasNext() {
            rrow, err := rs.NextRow()
            if err != nil {
                fmt.Println("GetNextRow err:", err)
                panic("err GetNextRow")
            }

            storedCreds.Username = rrow[0].(string)
            storedCreds.Password = rrow[1].(string)

        }

        // Compare hashed passwords to ensure the correct rawtext password was entered
        if err = bcrypt.CompareHashAndPassword([]byte(storedCreds.Password), []byte(creds.Password)); err != nil {
            fmt.Println("unauthorized")
            w.WriteHeader(http.StatusUnauthorized)
            return
        }

        // if successful,. issue out a token
        token := IssueToken()
        expirationTime := time.Now().Add(5 * time.Minute)

        http.SetCookie(w,
            &http.Cookie{
                Name:     "token",
                Value:    token,
                Expires:  expirationTime,
                HttpOnly: true,
            })

        // Once given a cookie, we can redirect the user into the webpage which is only granted to users with tokens
        http.Redirect(w, r, "/auth", http.StatusFound)
        return
    }

    data := struct {
        Message string
    }{
        Message: "Please enter your username and password",
    }

    err = tmpl.Execute(w, data)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
}

上で説明したように、ユーザ名とパスワードの組み合わせがデータベースに存在するかどうかを確認しています。もし存在すれば、IssueToken() 関数を使ってトークンを発行することができます。

JSON Web Tokens の作成と発行

ユーザが存在することがわかったら、トークンを発行しましょう。必要なのは、環境変数から取得する秘密鍵をgo jwtパッケージに与えることだけです。しかし全体として、この部分のコード・スニペットはとても小さく、すぐに終わります。注意点として、秘密鍵はkey.envというファイルにあります。プログラムを実行する前に $ source key.env を実行して秘密鍵を読み込む必要があります。さもなければ署名キーが空になってしまいエラーが発生します。

import (
    "fmt"
    "os"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

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

func IssueToken() string {

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    key := []byte(os.Getenv("SigningKey")) // grabbed from the key.env file
    if len(key) <= 0 {
        fmt.Println("Key is less than length 0")
        return ""
    }
    s, err := token.SignedString(key)
    if err != nil {
        fmt.Printf("Error, couldn't read os environment: %q", err)
        return ""
    }
    return s
}

このトークン文字列を取得したら、ユーザのブラウザにクッキーとして保存することができます。注意点として、XSRF (Cross Site Request Forgery) 攻撃を防ぐためにクッキーを HttpOnly として保存するときにフラグを含める必要があります。詳しくはこちらをご覧ください: https://keeplearning.dev/nodejs-jwt-authentication-with-http-only-cookie-5d8a966ac059

トークンを使用するもう 1 つの一般的な(そして時には推奨される)アプローチは、クッキーへの保存を省略し、代わりに作成する すべてのエンドポイントがリクエストのヘッダでトークンを必要とするようにすることです。前回のブログではこの方法を取りました: https://griddb.net/ja/blog/build-your-own-go-web-app-with-microservices-and-griddb/

簡単な例として、React.jsのコードでトークンをリクエストに含める例を示します(前回のブログより)。

const token = getJWT();
  axios.get("/getData",{ headers:{"Token": token}}).then(response => response.data);

GridDBデータの保護

サインアップとサインインシステムが整い、有効なトークンが認証されると発行されるようになったので、特定のルートには有効なトークンでしかアクセスできないように設定できます。そのためには、保護したいすべてのルートに対して実行される middlweware 関数を作成すればよいのです。

//isAuthorized.go
func isAuthorized(endpoint func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        cookie, err := r.Cookie("token")
        if err != nil {
            if err == http.ErrNoCookie {
                w.WriteHeader(http.StatusUnauthorized)
                return
            }
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        tokenString := cookie.Value

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

        if token.Valid {
            fmt.Println("Successful Authorization check")
            endpoint(w, r)
        } else if errors.Is(err, jwt.ErrTokenMalformed) {
            w.WriteHeader(http.StatusUnauthorized)
            fmt.Fprintf(w, "Authorization Token nonexistent or malformed")
            return
        } else if errors.Is(err, jwt.ErrTokenSignatureInvalid) {
            fmt.Println("Invalid signature")
            w.WriteHeader(http.StatusUnauthorized)
            fmt.Fprintf(w, "Authorization Signature Invalid")
            return
        } else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
            fmt.Println("Expired Token")
            w.WriteHeader(http.StatusUnauthorized)
            fmt.Fprintf(w, "Authorization Signature Expired")
            return
        } else {
            fmt.Println("Couldn't handle this token:", err)
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
    }
}

ここでは、1つの引数としてhttpハンドラを受け取り、1つの引数を返す関数を持っています。つまり、ハンドラを受け取り、トークンが存在し、有効かどうかをチェックし、有効であれば、リクエストされたオリジナルのエンドポイントを返します。

何か失敗した場合は、40Xのhttpエラーコードが表示されます。では、もう一度メイン関数を見てみましょう。

func main() {

    http.HandleFunc("/signUp", SignUp)
    http.HandleFunc("/signIn", SignIn)
    http.HandleFunc("/auth", isAuthorized(AuthPage))
    http.HandleFunc("/data", isAuthorized(DataEndPoints))

    log.Fatal(http.ListenAndServe(":2828", nil))

}

/auth/data という2つの新しいルートが追加されているのがわかります。authでは、ブラウザのクッキーに有効なJWTがある場合にのみ見られるHTMLページをレンダリングします。

ここで、トークンがブラウザのストレージに保存されていることがわかります。また HttpOnly フラグがtrueになっていることもわかります!

Show Dataボタンを押すと /data にリクエストが送信され、GridDBに問い合わせが行われ、device2 というコンテナからすべてのデータが返されます。このデータは以前の記事から取り込まれたものです: Exploring GridDB’s Group By Range Functionality

このデータセットへのクエリに使用しているコードはこちらでご覧いただけます。

func DataEndPoints(w http.ResponseWriter, r *http.Request) {
    gridstore := ConnectGridDB()
    defer griddb.DeleteStore(gridstore)

    devicesCol := GetContainer(gridstore, "device2")
    queryStr := "SELECT *"
    rs, err := QueryContainer(gridstore, devicesCol, queryStr)
    if err != nil {
        fmt.Println("Failed fetching device2", err)
        return
    }

    device := Device{}
    devices := []Device{}
    for rs.HasNext() {
        rrow, err := rs.NextRow()
        if err != nil {
            fmt.Println("GetNextRow err:", err)
            panic("err GetNextRow")
        }

        device.TS = rrow[0].(time.Time)
        device.CO = rrow[1].(float64)
        device.Humidity = rrow[2].(float64)
        device.Light = rrow[3].(bool)
        device.LPG = rrow[4].(float64)
        device.Motion = rrow[5].(bool)
        device.Smoke = rrow[6].(float64)
        device.Temp = rrow[7].(float64)
        devices = append(devices, device)
    }
    //fmt.Println(devices)
    fmt.Fprint(w, devices)
}

繰り返しになりますが、トークンが有効で期限切れでなければ、すべてのデータがウェブページに印刷されます。トークンが無効な場合はエラーコードが表示されます。

まとめ

本記事では GridDB のエンドポイントやその他の様々なウェブページを保護するために、JSON Web Tokens を使用することがいかに簡単であるかを学びました。

ブログの内容について疑問や質問がある場合は Q&A サイトである Stack Overflow に質問を投稿しましょう。 GridDB 開発者やエンジニアから速やかな回答が得られるようにするためにも "griddb" タグをつけることをお忘れなく。 https://stackoverflow.com/questions/ask?tags=griddb

Leave a Reply

Your email address will not be published. Required fields are marked *