GAE/pyでGoogleでもOAuthでもないログインを実装


# はじめに

はじめまして。カラフルボードのwasnotといいます。
もともとAndroidアプリ開発ばかりしていましたが、気づいたらpythonを使ったサーバサイドのAPIやバッチなどの開発を行っていました！

この記事はColorful Board Advent Calendar 2016の記事になります！
公開が遅れてしまいましたが、一応1日目の予定です。

カラフルボードはAIテクノロジーを使って「すべての人々に、人生が変わる出会いを。」与えたい、と日々苦戦しながらサービス開発をしています。

現在はスマホアプリや他社サービスへのASPの導入などの開発を中心に行っています。

## Google Cloud Platform! GCP!

最近GCPの盛り上がりが激しいですね！
弊社では以前はAWSオンリーでしたが、構成が小さなサービスを多数作る場面が多くなってきたため、無視できないコスト差があるな、と思い積極的にGCPも活用しはじめています。
まだ本格的に使いはじめてから半年ほどしか経っていないため、不慣れな部分もありますが、今回はGCP、特にGAE(Google App Engine)を使っていて、詰まったり、改善を行った点などを紹介しようと思います。

本当は弊社のサービス全般のサーバ構成についてなど最初にお話しした方が良かったかもしれませんが、それはCTOにお任せするとして、今回はGAE/pyでの開発での小ネタを紹介します。

GAEと言ったらGo、という時勢ですが、社内ではAI関連の開発をpythonでやっており、少ない人数で開発しやすいだろうということで、サーバサイド開発もなるべくpythonを使っています。

# GAE/pyでのログイン実装

## 標準Users APIによるログイン実装
GAEではpythonに限らず、Users APIというものが用意されており、
これを用いることでユーザのログイン、ログアウトやセッションの取得などを簡単に行うことができます。
[Users Python API Overview ](https://cloud.google.com/appengine/docs/python/users/)

このAPIはapp.yamlなどでの設定をしてendpointごとに制御したりできるので、AppEngine自体との親和性は高いかと思います。
ただ、Googleアカウントが必須になっていたりして、独自アカウントや他のOAuthでのログインなどを実装するときに少し使いづらいです。

## webapp2のauth機能
GAE/pyでのWebアプリは、標準でwebapp2というgoogleが開発したフレームワークになっています。
これはtornadoと似ているらしいですが、djangoよりは機能が少なくシンプルで、逆にflaskよりはちょっと多機能で面倒、という感じです。
RDBではなくDatastoreを使う前提だとdjangoのORMapperなどに依存しなくてもいいので、社内ではflask/djangoと検討して現在はwebapp2を使っています。

そして実はwebapp2にはデフォルトでauth機能がついていたのです。

[User authentication with webapp2 on Google App Engine](https://blog.abahgat.com/2013/01/07/user-authentication-with-webapp2-on-google-app-engine/)

こちらのブログも参考になりました。
[Google App Engine の webapp2 で認証してみた](https://takatamajp.wordpress.com/2012/05/01/google_app_engine_webapp2_how_to_use_auth/)

上の記事がとても丁寧に解説されているので、この通りにカスタマイズしていくとすぐに作れますが、抜粋して簡単に紹介します。

### Userモデルの設定
webapp2のauth機能は`webapp2_extras.auth`に配置されています。
また、ログインなどのセッション管理機能は`webapp2_extras.sessions`を使います。

ユーザモデルは基本何でもいいとは思いますが、sessionに入れたり出したりするときには`webapp2_extras.appengine.auth.models.User`クラスを前提に作られているので、カスタムするときはこちらを継承したモデルを使うといいでしょう。
このモデルではUser、Token、UniqueというエンティティをDatastore上に保存してユーザ管理をする設計になっています。

```py:models.user
from google.appengine.ext import ndb
import webapp2_extras.appengine.auth.models as auth_models

class User(auth_models.User):
    gender = ndb.StringProperty()
```
こんな感じに継承して独自のプロパティをもたせておくのもいいです。

そしてauthやsessionの設定をwebapp2の初期化時に行います。

```py:main.py
import webapp2
from models.user import User

webapp2_config = {
    'webapp2_extras.auth': {
        'cookie_name': 'sid',
        'user_model': User,
    },
    'webapp2_extras.sessions': {
        'secret_key': 'your secret session key',
        'session_max_age': 60*60*24*30,
    }
}
app = webapp2.WSGIApplication([
    ('/test', TestHandler),
    ('/delete', DeleteHandler),
], debug=True, config=webapp2_config)
```

WSGIApplicationの初期化時にconfigのdictinalyを渡すことでwebapp2_extrasの設定もできます。
上の例では

- session idを保持するcookieの名前を変える
- user_modelを先ほど作ったモデルにする
- sessionのsecret_keyを設定する
- セッションの有効期限を設定

などを行っています。

### ベースハンドラーとログイン必須のデコレータを作る

次にユーザのセッションを確認したり保存したりするために、URLリクエストを処理するHandlerにセッション保持の設定をします。
今回はBaseHandlerというHandlerを作って、以後は様々なHandlerをここから作れるようにします。

```py:views.base.py
import webapp2
from webapp2_extras import auth, sessions
class BaseHandler(webapp2.RequestHandler):
    # webapp2にキャッシュされるためにauthとsession_storeのインスタンスをcached_propertyにしておきます。
    @webapp2.cached_property
    def auth(self):
        return auth.get_auth()

    @webapp2.cached_property
    def session_store(self):
        return sessions.get_store(request=self.request)

    def dispatch(self):
        # このメソッドはRequestHandlerでHandlerが実行された後に呼ばれます。
        # ここでsession_storeにresponseの内容などを保存します。
        try:
            super(BaseJsonHandler, self).dispatch()
        finally:
            self.session_store.save_sessions(self.response)
```

また、各Handlerでログイン中かどうかをいちいち確認するのが面倒なので、便利なデコレータを作っておきます。

```py:views.base.py
def user_required(handler):
  # ログイン中じゃない場合はログインページに遷移するなどします。
  def check_login(self, *args, **kwargs):
    auth = self.auth
    if not auth.get_user_by_session():
      self.redirect(self.uri_for('login'), abort=True)
    else:
      return handler(self, *args, **kwargs)
 
  return check_login
```

これを各Handlerのメソッドにつけるだけで、ログインの管理ができます。

```py.views.test.py
import webapp2
from views.base user_required
class TestHandler(webapp2.RequestHandler):
  @user_required
  def get(self):
    # ログイン済みじゃないと見れないページ
    self.render_template('authenticated.html')
```

### ログイン/ログアウト機能の実装

最後に、ログイン、ログアウト等の基本機能の実装を紹介します。

```py:views.user.py
class UserRegisterHandler(BaseJsonHandler):
    def post(self, *args, **kwargs):
        """
        ユーザの登録orログイン
        :return:
        """
        email = self.request.get('email')
        password = self.request.get('password')
        # TODO: 適宜リクエストの不正等のエラーは返すといいでしょう
        
        # authモジュールのUserモデルにはauth_idからUserをクエリするメソッドが用意されています。
        # Idやpasswordのエラーが返されるのでハンドルします
        try:
            user = User.get_by_auth_password(email, password)
            # ログイン成功時はセッションにユーザ情報を保存します
            self._set_session(user)
            return 'logged in'
        except auth.InvalidAuthIdError:
            # 今回はユーザ未登録の場合は新規作成をします

            # 同じくUserモデルにはuser作成のメソッドも用意されています。
            # 返り値がタプルなので注意
            result, info = User.create_user(auth_id=email)
            # 成功時のみinfoにuserエンティティが入ります
            user = info if result else None
            if not user:
                # ユーザ作成失敗時はエラーを返すといいでしょう
                return 'user cannot create'

            self._set_session(user)
            return 'user created'
        except auth.InvalidPasswordError:
            # password error
            return 'password invalid'

    def _set_session(self, user):
        user_dict = self.auth.store.user_to_dict(user)
        self.auth.set_session(user_dict)
```

最後にログアウトですが、シンプルにセッションを削除する関数がauthクラスに用意されているので、それを呼び出して終了です。

```py:views.user.py
class LogoutHandler(BaseJsonHandler):
    def get(self):
        user = self.auth.get_user_by_session()
        if user:
            self.auth.unset_session()
            return self.make_base_response()
        else:
            return self.make_error_response('not logged in')
```

この処理はクライアント側でクッキー削除等で実装されることも多いですが、サーバ側のキャッシュからも削除してしまうと安心かと思います。

# まとめ

今回は社内で使っている、GAE/pyのwebapp2フレームワークのサーバサイドのログインの実装例について紹介しました。
このようなログイン実装以外にもOAuth2/OpenID Connectを使ったログイン管理についても検討しています。

今でも実戦で導入していたりしますが、GAE/pyはpython3へのアップデートの予定すらなかったりして、若干不安はあります。
社内では他にもGAE/FE環境やGCEインスタンス上でのpython開発なども行なっているので、ご紹介できたらと思います。