1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-01-13

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

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

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

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

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

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

  • ログイン時にユーザが入力したパスワードを確認
  • 照合できたらJWTにClaimsを付加
  • ハッシュ関数と秘密鍵から署名を作成してレスポンスとして返却
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が実行されるようになります。

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の中身なども照合した上でエラーがないことを確かめます。

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)
	})
}

#参考資料

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?