はじめに
- モバイルアプリのバックエンドサーバーとして GAE/Go を利用する場合の認証とユーザーの管理について考えてみた。
- 目標は普通のよくあるモバイルアプリの挙動をかなえられるようにすること。
要件
- ユーザー名やパスワードを入力せずに接続できる。
- 端末ごとに識別ができる。
- 一定期間ごとにユーザーに再ログインを求めてもよい。
- App Engine と Datastore を使って実装できる(無料枠が使えるように)。
リポジトリ
実装概要
- 初回アクセス時に Datastore にユーザーを登録する。
- ユーザーIDは自動採番される IntID を用いる。
- ユーザーごとにユニークな認証トークンを発行し端末に保存させる。
- アプリ開始時に認証をしてアクセストークンを発行する。
- 登録時に発行したユーザーIDと認証トークンで認証を行う。
- アクセストークンは JWT を用い、トークン中にユーザーID(IntID)を埋め込んでユーザーの識別を行う
- アクセストークンの有効期限を1時間などに設定し期限が切れたら再ログインしてもらう。
- アプリ開始後の認可にはアクセストークンを用いる。
- アクセストークンが有効であればアクセスを許可する。
- アクセス認可処理を各アクセスの最初に挟み込む。
- アクセストークン中のユーザーIDを使って Datastore からユーザー情報を取得しておきその後の処理で参照できるようにする。
実装詳細
初回アクセス時に Datastore にユーザーを登録する。
- ユーザーデータは
UserData{ IntID int64, UserToken string }
として、これを Datastore に登録する- IntID には Datastore で自動採番された IntID を保存する
- UserToken には UUID を保存する
handler.go
func RegistrationHandler(w http.ResponseWriter, r *http.Request) {
var req RegistrationHandlerRequest
DecodeJson(r, &req)
ctx := appengine.NewContext(r)
var userData UserData
UserToken := uuid.NewV4().String()
key := datastore.NewIncompleteKey(ctx, userDataStoreName, nil)
userData = UserData{UserToken: UserToken}
var err error
if key, err = datastore.Put(ctx, key, &userData); err != nil {
log.Errorf(ctx, "Failed to registration: %v", req)
EncodeJson(w, RegistrationHandlerResponse{Success: false})
return
}
// denormalization metadata
userData.IntID = key.IntID()
if _, err := datastore.Put(ctx, key, &userData); err != nil {
log.Errorf(ctx, "Faild to registration: %v (%v)", req, err)
EncodeJson(w, RegistrationHandlerResponse{Success: false})
return
}
EncodeJson(w, RegistrationHandlerResponse{
Success: true,
UserID: userData.IntID,
UserToken: userData.UserToken,
})
}
アプリ開始時に認証をしてアクセストークンを発行する。
- ユーザーから登録時に発行した UserToken を受けとって認証を行う。
- GAE/Go 環境での JWT 認証サーバーの実装は以前 こちら に記載した。
handler.go
func AuthenticationHandler(w http.ResponseWriter, r *http.Request) {
var req AuthenticationHandlerRequest
DecodeJson(r, &req)
ctx := appengine.NewContext(r)
// UserToken でユーザーの認証
query := datastore.NewQuery(userDataStoreName).KeysOnly().Filter("IntID =", req.UserID).Filter("UserToken =", req.UserToken)
keys, err := query.GetAll(ctx, nil)
if err != nil || len(keys) != 1 {
log.Errorf(ctx, "User not found: %v (%v)", req, err)
EncodeJson(w, AuthenticationHandlerResponse{Success: false})
return
}
// アクセストークンの発行
method := jwt.GetSigningMethod("ES256")
UserToken := jwt.NewWithClaims(method, jwt.MapClaims{
"sub": keys[0].IntID(),
"exp": time.Now().Add(time.Hour * 1).Unix(),
})
pem, e := bindata.Asset("ec256-key-pri.pem")
if e != nil {
panic(e.Error())
}
privateKey, e := jwt.ParseECPrivateKeyFromPEM(pem)
if e != nil {
panic(e.Error())
}
signedUserToken, e := UserToken.SignedString(privateKey)
if e != nil {
panic(e.Error())
}
EncodeJson(w, AuthenticationHandlerResponse{Success: true, AccessToken: signedUserToken})
}
アプリ開始後の認可にはアクセストークンを用いる。
- 認証時に発行されたアクセストークンをヘッダーで渡すことで認可が必要なページにアクセスを行うことができる。
- 認可処理はミドルウェアとして実装し、特定のパス以下で本処理の前に実行されるようにルーティングの設定をする。
- ルーティングには
github.com/gorilla/mux
を利用 - ミドルウェア管理には
github.com/urfave/negroni
を利用
- ルーティングには
- 認可されたユーザーはコンテキストを使って本処理へと渡される。
- GAE の Go は 1.6 なので
*http.Request
からcontext.Context
は取得できない - GAE では
appengine.NewContext(*http.Request)
でcontext.Context
を取得できるがセットする関数が無いので、ミドルウェアからハンドラーへ値を渡したりするのには使えない - ということで何か負けた気がしつつ
github.com/gorilla/context
を使ってユーザー情報を引き渡す
- GAE の Go は 1.6 なので
アクセストークンを使って認可を行うミドルウェア
func AuthorizationMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
ctx := appengine.NewContext(r)
// ヘッダーからアクセストークンを取得
// `Authorization: Bearer <ACCESS_TOKEN>` という形式を想定
header := r.Header.Get("Authorization")
if header == "" {
log.Errorf(ctx, "Invalid authorization hader")
w.WriteHeader(http.StatusUnauthorized)
return
}
parts := strings.SplitN(header, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
log.Errorf(ctx, "Invalid authorization hader")
w.WriteHeader(http.StatusUnauthorized)
return
}
// アクセストークンの検証
token, e := jwt.Parse(parts[1], func(t *jwt.Token) (interface{}, error) {
method := jwt.GetSigningMethod("ES256")
if method != t.Method {
return nil, errors.New("Invalid signing method")
}
pem, e := bindata.Asset("ec256-key-pub.pem")
if e != nil {
return nil, e
}
key, e := jwt.ParseECPublicKeyFromPEM(pem)
if e != nil {
return nil, e
}
return key, nil
})
if e != nil {
log.Errorf(ctx, e.Error())
w.WriteHeader(http.StatusUnauthorized)
return
}
// アクセストークンからユーザーIDを取得
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
log.Errorf(ctx, "invalid token: %v, %v", ok, token)
w.WriteHeader(http.StatusUnauthorized)
return
}
// Datastore からユーザー情報を取得
key := datastore.NewKey(ctx, userDataStoreName, "", int64(claims["sub"].(float64)), nil)
var userData UserData
if e := datastore.Get(ctx, key, &userData); e != nil {
log.Errorf(ctx, "user not found: %v", e)
w.WriteHeader(http.StatusUnauthorized)
return
}
// github.com/gorilla/context (gcontext として import している) にユーザー情報をセット
gcontext.Set(r, userDataContextKey, userData)
next(w, r)
}
認可されたユーザー情報を扱う任意のエンドポイントの実装例
func HelloWorldHandler(w http.ResponseWriter, r *http.Request) {
// Get UserData from gorilla context.
userData, ok := gcontext.GetOk(r, userDataContextKey)
if !ok {
EncodeJson(w, HelloWorldHandlerResponse{Success: false})
return
}
EncodeJson(w, HelloWorldHandlerResponse{Success: true, Message: fmt.Sprintf("Hello %d", userData.(UserData).IntID)})
}
任意のエンドポイントの処理前に認可を行うルーティング設定
func NewHandler() http.Handler {
// グローバルなルート
r := mux.NewRouter()
// 認可なしで公開するルート
public := mux.NewRouter().PathPrefix("/user").Subrouter()
public.HandleFunc("/registration", RegistrationHandler)
public.HandleFunc("/authentication", AuthenticationHandler)
// 公開ルートの登録
r.PathPrefix("/user").Handler(public)
// 認証が必要なルート
auth := mux.NewRouter().PathPrefix("/").Subrouter()
auth.HandleFunc("/hello", HelloWorldHandler)
// 認可が必要なルートを認可ミドルウェアを適用しつつ登録
r.PathPrefix("/").Handler(negroni.New(
negroni.HandlerFunc(AuthorizationMiddleware),
negroni.Wrap(auth),
))
return r
}
おわりに
- そこに限らずアドバイスもらえると嬉しいです。