Lift
- 【意訳】Scala on LiftでのFacebookサインイン (10 Aug 2012 | Tags:
【意訳】Scala on LiftでのFacebookサインイン 【意訳】Scala on LiftでのFacebookサインイン
この記事は、ayushmishra2005氏の記事の意訳です。参考にさせていただいたついでに訳してみました。参考程度にどうぞ。
誤訳・誤植等ありましたら、@modal_soulまでリプライいただけるとありがたいです。
ここ最近に、Lift2.4で構築したソーシャルプロジェクトで、Facebookへのサインイン機能を統合しました。この記事はその時の手順のサマリです。
1)Facebook APIを作る(※既に持っている場合不要です)
次のリンクを参考にアプリを作ってください。サイトURLを含む全ての詳細を記入してエンターします。サイトURLは以下のような感じになります。
http://www.com/api/facebook/auth
このサイトURL宛に、Facebookはレスポンスを送ります。このアプリケーションを保存すると、アプリキー/APIキー/秘密鍵が入手できます。
これらのキーは後ほど使うので注意してください。
2) Liftを使っている場合は、各キーをdefault.propsに追加設定します。
facebook.key=<your_key> facebook.secret=your <secret_key> facebook.callbackurl=/api/facebook/auth
3) Facebookログイン画像fb.pngをダウンロードします
4) アプリキー/APIキー/秘密鍵とコールバックURLを設定するために、FacebookGraph.scalaを新規作成します。またFacebookからアクセストークンを要求するためにこのScalaファイルを使用します。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characterspackage it.fbIntegration package lib import org.joda.time.DateTime import net.liftweb._ import common._ import json._ import http.{ Factory, S, SessionVar } import util.{ Helpers, Props } import dispatch._ import it.fbIntegration.config.Site object FacebookGraph extends Factory with AppHelpers with Loggable { /* * Config */ val key = new FactoryMaker[String](Props.get("facebook.key", "")) {} val secret = new FactoryMaker[String](Props.get("facebook.secret", "")) {} val callbackUrl = new FactoryMaker[String](Props.get("facebook.callbackurl", "/api/facebook/auth")) {} val channelUrl = new FactoryMaker[String](Props.get("facebook.channelurl", "/facebook/channel")) {} val permissions = new FactoryMaker[String](Props.get("facebook.permissions", "email,user_birthday")) {} private def baseReq = :/("graph.facebook.com").secure object currentAccessToken extends SessionVar[Box[AccessToken]](Empty) object currentFacebookId extends SessionVar[Box[Int]](Empty) /* * Do something with the current access token. */ private def doWithToken[T](f: AccessToken => Box[T]): Box[T] = { currentAccessToken.is.flatMap { at => if (at.isExpired) { // refresh the token val newToken = accessToken(at.code) currentAccessToken(newToken) newToken.flatMap(t => f(t)) } else f(at) } } // where to send the user after connecting with facebook object continueUrl extends SessionVar[String](Site.home.url) // CSRF token object csrf extends SessionVar[String](Helpers.nextFuncName) // url that sends user to facebook to authorize the app def authUrl = "http://www.facebook.com/dialog/oauth?client_id=%s&redirect_uri=%s&scope=%s&state=%s&display=popup" .format(key.vend, Helpers.urlEncode(S.hostAndPath + callbackUrl.vend), permissions.vend, csrf.is) /* * Make a request and process the output with the given function */ private[lib] def doRequest[T](req: Request)(func: String => Box[T]): Box[T] = /* * See: http://dispatch.databinder.net/Choose+an+Executor.html */ Http x (req as_str) { case (400, _, _, out) => Failure(parseError(out())) case (200, _, _, out) => val o = out() logger.debug("output from facebook: " + o) func(o) case (status, b, c, out) => //logger.debug("b: "+b.toString) //logger.debug("c: "+c.toString) Failure("Unexpected status code: %s - %s".format(status, out())) } private def parseError(in: String): String = Helpers.tryo { val jsn = JsonParser.parse(in) val JString(errMsg) = jsn \\ "message" errMsg } openOr "Error parsing error: " + in /* * Make a request and parse the output to json */ private[lib] def doReq(req: Request): Box[JValue] = doRequest(req) { out => Full(JsonParser.parse(out)) } /* * Make a request with the access token as a parameter */ private def doOauthReq(req: Request, token: AccessToken): Box[JValue] = { val params = Map("access_token" -> token.value) doReq(req <<? params) } /* * Request an access token from facebook */ def accessToken(code: String): Box[AccessToken] = { val req = baseReq / "oauth" / "access_token" <<? Map( "client_id" -> key.vend, "client_secret" -> secret.vend, "redirect_uri" -> (S.hostAndPath + callbackUrl.vend), "code" -> code) doRequest(req) { out => val map = Map.empty ++ out.split("&").map { param => val pair = param.split("=") (pair(0), pair(1)) } (map.get("access_token"), map.get("expires")) match { case (Some(at), Some(exp)) => Helpers.asInt(exp) .map(e => AccessToken(at, code, (new DateTime).plusSeconds(e))) case _ => Failure("Unable to parse access_token: " + map.toString) } } } def me(token: AccessToken): Box[JValue] = doOauthReq(baseReq / "me", token) def me(token: AccessToken, obj: String): Box[JValue] = doOauthReq(baseReq / "me" / obj, token) def me: Box[JValue] = doWithToken { token => me(token) } def me(obj: String): Box[JValue] = doWithToken { token => me(token, obj) } //def obj(id: String): Box[JValue] = doReq(baseReq / id) def deletePermission(facebookId: Int, perm: Box[String] = Empty): Box[Boolean] = doWithToken { token => val req = baseReq.DELETE / facebookId.toString / "permissions" <<? Map("access_token" -> token.value) ++ perm.map(p => ("permission" -> p)).toList <:< Map("Content-Length" -> "0") // http://facebook.stackoverflow.com/questions/4933780/why-am-i-getting-a-method-not-implemented-error-when-attempting-to-delete-a-fa doRequest(req) { out => out match { case "true" => Full(true) case _ => Empty } } } /* * http://forum.developers.facebook.net/viewtopic.php?pid=344787 */ def parseSignedRequest(in: String): Box[JValue] = { import java.util.Arrays import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import org.apache.commons.codec.binary.Base64 import Helpers.tryo in.split("""\.""").toList match { case sig :: payload :: Nil => // decode the data val base64 = new Base64(true) // url friendly val sentSig = base64.decode(sig.replaceAll("-", "+").replaceAll("_", "/").getBytes) (for { json <- tryo(JsonParser.parse(new String(base64.decode(payload)))) algo <- extractAlgo(json) ok <- boolToBox(algo.toUpperCase == "HMAC-SHA256") ?~ "Unknown algorithm. Expected HMAC-SHA256" } yield { logger.debug("signed request json: " + pretty(render(json))) val mac = Mac.getInstance("HmacSHA256") mac.init(new SecretKeySpec(secret.vend.getBytes, "HmacSHA256")) (mac.doFinal(payload.getBytes), json) }) match { case Full((expectedSig, json)) => if (Arrays.equals(expectedSig, sentSig)) Full(json) else { logger.debug("expectedSig: " + expectedSig.toString) logger.debug("sentSig: " + sentSig.toString) Failure("Bad Signed JSON signature!") } case Empty => Empty case f: Failure => f } case x => Failure("Couldn't split input: " + x.toString) } } private def extractAlgo(jv: JValue): Box[String] = Helpers.tryo { val JString(algo) = jv \\ "algorithm" algo } } case class AccessToken(val value: String, val code: String, val expires: DateTime = (new DateTime).plusSeconds(600)) { def isExpired: Boolean = expires.isBefore(new DateTime) }
5) 次にログインページにFacebookログインリンクを表示するためにFacebook素にペットを作成します。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characterspackage it.fbIntegration package snippet import lib.FacebookGraph import model.User import scala.xml.{ NodeSeq, Text } import net.liftweb._ import common._ import http.{ Factory, NoticeType, S, SHtml } import http.js.JsCmds._ import http.js.JE._ import util.Props import util.Helpers._ object Facebook extends Loggable { /** * If user is already connected, display a button with a direct login, otherwise * display a button that opens a facebook auth dialog window. */ def link = { <span id="id_facebooklink"> <a href="#"><img src="/img/fb.png" width="181" height="25"/></a> </span> <div lift="embed?what=/templates-hidden/parts/fb-init"></div> <script type="text/javascript"> <![CDATA[ var Cataalog = { api: { facebook: { init: function(data, success) { $.ajax({ type: "POST", url: "/api/facebook/init", data: data, success: success }); }, login: function(success) { $.ajax({ type: "POST", url: "/api/facebook/login", success: success }); } } }, facebook: { init: function(input, func) { window.fbAsyncInit = function() { FB.init({ appId : input.appId, // App ID channelURL : input.channelUrl, status : true, // check login status cookie : true, // enable cookies to allow the server to access the session oauth : true, // enable OAuth 2.0 xfbml : false // parse XFBML }); func(); }; } }, util: { wopen: function (url, name, w, h) { w += 32; h += 96; wleft = (screen.width - w) / 2; wtop = (screen.height - h) / 2; var win = window.open(url, name, 'width=' + w + ', height=' + h + ', ' + 'left=' + wleft + ', top=' + wtop + ', ' + 'location=no, menubar=no, ' + 'status=no, toolbar=no, scrollbars=no, resizable=no'); win.resizeTo(w, h); win.moveTo(wleft, wtop); win.focus(); } } } $("#id_facebooklink").click(function() { onClick(); }); var onClick = function() { Cataalog.util.wopen("/facebook/connect", "facebook_connect", 640, 360); return false; }; Cataalog.facebook.init(Input, function() { FB.getLoginStatus(function(response) { if (response.authResponse) { Cataalog.api.facebook.init(response.authResponse, function(data) { if (data.alert) { console.log(data.alert.level+": "+data.alert.message); } else if (data.status) { onClick = function() { Cataalog.api.facebook.login(function(resp) { if (resp.alert) { console.log(resp.alert.level+": "+resp.alert.message); } else if (resp.url) { window.location=resp.url } }) return false; }; } }) } }) }); ]]> </script> } def popupLink = { <span id="id_facebooklink"> <a href="#"><img src="/img/fb.png" width="181" height="25"/></a> </span> <script type="text/javascript"> <![CDATA[ $("#id_facebooklink").click(function() { Cataalog.util.wopen("/facebook/connect", "facebook_connect", 640, 360); return false; }); ]]> </script> } /** * Inject the data Facebook needs for initialization */ def init = "#id_jsinit" #> Script( JsCrVar("Input", JsObj( ("appId", Str(FacebookGraph.key.vend)), ("channelUrl", Str(S.hostAndPath + FacebookGraph.channelUrl.vend))))) def close = Script( JsCrVar("Input", JsObj( ("url", Str(S.param("url").openOr(User.loginContinueUrl.is)))))) /** * Only display if connected to facebook and access tokenis empty */ def checkAuthToken(in: NodeSeq): NodeSeq = if (User.isConnectedToFaceBook && FacebookGraph.currentAccessToken.is.isEmpty) in else NodeSeq.Empty }
6) login.htmlにFacebookログインリンクを追加します。
<div class="span4"> <span lift="Facebook.link"/></span> </div>
7) SiteMap.scalaにFacebook接続メニューを作ります。
val facebookConnect = MenuLoc( Menu.i("FacebookConnect") / "facebook" / "connect" >> EarlyResponse(() => { FacebookGraph.csrf(Helpers.nextFuncName) Full(RedirectResponse(FacebookGraph.authUrl, S.responseCookies: _*)) }))
8) 最後に、Facebookからレスポンスを受け取るためのFacebook.apiを作成します。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characterspackage it.fbIntegration package api import lib.{ AccessToken, AppHelpers, FacebookGraph } import model.User import net.liftweb._ import common._ import http._ import http.rest.RestHelper import json._ import util.Helpers._ import it.cataalog.config.Site import org.bson.types.ObjectId object FacebookApiStateful extends RestHelper with AppHelpers with Loggable { def registerUrl = "%s?url=%s".format(Site.facebookClose.url, Site.register.url) def successUrl = Site.facebookClose.url def errorUrl = Site.facebookError.url def homeUrl = "%s?url=%s".format(Site.facebookClose.url, Site.home.url) serve("api" / "facebook" prefix { /* * This is the url that Facebook calls back to when authorizing a user */ case "auth" :: Nil Get _ => { val redirectUrl: String = (S.param("code"), S.param("error"), S.param("error_reason"), S.param("error_description")) match { case (Full(code), _, _, _) => (for { state <- S.param("state") ?~ "State not provided" ok <- boolToBox(state == FacebookGraph.csrf.is) ?~ "The state does not match. You may be a victim of CSRF." accessToken <- FacebookGraph.accessToken(code) json <- FacebookGraph.me(accessToken) facebookId <- extractId(json) } yield { logger.debug("auth json: " + pretty(render(json))) // set the access token session var FacebookGraph.currentAccessToken(Full(accessToken)) User.findByFacebookId(facebookId) match { case Full(user) => validateUser(user) // already connected case _ => User.fromFacebookJson(json).map { facebookUser => User.findByEmail(facebookUser.email.is) match { case Full(user) => // needs merging validateUser(user) case _ => // new user; send to register page with form pre-filled val user = User User.id(new ObjectId) user.name(facebookUser.name.is) user.email(facebookUser.email.is) user.username(facebookUser.username.is) user.password(facebookUser.username.is) user.locale(facebookUser.locale.is) user.verified(false) user.save User.logUserIn(user, true, true) homeUrl } } openOr handleError("Error creating user from facebook json") } }) match { case Full(url) => url case Failure(msg, _, _) => handleError(msg) case Empty => handleError("Unknown error") } case (_, Full(error), Full(reason), Full(desc)) => // user denied authorization, ignore successUrl case _ => handleError("Unknown request type") } RedirectResponse(redirectUrl, S.responseCookies: _*) } /* * This is called by Facebook when a user deauthorizes this app on facebook.com */ case "deauth" :: Nil Post _ => { (for { signedReq <- S.param("signed_request") json <- FacebookGraph.parseSignedRequest(signedReq) facebookId <- extractUserId(json) user <- User.findByFacebookId(facebookId) } yield { // deauthorize facebook User.disconnectFacebook(user) }) match { case Full(_) => case Failure(msg, _, _) => handleError(msg) case Empty => handleError("Unknown error") } OkResponse() } /* * Call this via ajax when checking login status with JavaScript SDK. * Sets the access token and current facebookId. */ case "init" :: Nil Post _ => boxJsonToJsonResponse { import JsonDSL._ for { accessToken <- S.param("accessToken") ?~ "Token not provided" userId <- S.param("userID") ?~ "UserId not provided" facebookId <- asInt(userId) ?~ "Invalid Facebook user id" signedReq <- S.param("signedRequest") ?~ "Signed request not provided" expiresIn <- S.param("expiresIn") ?~ "ExpiresIn not provided" json <- FacebookGraph.parseSignedRequest(signedReq) } yield { val JString(code) = json \\ "code" logger.debug("expiresIn: " + expiresIn) // set the access token session var FacebookGraph.currentAccessToken(Full(AccessToken(accessToken, code))) // set the facebookId FacebookGraph.currentFacebookId(Full(facebookId)) ("status" -> "ok") } } /* * Log in a user by their facebookId */ case "login" :: Nil Post _ => boxJsonToJsonResponse { import JsonDSL._ for { facebookId <- FacebookGraph.currentFacebookId.is ?~ "currentFacebookId not set" user <- User.findByFacebookId(facebookId) ?~ "User not found by facebookId" } yield { if (user.validate.length == 0) { User.logUserIn(user, true, true) ("url" -> User.loginContinueUrl.is) } else { User.regUser(user) ("url" -> Site.register.url) } } } }) private def extractId(jv: JValue): Box[Int] = tryo { val JString(fbid) = jv \ "id" toInt(fbid) } private def extractUserId(jv: JValue): Box[Int] = tryo { val JString(fbid) = jv \ "user_id" toInt(fbid) } private def handleError(msg: String): String = { logger.error(msg) S.error(msg) errorUrl } private def validateUser(user: User): String = user.validate match { case Nil => User.logUserIn(user, true, true); successUrl case errs => User.regUser(user); registerUrl } } Latest post:
- OpenWhiskのScala sbtプロジェクトのgiter8テンプレートを作った
- OpenWhisk+Scalaで作るServerless Architectureとっかかり
- BluemixにPlayframeworkアプリケーションをデプロイする
- sbt、Giter8を統合するってよ
- Scala 2.12.0でSAM型
Recent Books: