LoginSignup
8
5

More than 5 years have passed since last update.

GAE/Go で普通のモバイルバックエンドサーバーの認証とユーザー管理

Last updated at Posted at 2016-12-22

はじめに

  • モバイルアプリのバックエンドサーバーとして GAE/Go を利用する場合の認証とユーザーの管理について考えてみた。
  • 目標は普通のよくあるモバイルアプリの挙動をかなえられるようにすること。

要件

  • ユーザー名やパスワードを入力せずに接続できる。
  • 端末ごとに識別ができる。
  • 一定期間ごとにユーザーに再ログインを求めてもよい。
  • App Engine と Datastore を使って実装できる(無料枠が使えるように)。

リポジトリ

gae-mobile-backend

実装概要

  • 初回アクセス時に 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 を使ってユーザー情報を引き渡す
アクセストークンを使って認可を行うミドルウェア
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
}

おわりに

  • そこに限らずアドバイスもらえると嬉しいです。
8
5
2

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
8
5