Posted in Play, Scala

Login and Registration in Play 2.3 with SecureSocial

A quick and easy way to setup login and registration in Play is using the SecureSocial play plugin.

The SecureSocial plugin provides out of the box:

  • Twitter (OAuth1)
  • Facebook (OAuth2)
  • GitHub (OAuth2)
  • Google (OAuth2)
  • LinkedIn (OAuth1 and OAuth2)
  • Foursquare (OAuth2)
  • Instagram (OAuth2)
  • VK (OAuth2)
  • XING (OAuth1)
  • Username/Password with signup and reset password functionality.

The main thing I was looking for was just simple username / password with signup so all the extra social integration is really a bonus.

There is currently no stable version of SecureSocial for Play 2.3 and as far as I can tell there isn’t any documentation on how to get SecureSocial working using the current milestone release of v3.0. The configuration has changed a lot since the v2 release so here are my notes mostly based on how the sample apps are built.

Prereq : Configure play mailer plugin

See https://github.com/playframework/play-mailer/tree/2.3.1

Include SecureSocial as a dependency

Add the dependency to the sbt build file

libraryDependencies ++= Seq(
  //...existing dependencies
  "ws.securesocial" %% "securesocial" % "3.0-M1"
)

Create a custom UserService

Create a custom UserService to map your internal user model to the SecureSocial model. The InMemoryUserService provides a good starter for testing. This is based on the sample in SecureSocial project InMemoryUserService.scala. This gives you an implementation that can be replaced with a database backed implementation.

// a simple User class that can have multiple identities
case class MyUser(main: BasicProfile, identities: List[BasicProfile])
import securesocial.core._
import securesocial.core.providers.{MailToken, UsernamePasswordProvider}
import securesocial.core.services.{SaveMode, UserService}

import scala.concurrent.Future

class InMemoryUserService extends UserService[MyUser] with common.Logger {
  var users = Map[(String, String), MyUser]()
  private var tokens = Map[String, MailToken]()

  def find(providerId: String, userId: String): Future[Option[BasicProfile]] = {
    logger.debug(s"findByUserId $userId, users = $users")

    val result = for (
      user <- users.values;
      basicProfile <- user.identities.find(su => su.providerId == providerId && su.userId == userId)
    ) yield {
      basicProfile
    }
    Future.successful(result.headOption)
  }

  def findByEmailAndProvider(email: String, providerId: String): Future[Option[BasicProfile]] = {
    logger.debug(s"findByEmail $email, users = $users")

    val someEmail = Some(email)
    val result = for (
      user <- users.values;
      basicProfile <- user.identities.find(su => su.providerId == providerId && su.email == someEmail)
    ) yield {
      basicProfile
    }
    Future.successful(result.headOption)
  }

  private def findProfile(p: BasicProfile) = {
    logger.debug(s"findByProfile $p, users = $users")

    users.find {
      case (key, value) if value.identities.exists(su => su.providerId == p.providerId && su.userId == p.userId) => true
      case _ => false
    }
  }

  private def updateProfile(user: BasicProfile, entry: ((String, String), MyUser)): Future[MyUser] = {
    logger.debug(s"updateProfile $user, $entry, users = $users")

    val identities = entry._2.identities
    val updatedList = identities.patch(identities.indexWhere(i => i.providerId == user.providerId && i.userId == user.userId), Seq(user), 1)
    val updatedUser = entry._2.copy(identities = updatedList)
    users = users + (entry._1 -> updatedUser)
    Future.successful(updatedUser)
  }

  def save(user: BasicProfile, mode: SaveMode): Future[MyUser] = {
    logger.debug(s"save $user, users = $users, mode = $mode" )

    mode match {
      case SaveMode.SignUp =>
        val newUser = MyUser(user, List(user))
        users = users + ((user.providerId, user.userId) -> newUser)
        Future.successful(newUser)
      case SaveMode.LoggedIn =>
        // first see if there is a user with this BasicProfile already.
        findProfile(user) match {
          case Some(existingUser) =>
            updateProfile(user, existingUser)

          case None =>
            val newUser = MyUser(user, List(user))
            users = users + ((user.providerId, user.userId) -> newUser)
            Future.successful(newUser)
        }

      case SaveMode.PasswordChange =>
        findProfile(user).map { entry => updateProfile(user, entry) }.getOrElse(
          // this should not happen as the profile will be there
          throw new Exception("missing profile)")
        )
    }
  }

  def link(current: MyUser, to: BasicProfile): Future[MyUser] = {
    logger.debug(s"link $current, to $to, users = $users" )

    if (current.identities.exists(i => i.providerId == to.providerId && i.userId == to.userId)) {
      Future.successful(current)
    } else {
      val added = to :: current.identities
      val updatedUser = current.copy(identities = added)
      users = users + ((current.main.providerId, current.main.userId) -> updatedUser)
      Future.successful(updatedUser)
    }
  }

  def saveToken(token: MailToken): Future[MailToken] = {
    logger.debug(s"saveToken $token, users = $users" )

    Future.successful {
      tokens += (token.uuid -> token)
      token
    }
  }

  def findToken(token: String): Future[Option[MailToken]] = {
    logger.debug(s"findToken $token, users = $users" )

    Future.successful { tokens.get(token) }
  }

  def deleteToken(uuid: String): Future[Option[MailToken]] = {
    logger.debug(s"deleteToken $uuid, users = $users" )

    Future.successful {
      tokens.get(uuid) match {
        case Some(token) =>
          tokens -= uuid
          Some(token)
        case None => None
      }
    }
  }

  def deleteExpiredTokens() {
    logger.debug(s"deleteExpiredTokens" )

    tokens = tokens.filter(!_._2.isExpired)
  }

  override def updatePasswordInfo(user: MyUser, info: PasswordInfo): Future[Option[BasicProfile]] = {
    logger.debug(s"updatePasswordInfo $user, users = $users" )

    Future.successful {
      for (
        found <- users.values.find(_ == user);
        identityWithPasswordInfo <- found.identities.find(_.providerId == UsernamePasswordProvider.UsernamePassword)
      ) yield {
        val idx = found.identities.indexOf(identityWithPasswordInfo)
        val updated = identityWithPasswordInfo.copy(passwordInfo = Some(info))
        val updatedIdentities = found.identities.patch(idx, Seq(updated), 1)
        val updatedEntry = found.copy(identities = updatedIdentities)
        users = users + ((updatedEntry.main.providerId, updatedEntry.main.userId) -> updatedEntry)
        updated
      }
    }
  }

  override def passwordInfoFor(user: MyUser): Future[Option[PasswordInfo]] = {
    logger.debug(s"passwordInfoFor $user, users = $users" )

    Future.successful {
      for (
        found <- users.values.find(u => u.main.providerId == user.main.providerId && u.main.userId == user.main.userId);
        identityWithPasswordInfo <- found.identities.find(_.providerId == UsernamePasswordProvider.UsernamePassword)
      ) yield {
        identityWithPasswordInfo.passwordInfo.get
      }
    }
  }
}

Update the controllers to use Secure Social

This involves changing your controllers from objects to classes that take the RuntimeEnvironment as a contructor parameter. The controllers will now need to extend SecureSocial instead of controller. Any actions that are to be secured should return SecuredAction instead of Action.

class Application(override implicit val env: RuntimeEnvironment[MyUser]) extends securesocial.core.SecureSocial[MyUser] {

  def index = SecuredAction {
    Ok(views.html.index("Your new application is ready."))
  }
}

Update the routes

Update the routes to include the controllers as classes and include the Secure Social routes for login and registration in your play application. This is now a lot easier than the previous version as the default routes can be added with one line. Note the ‘@’ sign needed for the controllers as they are now classes that need to be instantiated.

GET         /                                     @controllers.Application.index
// all secure social routes for login and registration
->          /auth                                 securesocial.Routes

Add to the Global.scala

Update Global.scala (this should be in default package unless you explicitly configured to be somewhere else) to create SecureSocial runtime and inject into the controller classes on request.

package app

import java.lang.reflect.Constructor

import actor.SyncActorGuardian
import common.Logger
import play.api.libs.concurrent.Akka
import play.api.{Application, GlobalSettings}
import securesocial.core.RuntimeEnvironment
import securesocial.core.providers.UsernamePasswordProvider
import services.{LoginEventListener, MyUser, InMemoryUserService}

import scala.collection.immutable.ListMap

object Global extends GlobalSettings with Logger {
  /**
   * The runtime environment
   */
  object SecureSocialRuntimeEnvironment extends RuntimeEnvironment.Default[MyUser] {
    //override lazy val routes = new CustomRoutesService()
    override lazy val userService: InMemoryUserService = new InMemoryUserService()
    override lazy val eventListeners = List(new LoginEventListener())
    override lazy val providers = ListMap(
      include(new UsernamePasswordProvider[MyUser](userService, avatarService, viewTemplates, passwordHashers))
      // ... other providers
    )
  }

  /**
   * An implementation that checks if the controller expects a RuntimeEnvironment and
   * passes the instance to it if required.
   */
  override def getControllerInstance[A](controllerClass: Class[A]): A = {
    val instance = controllerClass.getConstructors.find { c =>
      val params = c.getParameterTypes
      params.length == 1 && params(0) == classOf[RuntimeEnvironment[MyUser]]
    }.map {
      _.asInstanceOf[Constructor[A]].newInstance(SecureSocialRuntimeEnvironment)
    }
    instance.getOrElse(super.getControllerInstance(controllerClass))
  }
}

Configure Secure Social

The configuration guide is still valid for the current version http://securesocial.ws/guide/configuration.html. I’m just configuring the user password provider.

securesocial {
  onLoginGoTo=/
  onLogoutGoTo=/login
  ssl=false
  userpass {
    withUserNameSupport=false
    sendWelcomeEmail=false
    enableGravatarSupport=false
    signupSkipLogin=true
    tokenDuration=60
    tokenDeleteInterval=5
    minimumPasswordLength=6
    enableTokenJob=true
    hasher=bcrypt
  }
}

Done!
 

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s