#はじめに
APIサーバ構築の続きです。
JWTトークンを使って、ログインなどの認証まわりの処理を実装してみました。
#実装
ログイン処理と特定のリクエストへの操作権限の付与について実装します。
実装にあたって、属性情報(Claim)をJSONデータ構造で表現したトークンの仕様であるJWTを使用します。
JWTはヘッダー(Header).内容(Payload).署名(Signature)で構成されており、Claimは内容(Payload)に使われる情報の一部です。
##ログイン処理
クライアント側でログインを行うために、routes.go
に以下のルートハンドラーを追加します。
router.HandlerFunc(http.MethodPost, "/v1/signin", app.Signin)
Signin
メソッドを定義するファイルtokens.go
を新しく作成します。
このメソッドでは以下のような流れで処理を行います。
- ログイン時にユーザが入力したパスワードを確認
- 照合できたらJWTにClaimsを付加
- ハッシュ関数と秘密鍵から署名を作成してレスポンスとして返却
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
が実行されるようになります。
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の中身なども照合した上でエラーがないことを確かめます。
// 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)
})
}
#参考資料