Mettre en œuvre des flux à haute assurance avec IBM Security Verify et des applications mobiles

Dans cet article, nous allons vous montrer comment mettre en œuvre l'utilisation des jetons "Demonstration Proof-of-Possession" ( DPoP ) d' IBM Security Verify (ISV) dans votre application mobile et web personnalisée.

Nous décrivons les mécanismes pour les parties concernées (client, serveur d'autorisation, serveur de ressources) et expliquons ensuite comment mettre en œuvre les étapes pertinentes dans l'environnement de démonstration. Cet environnement comprend IBM Security Verify, une application web personnalisée et une application mobile.

Table des matières

Pré-requis

Vous devez avoir accès à un locataire ISV et être autorisé à configurer une application. Si vous n'avez pas accès à un locataire ISV, vous pouvez commencer votre parcours ici. Vous devez également être en mesure de créer et d'exploiter une application mobile Android ou iOS et une application web personnalisée.

Introduction

L' option "OAuth Demonstrating Proof of Possession" ajoute une couche de sécurité supplémentaire au protocole OAuth 2.0 Elle oblige les clients à prouver qu'ils possèdent une clé spécifique, généralement cryptographique, lorsqu'ils accèdent à des ressources protégées par l'intermédiaire d'API. Ce mécanisme est essentiel pour renforcer la sécurité en réduisant les risques associés à la fuite de jetons, à l'accès non autorisé et à divers types d'attaques. Dans les situations exigeant une sécurité solide, telles que celles impliquant des fournisseurs et des clients d'API, le DPoP est un dispositif de sécurité essentiel. Elle définit également un mécanisme permettant d'empêcher les acteurs malveillants d'utiliser un jeton OAuth qu'ils ont obtenu de manière illicite. Ce mécanisme comprend des contrôles visant à vérifier si l'application qui présente le jeton d'accès est la même que celle à laquelle le jeton d'accès a été initialement délivré. L'application le prouve en liant le jeton 'DPoP' à une clé privée, détenue en toute sécurité par le client.

Ce diagramme illustre le flux :

838

Examinons chaque étape plus en détail :

  1. L'application mobile génère une paire de clés dans le stockage sécurisé.

  2. L' en-tête DPoP est un jeton Web JSON ( JWT ) qui comprend la représentation de la clé publique sous forme de clé Web JSON ( JWK ):

    • typ: doit être dpop+jwt
    • alg identifiant d'un algorithme de signature numérique asymétrique JWS
    • jwk: { "kty": "RSA" ...}

    Et une liste d'attributs spécifiques à la demande dans la charge utile :

    • jti identifiant unique : identifiant unique
    • iat date de création : date de création du JWT
    • htm la valeur de la méthode HTTP (par exemple GET, POST )
    • htu uRI : l'URI de la demande sans les parties requête et fragment

    Il est signé avec la clé privée.

    Exemple de la structure de l'en-tête et de la charge utile :

     {
         "alg": "RS256",
         "typ": "dpop+jwt",
         "jwk": {
             "kty": "RSA",
             "kid": "91e1b216-d330-47ec-b6a1-89c81049ff9f",
             "n": "0rP7xyhCH6Lauw5mA2nokBElT1NQ-zOAK4ybfIe-tEE_JRbXc-OCveJnQ8hHCFtjq9vZyHIqxA3TzQgnMP86ozLMqPt3BoxbSg7dAxXZ8UfNnwU--baVcXBKMVhc_vas8ZDWdI2BUBQqkLsdmzRdiXKwROMamVUzXoTNxHj513Ac-hcEZBaM7cLKADKCVjAl4h9Ui_Bep3IKxPfeGRf34yc_lxDxo08jc9ZPDW5LY76TOTGncKq7dJp7A0Z2btIX6mL-z6ctsfCFRfcGeL8w5umyxuNhXrut7LQd_d5KwClQXeTKEE7IRymK96pWiCldECdwfo0Fgrt7ZvxnsIB2eQ",
             "e": "AQAB"
         }
     }
    
     {
         "jti": "rImC2rQeKg2J1sQcUKRhqw",
         "iat": 1698124549,
         "htm": "POST",
         "htu": "https://your-tenant.verify.ibm.com/oauth2/token"
     }
    
  3. Cette adresse JWT est envoyée en tant qu'en-tête DPoP dans la demande de jeton.

  4. Le serveur d'autorisation extrait l'en-tête DPoP et vérifie sa signature.

  5. Un jeton d'accès de type DPoP est généré pour le client.

  6. Dans le cadre de ce processus, l'empreinte du pouce est générée à partir de l'adresse JWK intégrée dans la preuve DPoP et ajoutée à l'octroi de l'autorisation dans le cadre de la demande de "confirmation" ( cnf ). Cette demande de confirmation est ensuite mise à disposition dans le cadre de la réponse d'introspection du jeton (voir le point 12 de la présente liste).

  7. Le jeton d'accès de type DPoP est délivré au client.

  8. Chaque fois que le client veut faire une demande avec le jeton DPoP, il doit générer un en-tête DPoP correspondant. Cet en-tête contient les mêmes valeurs qu'au point 2. et en fonction de la demande :

    • ath le code d'accès : base64url encodé SHA256 hash du jeton d'accès
  9. La demande est adressée au serveur de ressources, avec l'en-tête DPoP généré et DPoP <access token> en tant qu'en-tête Authorization. Ce préfixe indique l'utilisation d'un jeton DPoP-bound et exige que la preuve soit incluse dans l'en-tête DPoP.

  10. Le serveur de ressources extrait l'en-tête DPoP et le jeton d'accès de la demande.

  11. Le serveur de ressources appelle le serveur d'autorisation pour analyser le jeton, par exemple ici.

  12. Le serveur d'autorisation renvoie les détails de l'introspection du jeton d'accès.

  13. Le serveur de ressources effectue quelques contrôles pour valider le jeton DPoP et la demande. La "preuve de possession" est validée par :

    • comparer la valeur ath de l'en-tête DPoP avec la valeur calculée du jeton d'accès
    • comparer le hachage SHA256 du codage bas64url de la clé publique de l'en-tête DPoP avec l'attribut cnf.jkt de la réponse d'introspection
  14. Si toutes les validations sont réussies, l'accès à la ressource est accordé.

  15. Ou rejetée dans le cas contraire.

Environnement de démo

Nous fournissons un exemple d'application pour Android et iOS ainsi qu'une application web qui vous permet de tester le flux décrit de bout en bout.

IBM Security Verify

  1. Connectez-vous à votre locataire en tant qu'administrateur.

  2. From the sidebar menu: Applications--> Applications

  3. Cliquez sur Add Application

  4. Dans la barre de recherche, tapez open et sélectionnez OpenID Connect dans la liste des applications disponibles et cliquez sur Add Application (bouton en bas à droite)

  5. Remplissez les valeurs des attributs requis et configurez l'application selon vos besoins.

  6. Les éléments pertinents pour cette démonstration se trouvent dans l'onglet Sign-on :

    • pour Grant types sélectionner Authorization code et Client credentials
    • dans la section Proof-of-Possession settings (voir ci-dessous), sélectionnez Enforce DPoP-bound access token
  7. Cliquez sur Save.

  8. La configuration est sauvegardée et les fichiers Client ID et Client secret sont créés. Ces valeurs sont nécessaires pour configurer les applications mobiles et web.

    800

Application Web personnalisée

L'application web personnalisée imite un serveur de ressources qui valide les jetons DPoP.

Configuration

  1. Téléchargez l'application de démonstration ici
  2. Configurez le paramètre correspondant dans app.js
  3. Exécuter node install
  4. Démarrez le serveur en node app.js

Cela permet de démarrer le serveur sur https://localhost:8080.

Noeuds finaux

Il fournit deux points d'extrémité :

  1. /status GET - retours Running
  2. /validate-token GET - valide le jeton DPoP. Renvoie HTTP 204 si le jeton a été validé avec succès. Ou HTTP 401 dans le cas contraire.

Lorsqu'il reçoit une demande, le serveur extrait l'en-tête DPoP ( JWT ) et le jeton d'accès :

const accessToken = request.headers.authorization.split(" ")[1]
const dpopHeader = request.headers["dpop"]

Il vérifie ensuite qu'un seul en-tête DPoP est présent...

let dpopProof = true
dpopProof = dpopProof && (request.headers["dpop"].split(',').length == 1)
console.log("There is only one DPoP HTTP request header field: " + dpopProof)

...et valide la signature du site JWT et extrait la charge utile...

let dpopHeaderUnpacked = await jose.JWS.createVerify().verify(dpopHeader, { allowEmbeddedKey: true })
let jsonPayload = JSON.parse(dpopHeaderUnpacked.payload)

...et effectue ensuite les autres contrôles énumérés à l'https://datatracker.ietf.org/doc/html/rfc9449#name-checking-dpop-proofs

  1. Toutes les allégations requises figurent dans le JWT
    dpopProof = dpopProof && (jsonPayload.htu !== undefined)
    ...
    // htu, htm, ath, jti, ait must be present
    
  2. Le type JOSE Header Parameter a pour valeur dpop+jwt
    dpopProof = dpopProof && (dpopHeaderUnpacked.header.typ === "dpop+jwt")
    
  3. Le paramètre d'en-tête alg JOSE indique un algorithme de signature numérique asymétrique enregistré
    dpopProof = dpopProof && (dpopHeaderUnpacked.header.alg === "RS256")
    
  4. La demande htm correspond à la méthode HTTP de la demande actuelle
    dpopProof = dpopProof && (jsonPayload.htm === request.method)
    
  5. La demande htu correspond à la valeur de l'URI HTTP pour la requête HTTP
    const fullUrl = request.protocol + '://' + request.get('host') + request.originalUrl
    dpopProof = dpopProof && (jsonPayload.htu === fullUrl)
    
  6. L'heure de création du JWT se situe dans une fenêtre acceptable
const timeInSec = new Date().getTime() / 1000
dpopProof = dpopProof && (iat < timeInSec + 1) && (exp > timeInSec)
  1. La valeur de la revendication ath est égale au hash de ce jeton d'accès
let digest = crypto.createHash('sha256').update(accessToken).digest()
let atHash = jose.util.base64url.encode(digest);
dpopProof = dpopProof && (atHash === jsonPayload.ath)

La clé publique à laquelle le jeton d'accès est lié correspond à la clé publique de la preuve DPoP :

let thumbprint = await dpopHeaderUnpacked.key.thumbprint('SHA-256');
computedFingerprint = jose.util.base64url.encode(thumbprint);
...
doTokenInspectionRequest(accessToken).then((response) => {
    const introspectionResponse = JSON.parse(response)
    dpopProof = dpopProof && (introspectionResponse["cnf"]["jkt"] !== undefined)
    dpopProof = dpopProof && (introspectionResponse["cnf"]["jkt"] === computedFingerprint)
  })

Pour valider que le client est le propriétaire légitime du jeton d'accès, le serveur de ressources vérifie que la clé publique à laquelle le jeton d'accès est lié (la revendication jkt.cnf ) correspond à la clé publique de la preuve DPoP (de l'en-tête DPoP ). Il vérifie également que le hachage du jeton d'accès dans la preuve DPoP correspond au jeton d'accès présenté dans la demande.

Pour que la demande aboutisse, chacun des contrôles énumérés ci-dessus doit être réussi.

Lorsqu'il est validé avec succès, le site accessToken est ajouté à un cache avec son délai d'expiration (valeur exp ). Ce cache est vérifié avant un appel d'introspection si le site accessToken est présent et n'a pas expiré afin d'éviter des requêtes inutiles sur le réseau.

Applications mobiles

Les applications mobiles montrent comment obtenir un jeton DPoP auprès d' IBM Security Verify et comment ce jeton est utilisé dans les demandes ultérieures adressées au serveur de ressources.

ATTENTION
La recommandation pour une application mobile est d'obtenir un code d'autorisation avec un flux d'autorisation du navigateur comme décrit dans OAuth 2.0 for Native Apps et d'échanger ce code contre un jeton d'accès.

Nous utilisons le flux OAuth Client Credentials dans l'application de démonstration pour ne pas alourdir inutilement le code. Il n'est pas recommandé de stocker Client ID et Client secret dans une application mobile en production, car un acteur malveillant pourrait extraire ces informations d'identification.

Application Android

Configuration

  1. Téléchargez l'application de démonstration ici
  2. Ouvrir l'application dans Android Studio "
  3. Configurez les paramètres appropriés dans MainActivity.kt : resourceServer est l'adresse IP de l'application web personnalisée.

L'application présente une seule activité, montrant la configuration et deux boutons pour demander et valider un jeton DPoP:

300 300

Dans cette application de démonstration, nous utilisons la bibliothèque jose4j qui offre un support pratique pour les normes JOSE (JSON Object Signing and Encryption).

Demande de jeton DPoP

Pour chaque requête réseau adressée aux serveurs d'autorisation et de ressources, l'application ajoute un en-tête DPoP généré par la fonction generateDpopHeader :

private fun generateDpopHeader(htu: String, htm: String, accessToken: String?): String {
    val jwtClaims: JwtClaims = JwtClaims()
    jwtClaims.setGeneratedJwtId()
    jwtClaims.setIssuedAtToNow()
    jwtClaims.setClaim("htm", htm)
    jwtClaims.setClaim("htu", htu)
    if (accessToken != null) {
        val bytes = accessToken.toByteArray(StandardCharsets.UTF_8)
        val messageDigest = MessageDigest.getInstance("SHA-256")
        messageDigest.update(bytes, 0, bytes.size)
        val digest = messageDigest.digest()
        val base64encodedFromDigest =
            Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
        Log.d(TAG, "Token: $accessToken")
        Log.d(TAG, "Base64 encoded (digest): $base64encodedFromDigest")
        jwtClaims.setClaim("ath", base64encodedFromDigest)
    }
    val jws: JsonWebSignature = JsonWebSignature()
    jws.payload = jwtClaims.toJson()
    jws.key = getRsaSigningKey()
    jws.algorithmHeaderValue = "RS256"
    jws.jwkHeader = RsaJsonWebKey(keyStore.getCertificate(RSA_KEY_NAME).publicKey as RSAPublicKey)
    jws.setHeader("typ", "dpop+jwt")
    return jws.compactSerialization
}

La demande de jeton est envoyée au serveur :

--> POST https://your-tenant.verify.ibm.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
Content-Length: 114
Accept: application/json
DPoP: ey... from generateDpopHeader(...)
client_id=...&client_secret=...&grant_type=client_credentials&scope=openid
--> END POST (114-byte body)
<-- 200 https://your-tenant.verify.ibm.com/oauth2/token (2672ms)
x-backside-transport: OK OK
content-type: application/json;charset=UTF-8
content-length: 274
date: Fri, 27 Oct 2023 01:34:13 GMT
{"access_token":"abc...123","expires_in":1799,"grant_id":"228048a8-2de0-42f0-8642-0111eb8a0c17","scope":"openid","token_type":"DPoP"}
<-- END HTTP (274-byte body)

Le serveur renvoie un jeton d'accès de type DPoP.

Notez également l'absence de jeton de rafraîchissement à cause de grant_type=client_credentials. D'après les documents:

Le jeton de rafraîchissement utilisé pour obtenir de nouveaux jetons d'accès. Elle n'est disponible pour la subvention authorization_code que si la subvention refresh_token est activée.

Valider le jeton DPoP

Avec le jeton DPoP, l'application peut effectuer des demandes ultérieures au serveur de ressources. Pour chaque demande, un nouvel en-tête DPoP doit être construit à l'aide de la méthode generateDpopHeader(...) mentionnée ci-dessus...

val headers = HashMap<String, String>()
headers["DPoP"] = generateDpopHeader(
    htu = resourceEndpoint,
    htm = "GET",
    accessToken = dpopToken.accessToken)

...et le point de terminaison /validate-token de l'application web personnalisée est appelé :

apiService.validateDpopToken(
    headers,
    String.format("DPoP %s", dpopToken.accessToken),
    resourceEndpoint)
    .enqueue(object : Callback<ResponseBody> {
        override fun onResponse(
            call: Call<ResponseBody>,
            response: Response<ResponseBody>
        ) {
            if (response.isSuccessful) {
                Log.d(TAG, "DPoP token validation successful")
            } else {
                Log.d(TAG, "DPoP token validation failed")
            }
        }

        override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
            throw (t)
        }
    })
--> GET http://your-resource-server:8080/validate-token
Accept: application/json
DPoP: ey... from generateDpopHeader(...)
Authorization: DPoP abc...123
--> END GET

Protection de la clé de signature

La paire de clés RSA liée au jeton d'accès et utilisée pour signer le site JWT est générée dans la base de données de clés d'Android pour la protéger.

private fun getRsaSigningKey() : Key {

        if (keyStore.containsAlias(RSA_KEY_NAME)) {
            Log.d(TAG, "Key $RSA_KEY_NAME found in KeyStore")
        } else {
            val keyGenParameterSpec = KeyGenParameterSpec.Builder(
                RSA_KEY_NAME,
                KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
            )
                .setDigests(KeyProperties.DIGEST_SHA256)
                .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
                .setAlgorithmParameterSpec(RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4))
                .build();

            val keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA,ANDROID_KEYSTORE)
            keyPairGenerator.initialize(keyGenParameterSpec)
            Log.d(TAG, "Key $RSA_KEY_NAME generated")
            keyPairGenerator.generateKeyPair()
        }

        return keyStore.getKey(RSA_KEY_NAME, null)
    }

Cette clé peut faire l'objet d'une rotation pour chaque demande de jeton.

La clé privée est transmise à JWS pour l'opération de signature...

jws.key = getRsaSigningKey()

...et la clé publique est ajoutée en tant qu'en-tête JWK :

jws.jwkHeader = RsaJsonWebKey(keyStore.getCertificate(RSA_KEY_NAME).publicKey as RSAPublicKey)

Application iOS

Configuration

  1. Téléchargez l'application de démonstration ici
  2. Ouvrir l'application dans "Xcode"
  3. Configurez les paramètres appropriés dans ViewModel.swift : resourceServer est l'adresse IP de l'application web personnalisée.

L'application présente une vue unique, montrant la configuration et deux boutons pour demander et valider un jeton DPoP:

300 300

Demande de jeton DPoP

Pour chaque demande de réseau adressée aux serveurs d'autorisation et de ressources, l'application ajoute un en-tête DPoP généré par la fonction DPoP.generateProof, qui fait partie du module d'authentification Verify SDK.

import Authentication
import Core

var token: TokenInfo? = nil
private let key = RSA.Signing.PrivateKey()

public func requestToken() async {
    do {
        let parameters: [String: Any] = ["grant_type": "client_credentials",
                                         "client_id": "\(clientId)",
                                         "client_secret": "\(clientSecret)"]

        let body = urlEncode(from: parameters).data(using: .utf8)!

        // Generate the request for a DPoP access token
        let tokenResource = HTTPResource<TokenInfo>(json: .post,
                                                    url: URL(string: tokenURL)!,
                                                    contentType: .urlEncoded,
                                                    body: body,
                                                    headers: ["DPoP": try DPoP.generateProof(key, uri: tokenURL)],
                                                    timeOutInterval: 30)

        self.token = try await URLSession(configuration: .default).dataTask(for: tokenResource)
        print("Succesfully request an access token with a DPoP header.")
    }
    catch let error {
        print(error.localizedDescription)
    }
}

La demande de jeton est envoyée au serveur :

--> POST https://your-tenant.verify.ibm.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
Content-Length: 114
Accept: application/json
DPoP: ey... from generateDpopHeader(...)
client_id=...&client_secret=...&grant_type=client_credentials&scope=openid
--> END POST (114-byte body)
<-- 200 https://your-tenant.verify.ibm.com/oauth2/token (2672ms)
x-backside-transport: OK OK
content-type: application/json;charset=UTF-8
content-length: 274
date: Fri, 27 Oct 2023 01:34:13 GMT
{"access_token":"abc...123","expires_in":1799,"grant_id":"228048a8-2de0-42f0-8642-0111eb8a0c17","scope":"openid","token_type":"DPoP"}
<-- END HTTP (274-byte body)

Le serveur renvoie un jeton d'accès de type DPoP.

Notez également l'absence de jeton de rafraîchissement à cause de grant_type=client_credentials. D'après les documents:

Le jeton de rafraîchissement utilisé pour obtenir de nouveaux jetons d'accès. Elle n'est disponible pour la subvention authorization_code que si la subvention refresh_token est activée.

Valider le jeton DPoP

Avec le jeton DPoP, l'application peut effectuer des demandes ultérieures au serveur de ressources. Pour chaque demande, un nouvel en-tête DPoP doit être construit avec la fonction generateProof(...) mentionnée ci-dessus et le point de terminaison /validate-token de l'application web personnalisée est appelé :

public func validateToken() async {
    guard let token = self.token else {
        print("No token to validate")
        return
    }

    do {
        // Generate the JWT to validate the DPoP against the intraspection resource.
        let validationResource = HTTPResource<()>(.get,
            url: URL(string: resourceServer)!,
            headers: ["DPoP": try DPoP.generateProof(key,
                uri: resourceServer, method: .get,
                accessToken: token.accessToken),
                "Authorization": "\(token.tokenType) \(token.accessToken)"],
            timeOutInterval: 30)

        try await URLSession(configuration: .default, delegate: SelfSignedCertificateDelegate(), delegateQueue: nil).dataTask(for: validationResource)
        print("Succesfully validated the access token with a DPoP header against \(resourceServer).")

    }
    catch let error {
        print(error.localizedDescription)
    }
}
--> GET https://your-resource-server:8080/validate-token
Accept: application/json
DPoP: ey... from generateProof(...)
Authorization: DPoP abc...123
--> END GET

Protection de la clé de signature

La paire de clés RSA liée au jeton d'accès et utilisée pour signer le site JWT est générée dans la base de données de clés d' iOS pour la protéger.

// Generate the private key.
let privateKey = RSA.Signing.PrivateKey()

// Save the private key to the Keychain.
try KeychainService.default.addItem("MyPrivateKey", value: privateKey.derRepresentation)

Cette clé peut faire l'objet d'une rotation pour chaque demande de jeton.

Limitations

L'utilisation de DPoP empêche les acteurs malveillants d'accéder aux ressources protégées en extrayant un jeton d'accès d'un client. Ils doivent également avoir accès à la clé cryptographique liée à ce jeton, ce qui accroît considérablement la complexité de l'attaque.

Alors que la méthode DPoP et d'autres méthodes de jetons limitées à l'expéditeur empêchent l'utilisation non autorisée de jetons volés, si un client dispose d'un ensemble valide d'informations d'identification, il peut générer sa propre preuve DPoP et obtenir un jeton qui peut être utilisé pour accéder à la ressource protégée. Les jetons liés à un certificat https://docs.verify.ibm.com/verify/v2.0-fr/docs/oauth-20-cert-bound-access-tokens ), en revanche, peuvent atténuer ce problème si le processus d'émission des certificats est correctement régi. Toutefois, cela n'est généralement pas pratique pour les applications mobiles et les applications à page unique.

En outre, d'autres mesures d'atténuation, telles que la détection de nouveaux appareils et l'accès adaptatif avec des méthodes d'authentification forte, devraient toujours être utilisées.

Conclusion

La prise en charge d'OAuth DPoP est importante pour les entreprises qui s'appuient sur OAuth 2.0 pour sécuriser leurs API, en particulier lorsqu'une sécurité forte et une protection contre certains types d'attaques sont cruciales, par exemple pour les institutions financières. OAuth DPoP renforce la sécurité d'OAuth 2.0 en fournissant un moyen de prouver la possession d'une clé cryptographique lors de requêtes à une API protégée par OAuth.

En résumé, nous avons décrit les mécanismes pour les parties concernées (client, serveur d'autorisation, serveur de ressources) et expliqué comment mettre en œuvre les étapes pertinentes dans un environnement de démonstration, y compris IBM Security Verify, une application web personnalisée et une application mobile.