この記事は、ayushmishra2005氏の記事の意訳です。参考にさせていただいたついでに訳してみました。参考程度にどうぞ。

Providing a “Sign-in with Facebook” functionality using Scala

誤訳・誤植等ありましたら、@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ファイルを使用します。

package 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素にペットを作成します。

package 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
}
view raw Facebook.scala hosted with ❤ by GitHub

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を作成します。

package 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
}
}