Gestion de l’authentification dans une application Play framework avec Silhouette

Dans le domaine informatique et en particulier pour les applications Web on peut pas faire l’impasse sur la sécurité. En effet toute personne utilisant Internet veut s’assurer que toutes ses informations personnelles sont bien conservées et sécurisées. C’est pourquoi il est impératif de mettre la priorité sur la sécurité de nos applications Web et d’y allouer l’attention et le budget nécessaire.

Dans cet article on va s’intéresser à l’authentification dans une application Play/Scala. On va expliquer le processus de mise en oeuvre de l’authentification JWT en utilisant bibliothèque Silhouette 5.0 et MongoDB comme système de stockage.

Configuration de Silhouette

Silhouette est une bibliothèque d’authentification compatible uniquement avec les applications Play Framework qui prend en charge plusieurs méthodes d’authentification dont OAuth1, OAuth2, Credentials ou l’Authentication Basique.
Pour configurer Silhouette et MongoDB, nous devons ajouter des dépendances dans notre fichier build.sbt. Pour MongoDB nous utiliserons le driver asynchrone ReactiveMongo :

"org.reactivemongo" %% "play2-reactivemongo" % "0.12.6-play26",
"net.codingwell" %% "scala-guice" % "4.2.1",
"com.iheart" %% "ficus" % "1.4.2",
"com.mohiva" %% "play-silhouette" % "5.0.0",
"com.mohiva" %% "play-silhouette-password-bcrypt" % "5.0.0",
"com.mohiva" %% "play-silhouette-persistence" % "5.0.0",
"com.mohiva" %% "play-silhouette-crypto-jca" % "5.0.0",
"com.mohiva" %% "play-silhouette-testkit" % "5.0.0" % "test"

Juste après nous devons régler les paramètres de configurations et activer les modules.

play.modules {
  enabled += "play.modules.reactivemongo.ReactiveMongoModule"
  enabled += "modules.SilhouetteModule"
  # If there are any built-in modules that you want to disable, you can list them here.
}

Pour finir, Silhouette vous donne la possibilité de personnaliser votre environnement. Donc on va expliquer brièvement :

  • Le fichier Env définit les types d’identité et d’authentification pour un environnement et vous pouvez implémenter autant de types que vous voulez.
  • Le fichier Authorization permet d’ajouter une logique d’autorisation à vos points sécurisés.
  • Le fichier silhouette.conf est dédié aux paramètres du JWT.

Authenticator de Silhouette

Authenticator est le cœur de Silhouette, il identifie un utilisateur pour chaque requête après authentification. Vous trouverez ci-dessous une liste des authentificateurs disponibles :

Dans notre exemple nous utilisons la méthode basée sur l’en-tête – JWT

Authentification avec Silhouette :

Tout d’abord, on va commencer par créer notre modèle User. Dans Silhouette, l’utilisateur est défini comme Trait d’identité.

case class User(
    _id: Option[BSONObjectID] = None,
    loginInfo: LoginInfo,
    firstName: Option[String],
    lastName: Option[String],
    fullName: Option[String],
    email: String,
    roles: Seq[String] = Seq.empty[String],
    activated: Boolean = false,
    avatarURL: Option[String] = None,
    phoneNumber: Option[String] = None,
    creationDate: Option[DateTime] = Some(DateTime.now()),
    deletionDate: Option[DateTime] = None
) extends Identity

On doit aussi créer un modèle nommé CredentielInfos pour permettre de stocker les informations d’authentification de chaque User.

case class CredentialInfos(
  loginInfo: LoginInfo,
  authInfo: PasswordInfo
)

Maintenant qu’on a créé nos modèles on passe à la partie métier. Pour commencer on a créé les repository pour permettre de réagir avec la base de données. UserRepository qui est responsable du modèle User et MongoDbAuthInfoRepository extends DelegableAuthInfoDAO[PasswordInfo] utilisé pour le stockage du mot de passe.

case class MongoDBAuthInfoRepository @Inject() (
    reactiveMongoApi: ReactiveMongoApi
) extends DelegableAuthInfoDAO[PasswordInfo] with AbstractRepository[User] {
  import reactivemongo.play.json._
  import models.UserBSONFormat._

  val logger = LoggerFactory.getLogger(this.getClass)

  val collectionName = "credentialInfos"

  override def find(loginInfo: LoginInfo): Future[Option[PasswordInfo]] = collection.flatMap { coll =>
    coll.find(
      Json.obj(
        "loginInfo.providerID" -> loginInfo.providerID,
        "loginInfo.providerKey" -> loginInfo.providerKey
      )
    ).one[CredentialInfos].map(_.map(_.authInfo))
  }

  def retrieve(loginInfo: LoginInfo): Future[Option[User]] = collection.flatMap { coll =>
    coll.find(
      Json.obj(
        "loginInfo.providerID" -> loginInfo.providerID,
        "loginInfo.providerKey" -> loginInfo.providerKey
      )
    ).one[User]
  }

  override def add(loginInfo: LoginInfo, authInfo: PasswordInfo): Future[PasswordInfo] = {
    collection.flatMap { coll =>
      coll.insert(CredentialInfos(loginInfo, authInfo))
    }.map {
      case result if result.ok => authInfo
      case result if !result.ok => throw new Exception(result.writeErrors.toString())
    }
  }

  override def update(loginInfo: LoginInfo, authInfo: PasswordInfo): Future[PasswordInfo] = {
    val selector = BSONDocument("loginInfo.providerKey" -> loginInfo.providerKey)

    collection.flatMap { coll =>
      coll.find(
        Json.obj(
          "loginInfo.providerID" -> loginInfo.providerID,
          "loginInfo.providerKey" -> loginInfo.providerKey
        )
      ).one[CredentialInfos].flatMap {
          case Some(_) => coll.update(selector, CredentialInfos(loginInfo, authInfo)).map {
            case result if result.ok => authInfo

            case result if !result.ok => throw new Exception(result.writeErrors.toString())
          }

          case None => add(loginInfo, authInfo)
        }
    }

  }

  override def save(loginInfo: LoginInfo, authInfo: PasswordInfo): Future[PasswordInfo] = {
    find(loginInfo).flatMap {
      case Some(_) => update(loginInfo, authInfo)
      case None => add(loginInfo, authInfo)
    }
  }

  override def remove(loginInfo: LoginInfo) = collection.flatMap { coll =>
    val selector = BSONDocument("loginInfo.providerKey" -> loginInfo.providerKey)
    coll.remove(selector)
    Future.successful(())
  }
}

À cette étape, on doit créer notre service UserService comme suit :

@Singleton
class UserService @Inject()(
                             userRepository: UserRepository,
                             passwordHasher: PasswordHasher,
                             avatarService: AvatarService,
                             authInfoRepository: AuthInfoRepository,
                             appConfig: AppConfig) extends IdentityService[User]  {
  val logger = LoggerFactory.getLogger(getClass)

  import models.UserBSONFormat._
  
  def newUser(user: User) = {
    logger.trace(s"insert new user: $user")
    userRepository.insert(user)
  }
  def getAllUsers() = {
    logger.trace(s"get All users")
    userRepository.findAll()
  }

  def getUser(userId: String) = {
    logger.trace(s"get user with id $userId")
    if(userId.nonEmpty) {
      userRepository.findById(userId: String)
    } else {
      Future.successful(None)
    }
  }

  def deleteUser(userId: String) = {
    logger.trace(s"delete user with id $userId")
    userRepository.remove(userId)
  }

  def updateUser(userId: String, user: User) = {
    logger.trace(s"update user: $user")

    userRepository.update(BSONObjectID.parse(userId).get, user.copy(fullName = computeFullName(user.firstName, user.lastName)))
  }

  override def retrieve(loginInfo: LoginInfo): Future[Option[User]] = {
    userRepository.retrieve(loginInfo)
  }

  def computeFullName(firstName: Option[String], lastName: Option[String]): Option[String] = {
    if (firstName.isDefined && lastName.isDefined) {
      Some(firstName.getOrElse("") + " " + lastName.getOrElse(""))
    } else {
      if (firstName.isDefined) {
        firstName
      } else if (lastName.isDefined) {
        lastName
      } else {
        None
      }
    }
  }
}

À ce stade on est au point final pour l’inscription, l’utilisateur va être stocké dans la collection user et ses information d’authentification vont être stocker dans la collection CredentialInfos.
Nous avons utiliser l’API PasswordHasher pour générer la valeur de hachage du mot de passe.

def signUp() = Action.async(parse.json) { implicit request =>
    request.body.validate[SignUpForm.Data].map { data =>
      val loginInfo = LoginInfo(CredentialsProvider.ID, data.email.toLowerCase) 

      userService.retrieve(loginInfo).flatMap {
        case Some(_) =>
          Future.successful(BadRequest(Json.obj("message" -> Messages("user.exists"))))
        case None =>
          val authInfo: PasswordInfo = passwordHasher.hash(data.password)

          val user = User(
            _id = Some(BSONObjectID.generate()),
            loginInfo = loginInfo,
            firstName = data.firstName,
            lastName = data.lastName,
            fullName = computeFullName(data.firstName, data.lastName),
            email = data.email.toLowerCase,
            roles = newUserRoles,
            activated = false 
          )

          for {

            avatar <- avatarService.retrieveURL(data.email.toLowerCase)
            _ <- userService.newUser(user.copy(avatarURL = avatar), Some(data))
            authInfo <- authInfoRepository.add(loginInfo, authInfo)
            authenticator <- silhouette.env.authenticatorService.create(loginInfo)
            token <- silhouette.env.authenticatorService.init(authenticator)


          } yield {

            silhouette.env.eventBus.publish(SignUpEvent(user, request))
            silhouette.env.eventBus.publish(LoginEvent(user, request))

            Ok(Json.obj(  "token" -> token))
          }
      }
    }.recoverTotal {
      case error =>
        Future.successful(Unauthorized(Json.obj("message" -> Messages("invalid.data"))))
    }
  }

Cette fonction renvoie un JWT comme réponse

On va maintenant s’intéresser à la partie authentification (SingIn)

  def signIn = Action.async(parse.json) { implicit request =>

    request.body.validate[SignInForm.Data].map { data =>
      credentialsProvider.authenticate(Credentials(data.email.toLowerCase, data.password)).flatMap { loginInfo =>
        userService.retrieve(loginInfo).flatMap {
          case Some(user) => silhouette.env.authenticatorService.create(loginInfo).map {
            case authenticator if data.rememberMe =>
              val c: Config = configuration.underlying
              authenticator.copy(
                expirationDateTime = clock.now + c.as[FiniteDuration]("silhouette.authenticator.rememberMe.authenticatorExpiry"),
                idleTimeout = c.getAs[FiniteDuration]("silhouette.authenticator.rememberMe.authenticatorIdleTimeout")
              )
            case authenticator => authenticator
          }.flatMap { authenticator =>
            silhouette.env.eventBus.publish(LoginEvent(user, request))
            silhouette.env.authenticatorService.init(authenticator).map { token =>
              Ok(Json.obj(
                "token" -> token,
                "userId" -> user._id.get.stringify,
                "activated" -> user.activated,
              ))
            }
          }
          case None => Future.failed(new IdentityNotFoundException("Couldn't find user"))
        }
      }.recover {
        case e: ProviderException =>
          Unauthorized(Json.obj("message" -> Messages("invalid.credentials")))
      }
    }.recoverTotal {
      case error =>
        Future.successful(Unauthorized(Json.obj("message" -> Messages("invalid.credentials"))))
    }
  }

Dans cette partie le JWT est renvoyé par AuthenticatorService en fonction de l’identifiant et du mot de passe de l’utilisateur.

Une dernière étape reste à faire, c’est l’implémentation de la fonction changePassword pour le changement du mot de passe.
C’est pas si complexe que ça, en effet on doit sécuriser notre requête en limitant l’accès seulement aux utilisateurs qui ont l’autorisation. Ceci est fait par le RequestHandler pour sécuriser les endpoints en ajoutant SecuredAction(WithService(userWrite)).

  /**
    * Saves the new password and renew the cookie
    */
  def changePassword = SecuredAction(WithService(userWrite)).async(parse.json) { implicit request =>

    request.body.validate[ChangePasswordForm.Data].map { data =>
      val credentials = Credentials(data.email.toLowerCase, data.currentPassword)

      val authInfo: PasswordInfo = passwordHasher.hash(data.newPassword)

      credentialsProvider.authenticate(credentials).flatMap { loginInfo =>
        userService.retrieve(loginInfo).flatMap {
          case Some(user) =>
            for {
              authInfo <- mongoDBAuthInfoRepository.update(loginInfo, authInfo)
              authenticator <- silhouette.env.authenticatorService.create(loginInfo)
              result <- silhouette.env.authenticatorService.renew(authenticator, Ok("success"))
            } yield result
          case None => Future.failed(new IdentityNotFoundException("Couldn't find user"))
        }
      }
    }.recoverTotal {
      case error =>
        Future.successful(BadRequest(Json.obj("message" -> "error changing user password")))
    }
  }

En effectuant le test sans qu’on passe le token dans le header HTTP on remarque que la requête n’aboutit pas. Ceci est le rôle du RequestHeadler.

Enfin notre travail est terminé. On peut constater que sécuriser une API Play REST avec JWT n’est pas une tâche difficile. Compte tenu de la simplicité du langage Scala, vous pouvez très rapidement implémenter une application métier avec une authentification solide.

Références

Vous pouvez trouver les codes sources complets du projet ci-dessus sur Github : https://github.com/moben-technology/play-silhouette

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *