Skip to content

Commit

Permalink
Fixes #23234: Hash API tokens (#4971)
Browse files Browse the repository at this point in the history
  • Loading branch information
amousset committed Aug 16, 2023
1 parent 47e67d8 commit 4c1ee7f
Show file tree
Hide file tree
Showing 21 changed files with 325 additions and 112 deletions.
16 changes: 8 additions & 8 deletions api-doc/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions api-doc/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"dependencies": {
"@redocly/cli": "^1.0.0"
"@redocly/cli": "^1.0.2"
}
}
}
Binary file removed webapp/sources/api-doc/assets/APISettings.png
Binary file not shown.
Binary file added webapp/sources/api-doc/assets/api-tokens.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added webapp/sources/api-doc/assets/api-user.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added webapp/sources/api-doc/assets/custom-acl.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 9 additions & 20 deletions webapp/sources/api-doc/components/securitySchemes/token.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,15 @@
# SPDX-License-Identifier: CC-BY-SA-2.0
# SPDX-FileCopyrightText: 2013-2020 Normation SAS
"API-Tokens":
description: >-
Apart for the status API, authenticating is mandatory for every request, as sensitive information like inventories or configuration rules may get exposed.
It is done using a dedicated API account, than can be created in the web interface on the 'API accounts' page located inside the Administration part.
![API Tokens settings](assets/APISettings.png "API tokens settings")
API accounts are not linked to standard user accounts, and currently give full administrative privileges: they must be secured adequately.
Once you have created an API account, you get a token that will be needed to authenticate every request. This token is the API equivalent of a password, and must
be secured just like a password would be.
On any call to the API, you will need to add a **X-API-Token** header to your request to authenticate:
curl --request GET --header "X-API-Token: yourToken" https://rudder.example.com/rudder/api/latest/rules
If you perform any action (creation, update, deletion) using the API, the event log generated will record the API account as the user.
description: >
This request must be authenticated with a valid API token passed in a `X-API-Token` header,
like in:
```bash
curl --header "X-API-Token: yourToken" https://rudder.example.com/rudder/api/latest/rules
```
See the [authentication section](#section/Introduction/Authentication) for details.
type: apiKey
in: header
name: X-API-Token
49 changes: 46 additions & 3 deletions webapp/sources/api-doc/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,52 @@ Download OpenAPI specification: [openapi.yml](openapi.yml)

Rudder exposes a REST API, enabling the user to interact with Rudder without using the webapp, for example, in scripts or cron jobs.

## Authentication

The Rudder REST API uses simple API keys for authentication.
All requests must be authenticated (except from a generic status API).
The tokens are 32-character strings, passed in a `X-API-Token` header, like in:

```bash
curl --header "X-API-Token: yourToken" https://rudder.example.com/rudder/api/latest/rules
```

The tokens are the API equivalent of a password, and must
be secured just like a password would be.

### API accounts

The accounts are managed in the Web interface. There are two possible types of accounts:

* **Global API accounts**: they are not linked to a Rudder user, and are managed by Rudder administrators in the _Administration -> API accounts_ page. You should define an expiration date whenever possible.

![General API tokens settings](assets/api-tokens.png "General API tokens settings")

* **User tokens**: they are linked to a Rudder user, and give the same rights the user has.
There can be only one token by user. This feature is provided by the `api-authorizatons` plugin.

![User API token](assets/api-user.png "User API token")

When an action produces a change of configuration on the server, the API account that made it will
be recorded in the event log, like for a Web interaction.

### Authorization

When using Rudder without the `api-authorizatons` plugin, only global accounts are available, with
two possible privilege levels, read-only or write.
With the `api-authorizatons` plugin, you also get access to:

* User tokens, which have the same permissions as the user, using the Rudder roles and permissions feature.
* Custom ACLs on global API accounts. They provide fine-grained permissions on every endpoint:

![Custom API ACL](assets/custom-acl.png "Custom API ACL")

As a general principle,
you should create dedicated tokens with the least privilege level for each different interaction you have with the
API.
This limits the risks of exploitation if a token is stolen, and allows tracking the activity
of each token separately. Token renewal is also easier when they are only used for a limited purpose.

## Versioning

Each time the API is extended with new features (new functions, new parameters, new responses, ...), it will be assigned a new version number. This will allow you
Expand Down Expand Up @@ -248,8 +294,6 @@ Parameters in URLs are used to indicate which resource you want to interact with
curl -H "X-API-Token: yourToken" https://rudder.example.com/rudder/api/latest/rules/id
```



CAUTION: To avoid surprising behavior, do not put a '/' at the end of a URL: it would be interpreted as '/[empty string parameter]' and redirected to '/index', likely not what you wanted to do.


Expand Down Expand Up @@ -277,7 +321,6 @@ The (human-readable) format is:
}
```


Here is an example with inlined data:

```bash
Expand Down
2 changes: 1 addition & 1 deletion webapp/sources/api-doc/openapi.src.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ components:
securitySchemes:
$ref: components/securitySchemes/token.yml
security:
# Apply the same auth everywhere
# Apply the same auth everywhere by default, can be overridden
- "API-Tokens": []
tags:
- name: API Info
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import com.normation.ldap.sdk.LDAPConnectionProvider
import com.normation.ldap.sdk.LDAPRudderError
import com.normation.ldap.sdk.RoLDAPConnection
import com.normation.ldap.sdk.RwLDAPConnection
import com.normation.rudder.api.TokenGenerator
import com.normation.rudder.domain.RudderDit
import com.normation.rudder.domain.RudderLDAPConstants
import com.normation.rudder.domain.RudderLDAPConstants.A_API_UUID
Expand Down Expand Up @@ -101,14 +100,12 @@ final class RoLDAPApiAccountRepository(
val systemAcl: List[ApiAclElement]
) extends RoApiAccountRepository {

val tokenSize = 32

val systemAPIAccount = {
ApiAccount(
ApiAccountId("rudder-system-api-account"),
ApiAccountKind.System,
ApiAccountName("Rudder system account"),
ApiToken(tokenGen.newToken(tokenSize) + "-system"),
ApiToken(ApiToken.generate_secret(tokenGen, "-system")),
"For internal use",
true,
DateTime.now,
Expand Down Expand Up @@ -140,16 +137,47 @@ final class RoLDAPApiAccountRepository(
}
}

// Here the process is:
//
// * Ensure it is a clear-text token
// * Check if token matches in-memory system account
// * Then look for it in the LDAP:
// * First as a hash
// * Then, in fallback, as clear-text token
//
// 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 == systemAPIAccount.token) {
if (token.isHashed) {
None.succeed
} else if (token == systemAPIAccount.token) {
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, token.value))
optEntry <- ldap.get(rudderDit.API_ACCOUNTS.dn, BuildFilter.EQ(RudderLDAPConstants.A_API_TOKEN, hash))
optRes <- optEntry match {
case None => None.succeed
case None => {
// Fallback on v1 clear text tokens
for {
optEntry <-
// here, be careful to the semantic of get with a filter!
ldap.get(rudderDit.API_ACCOUNTS.dn, BuildFilter.EQ(RudderLDAPConstants.A_API_TOKEN, token.value))
optRes <- optEntry match {
case None => None.succeed
case Some(e) =>
mapper
.entry2ApiAccount(e)
.map(Some(_))
.toIO
}
} yield {
optRes
}
}
case Some(e) => mapper.entry2ApiAccount(e).map(Some(_)).toIO
}
} yield {
Expand Down Expand Up @@ -187,7 +215,7 @@ final class WoLDAPApiAccountRepository(
repo =>
/*
* We want to make all API account modification purely exclusive.
* The action is rare, so there is no contention/scalling problem here.
* The action is rare, so there is no contention/scaling problem here.
*/
val semaphore = Semaphore.make(1).runNow

Expand All @@ -200,14 +228,10 @@ final class WoLDAPApiAccountRepository(
for {
ldap <- ldapConnexion
existing <-
ldap.get(rudderDit.API_ACCOUNTS.dn, BuildFilter.EQ(RudderLDAPConstants.A_API_TOKEN, principal.token.value)) map {
ldap.get(rudderDit.API_ACCOUNTS.API_ACCOUNT.dn(principal.id)) map {
case None => None.succeed
case Some(e) =>
if (e(A_API_UUID) == Some(principal.id.value)) {
Some(e).succeed
} else {
LDAPRudderError.Consistancy("An account with given token but different id already exists").fail
}
Some(e).succeed
}
name <- ldap.get(rudderDit.API_ACCOUNTS.dn, BuildFilter.EQ(LDAPConstants.A_NAME, principal.name.value)) map {
case None => None.succeed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ package com.normation.rudder.api

import cats.data._
import cats.implicits._
import com.normation.rudder.api.ApiToken.prefixV2
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import org.bouncycastle.util.encoders.Hex
import org.joda.time.DateTime

/**
Expand All @@ -53,20 +57,43 @@ final case class ApiAccountName(value: String) extends AnyVal

/**
* The actual authentication token.
* A token is defined with [0-9a-zA-Z]{n}, with n not small.
*
* There are two versions of tokens:
*
* * v1: 32 alphanumeric characters stored as clear text
* they are also displayed in clear text in the interface.
* * 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.
*
* To make the difference, we use a prefix to the hash value in v2
*
* * 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
*/
final case class ApiToken(value: String) extends AnyVal {
// Avoid printing the value in logs
case class ApiToken(value: String) extends AnyVal {
// Avoid printing the value in logs, regardless of token type
override def toString: String = s"[REDACTED ApiToken]"

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

object ApiToken {
private val tokenSize = 32
private val prefixV2 = "v2:"

val tokenRegex = """[0-9a-zA-Z]{12,128}""".r
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)
}

def buildCheckValue(value: String): Option[ApiToken] = value.trim match {
case tokenRegex(v) => Some(ApiToken(v))
case _ => None
def generate_secret(tokenGenerator: TokenGenerator, suffix: String = ""): String = {
tokenGenerator.newToken(tokenSize) + suffix
}
}

Expand Down

0 comments on commit 4c1ee7f

Please sign in to comment.