冥冥乃志

ソフトウェア開発会社でチームマネージャをしているエンジニアの雑記。アウトプットは少なめです。

follow us in feedly

Unfiltered + HerokuでTwitter ToDoサービスを作る(Twitter IDでOAuth認証をしてみる編)

Twitter ToDoサービス(TwoDo)を作る連載3回目。
今回もheroku Add-onの利用は見送りです。作り始めてみたら何らかの問題が出る、そういうもんです。仕事であればともかく、プライベートであれば、この辺りはのんびりおつきあいください。
Add-onの利用を見送らなければならなかったのには、Unfiltered特有の理由があります。

セッション管理の問題

Unfilteredは公式のドキュメントのはじめにも書かれている通り、HTTPリクエストに対するサービスを返すためのツールキットです。つまり、Webアプリケーションフレームワークではないという訳ですね。おそらく、そのための割り切りだと思いますが、UnfilteredではデフォルトでHttpセッションを無効にしているようです*1
セッション管理なしに扱うのは非常に厳しいですね。諦めてフレームワークを乗り換えるのも一つの手ですが、冠をつけてしまった以上、乗り換えは負けです(ぇ。ここは意地を張ってやりきってみましょう*2

セッション管理の簡単な実装

もちろん簡単なのは実装サンプルがあるからですよっと。
「Unfilteredでもセッションが使いたい」当然のことながら、そういうことを考える人がいるらしく*3、プレーンなCookieを拡張してセッション管理を自前で実装するよ、というのが一つの流れのようです。インターネットの恩恵受けまくりなこの連載、今回も遠慮なく先達の知恵のお世話になることにします。参考にしたのはこちらunfiltered-session.scala。ユーザ認証に特化している内容なので、もう少し汎用性をもって拡張してみたいと思います。
まずは、セッション管理の振る舞いです。セッションIDとセッションデータをひも付けて管理する必要があります。サンプルではセッションデータは認証データのみとなっていますが、今回はもう少し汎用的に扱うことを目指しているので、セッションデータにはマップを使うことにします。

object SimpleSessionStore {
  private val storage = mutable.Map[String, mutable.Map[String, Any]]()

  def createSession = {
    val sid = generateSid
    SimpleSessionStore.synchronized {
      storage += (sid -> mutable.Map[String, Any]())
    }
    sid
  }

  def getSession(sid: String) = storage get sid

  def removeSession(sid: String) { storage remove sid }

  protected def generateSid = scala.util.Random.alphanumeric.take(256).mkString
}

セッションIDの衝突は考慮されていないような気がしますが、バッティングするほど人はこないでしょう。
ただ、これだとセッションそのものの取得やセットです。実際にはセッションデータにセットする属性を扱いたい訳なので、そういうメソッドとかあると便利ですよね?以下を追加してみます。

  def setSessionAttribute(sid: String, attrKey: String, attrValue: Any) {
    storage get sid match {
      case Some(attrMap) => attrMap.put(attrKey, attrValue)
      case None => throw new IllegalStateException("session Id is not generated")
    }
  }

  def getSessionAttribute(sid: String, attrKey: String) = {
    storage get sid match {
      case Some(attrMap) => attrMap.get(attrKey)
      case None => throw new IllegalStateException("session Id is not generated")
    }
  }

  def removeSessionAttribute(sid: String, attrKey: String) {
    storage get sid match {
      case Some(attrMap) => attrMap.remove(attrKey)
      case None => throw new IllegalStateException("session Id is not generated")
    }
  }

これを使う訳ですが、カリー化とか上手く使えばセッションIDの指定あたりを指定して、もっとコードがすっきりしそうな気がします。ってか、それ以前にエレガントじゃないっすね。リファクタリングの余地はありますが、まだそこまでScalaを使いこなせる感じではないので、今後の課題ということにしておきます。

ともあれ、セッションデータ永続化の仕組みができました。この仕組みとCookieを使って、セッション管理を作っていく訳です。

Twitter IDを使ってのOAuth認証

OAhtu認証は以下のような感じです。

  1. 認証のためのURL
  2. リクエストトークンを取得してリダイレクト
  3. Twitterサイドで認証
  4. コールバックしてユーザごとのアクセストークンを取得
  5. 認証完了

リダイレクト->Twitter認証->コールバックの流れでリクエストトークンを持ち回る必要があるため、セッションが必須だった訳です。
まず、認証の要求を受けてからリダイレクトするまでの処理を実装します。

class App extends unfiltered.filter.Plan {
  
  private val reqTokenKey = "RequestToken"
  
  private val sessionKey = // 省略
  
  def intent = {
  	// 省略
    case GET(Path(Seg("authwithtwitter" :: Nil))) => {
      val reqToken = getRequestToken // リクエストトークンを取得

      val sessionId = SimpleSessionStore.createSession
      SimpleSessionStore.setSessionAttribute(sessionId, reqTokenKey, reqToken)

      ResponseCookies(Cookie(sessionKey, sessionId)) ~> Redirect(reqToken.getAuthenticationURL)
    }
    // 省略
  }
}

セッションIDを指定しなければならないところが不格好で、エレガントさに書ける感じがしていますが、一応セッション管理らしいことは一通りできています。最終的に生成したセッションIDをCookieに保存してリダイレクトしてあげれば、とりあえず認証の前段階までは完了です。
getRequestTokenの実装は以下の通りです。

  def getRequestToken: auth.RequestToken = {
    twitter.getOAuthRequestToken("http://localhost:8080/getaccesstoken")
  }

単にコールバックURLを指定してリクエストトークンを呼んでいるだけです。consumerKeyとconsumerSecretはtwitter4j.propertiesに設定しています。また、object生成時にtwitterインスタンスを取得するようにしています。

Twitter上での認証が終わると、リクエストトークン生成時に指定したコールバックURLが呼ばれるので、コールバック時の処理を書いてあげましょう。

class App extends unfiltered.filter.Plan {
  
  private val reqTokenKey = "RequestToken"
  
  private val sessionKey = // 省略
  
  def intent = {
  	// 省略
	case GET(Path(Seg("getaccesstoken" :: Nil)) & Cookies(cookies) & Params(param)) => {
      def p(k: String) = param.get(k).flatMap {
        _.headOption
      } getOrElse ("")
      cookies(sessionKey) match {
        case Some(Cookie(_, sessionId, _, _, _, _)) => {
          SimpleSessionStore.getSessionAttribute(sessionId, reqTokenKey) match {
            case Some(reqToken) => {
              val verifier = p("oauth_verifier") // リクエストパラメータからoauth_verifierを取得する

              val accToken = getAccessToken(reqToken.asInstanceOf[auth.RequestToken], verifier)
              
              SimpleSessionStore.removeSessionAttribute(sessionId, reqTokenKey) // リクエストトークンは不要になるので、セッションから削除

              Ok ~> HtmlContent ~> ResponseString("token=" + accToken.getToken + "<br/>tokenSecret=" + accToken.getTokenSecret)
            }
            case None => Unauthorized ~> HtmlContent ~> ResponseString("authorized error")
          }
        }
        case _ => Unauthorized ~> HtmlContent ~> ResponseString("authorized error")
      }
    }
    // 省略
  }
}

さっきに輪をかけてエレガントではないソースですね。本気で、Scalaのすごい人に突っ込み入れてほしいところです。とりあえず、認証情報の永続化まではしていなくて、取得したアクセストークンを表示するようにしています。これで、ローカル環境でOAuth認証までやって、アクセストークンを表示することができます。


さて、OAuth認証はできましたが、このままだとAccessTokenを永続化していないので、都度のログインが必要になります。次回以降で、Mongo-DBを使ってAccessTokenを永続化し、Cookieと併用することで次回以降のログインを省略する仕組みを作っていきたいと思います。それから、プロトコルが生httpなので、Add-on使ってhttpsにするあたりも。
まずは、Mongo-DBのインストールから(ぇ)ですよ。

*1:Unfiltered - Session参照。Unfilteredの中の人が回答しています。

*2:でもまあ、これ作った後に想定しているいくつかのサイト構築はPlay Frameworkにより変えようかなあ、とか思ってますが。

*3:Support HTTP sessions · Issue #78 · unfiltered/unfiltered · GitHubこの辺りでも議論されています。