scalaoauth-2.0oauthakkaakka-http

In Scala/Akka, how would I go about correctly implementing user permissions in combination with oAuth2?


Background:

I have a webserver that uses oAuth2 to verify user credentials. I have a Auth class that defines the methods to verify a user and provide a token, to which you pass the routes you want protected. If the credentials are correct, the protectedRoutes are returned.

This all works really well.

What I would now like to do is define user permissions. I only want some routes available to some users.

Current setup:

Auth trait:

trait Auth extends JSONMarshalling
  with ProtobufMarshalling[ApiCall, ApiResponse] {

  import spray.json._

  def BasicAuthAuthenticator(credentials: Credentials): Option[Route] = {
    credentials match {
      case p@Credentials.Provided(_) =>
        var message = "Incorrect Username"
        val database = DatabaseUtil.getInstance
        var resultingRoute = Option(complete(failedResponse(StatusCodes.Unauthorized, "Incorrect Username")))
        val userResult = database.queryByID[User, String](classOf[User], p.identifier)

        if (userResult.isDefined) {
          val user = classOf[User].cast(userResult.head)
          var loggedInUser = LoggedInUser(user)
          if (p.verify(user.password)) {
            if (user.username == "system") {
              loggedInUser = LoggedInUser(user, oneTime = true) // <- this is a one time use token, for security
            }
            loggedInUsers.append(loggedInUser)
            val token: AuthToken = loggedInUser.oAuthToken
            resultingRoute = Option(complete(token)) // <- Auth passed
          } else { // <- password is incorrect
            if (user.username == "system") {
              resultingRoute = Option(complete(failedResponse(StatusCodes.Unauthorized, "Invalid license key")))
            } else {
              resultingRoute = Option(complete(failedResponse(StatusCodes.Unauthorized, "Incorrect Password")))
            }
          }
        }
        resultingRoute

      case _ =>
        Option(complete(failedResponse(StatusCodes.Unauthorized, "Credentials Missing")))
    }
  }

  def oAuthAuthenticator(credentials: Credentials, protectedRoutes: Route): Option[Route] =
    credentials match {
      case p@Credentials.Provided(_) =>
        val user = loggedInUsers.find(user => p.verify(user.oAuthToken.access_token))
        if (user.isDefined) {
          if (user.head.oneTime) loggedInUsers -= user.head
          Option(protectedRoutes)
        } else {
          Option(complete(ApiResponse().withErrorResponse(ApiErrorResponse(StatusCodes.Unauthorized._1, "Token does not exist"))))
        }
      case _ =>
        Option(complete(ApiResponse().withErrorResponse(ApiErrorResponse(StatusCodes.Unauthorized._1, "No credentials provided"))))
    }

  private def failedResponse(statusCode: StatusCode, message: String): HttpResponse = {
    HttpResponse(statusCode, entity = HttpEntity(ContentTypes.`application/json`, ErrorResponse(message).toJson.toString))
  }

}

My HydraRoute class (used by multiple http/https sockets):

class HydraRoute(apiRoute: Route) extends Auth with CORSHandler {

  def masterRoute: Route = {
    concat(
      authRoute,
      protectedRoute,
      pingRoute
    )
  }
  
  private lazy val pingRoute: Route = {
    pathPrefix("ping") {
      pathEndOrSingleSlash {
        get {
          complete(StatusCodes.OK)
        }
      }
    }
  }

  private lazy val authRoute: Route = {
    pathPrefix("auth") {
      pathEndOrSingleSlash {
        authenticateBasic(realm = "auth", BasicAuthAuthenticator) { authResponse =>
          post {
            authResponse
          }
        }
      }
    }
  }

  private lazy val protectedRoute: Route = {
    authenticateOAuth2(realm = "api", oAuthAuthenticator(_, apiRoute)) { tokenRoute =>
      tokenRoute
    }
  }

}

And finally, to combine these when creating a socket:


Http().newServerAt("localhost", 8080).bind(new HydraRoute(HttpRoutes()).masterRoute)
      .onComplete {
        case Success(binding) =>
          val address = binding.localAddress
          system.log.info(s"HTTP Server is listening on ${address.getHostString}:${address.getPort}")
        case Failure(ex) =>
          system.log.error("HTTP Server could not be started", ex)
          stop()
      }

object HttpRoutes extends ProtobufMarshalling[CertificateRequest, CertificateResponse] {

  def apply()(implicit actorSystem: ActorSystem[_]): Route = {
    pathPrefix("api") {
      path("certificate") {
        pathEndOrSingleSlash {
          post {
            entity(as[CertificateRequest]) { certificateRequest => complete(SSLManager.registerEncryptedCertificate(certificateRequest)) }
          }
        }
      }
    }
  }
}

Initial Approach:

I added an enum with a case for each user, into the Auth trait, like such:

enum UserPermissions(allowedRoutes: ArrayBuffer[Route]) {
  case ADMIN extends UserPermissions(ArrayBuffer.empty[Route])
  case REGISTRATION extends UserPermission(ArrayBuffer.empty[Route])
  case SYSTEM extends UserPermissions(ArrayBuffer.empty[Route])
  
  def getAllowedRoutes: ArrayBuffer = allowedRoutes
  
  def addRoute(route: Route): Unit = allowedRoutes.append(route)
}

def registerRoute(username: String, route: Route): Unit = {
  val userPermission: UserPermissions = UserPermissions.valueOf(username.toUpperCase)
  if (!userPermission.getAllowedRoutes.contains(route)) {
    userPermission.addRoute(route)
  }
}

What I would like to do, inside the oAuthAuthenticator is something like this:

      case p@Credentials.Provided(_) =>
        val user = loggedInUsers.find(user => p.verify(user.oAuthToken.access_token))
        if (user.isDefined) {
          val userPermissions = UserPermissions.valueOf(user.head.user.username.toUpperCase) // <- get permissions for this user
          
          if (userPermissions.getAllowedRoutes.contains(/* SOMEHOW GET THE CALLING ROUTE */)) {
            if (user.head.oneTime) loggedInUsers -= user.head // remove token if its a one-time use token
            Option(protectedRoutes)
          } else {
            Option(complete(ApiResponse().withErrorResponse(ApiErrorResponse(StatusCodes.Unauthorized._1, "User does not have permission to access this route"))))
          }
        } else {
          Option(complete(ApiResponse().withErrorResponse(ApiErrorResponse(StatusCodes.Unauthorized._1, "Token does not exist"))))
        }

How would I go about correctly implementing this?


Solution

  • I think I have a solution.

    Using this answer, I was able to extract the calling URI and convert it to a string. I simply register URI strings into the appropriate UserPermissions enum case, then check it in oAuth2 function.

    Revised oAuth2 route:

      private lazy val protectedRoute: Route = {
        extractUri { uri =>
          val callingURI = uri.toRelative.path.dropChars(1).toString
    
          actorSystem.log.info(s"Calling URI:$callingURI")
    
          authenticateOAuth2(realm = "api", oAuthAuthenticator(_, apiRoute, callingURI)) { tokenRoute =>
            tokenRoute
          }
        }
      }
    

    Revised Auth trait:

    enum UserPermissions(allowedRoutes: ArrayBuffer[String]) {
      case ADMIN extends UserPermissions(ArrayBuffer.empty[String])
      case REGISTRATION extends UserPermissions(ArrayBuffer.empty[String])
      case SYSTEM extends UserPermissions(ArrayBuffer.empty[String])
      
      def getAllowedRoutes: ArrayBuffer[String] = allowedRoutes
      
      def addRoute(route: String): Unit = allowedRoutes.append(route)
    }
    
    def registerRoute(username: String, route: String): Unit = {
      val userPermission: UserPermissions = UserPermissions.valueOf(username.toUpperCase)
      if (!userPermission.getAllowedRoutes.contains(route)) {
        userPermission.addRoute(route)
      }
    }
    
    
      def oAuthAuthenticator(credentials: Credentials, protectedRoutes: Route, callingURI: String): Option[Route] =
        credentials match {
          case p@Credentials.Provided(_) =>
            val user = loggedInUsers.find(user => p.verify(user.oAuthToken.access_token))
            if (user.isDefined) {
              val userPermissions = UserPermissions.valueOf(user.head.user.username.toUpperCase) // <- get permissions for this user
              
              if (userPermissions.getAllowedRoutes.contains(callingURI)) {
                if (user.head.oneTime) loggedInUsers -= user.head // remove token if its a one-time use token
                Option(protectedRoutes)
              } else {
                Option(complete(ApiResponse().withErrorResponse(ApiErrorResponse(StatusCodes.Unauthorized._1, "User does not have permission to access this route"))))
              }
            } else {
              Option(complete(ApiResponse().withErrorResponse(ApiErrorResponse(StatusCodes.Unauthorized._1, "Token does not exist"))))
            }
          case _ =>
            Option(complete(ApiResponse().withErrorResponse(ApiErrorResponse(StatusCodes.Unauthorized._1, "No credentials provided"))))
        }