Skip to content

Commit

Permalink
fix: prohibit tenants to use equal or revoked api keys (#742)
Browse files Browse the repository at this point in the history
Signed-off-by: Yurii Shynbuiev <[email protected]>
Signed-off-by: Anton Baliasnikov <[email protected]>
Co-authored-by: Anton Baliasnikov <[email protected]>
  • Loading branch information
yshyn-iohk and Anton Baliasnikov authored Oct 3, 2023
1 parent f07315b commit 4b10c3a
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ case class ApiKeyAuthenticatorImpl(
UnexpectedError("Internal error")
case AuthenticationRepositoryError.ServiceError(message) =>
UnexpectedError("Internal error")
case AuthenticationRepositoryError.AuthenticationCompromised(entityId, amt, secret) =>
InvalidCredentials("API key is compromised")
}
}
} else {
Expand Down Expand Up @@ -89,19 +91,20 @@ case class ApiKeyAuthenticatorImpl(
.mapError(cause => AuthenticationError.UnexpectedError(cause.getMessage))
_ <- repository
.insert(entityId, AuthenticationMethodType.ApiKey, secret)
.logError(s"Insert operation failed for entityId: $entityId")
.mapError(are => AuthenticationError.UnexpectedError(are.message))
} yield ()
}

override def delete(entityId: _root_.java.util.UUID, apiKey: String): IO[AuthenticationError, Unit] = {
override def delete(entityId: UUID, apiKey: String): IO[AuthenticationError, Unit] = {
for {
saltAndApiKey <- ZIO.succeed(apiKeyConfig.salt + apiKey)
secret <- ZIO
.fromTry(Try(Sha256.compute(saltAndApiKey.getBytes).getHexValue))
.logError("Failed to compute SHA256 hash")
.mapError(cause => AuthenticationError.UnexpectedError(cause.getMessage))
_ <- repository
.deleteByEntityIdAndSecret(entityId, secret)
.delete(entityId, AuthenticationMethodType.ApiKey, secret)
.mapError(are => AuthenticationError.UnexpectedError(are.message))
} yield ()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,52 +7,79 @@ import zio.IO
import zio.*
import zio.interop.catz.*

import java.time.OffsetDateTime
import java.util.UUID

enum AuthenticationMethodType(val value: String) {
case ApiKey extends AuthenticationMethodType("apikey")
}

object AuthenticationMethodType {
def fromString(value: String) = {
AuthenticationMethodType.values.find(_.value == value).get
}
}

case class AuthenticationMethod(
id: UUID,
`type`: AuthenticationMethodType,
entityId: UUID,
secret: String
)
secret: String,
createdAt: OffsetDateTime = OffsetDateTime.now(),
deletedAt: Option[OffsetDateTime] = None
) {
def isDeleted = deletedAt.isDefined
}

trait AuthenticationRepository {
def insert(
entityId: UUID,
authenticationMethod: AuthenticationMethodType,
amt: AuthenticationMethodType,
secret: String
): zio.IO[AuthenticationRepositoryError, UUID]
): zio.IO[AuthenticationRepositoryError, Unit]

def getEntityIdByMethodAndSecret(
method: AuthenticationMethodType,
amt: AuthenticationMethodType,
secret: String
): zio.IO[AuthenticationRepositoryError, UUID]

def deleteById(id: UUID): zio.IO[AuthenticationRepositoryError, Unit]
def findAuthenticationMethodByTypeAndSecret(
amt: AuthenticationMethodType,
secret: String
): zio.IO[AuthenticationRepositoryError, Option[AuthenticationMethod]]

def deleteByMethodAndEntityId(
method: AuthenticationMethodType,
entityId: UUID
entityId: UUID,
amt: AuthenticationMethodType
): zio.IO[AuthenticationRepositoryError, Unit]

def deleteByEntityIdAndSecret(id: UUID, secret: String): zio.IO[AuthenticationRepositoryError, Unit]
def delete(
entityId: UUID,
amt: AuthenticationMethodType,
secret: String
): zio.IO[AuthenticationRepositoryError, Unit]
}

type AuthenticationMethodConfiguration = zio.json.ast.Json

//TODO: reconsider the hierarchy of the service and dal layers
sealed trait AuthenticationRepositoryError {
def message: String
}

object AuthenticationRepositoryError {

def hide(secret: String) = secret.take(8) + "****"
case class AuthenticationNotFound(authenticationMethodType: AuthenticationMethodType, secret: String)
extends AuthenticationRepositoryError {
def message =
s"Authentication method not found for type ${authenticationMethodType.value} and secret $secret"
s"Authentication method not found for type:${authenticationMethodType.value} and secret:${hide(secret)}"
}

case class AuthenticationCompromised(
entityId: UUID,
authenticationMethodType: AuthenticationMethodType,
secret: String
) extends AuthenticationRepositoryError {
def message =
s"Authentication method is compromised for entityId:$entityId, type:${authenticationMethodType.value}, and secret:${hide(secret)}"
}

case class ServiceError(message: String) extends AuthenticationRepositoryError
Expand All @@ -71,52 +98,61 @@ object AuthenticationRepositorySql extends DoobieContext.Postgres(SnakeCase) wit
MappedEncoding[AuthenticationMethodType, String](_.value)

implicit val string2AuthenticationMethodType: MappedEncoding[String, AuthenticationMethodType] =
MappedEncoding[String, AuthenticationMethodType](str => AuthenticationMethodType.valueOf(str))
MappedEncoding[String, AuthenticationMethodType](AuthenticationMethodType.fromString)

def insert(authenticationMethod: AuthenticationMethod) = {
run {
quote {
query[AuthenticationMethod].insertValue(lift(authenticationMethod)).returning(_.id)
query[AuthenticationMethod].insertValue(lift(authenticationMethod))
}
}
}

def getEntityIdByMethodAndSecret(method: AuthenticationMethodType, secret: String) = {
def getEntityIdByMethodAndSecret(amt: AuthenticationMethodType, secret: String) = {
run {
quote {
query[AuthenticationMethod]
.filter(am => am.secret == lift(secret) && am.`type` == lift(method))
.filter(am => am.secret == lift(secret) && am.`type` == lift(amt) && am.deletedAt.isEmpty)
.map(_.entityId)
.take(1)
}
}
}

def deleteById(id: UUID) = {
def filterByTypeAndSecret(amt: AuthenticationMethodType, secret: String) = {
run {
quote {
query[AuthenticationMethod].filter(_.id == lift(id)).delete
query[AuthenticationMethod]
.filter(am => am.secret == lift(secret) && am.`type` == lift(amt))
}
}
}

def deleteByMethodAndEntityId(method: AuthenticationMethodType, entityId: UUID) = {
def softDeleteByEntityIdAndType(
entityId: UUID,
amt: AuthenticationMethodType,
deletedAt: Option[OffsetDateTime]
) = {
run {
quote {
query[AuthenticationMethod].filter(am => am.`type` == lift(method) && am.entityId == lift(entityId)).delete
query[AuthenticationMethod]
.filter(am => am.`type` == lift(amt) && am.entityId == lift(entityId))
.update(_.deletedAt -> lift(deletedAt))
}
}
}

def deleteByEntityIdAndSecret(entityId: UUID, secret: String) = {
def softDeleteBy(
entityId: UUID,
amt: AuthenticationMethodType,
secret: String,
deletedAt: Option[OffsetDateTime]
) = {
run {
quote {
query[AuthenticationMethod]
.filter(am =>
am.entityId == lift(entityId) &&
am.secret == lift(secret)
)
.delete
.filter(am => am.entityId == lift(entityId) && am.`type` == lift(amt) && am.secret == lift(secret))
.update(_.deletedAt -> lift(deletedAt))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,134 @@ package io.iohk.atala.iam.authentication.apikey

import doobie.*
import doobie.implicits.*
import org.postgresql.util.PSQLException
import zio.*
import zio.interop.catz.*

import java.time.OffsetDateTime
import java.util.UUID

case class JdbcAuthenticationRepository(xa: Transactor[Task]) extends AuthenticationRepository {

import AuthenticationRepositorySql.*
import AuthenticationRepositoryError.*
override def insert(
entityId: UUID,
authenticationMethodType: AuthenticationMethodType,
amt: AuthenticationMethodType,
secret: String
): IO[AuthenticationRepositoryError, UUID] = {
val authenticationMethod = AuthenticationMethod(UUID.randomUUID(), authenticationMethodType, entityId, secret)
): IO[AuthenticationRepositoryError, Unit] = {
val authenticationMethod = AuthenticationMethod(amt, entityId, secret)
AuthenticationRepositorySql
.insert(authenticationMethod)
.transact(xa)
.map(_ => ())
.logError(
s"insert failed for entityId: $entityId, authenticationMethod: $authenticationMethod, and secret: $secret"
s"insert failed for entityId: $entityId, authenticationMethodType: $amt, and secret: $secret"
)
.mapError(AuthenticationRepositoryError.StorageError.apply)
.mapError {
case sqlException: PSQLException
if sqlException.getMessage
.contains("ERROR: duplicate key value violates unique constraint \"unique_type_secret_constraint\"") =>
AuthenticationCompromised(entityId, amt, secret)
case otherSqlException: PSQLException =>
StorageError(otherSqlException)
case unexpected: Throwable =>
UnexpectedError(unexpected)
}
.catchSome { case AuthenticationCompromised(eId, amt, s) =>
ensureThatTheApiKeyIsNotCompromised(eId, amt, s)
}
}

private def ensureThatTheApiKeyIsNotCompromised(
entityId: UUID,
authenticationMethodType: AuthenticationMethodType,
secret: String
): IO[AuthenticationRepositoryError, Unit] = {
val ac = AuthenticationCompromised(entityId, authenticationMethodType, secret)
val acZIO: IO[AuthenticationRepositoryError, Unit] = ZIO.fail(ac)

for {
authRecordOpt <- findAuthenticationMethodByTypeAndSecret(authenticationMethodType, secret)
authRecord <- ZIO.fromOption(authRecordOpt).mapError(_ => ac)
compromisedEntityId = authRecord.entityId
isTheSameEntityId = authRecord.entityId == entityId
isNotDeleted = authRecord.deletedAt.isEmpty
result <-
if (isTheSameEntityId && isNotDeleted)
ZIO.unit
else if (isNotDeleted)
delete(compromisedEntityId, authenticationMethodType, secret) *> acZIO
else
acZIO
} yield result
}

override def getEntityIdByMethodAndSecret(
method: AuthenticationMethodType,
amt: AuthenticationMethodType,
secret: String
): IO[AuthenticationRepositoryError, UUID] = {
AuthenticationRepositorySql
.getEntityIdByMethodAndSecret(method, secret)
.getEntityIdByMethodAndSecret(amt, secret)
.transact(xa)
.logError(s"getEntityIdByMethodAndSecret failed for method: $method and secret: $secret")
.logError(s"getEntityIdByMethodAndSecret failed for method: $amt and secret: $secret")
.mapError(AuthenticationRepositoryError.StorageError.apply)
.flatMap(
_.headOption.fold(ZIO.fail(AuthenticationRepositoryError.AuthenticationNotFound(method, secret)))(entityId =>
_.headOption.fold(ZIO.fail(AuthenticationRepositoryError.AuthenticationNotFound(amt, secret)))(entityId =>
ZIO.succeed(entityId)
)
)
}

override def deleteById(id: UUID): IO[AuthenticationRepositoryError, Unit] = {
override def findAuthenticationMethodByTypeAndSecret(
amt: AuthenticationMethodType,
secret: String
): IO[AuthenticationRepositoryError, Option[AuthenticationMethod]] = {
AuthenticationRepositorySql
.deleteById(id)
.filterByTypeAndSecret(amt, secret)
.transact(xa)
.logError(s"deleteById failed for id: $id")
.logError(s"findAuthenticationMethodBySecret failed for secret:$secret")
.map(_.headOption)
.mapError(AuthenticationRepositoryError.StorageError.apply)
.map(_ => ())
}

override def deleteByMethodAndEntityId(
method: AuthenticationMethodType,
entityId: UUID
entityId: UUID,
amt: AuthenticationMethodType
): IO[AuthenticationRepositoryError, Unit] = {
AuthenticationRepositorySql
.deleteByMethodAndEntityId(method, entityId)
.softDeleteByEntityIdAndType(entityId, amt, Some(OffsetDateTime.now()))
.transact(xa)
.logError(s"deleteByMethodAndEntityId failed for method: $method and entityId: $entityId")
.logError(s"deleteByMethodAndEntityId failed for method: $amt and entityId: $entityId")
.mapError(AuthenticationRepositoryError.StorageError.apply)
.map(_ => ())
}

override def deleteByEntityIdAndSecret(id: UUID, secret: String): IO[AuthenticationRepositoryError, Unit] = {
override def delete(
entityId: UUID,
amt: AuthenticationMethodType,
secret: String
): IO[AuthenticationRepositoryError, Unit] = {
AuthenticationRepositorySql
.deleteByEntityIdAndSecret(id, secret)
.softDeleteBy(entityId, amt, secret, Some(OffsetDateTime.now()))
.transact(xa)
.logError(s"deleteByEntityIdAndSecret failed for id: $id and secret: $secret")
.logError(s"deleteByEntityIdAndSecret failed for id: $entityId and secret: $secret")
.mapError(AuthenticationRepositoryError.StorageError.apply)
.map(_ => ())
}

def checkDeleted(method: AuthenticationMethodType, secret: String) = {
AuthenticationRepositorySql
.getEntityIdByMethodAndSecret(method, secret)
.transact(xa)
.logError(s"getEntityIdByMethodAndSecret failed for method: $method and secret: $secret")
.mapError(AuthenticationRepositoryError.StorageError.apply)
.flatMap(
_.headOption.fold(ZIO.fail(AuthenticationRepositoryError.AuthenticationNotFound(method, secret)))(entityId =>
ZIO.succeed(entityId)
)
)
}
}

object JdbcAuthenticationRepository {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package io.iohk.atala.iam.entity.http.model

import io.iohk.atala.api.http.Annotation
import io.iohk.atala.iam.entity.http.model.ApiKeyAuthenticationRequest.annotations
import sttp.tapir.Schema
import sttp.tapir.{Schema, Validator}
import sttp.tapir.Schema.annotations.{description, encodedExample, validate, validateEach}
import sttp.tapir.Validator.*
import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder}
Expand All @@ -15,7 +15,7 @@ case class ApiKeyAuthenticationRequest(
entityId: UUID,
@description(annotations.apikey.description)
@encodedExample(annotations.apikey.example)
@validate(nonEmptyString)
@validate(all(minLength(16), maxLength(128)))
apiKey: String
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import io.iohk.atala.iam.authentication.AuthenticationError
import io.iohk.atala.iam.authentication.AuthenticationError.InvalidCredentials
import io.iohk.atala.shared.models.WalletId
import io.iohk.atala.shared.test.containers.PostgresTestContainerSupport
import zio.Runtime.removeDefaultLoggers
import zio.test.Assertion.*
import zio.test.TestAspect.sequential
import zio.test.{Spec, TestEnvironment, ZIOSpecDefault, assert, *}
Expand Down Expand Up @@ -67,7 +68,9 @@ object ApiKeyAuthenticatorSpec extends ZIOSpecDefault, PostgresTestContainerSupp
paths = "classpath:sql/agent"
)

testSuite.provideSomeLayerShared(testEnvironmentLayer)
testSuite
.provideSomeLayerShared(testEnvironmentLayer)
.provide(removeDefaultLoggers)
}

val failWhenTheHeaderIsAnEmptyStringTest = test("should fail when the header is empty string")(
Expand Down
Loading

0 comments on commit 4b10c3a

Please sign in to comment.