Skip to content

Commit

Permalink
Fixes #25903: Refactor API tokens after clear-text removal
Browse files Browse the repository at this point in the history
  • Loading branch information
amousset committed Nov 20, 2024
1 parent c8fbcc3 commit e729750
Show file tree
Hide file tree
Showing 22 changed files with 331 additions and 189 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ import com.normation.rudder.repository.ldap.LDAPDiffMapper
import com.normation.rudder.repository.ldap.LDAPEntityMapper
import com.normation.rudder.services.user.PersonIdentService
import com.normation.zio.*
import java.security.MessageDigest
import org.joda.time.DateTime
import zio.*
import zio.syntax.*
Expand All @@ -71,7 +70,7 @@ trait RoApiAccountRepository {
*/
def getAllStandardAccounts: IOResult[Seq[ApiAccount]]

def getByToken(token: ApiToken): IOResult[Option[ApiAccount]]
def getByToken(hashedToken: ApiTokenHash): IOResult[Option[ApiAccount]]

def getById(id: ApiAccountId): IOResult[Option[ApiAccount]]

Expand All @@ -98,16 +97,16 @@ final class RoLDAPApiAccountRepository(
val rudderDit: RudderDit,
val ldapConnexion: LDAPConnectionProvider[RoLDAPConnection],
val mapper: LDAPEntityMapper,
val tokenGen: TokenGenerator,
val systemAcl: List[ApiAclElement]
val systemAcl: List[ApiAclElement],
val systemToken: ApiTokenHash
) extends RoApiAccountRepository {

val systemAPIAccount: ApiAccount = {
ApiAccount(
ApiAccountId("rudder-system-api-account"),
ApiAccountKind.System,
ApiAccountName("Rudder system account"),
ApiToken(ApiToken.generate_secret(tokenGen, "-system")),
systemToken,
"For internal use",
isEnabled = true,
creationDate = DateTime.now,
Expand Down Expand Up @@ -151,25 +150,18 @@ final class RoLDAPApiAccountRepository(
// Warning: When matching clear-text value we MUST make sure it is not
// a hash but a clear text token to avoid accepting the hash as valid token itself.
//
override def getByToken(token: ApiToken): IOResult[Option[ApiAccount]] = {
if (token.isHashed) {
None.succeed
} else if (MessageDigest.isEqual(token.value.getBytes(), systemAPIAccount.token.value.getBytes())) {
// Constant-time comparison
Some(systemAPIAccount).succeed
} else {
val hash = ApiToken.hash(token.value)
for {
ldap <- ldapConnexion
// here, be careful to the semantic of get with a filter!
optEntry <- ldap.get(rudderDit.API_ACCOUNTS.dn, BuildFilter.EQ(RudderLDAPConstants.A_API_TOKEN, hash))
optRes <- optEntry match {
case None => None.succeed
case Some(e) => mapper.entry2ApiAccount(e).map(Some(_)).toIO
}
} yield {
optRes
}
override def getByToken(hashedToken: ApiTokenHash): IOResult[Option[ApiAccount]] = {
for {
ldap <- ldapConnexion
// here, be careful to the semantic of get with a filter!
optEntry <- ldap.get(rudderDit.API_ACCOUNTS.dn, BuildFilter.EQ(RudderLDAPConstants.A_API_TOKEN, hashedToken.exposeHash()))
optRes <- optEntry match {
case None => None.succeed
case Some(e) => mapper.entry2ApiAccount(e).map(Some(_)).toIO
}
} yield {
optRes

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ package com.normation.rudder.api

import cats.data.*
import cats.implicits.*
import com.normation.rudder.api.ApiToken.prefixV2
import com.normation.rudder.facts.nodes.NodeSecurityContext
import enumeratum.*
import java.nio.charset.StandardCharsets
Expand All @@ -58,53 +57,102 @@ final case class ApiAccountId(value: String) extends AnyVal
final case class ApiAccountName(value: String) extends AnyVal

/**
* The actual authentication token.
* The actual authentication token, in clear text.
*
* TODO: Once support for plain text tokens is dropped, make separate types for plain and hashed tokens.
Current situation is confusing, and hence a bit risky.
* All tokens are 32 alphanumeric characters, optionally
* followed by a "-system" suffix, indicating a system token.
*
* There are two versions of tokens:
*/
final case class ApiTokenSecret(private val secret: String) {
// Avoid printing the value in logs, regardless of token type
override def toString: String = "[REDACTED ApiTokenSecret]"

// Prevent comparison
override def equals(obj: Any) = false

// For cases when we need to print a part of the plain token for debugging.
// Show the first 4 chars: enough to disambiguate, and preserves 166 bits of randomness.
def exposeSecretBeginning: String = {
secret.take(4) + "[SHORTENED ApiTokenSecret]"
}

def exposeSecret(): String = {
secret
}

def hash(): ApiTokenHash = {
ApiTokenHash.hash(this)
}
}

object ApiTokenSecret {
private val tokenSize = 32

def generate(tokenGenerator: TokenGenerator, suffix: String = ""): ApiTokenSecret = {
val completeSuffix = if (suffix.isEmpty) "" else "-" + suffix
ApiTokenSecret(tokenGenerator.newToken(tokenSize) + completeSuffix)
}
}

/*
* There are two versions of token hashes:
*
* * v1: 32 alphanumeric characters stored as clear text
* they are also displayed in clear text in the interface.
* * v1: 32 alphanumeric characters stored as clear text.
* They were also displayed in clear text in the interface.
* They are not supported anymore since 8.3, we just ignore them.
* * v2: starting from Rudder 8.1, tokens are still 32 alphanumeric characters,
* but are now stored hashed in sha512 (128 characters), prefixed with "v2:".
* The tokens are only displayed once at creation.
*
* Both can have a `-system` suffix to mark the system token.
* The secret are only displayed once at creation.
*
* To make the difference, we use a prefix to the hash value in v2
* Hashes are stored with a prefix indicating the hash algorithm:
*
* * If it starts with "v2:", it is a v2 SHA512 hash of the token
* * If it does not start with "v2:", it is a clear-text v1 token
* Note: v2 tokens can never start with "v" as they are encoded as en hexadecimal string
* Note: stored v1 tokens can never start with "v" as they are encoded as en hexadecimal string.
*
* We don't have generic code as V2 is (very) likely the last simple API key mechanism we'll need.
*
*/
case class ApiToken(value: String) extends AnyVal {
// Avoid printing the value in logs, regardless of token type
override def toString: String = "[REDACTED ApiToken]"

// For cases we need to print a part of the plain token for debug.
// Show the first 4 chars: enough to disambiguate, and preserves 166 bits of randomness.
def exposeSecretBeginning: String = {
value.take(4) + "[SHORTENED ApiToken]"
final case class ApiTokenHash(private val value: String) {
override def toString: String = "[REDACTED ApiTokenHash]"

// Constant time comparison
override def equals(obj: Any): Boolean = {
obj match {
case ApiTokenHash(other) => MessageDigest.isEqual(value.getBytes(), other.getBytes())
case _ => false
}
}

def isHashed: Boolean = {
value.startsWith(prefixV2)
def exposeHash(): String = {
value
}

def version(): Int = {
if (value.startsWith(ApiTokenHash.prefix)) {
2
} else {
1
}
}
}

object ApiToken {
private val tokenSize = 32
private val prefixV2 = "v2:"
object ApiTokenHash {
val prefix = "v2"
// Guaranteed to never match
val neverMatch = ApiTokenHash.build("not-matching")

def hash(clearText: String): String = {
val digest = MessageDigest.getInstance("SHA-512")
prefixV2 + new String(Hex.encode(digest.digest(clearText.getBytes(StandardCharsets.UTF_8))), StandardCharsets.UTF_8)
// Build from hashed value
private def build(value: String): ApiTokenHash = {
ApiTokenHash(prefix + ":" + value)
}

def generate_secret(tokenGenerator: TokenGenerator, suffix: String = ""): String = {
tokenGenerator.newToken(tokenSize) + suffix
def hash(token: ApiTokenSecret): ApiTokenHash = {
val sha512Digest = MessageDigest.getInstance("SHA-512")
val hash = sha512Digest.digest(token.exposeSecret().getBytes(StandardCharsets.UTF_8))
val hexHash = Hex.encode(hash)
ApiTokenHash.build(new String(hexHash, StandardCharsets.UTF_8))
}
}

Expand Down Expand Up @@ -354,10 +402,54 @@ final case class ApiAccount(

name: ApiAccountName, // used in event log to know who did actions.

token: ApiToken,
token: ApiTokenHash,
description: String,
isEnabled: Boolean,
creationDate: DateTime,
tokenGenerationDate: DateTime,
tenants: NodeSecurityContext
)
) {
def toNewApiAccount(secret: ApiTokenSecret): NewApiAccount = {
NewApiAccount(
id,
kind,
name,
secret,
description,
isEnabled,
creationDate,
tokenGenerationDate,
tenants
)
}
}

/**
* An API principal, containing the secret, to be used just after creation, and never stored.
*/
final case class NewApiAccount(
id: ApiAccountId,
kind: ApiAccountKind,
name: ApiAccountName,
// Clear text token, only used for just-created accounts, never stored
token: ApiTokenSecret,
description: String,
isEnabled: Boolean,
creationDate: DateTime,
tokenGenerationDate: DateTime,
tenants: NodeSecurityContext
) {
def toApiAccount(): ApiAccount = {
ApiAccount(
id,
kind,
name,
token.hash(),
description,
isEnabled,
creationDate,
tokenGenerationDate,
tenants
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import java.security.SecureRandom
import scala.util.Random

/**
* Generate random string usable as token
* Generate a random string usable as secret (token, password, etc.)
*/
trait TokenGenerator {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import com.normation.rudder.api.ApiAuthorization.None as NoAccess
import com.normation.rudder.api.ApiAuthorization.RO
import com.normation.rudder.api.ApiAuthorization.RW
import com.normation.rudder.api.ApiVersion
import com.normation.rudder.api.NewApiAccount
import com.normation.rudder.domain.nodes.*
import com.normation.rudder.domain.policies.*
import com.normation.rudder.domain.properties.*
Expand Down Expand Up @@ -604,33 +605,49 @@ object ApiAccountSerialisation {

implicit val formats: Formats = DefaultFormats

def toJsonPartial(account: ApiAccount): JObject = {
val (expirationDate, authzType, acl): (Option[String], Option[String], Option[List[JsonApiAcl]]) = {
account.kind match {
case User | System => (None, None, None)
case PublicApiAccount(authz, expirationDate) =>
val acl = authz match {
case NoAccess | RO | RW => None
case ACL(acls) => Some(acls.flatMap(x => x.actions.map(a => JsonApiAcl(x.path.value, a.name))))
}
(expirationDate.map(DateFormaterService.getDisplayDateTimePicker), Some(authz.kind.name), acl)
}
}

("id" -> account.id.value) ~
("name" -> account.name.value) ~
("tokenGenerationDate" -> DateFormaterService.serialize(account.tokenGenerationDate)) ~
("kind" -> account.kind.kind.name) ~
("description" -> account.description) ~
("creationDate" -> DateFormaterService.serialize(account.creationDate)) ~
("enabled" -> account.isEnabled) ~
("expirationDate" -> expirationDate) ~
("expirationDateDefined" -> expirationDate.isDefined) ~
("authorizationType" -> authzType) ~
("acl" -> acl.map(x => Extraction.decompose(x))) ~
("tenants" -> account.tenants.serialize)
}

implicit class Json(val account: ApiAccount) extends AnyVal {
def toJson: JObject = {
val (expirationDate, authzType, acl): (Option[String], Option[String], Option[List[JsonApiAcl]]) = {
account.kind match {
case User | System => (None, None, None)
case PublicApiAccount(authz, expirationDate) =>
val acl = authz match {
case NoAccess | RO | RW => None
case ACL(acls) => Some(acls.flatMap(x => x.actions.map(a => JsonApiAcl(x.path.value, a.name))))
}
(expirationDate.map(DateFormaterService.getDisplayDateTimePicker), Some(authz.kind.name), acl)
}
}
toJsonPartial(account) ~
("token" -> account.token.version().toString)
}
}
}

object NewApiAccountSerialisation {

("id" -> account.id.value) ~
("name" -> account.name.value) ~
("token" -> account.token.value) ~
("tokenGenerationDate" -> DateFormaterService.serialize(account.tokenGenerationDate)) ~
("kind" -> account.kind.kind.name) ~
("description" -> account.description) ~
("creationDate" -> DateFormaterService.serialize(account.creationDate)) ~
("enabled" -> account.isEnabled) ~
("expirationDate" -> expirationDate) ~
("expirationDateDefined" -> expirationDate.isDefined) ~
("authorizationType" -> authzType) ~
("acl" -> acl.map(x => Extraction.decompose(x))) ~
("tenants" -> account.tenants.serialize)
implicit val formats: Formats = DefaultFormats

implicit class NewJson(val account: NewApiAccount) extends AnyVal {
def toJson: JObject = {
toJsonPartial(account) ~
("token" -> account.token.exposeSecret())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ class LDAPDiffMapper(
}
case A_API_TOKEN =>
nonNull(diff, mod.getOptValueDefault("")) { (d, value) =>
d.copy(modToken = Some(SimpleDiff(oldAccount.token.value, value)))
d.copy(modToken = Some(SimpleDiff(oldAccount.token.exposeHash(), value)))
}
case A_DESCRIPTION =>
nonNull(diff, mod.getOptValueDefault("")) { (d, value) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1046,7 +1046,7 @@ class LDAPEntityMapper(
for {
id <- e.required(A_API_UUID).map(ApiAccountId(_))
name <- e.required(A_NAME).map(ApiAccountName(_))
token <- e.required(A_API_TOKEN).map(ApiToken(_))
token <- e.required(A_API_TOKEN).map(ApiTokenHash(_))
creationDatetime <- e.requiredAs[GeneralizedTime](_.getAsGTime, A_CREATION_DATETIME)
tokenCreationDatetime <- e.requiredAs[GeneralizedTime](_.getAsGTime, A_API_TOKEN_CREATION_DATETIME)
isEnabled = e.getAsBoolean(A_IS_ENABLED).getOrElse(false)
Expand Down Expand Up @@ -1142,7 +1142,7 @@ class LDAPEntityMapper(
mod.resetValuesTo(A_API_UUID, principal.id.value)
mod.resetValuesTo(A_NAME, principal.name.value)
mod.resetValuesTo(A_CREATION_DATETIME, GeneralizedTime(principal.creationDate).toString)
mod.resetValuesTo(A_API_TOKEN, principal.token.value)
mod.resetValuesTo(A_API_TOKEN, principal.token.exposeHash())
mod.resetValuesTo(A_API_TOKEN_CREATION_DATETIME, GeneralizedTime(principal.tokenGenerationDate).toString)
mod.resetValuesTo(A_DESCRIPTION, principal.description)
mod.resetValuesTo(A_IS_ENABLED, principal.isEnabled.toLDAPString)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ class APIAccountSerialisationImpl(xmlVersion: String) extends APIAccountSerialis
(
<id>{account.id.value}</id>
<name>{account.name.value}</name>
<token>{account.token.value}</token>
<token>{account.token.exposeHash()}</token>
<description>{account.description}</description>
<isEnabled>{account.isEnabled}</isEnabled>
<creationDate>{account.creationDate.toString(ISODateTimeFormat.dateTime)}</creationDate>
Expand Down
Loading

0 comments on commit e729750

Please sign in to comment.