【Go】APIサーバの構築 ~ログイン処理と操作権限の付加編~

#はじめに
APIサーバ構築の続きです。
JWTトークンを使って、ログインなどの認証まわりの処理を実装してみました。

https://qiita.com/suzuki0430/items/f4396a337209cd1e37b7

https://qiita.com/suzuki0430/items/3eacf2329e0ce85d23d7

#実装
ログイン処理と特定のリクエストへの操作権限の付与について実装します。

実装にあたって、属性情報(Claim)をJSONデータ構造で表現したトークンの仕様であるJWTを使用します。
JWTはヘッダー(Header).内容(Payload).署名(Signature)で構成されており、Claimは内容(Payload)に使われる情報の一部です。

##ログイン処理
クライアント側でログインを行うために、`routes.go`に以下のルートハンドラーを追加します。

```go:routes.go
router.HandlerFunc(http.MethodPost, "/v1/signin", app.Signin)
```

`Signin`メソッドを定義するファイル`tokens.go`を新しく作成します。
このメソッドでは以下のような流れで処理を行います。

- ログイン時にユーザが入力したパスワードを確認
- 照合できたらJWTにClaimsを付加
- ハッシュ関数と秘密鍵から署名を作成してレスポンスとして返却

```go:tokens.go
package main

import (
	"backend/models"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"time"

	"github.com/pascaldekloe/jwt"
	"golang.org/x/crypto/bcrypt"
)

// ログインユーザ(モック)
var validUser = models.User {
	ID: 10, 
	Email: "me@here.com",
	Password: "$2a$12$TBZJBBs0TfWdXHeujpGBn.TTwJq5V7Ra4yu.w9VV/Xgp9R3XS2YCq",
}

type Credentials struct {
	Username string `json:"email"`
	Password string `json:"password"`
}

func (app *application) Signin(w http.ResponseWriter, r *http.Request) {
	var creds Credentials

	// リクエストのJSONを構造体credsに変換する
	err := json.NewDecoder(r.Body).Decode(&creds)
	if err != nil {
		app.errorJSON(w, errors.New("unauthorized"))
		return
	}

	// ハッシュ化されたパスワード
	hashedPassword := validUser.Password

	// 入力したパスワードを照合する
	err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(creds.Password))
	if err != nil {
		app.errorJSON(w, errors.New("unauthorized"))
		return
	}
	
	// JWTトークンのclaimsを生成する
	var claims jwt.Claims
	claims.Subject = fmt.Sprint(validUser.ID) // JWTのタイトル
	claims.Issued = jwt.NewNumericTime(time.Now()) // JWTが発行された日時
	claims.NotBefore = jwt.NewNumericTime(time.Now()) // JWTが有効になる日時
	claims.Expires = jwt.NewNumericTime(time.Now().Add(24 * time.Hour)) // JWTが失効する日時
	claims.Issuer = "mydomain.com" // JWTの発行者
	claims.Audiences = []string{"mydomain.com"} // JWTの想定利用者

	// ハッシュ関数(SHA-256)と秘密鍵から署名を作成する
	jwtBytes, err := claims.HMACSign(jwt.HS256, []byte(app.config.jwt.secret))
	if err != nil {
		app.errorJSON(w, errors.New("error signing"))
		return
	}

	// 署名(JWTトークン)をレスポンスとして返す
	app.writeJSON(w, http.StatusOK, string(jwtBytes), "response")
}
```

##特定のリクエストへの操作権限の付与
データの更新や削除など、クライアント側ではログインユーザのみに権限を与えたい操作があります。

このとき、クライアント側から送られてきたJWTトークンを確認し、照合できたら次の処理(更新、削除)を行うような実装を行います。
トークンの確認と照合の処理のために、`checkToken`というメソッドのミドルウェアを作成します。

Aliceというライブラリで、`alice.New(app.checkToken)`のようにミドルウェアのチェーンを作成します(今回は一つ)。
`secure.ThenFunc(app.editMovie)`で`app.checkToken`の後に`app.editMovie`が実行されるようにし、`app.wrap()`でラップすることでミドルウェアハンドラ`checkToken`と通常のアプリケーションのハンドラ`editMovie`のチェーンを構築します。

このようにして、`checkToken`を通った場合のみ、`editMovie`が実行されるようになります。

```go:routes.go
func (app *application) wrap(next http.Handler) httprouter.Handle {
	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
		ctx := context.WithValue(r.Context(), "params", ps)
		next.ServeHTTP(w, r.WithContext(ctx))
	}
}

// ルートハンドラーのレシーバ
func (app *application) routes() http.Handler {
	router := httprouter.New()
	// ミドルウェアチェーンをつくる(今回は一つだけ)
	secure := alice.New(app.checkToken)
    ...
	// checkTokenミドルウェアを通過したときのみリクエストを通す
	router.POST("/v1/admin/editmovie", app.wrap(secure.ThenFunc(app.editMovie)))

	router.GET("/v1/admin/deletemovie/:id", app.wrap(secure.ThenFunc(app.deleteMovie)))

	return app.enableCORS(router)
}
```
`checkToken`の処理の中身は以下のようになります。
クライアント側のリクエストで"Authorization"ヘッダーに付加されたJWTトークン`Bearer [JWT]`を取得・照合し、トークンに含まれるClaimsの中身なども照合した上でエラーがないことを確かめます。

```go:middleware.go
// JWTトークンが正しいかどうかを検証するミドルウェア
func (app *application) checkToken(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// キャッシュを行う際、データを一意に特定するためにURI以外に"Authorization"を利用する
		w.Header().Add("Vary", "Authorization")

		// Authorizationヘッダーの値(Bearer ~)を取得する
		authHeader := r.Header.Get("Authorization")

		if authHeader == "" {
			// could set an anonymous user
		}

		// ["Bearer", "~"]を返す
		headerParts := strings.Split(authHeader, " ")
		if len(headerParts) != 2 {
			app.errorJSON(w, errors.New("invalid auth header"))
			return
		}

		if headerParts[0] != "Bearer" {
			app.errorJSON(w, errors.New("unauthorized - no bearer"))
			return
		}

		// ~(JWTトークン)を取得する
		token := headerParts[1]

		// 取得したトークンが照合できたらclaimsを返す
		claims, err := jwt.HMACCheck([]byte(token), []byte(app.config.jwt.secret))
		if err != nil {
			app.errorJSON(w, errors.New("unauthorized - failed hmac check"), http.StatusForbidden)
			return
		}

		// 期限内かどうかを確認する
		if !claims.Valid(time.Now()) {
			app.errorJSON(w, errors.New("unauthorized - token expired"), http.StatusForbidden)
			return
		}

		// 想定利用者を確認する
		if !claims.AcceptAudience("mydomain.com") {
			app.errorJSON(w, errors.New("unauthorized - invalid audience"), http.StatusForbidden)
			return
		}

		// tokenの発行者を確認する
		if claims.Issuer != "mydomain.com" {
			app.errorJSON(w, errors.New("unauthorized - invalid issuer"), http.StatusForbidden)
			return
		}

		// 認証したuserIDを返す
		userID, err := strconv.ParseInt(claims.Subject, 10, 64)
		if err != nil {
			app.errorJSON(w, errors.New("unauthorized"), http.StatusForbidden)
			return
		}

		log.Println("Valid user:", userID)

		// ここまでエラーにならなければOK
		next.ServeHTTP(w, r)
	})
}
```

#参考資料

https://www.udemy.com/share/104HTy3@3zTNggUpMs4dygdSGqMUVjGyk__Z9v-UYJNHSn0Ew-jVQh3cudvaerfnQ8JGLY-f/

https://kamichidu.github.io/post/2017/01/24-about-json-web-token/

https://qiita.com/Naoto9282/items/8427918564400968bd2b

https://techblog.yahoo.co.jp/advent-calendar-2017/jwt/

https://dev-yakuza.posstree.com/laravel/jwt-signin/

https://tutuz-tech.hatenablog.com/entry/2020/03/23/220326