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 3d3c090
Show file tree
Hide file tree
Showing 18 changed files with 252 additions and 151 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,103 @@ 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:
*/
case class ApiTokenSecret(private val secret: String) extends AnyVal {
// Avoid printing the value in logs, regardless of token type
override def toString: String = "[REDACTED ApiTokenSecret]"

// 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.secret)
}
}

object ApiTokenSecret {
private val tokenSize = 32

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

/*
* 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.
* The secret are only displayed once at creation.
*
* Both can have a `-system` suffix to mark the system token.
*
* 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 implement generic versions as V2 is 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]"
// Implements the "V2" hashes
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
}
}

object ApiToken {
private val tokenSize = 32
private val prefixV2 = "v2:"
def version(): Int = {
if (value.startsWith(ApiTokenHash.prefix)) {
2
} else {
1
}
}

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)
// Remove the actual hash values but keep prefix
// These will never match.
def filterValues(): ApiTokenHash = {
if (value.startsWith(ApiTokenHash.prefix)) {
ApiTokenHash.neverMatch
} else {
ApiTokenHash("v1:na")
}
}
}

def generate_secret(tokenGenerator: TokenGenerator, suffix: String = ""): String = {
tokenGenerator.newToken(tokenSize) + suffix
object ApiTokenHash {
val prefix = "v2:"
// Guaranteed to never match
val neverMatch = ApiTokenHash("v2:na")

def hash(secret: String): ApiTokenHash = {
val digest = MessageDigest.getInstance("SHA-512")
val hash = digest.digest(secret.getBytes(StandardCharsets.UTF_8))
ApiTokenHash(prefix + new String(Hex.encode(hash), StandardCharsets.UTF_8))
}
}

Expand Down Expand Up @@ -354,10 +403,44 @@ 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 filterToken(): ApiAccount = {
this.copy(token = token.filterValues())
}
}

/**
* 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 @@ -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 @@ -620,7 +621,42 @@ object ApiAccountSerialisation {

("id" -> account.id.value) ~
("name" -> account.name.value) ~
("token" -> account.token.value) ~
("tokenHashVersion" -> account.token.version()) ~
("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)
}
}
}

object NewApiAccountSerialisation {

implicit val formats: Formats = DefaultFormats

implicit class Json(val account: NewApiAccount) 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)
}
}

("id" -> account.id.value) ~
("name" -> account.name.value) ~
("token" -> account.token.exposeSecret()) ~
("tokenGenerationDate" -> DateFormaterService.serialize(account.tokenGenerationDate)) ~
("kind" -> account.kind.kind.name) ~
("description" -> account.description) ~
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -875,7 +875,7 @@ class ApiAccountUnserialisationImpl extends ApiAccountUnserialisation {
ApiAccountId(id),
kind,
ApiAccountName(name),
ApiToken(token),
ApiTokenHash(token),
description,
isEnabled,
creationDate,
Expand Down
Loading

0 comments on commit 3d3c090

Please sign in to comment.