Samstag, 8. Juni 2013

Social login with RPXNow in Lift

I just got Social login (OpenID) working in Lift, using RPXNow. RPXNow is intented to make OpenID login easy. It's free bellow 6 providers.

This guide assumes you have already setup the application in rpxnow.

Here are the steps:

1. Add this JS to the page where login will appear (preferably in head):

(function() {
   if (typeof window.janrain !== 'object') window.janrain = {};
   if (typeof window.janrain.settings !== 'object') window.janrain.settings = {};
   
   janrain.settings.tokenUrl = 'http://localhost:8080/signupr';

    function isReady() { janrain.ready = true; };
   if (document.addEventListener) {
     document.addEventListener("DOMContentLoaded", isReady, false);
   } else {
     window.attachEvent('onload', isReady);
   }

    var e = document.createElement('script');
   e.type = 'text/javascript';
   e.id = 'janrainAuthWidget';

    if (document.location.protocol === 'https:') {
     e.src = 'https://rpxnow.com/js/lib/myapp/engage.js';
   } else {
     e.src = 'http://widget-cdn.rpxnow.com/js/lib/myapp/engage.js';
   }

    var s = document.getElementsByTagName('script')[0];
   s.parentNode.insertBefore(e, s);
})();
Notes:
- http://localhost:8080/signupr is obviously my testing host + signupr is how I named the path to my webservice handler - see step 3.
- https://rpxnow.com/js/lib/myapp/engage.js -> "myapp" is the name of the app given in RPXNow.

  2. Add a placeholder HTML element with id "janrainEngageEmbed" where the login widget has to be inserted: In my case, I want to use it together with the existing login system of lift - so I added it just above lift's login form. In order to do that, I overwrote screenWrap in User singleton:
 override def screenWrap = Full{
    
     
}
3. Add handler to get the token from JS call and sign in the user:
class Boot extends Loggable {
 
 def boot {
        //...
   LiftRules.dispatch.append {
     case req @ Req(List("signupr"), _, _) =>
     val userData:SigninResponse = getLoggedInUserData(S.param("token").openOr(""))
    loginOrRegisterUser(userData)
    
    () => for (hrs <- Box.asA[net.liftweb.http.provider.servlet.HTTPRequestServlet](req.request)) yield {
     new RedirectResponse("/page_to_load_after_login.html", null)
    }
  }
}
}
getLoggedInUserData calls rpxnow api to get logged in user's data:
    
    def getLoggedInUserData(token:String):SigninResponse = {
      val query = Map(
        "apiKey" -> "myapikey",
        "token" -> token
      )

     var queryStr = query.foldLeft("") { (s: String, pair: (String, String)) =>
      s + pair._1 + "=" + pair._2 + "&"
     } //TODO without "&" at the end
     
     queryStr = queryStr.substring(0, queryStr.length - 1)
     
     val url = new URL("https://myapp.rpxnow.com/api/v2/auth_info")
     val conn:HttpURLConnection = url.openConnection().asInstanceOf[HttpURLConnection] 
     conn.setRequestMethod("POST");
     conn.setDoOutput(true)
     conn.connect()
     val osw = new OutputStreamWriter(conn.getOutputStream(), "UTF-8")
     osw.write(queryStr)
     osw.close()
        
     val response = streamToString(conn.getInputStream())
        
     implicit val formats = net.liftweb.json.DefaultFormats //need this for extract to work
     val userData:SigninResponse = parse(response).extract[SigninResponse]
        
     userData
    }
SigninResponse class is just a model class for the returned JSON. The field names may change depending of the provider (there are also more fields). I use with these fields, which I need and are also common in the providers I use:
 case class SigninResponse(stat: String, profile:SigninProfile)
 case class SigninProfile(providerName: String, identifier:String, name:SigninName, displayName:String)
 case class SigninName(formatted: String, givenName:String, familyName:String)
At last this method to add the user to my user database (just to keep track of them), and set the user in the session:
 def loginOrRegisterUser(userData:SigninResponse) = {
  var userBox:Box[User] = User.find(By(User.externId, userData.profile.identifier))
  
  if (userBox.isEmpty) {
   userBox = Full(User.create)

  } else {
//   user = userBox.get
  }
  
  val user:User = userBox.get
  
  user.externId(userData.profile.identifier).
    username(userData.profile.displayName).
    provider(userData.profile.providerName).
    firstName(userData.profile.name.givenName).
    lastName(userData.profile.name.familyName).
    saveMe()

    User.logUserIn(user) //set user in the session
  }
In order for that to work I extended the user object / table with provider, externId and username. For the users registered with the default system, externid and provider are never set.

4. Now I can access these users in the app, like the default users:
User.currentUser

How to load translations in Lift from a database

I added this in Boot.scala:


LiftRules.resourceBundleFactories.append {
    case (key, locale) => new TranslationsResourceLoader(locale)
}

And this is the TranslationsResourceLoader class:

import java.util.Locale
import java.util.ResourceBundle

//important! otherwise scala's Enumeration is used and we get
//compiler errors
import java.util.Enumeration 

class TranslationsResourceLoader(val locale:Locale)
  extends ResourceBundle {

  private val translations:List[Translation] = Translation.findAll()
    .filter(t => t.lang.equals(locale.toString.split("_")(0)))

  def getKeys(): Enumeration[String] = {
   val it = translations.iterator
   new Enumeration[String] {
     def hasMoreElements() = it.hasNext
     def nextElement() = it.next.tkey.toString
   }
 }

  def handleGetObject(key: String):String = {
   translations.find(t => t.tkey.equals(key)).get.value
  }
}
And that's it! Now you can use the translations with the usual internationalization methods. In HTML this would be:

<lift:loc locid="dummykey">translation test<lift:loc>

Or:

<p class="lift:loc?locid="dummykey">translation test<p>

And in code:

import net.liftweb.http.S.?

?("dummykey")

Note: This assumes a (custom) class "Translation". In my case this is mapped to a Translation database table. I'm using Lift's Mapper.

Here are useful links about internationalization in Lift in general:
https://www.assembla.com/spaces/liftweb/wiki/Internationalization/print http://timperrett.com/2009/02/28/new-internationalization-extendability-in-lift/