From 0529d84f008b0e39ed04e900fbb29b24f6105639 Mon Sep 17 00:00:00 2001 From: To-om Date: Mon, 4 Sep 2017 15:08:03 +0200 Subject: [PATCH] #25 Add API key authentication type --- .../controllers/Authenticated.scala | 33 +++-- app/org/elastic4play/services/UserSrv.scala | 25 +++- .../services/auth/ADAuthSrv.scala | 8 +- .../services/auth/LdapAuthSrv.scala | 131 +++++++++++++----- .../services/auth/MultiAuthSrv.scala | 19 ++- 5 files changed, 158 insertions(+), 58 deletions(-) diff --git a/app/org/elastic4play/controllers/Authenticated.scala b/app/org/elastic4play/controllers/Authenticated.scala index daeaa70..1e4ba71 100644 --- a/app/org/elastic4play/controllers/Authenticated.scala +++ b/app/org/elastic4play/controllers/Authenticated.scala @@ -97,6 +97,17 @@ class Authenticated( * Retrieve authentication information from API key */ def getFromApiKey(request: RequestHeader): Future[AuthContext] = + for { + auth ← request + .headers + .get(HeaderNames.AUTHORIZATION) + .fold(Future.failed[String](AuthenticationError("Authentication header not found")))(Future.successful) + _ ← if (!auth.startsWith("Bearer ")) Future.failed(AuthenticationError("Only bearer authentication is supported")) else Future.successful(()) + key = auth.substring(7) + authContext ← authSrv.authenticate(key)(request) + } yield authContext + + def getFromBasicAuth(request: RequestHeader): Future[AuthContext] = for { auth ← request .headers @@ -119,15 +130,19 @@ class Authenticated( case getFromSessionError ⇒ getFromApiKey(request).recoverWith { case getFromApiKeyError ⇒ - userSrv.getInitialUser(request).recoverWith { - case getInitialUserError ⇒ - logger.error( - s"""Authentication error: - | From session: ${getFromSessionError.getClass.getSimpleName} ${getFromSessionError.getMessage} - | From api key: ${getFromApiKeyError.getClass.getSimpleName} ${getFromApiKeyError.getMessage} - | Initial user: ${getInitialUserError.getClass.getSimpleName} ${getInitialUserError.getMessage} - """.stripMargin) - Future.failed(AuthenticationError("Not authenticated")) + getFromBasicAuth(request).recoverWith { + case getFromBasicAuthError ⇒ + userSrv.getInitialUser(request).recoverWith { + case getInitialUserError ⇒ + logger.error( + s"""Authentication error: + | From session : ${getFromSessionError.getClass.getSimpleName} ${getFromSessionError.getMessage} + | From api key : ${getFromApiKeyError.getClass.getSimpleName} ${getFromApiKeyError.getMessage} + | From basic auth: ${getFromBasicAuthError.getClass.getSimpleName} ${getFromBasicAuthError.getMessage} + | Initial user : ${getInitialUserError.getClass.getSimpleName} ${getInitialUserError.getMessage} + """.stripMargin) + Future.failed(AuthenticationError("Not authenticated")) + } } } } diff --git a/app/org/elastic4play/services/UserSrv.scala b/app/org/elastic4play/services/UserSrv.scala index 344875f..72cfb92 100644 --- a/app/org/elastic4play/services/UserSrv.scala +++ b/app/org/elastic4play/services/UserSrv.scala @@ -1,12 +1,16 @@ package org.elastic4play.services +import java.util.Base64 import java.util.concurrent.atomic.AtomicBoolean import scala.concurrent.Future +import scala.util.Random import play.api.libs.json.JsObject import play.api.mvc.RequestHeader +import org.elastic4play.{ AuthenticationError, AuthorizationError } + abstract class Role(val name: String) trait AuthContext { @@ -35,15 +39,26 @@ trait User { object AuthCapability extends Enumeration { type Type = Value - val changePassword, setPassword = Value + val changePassword, setPassword, renewKey = Value } + trait AuthSrv { + protected final def generateKey(): String = { + val bytes = Array.ofDim[Byte](24) + Random.nextBytes(bytes) + Base64.getEncoder.encodeToString(bytes) + } val name: String - def capabilities: Set[AuthCapability.Type] - def authenticate(username: String, password: String)(implicit request: RequestHeader): Future[AuthContext] - def changePassword(username: String, oldPassword: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] - def setPassword(username: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] + val capabilities = Set.empty[AuthCapability.Type] + + def authenticate(username: String, password: String)(implicit request: RequestHeader): Future[AuthContext] = Future.failed(AuthenticationError("Operation not supported")) + def authenticate(key: String)(implicit request: RequestHeader): Future[AuthContext] = Future.failed(AuthenticationError("Operation not supported")) + def changePassword(username: String, oldPassword: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = Future.failed(AuthorizationError("Operation not supported")) + def setPassword(username: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = Future.failed(AuthorizationError("Operation not supported")) + def renewKey(username: String)(implicit authContext: AuthContext): Future[String] = Future.failed(AuthorizationError("Operation not supported")) + def getKey(username: String)(implicit authContext: AuthContext): Future[String] = Future.failed(AuthorizationError("Operation not supported")) } + trait AuthSrvFactory { val name: String def getAuthSrv: AuthSrv diff --git a/app/org/elastic4play/services/auth/ADAuthSrv.scala b/app/org/elastic4play/services/auth/ADAuthSrv.scala index 6e29757..69a4a19 100644 --- a/app/org/elastic4play/services/auth/ADAuthSrv.scala +++ b/app/org/elastic4play/services/auth/ADAuthSrv.scala @@ -36,7 +36,7 @@ class ADAuthSrvFactory @Inject() ( private[ADAuthSrv] lazy val logger = Logger(getClass) val name: String = factory.name - val capabilities: Set[AuthCapability.Value] = Set(AuthCapability.changePassword) + override val capabilities: Set[AuthCapability.Value] = Set(AuthCapability.changePassword) private[auth] def connect[A](username: String, password: String)(f: InitialDirContext ⇒ A): Try[A] = { val protocol = if (useSSL) "ldaps://" else "ldap://" @@ -65,7 +65,7 @@ class ADAuthSrvFactory @Inject() ( } } - def authenticate(username: String, password: String)(implicit request: RequestHeader): Future[AuthContext] = { + override def authenticate(username: String, password: String)(implicit request: RequestHeader): Future[AuthContext] = { (for { _ ← Future.fromTry(connect(domainName + "\\" + username, password)(identity)) u ← userSrv.get(username) @@ -78,7 +78,7 @@ class ADAuthSrvFactory @Inject() ( } } - def changePassword(username: String, oldPassword: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = { + override def changePassword(username: String, oldPassword: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = { val unicodeOldPassword = ("\"" + oldPassword + "\"").getBytes("UTF-16LE") val unicodeNewPassword = ("\"" + newPassword + "\"").getBytes("UTF-16LE") val changeTry = connect(domainName + "\\" + username, oldPassword) { ctx ⇒ @@ -98,7 +98,5 @@ class ADAuthSrvFactory @Inject() ( Future.failed(AuthorizationError("Change password failure")) } } - - def setPassword(username: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = Future.failed(AuthorizationError("Operation not supported")) } } \ No newline at end of file diff --git a/app/org/elastic4play/services/auth/LdapAuthSrv.scala b/app/org/elastic4play/services/auth/LdapAuthSrv.scala index 0659d6c..5926584 100644 --- a/app/org/elastic4play/services/auth/LdapAuthSrv.scala +++ b/app/org/elastic4play/services/auth/LdapAuthSrv.scala @@ -6,20 +6,21 @@ import javax.naming.Context import javax.naming.directory._ import scala.concurrent.{ ExecutionContext, Future } -import scala.util.Try +import scala.util.{ Success, Try } import play.api.mvc.RequestHeader import play.api.{ Configuration, Logger } -import org.elastic4play.services._ +import org.elastic4play.services.{ AuthCapability, _ } import org.elastic4play.{ AuthenticationError, AuthorizationError } @Singleton class LdapAuthSrvFactory @Inject() ( configuration: Configuration, userSrv: UserSrv, - ec: ExecutionContext) extends AuthSrvFactory { factory ⇒ + ec: ExecutionContext) extends AuthSrvFactory { val name = "ldap" + def getAuthSrv: AuthSrv = new LdapAuthSrv( configuration.get[String]("auth.ldap.serverName"), configuration.getOptional[Boolean]("auth.ldap.useSSL").getOrElse(false), @@ -27,6 +28,9 @@ class LdapAuthSrvFactory @Inject() ( configuration.get[String]("auth.ldap.bindPW"), configuration.get[String]("auth.ldap.baseDN"), configuration.get[String]("auth.ldap.filter"), + configuration.getOptional[String]("auth.ldap.keyAttribute"), + configuration.getOptional[String]("auth.ldap.keyFilter"), + configuration.getOptional[String]("auth.ldap.loginAttribute").getOrElse("uid"), userSrv, ec) @@ -37,28 +41,20 @@ class LdapAuthSrvFactory @Inject() ( bindPW: String, baseDN: String, filter: String, + keyAttribute: Option[String], + keyFilter: Option[String], + loginAttribute: String, userSrv: UserSrv, implicit val ec: ExecutionContext) extends AuthSrv { private[LdapAuthSrv] lazy val logger = Logger(getClass) val name = "ldap" - val capabilities = Set(AuthCapability.changePassword) + override val capabilities: Set[AuthCapability.Value] = keyAttribute match { + case Some(_) ⇒ Set(AuthCapability.changePassword, AuthCapability.renewKey) + case None ⇒ Set(AuthCapability.changePassword) + } - @Inject() def this( - configuration: Configuration, - userSrv: UserSrv, - ec: ExecutionContext) = - this( - configuration.get[String]("auth.ldap.serverName"), - configuration.getOptional[Boolean]("auth.ldap.useSSL").getOrElse(false), - configuration.get[String]("auth.ldap.bindDN"), - configuration.get[String]("auth.ldap.bindPW"), - configuration.get[String]("auth.ldap.baseDN"), - configuration.get[String]("auth.ldap.filter"), - userSrv, - ec) - - private[auth] def connect[A](username: String, password: String)(f: InitialDirContext ⇒ A): Try[A] = { + private[auth] def connect[A](username: String, password: String)(f: InitialDirContext ⇒ Try[A]): Try[A] = { val protocol = if (useSSL) "ldaps://" else "ldap://" val env = new util.Hashtable[Any, Any] env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory") @@ -71,6 +67,7 @@ class LdapAuthSrvFactory @Inject() ( try f(ctx) finally ctx.close() } + .flatten } private[auth] def getUserDN(ctx: InitialDirContext, username: String): Try[String] = { @@ -84,19 +81,27 @@ class LdapAuthSrvFactory @Inject() ( } } - def authenticate(username: String, password: String)(implicit request: RequestHeader): Future[AuthContext] = { + private[auth] def getUserNameFromKey(ctx: InitialDirContext, key: String): Try[String] = { + Try { + val controls = new SearchControls() + controls.setSearchScope(SearchControls.SUBTREE_SCOPE) + controls.setCountLimit(1) + val searchResult = ctx.search(baseDN, keyFilter.getOrElse(throw AuthenticationError("Authentication by key is not possible as auth.ldap.keyFilter is not configured")), Array[Object](key), controls) + if (searchResult.hasMore) searchResult.next().getAttributes.get(loginAttribute).get().toString + else throw AuthenticationError("User not found in LDAP server") + } + } + + override def authenticate(username: String, password: String)(implicit request: RequestHeader): Future[AuthContext] = { connect(bindDN, bindPW) { ctx ⇒ getUserDN(ctx, username) } - .flatten - .flatMap { userDN ⇒ - connect(userDN, password) { _ ⇒ - userSrv.get(username) - .flatMap { u ⇒ userSrv.getFromUser(request, u) } - } + .flatMap { userDN ⇒ connect(userDN, password)(_ ⇒ Success(())) } + .map { _ ⇒ + userSrv.get(username) + .flatMap { u ⇒ userSrv.getFromUser(request, u) } } - .recover { case t ⇒ Future.failed(t) } - .get + .fold[Future[AuthContext]](Future.failed, identity) .recoverWith { case t ⇒ logger.error("LDAP authentication failure", t) @@ -104,19 +109,34 @@ class LdapAuthSrvFactory @Inject() ( } } - def changePassword(username: String, oldPassword: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = { - val changeTry = connect(bindDN, bindPW) { ctx ⇒ + override def authenticate(key: String)(implicit request: RequestHeader): Future[AuthContext] = { + keyFilter + .map { _ ⇒ + connect(bindDN, bindPW) { ctx ⇒ + getUserNameFromKey(ctx, key) + } + .map(username ⇒ userSrv.getFromId(request, username)) + .fold(Future.failed, identity) + .recoverWith { + case t ⇒ + logger.error("LDAP authentication failure", t) + Future.failed(AuthenticationError("Authentication failure")) + } + } + .getOrElse(Future.failed(AuthorizationError("ldap authenticator doesn't support api key authentication"))) + } + + override def changePassword(username: String, oldPassword: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = { + connect(bindDN, bindPW) { ctx ⇒ getUserDN(ctx, username) } - .flatten .flatMap { userDN ⇒ connect(userDN, oldPassword) { ctx ⇒ val mods = Array(new ModificationItem(DirContext.REPLACE_ATTRIBUTE, new BasicAttribute("userPassword", newPassword))) - ctx.modifyAttributes(userDN, mods) + Try(ctx.modifyAttributes(userDN, mods)) } } - Future - .fromTry(changeTry) + .fold(Future.failed, Future.successful) .recoverWith { case t ⇒ logger.error("LDAP change password failure", t) @@ -124,6 +144,47 @@ class LdapAuthSrvFactory @Inject() ( } } - def setPassword(username: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = Future.failed(AuthorizationError("Operation not supported")) + override def renewKey(username: String)(implicit authContext: AuthContext): Future[String] = { + keyAttribute.map { ka ⇒ + connect(bindDN, bindPW) { ctx ⇒ + getUserDN(ctx, username).flatMap { userDN ⇒ + val newKey = generateKey() + val mods = Array(new ModificationItem(DirContext.REPLACE_ATTRIBUTE, new BasicAttribute(ka, newKey))) + Try(ctx.modifyAttributes(userDN, mods)).map(_ ⇒ newKey) + } + } + .fold(Future.failed, Future.successful) + .recoverWith { + case t ⇒ + logger.error("LDAP renew key failure", t) + Future.failed(AuthorizationError("Renew key failure")) + } + } + .getOrElse(Future.failed(AuthorizationError("Operation not supported"))) + } + + override def getKey(username: String)(implicit authContext: AuthContext): Future[String] = { + keyAttribute.map { ka ⇒ + connect(bindDN, bindPW) { ctx ⇒ + Try { + val controls = new SearchControls() + controls.setSearchScope(SearchControls.SUBTREE_SCOPE) + controls.setCountLimit(1) + val searchResult = ctx.search(baseDN, filter, Array[Object](username), controls) + if (searchResult.hasMore) { + searchResult.next().getAttributes.get(ka).get().toString + } + else throw AuthenticationError("User not found in LDAP server") + } + } + .fold(Future.failed, Future.successful) + .recoverWith { + case t ⇒ + logger.error("LDAP renew key failure", t) + Future.failed(AuthorizationError("Renew key failure")) + } + } + .getOrElse(Future.failed(AuthorizationError("Operation not supported"))) + } } } \ No newline at end of file diff --git a/app/org/elastic4play/services/auth/MultiAuthSrv.scala b/app/org/elastic4play/services/auth/MultiAuthSrv.scala index 67627bc..efac95f 100644 --- a/app/org/elastic4play/services/auth/MultiAuthSrv.scala +++ b/app/org/elastic4play/services/auth/MultiAuthSrv.scala @@ -61,7 +61,7 @@ class MultiAuthSrv( ec) val name = "multi" - def capabilities: Set[Type] = authProviders.flatMap(_.capabilities).toSet + override val capabilities: Set[Type] = authProviders.flatMap(_.capabilities).toSet private[auth] def forAllAuthProvider[A](body: AuthSrv ⇒ Future[A]) = { authProviders.foldLeft(Future.failed[A](new Exception("no authentication provider found"))) { @@ -69,13 +69,24 @@ class MultiAuthSrv( } } - def authenticate(username: String, password: String)(implicit request: RequestHeader): Future[AuthContext] = + override def authenticate(username: String, password: String)(implicit request: RequestHeader): Future[AuthContext] = forAllAuthProvider(_.authenticate(username, password)) .recoverWith { case _ ⇒ Future.failed(AuthenticationError("Authentication failure")) } - def changePassword(username: String, oldPassword: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = + override def authenticate(key: String)(implicit request: RequestHeader): Future[AuthContext] = + forAllAuthProvider(_.authenticate(key)) + .recoverWith { case _ ⇒ Future.failed(AuthenticationError("Authentication failure")) } + + override def changePassword(username: String, oldPassword: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = forAllAuthProvider(_.changePassword(username, oldPassword, newPassword)) - def setPassword(username: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = + override def setPassword(username: String, newPassword: String)(implicit authContext: AuthContext): Future[Unit] = forAllAuthProvider(_.setPassword(username, newPassword)) + + override def renewKey(username: String)(implicit authContext: AuthContext): Future[String] = + forAllAuthProvider(_.renewKey(username)) + + override def getKey(username: String)(implicit authContext: AuthContext): Future[String] = + forAllAuthProvider(_.getKey(username)) + } \ No newline at end of file