A la découverte OAuth 2

Table des matières

Dans le cadre d’un projet, j’avais besoin de réaliser une intégration OAuth2.

Pour comprendre ce qu’il y a sous le capot, rien de tel qu’une petite maquette.

Dans cet article, je partage le minimum théorique pour embarquer suivi d’une déclinaison pratique illustrant la cinématique de fonctionnement.

alt

La problématique

Comment une application peut-elle accéder à une ressource protégée au nom de son propriétaire sans connaitre ses identifiants ?

Les acteurs ?

  • Le client (un utilisateur avec son navigateur)

  • La ressource (un applicatif Web)

  • Le serveur d’autorisation (qui partage un secret avec le client)

Les composants

👉 Le jeton (JWT - JSON Web Token)

La demande d’accès à une ressource protégée aboutit à la délivrance de jetons.

Le jeton est une chaîne de caractères uniques permettant d’identifier le client et embarque différentes informations liées au processus d’autorisation.

Et un jeton cela ressemble à 👇

alt

On constate que ce charabia est composé de trois blocs séparés par un point.

alt

Chaque bloc est un objet json encodé en Base 64.

Metadatas qui fournissent les informations pour valider la signature.

Payload

Les informations sur l’utilisateur ainsi que des metatadas :

  • iat (issue at) - Date de génération (Format Unix Epoch),
  • exp (expiry) - Date d’expiration du token
  • iss (issuer) - Qui a généré le token
  • sub (subject) - Identification du propriétaire de la ressource
  • jti (unique identifier) - Identifiant du token
  • […]

Signature

Le concept derrière JWT et que n’importe qui doit pouvoir lire le contenu d’un token mais seul un serveur habilité peut en générer un valide.

Pour garantir intégrité et authenticité, le header et le payload sont hashés et signés avec la clé secrète du serveur.

👉 Le jeton d’accès (Access Token)

Le token d’accès permet au client d’accéder à la ressource protégée. Ce token a une durée de validité limitée.

👉 Le jeton d’identification (ID Token )

Il permet de diffuser l’identité de l’utilisateur qui s’est authentifié

👉 Le Service userinfo

Service accessible uniquement via une requête portant un Access Token, il renvoie les informations de l’utilisateur authentifié.

Atelier pratique


Avec ces bases rentrons dans le dur !

alt
Objectif : permettre à un utilisateur de s'authentifier sur une application CTFd avec un serveur Keycloak.

Le workflow

alt

(1) le client demande à s’authentifier sur la plateforme Keycloak

Il est redirigé vers le portail d’authentification. Nous pouvons faire l’analogie avec l’authentification sur des services en ligne avec les identifiants de réseaux sociaux 👇

alt

(2) le client s’authentifie sur le portail

(3) le Client reçoit l’Authorization Code par redirection

(4) le Client demande à échanger l’Authorization Code contre un Access Token.

(5) Le serveur applicatif s’authentifie (client_id + secret) auprès du serveur d’authentification et demande un token associé au compte du client.

alt

(6) Le serveur fait appel au service user_info pour avoir les détails du l’utilisateur.

(7) et (8) L’utilisateur et ses informations sont validés, l’accès à la plateforme applicative est autorisé.

Mise en oeuvre


Le serveur d’autorisation : Keycloak

Création du serveur

Faisons simple : un serveur Keycloak à l’aide de docker !

version: '3'

volumes:
  mysql_data:
      driver: local

services:
  mariadb:
      image: mariadb
      volumes:
        - mysql_data:/var/lib/mysql
      environment:
        MYSQL_ROOT_PASSWORD: root
        MYSQL_DATABASE: keycloak
        MYSQL_USER: keycloak
        MYSQL_PASSWORD: password
      healthcheck:
        test: ["CMD-SHELL", "mysqladmin ping -P 3306 -proot | grep 'mysqld is alive' || exit 1"]
  keycloak:
      image: quay.io/keycloak/keycloak:legacy
      environment:
        DB_VENDOR: mariadb
        DB_ADDR: mariadb
        DB_DATABASE: keycloak
        DB_USER: keycloak
        DB_PASSWORD: password
        KEYCLOAK_USER: admin
        KEYCLOAK_PASSWORD: Pa55w0rd
        JGROUPS_DISCOVERY_PROTOCOL: JDBC_PING
      depends_on:
        - mariadb
      ports:
        - "28080:8080"

Reste à configurer !

alt

1. Création du Realm

Un realm peut être vu comme un domaine, chaque realm est indépendant avec sa propre base d’utilisateur.

alt

2. Création du client

Le client est notre applicatif ici une instance CTFd.

alt

Le client va s’identifier grâce à son client-id : ctfd-client et son secret.

alt

Dans le cas de notre applicatif nous avons besoin d’un attribut id.

alt

3. Création d’un utilisateur

alt

Avec son attribut id:

alt

Le serveur applicatif : CTFd

Extrait du code client adapté pour fonctionner avec Keycloak :

@auth.route("/oauth")
def oauth_login():

    scope = "profile"
    redirect_uri = "http://ctfd.homelab.lan/redirect"
    realm="ctfd"    
    endpoint="http://192.168.1.236:28080/auth/realms/%s/protocol/openid-connect/auth"%realm

    client_id = "ctfd-client"

    redirect_url = "{endpoint}?response_type=code&client_id={client_id}&state={state}&redirect_uri={redirect_uri}".format(
        endpoint=endpoint, client_id=client_id, scope=scope, state=session["nonce"],redirect_uri=redirect_uri
    )
    print("==> Redirect to url %s\n"%redirect_url)

    return redirect(redirect_url)
@auth.route("/redirect", methods=["GET"])
@ratelimit(method="GET", limit=10, interval=60)
def oauth_redirect():
    
    oauth_code = request.args.get("code")
    state = request.args.get("state")
    if session["nonce"] != state:
        log("logins", "[{date}] {ip} - OAuth State validation mismatch")
        error_for(endpoint="auth.login", message="OAuth State validation mismatch.")
        return redirect(url_for("auth.login"))

    print("==> Receiving redirect with oauth_code=%s\n"%oauth_code)

    if oauth_code:

        url= "http://192.168.1.236:28080/auth/realms/ctfd/protocol/openid-connect/token"

        client_id = "ctfd-client"
        client_secret = get_app_config("OAUTH_CLIENT_SECRET") or get_config("oauth_client_secret")
        headers = {"content-type": "application/x-www-form-urlencoded"}

        data = {
            "code": oauth_code,
            "client_id": client_id,
            "client_secret": client_secret,
            "grant_type": "authorization_code",
            "redirect_uri":"http://ctfd.homelab.lan/redirect",
        }
        print("==> Post URL %s with data %s\n"%(url,data))
        token_request = requests.post(url, data=data, headers=headers)        

        if token_request.status_code == requests.codes.ok:
            token = token_request.json()["access_token"]

            user_url="http://192.168.1.236:28080/auth/realms/ctfd/protocol/openid-connect/userinfo"

            print("==> Token %s\n"%token_request.json())

            headers = {
                "Authorization": "Bearer " + str(token),
                "Content-type": "application/json",
            }

            result=requests.get(url=user_url, headers=headers)
            print("==> Request userinfo : post URL %s with header %s\n"%(user_url,headers))
            api_data = result.json()
            print("==> %s\n"%api_data)

            user_id=api_data["id"]
            user_name = api_data["name"]
            user_email = api_data["email"]            

            user = Users.query.filter_by(email=user_email).first()
            
            if user is None:
                error_for(
                        endpoint="auth.login",
                        message="Public registration is disabled. Please try again later.",
                )
                return redirect(url_for("auth.login"))
            else:
                log("logins", "[{date}] {ip} %s %s %s - login OK"%(user_name,user_email,user_id))

            login_user(user)

            return redirect(url_for("challenges.listing"))
        else:
            log("logins", "[{date}] {ip} - OAuth token retrieval failure")
            error_for(endpoint="auth.login", message="OAuth token retrieval failure. %s"%token_request.json())
            return redirect(url_for("auth.login"))
    else:
        log("logins", "[{date}] {ip} - Received redirect without OAuth code")
        error_for(
            endpoint="auth.login", message="Received redirect without OAuth code."
        )
        return redirect(url_for("auth.login"))

Voici la séquence :

==> Redirect to url http://192.168.1.236:28080/auth/realms/ctfd/protocol/openid-connect/auth?response_type=code&client_id=ctfd-client&state=7366f5ea8f80797443b41e3c8a953f7f4f045758a5016d42efc9b0e41eee567b&redirect_uri=http://ctfd.homelab.lan/redirect

==> Receiving redirect with oauth_code=6c1c8da3-7894-449e-820c-4b0ff0bd52b0.4f10a91b-1960-4b8b-bc8b-38c37f6e40d2.477734bb-292f-41ab-a0d6-fc0a390d5738

==> Post URL http://192.168.1.236:28080/auth/realms/ctfd/protocol/openid-connect/token with data {'code': '6c1c8da3-7894-449e-820c-4b0ff0bd52b0.4f10a91b-1960-4b8b-bc8b-38c37f6e40d2.477734bb-292f-41ab-a0d6-fc0a390d5738', 'client_id': 'ctfd-client', 'client_secret': '44L3PyfxHfEc6uDJ2lg8uDQ1XylJaGAG', 'grant_type': 'authorization_code', 'redirect_uri': 'http://ctfd.homelab.lan/redirect'}

==> Token {'access_token': 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJaUTI1bXhxeXhJZVM2dks4MEhQNkhOdzRTbWdnVlZjcGtQajExTkRLWWRnIn0.eyJleHAiOjE2NTE0MTkxMDUsImlhdCI6MTY1MTQxODgwNSwiYXV0aF90aW1lIjoxNjUxNDE4ODA1LCJqdGkiOiIxYmUzNjMzZS1lMWM1LTQ3ZWMtYTIyMC03YjIyNzU0MjI5ZTMiLCJpc3MiOiJodHRwOi8vMTkyLjE2OC4xLjIzNjoyODA4MC9hdXRoL3JlYWxtcy9jdGZkIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjJlYjFhZmU1LWM0ZGMtNGEyZS04OTA2LWZhMTEwYjVjMjE5MiIsInR5cCI6IkJlYXJlciIsImF6cCI6ImN0ZmQtY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6IjRmMTBhOTFiLTE5NjAtNGI4Yi1iYzhiLTM4YzM3ZjZlNDBkMiIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJkZWZhdWx0LXJvbGVzLWN0ZmQiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSBlbWFpbCIsInNpZCI6IjRmMTBhOTFiLTE5NjAtNGI4Yi1iYzhiLTM4YzM3ZjZlNDBkMiIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiVGVzdCBUZXN0IiwicHJlZmVycmVkX3VzZXJuYW1lIjoicGxheWVyMDEiLCJpZCI6IjEwMDEiLCJnaXZlbl9uYW1lIjoiVGVzdCIsImZhbWlseV9uYW1lIjoiVGVzdCIsImVtYWlsIjoiY29udGFjdEByZWR0ZWFtcy5mciJ9.YDFQCnWeuMEgKkdcWmZrwHFX58nGFLPDENxpfV-VvujxuXxiC1ieSCqXBrgf23cuadTsqlbYMq9DZcdKpBpN8XNFDHcNmpoq63LE7GroWky-Xal3ph8MLzzZBKgQ7UiqvcAkKdrlTNJ0p5CSicjLvPo1E6agNiL9HqA0Ts5IXHmHvqTrq5SZypyjDkVMqQVC-46fVpUJd_RkvngUTCIERbghkx5_ZAV3PPIlsqy2uRDJD9FNJ1FGeOBz3YJG3jhPMYbmF58h9DFJZ-tFmaPEFqnIk2dMsy__ca5m52f8Bxyn1aKp4YXECbF00KAV36zKOxlQZL2s1ruQ4oN_WYcNxg', 'expires_in': 300, 'refresh_expires_in': 1800, 'refresh_token': 'eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhMTM2OWVlNC03NTQ2LTQ0NDEtYWExOS1mMjU0Y2FhMTFiMjEifQ.eyJleHAiOjE2NTE0MjA2MDUsImlhdCI6MTY1MTQxODgwNSwianRpIjoiMzY2ODIxOGEtMzMxZC00NGFjLWJjZDEtNDc3YjQ4ZDMzNzk1IiwiaXNzIjoiaHR0cDovLzE5Mi4xNjguMS4yMzY6MjgwODAvYXV0aC9yZWFsbXMvY3RmZCIsImF1ZCI6Imh0dHA6Ly8xOTIuMTY4LjEuMjM2OjI4MDgwL2F1dGgvcmVhbG1zL2N0ZmQiLCJzdWIiOiIyZWIxYWZlNS1jNGRjLTRhMmUtODkwNi1mYTExMGI1YzIxOTIiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoiY3RmZC1jbGllbnQiLCJzZXNzaW9uX3N0YXRlIjoiNGYxMGE5MWItMTk2MC00YjhiLWJjOGItMzhjMzdmNmU0MGQyIiwic2NvcGUiOiJwcm9maWxlIGVtYWlsIiwic2lkIjoiNGYxMGE5MWItMTk2MC00YjhiLWJjOGItMzhjMzdmNmU0MGQyIn0.indLWS6dpwlcX6TcIFtc0Mdc0n2OqPFhh452QJVg3To', 'token_type': 'Bearer', 'not-before-policy': 0, 'session_state': '4f10a91b-1960-4b8b-bc8b-38c37f6e40d2', 'scope': 'profile email'}

==> Request userinfo : post URL http://192.168.1.236:28080/auth/realms/ctfd/protocol/openid-connect/userinfo with header {'Authorization': 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJaUTI1bXhxeXhJZVM2dks4MEhQNkhOdzRTbWdnVlZjcGtQajExTkRLWWRnIn0.eyJleHAiOjE2NTE0MTkxMDUsImlhdCI6MTY1MTQxODgwNSwiYXV0aF90aW1lIjoxNjUxNDE4ODA1LCJqdGkiOiIxYmUzNjMzZS1lMWM1LTQ3ZWMtYTIyMC03YjIyNzU0MjI5ZTMiLCJpc3MiOiJodHRwOi8vMTkyLjE2OC4xLjIzNjoyODA4MC9hdXRoL3JlYWxtcy9jdGZkIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjJlYjFhZmU1LWM0ZGMtNGEyZS04OTA2LWZhMTEwYjVjMjE5MiIsInR5cCI6IkJlYXJlciIsImF6cCI6ImN0ZmQtY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6IjRmMTBhOTFiLTE5NjAtNGI4Yi1iYzhiLTM4YzM3ZjZlNDBkMiIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJkZWZhdWx0LXJvbGVzLWN0ZmQiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSBlbWFpbCIsInNpZCI6IjRmMTBhOTFiLTE5NjAtNGI4Yi1iYzhiLTM4YzM3ZjZlNDBkMiIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiVGVzdCBUZXN0IiwicHJlZmVycmVkX3VzZXJuYW1lIjoicGxheWVyMDEiLCJpZCI6IjEwMDEiLCJnaXZlbl9uYW1lIjoiVGVzdCIsImZhbWlseV9uYW1lIjoiVGVzdCIsImVtYWlsIjoiY29udGFjdEByZWR0ZWFtcy5mciJ9.YDFQCnWeuMEgKkdcWmZrwHFX58nGFLPDENxpfV-VvujxuXxiC1ieSCqXBrgf23cuadTsqlbYMq9DZcdKpBpN8XNFDHcNmpoq63LE7GroWky-Xal3ph8MLzzZBKgQ7UiqvcAkKdrlTNJ0p5CSicjLvPo1E6agNiL9HqA0Ts5IXHmHvqTrq5SZypyjDkVMqQVC-46fVpUJd_RkvngUTCIERbghkx5_ZAV3PPIlsqy2uRDJD9FNJ1FGeOBz3YJG3jhPMYbmF58h9DFJZ-tFmaPEFqnIk2dMsy__ca5m52f8Bxyn1aKp4YXECbF00KAV36zKOxlQZL2s1ruQ4oN_WYcNxg', 'Content-type': 'application/json'}

==> {'sub': '2eb1afe5-c4dc-4a2e-8906-fa110b5c2192', 'email_verified': True, 'name': 'Test Test', 'preferred_username': 'player01', 'id': '1001', 'given_name': 'Test', 'family_name': 'Test', 'email': 'contact@redteams.fr'}

Il ne faut prendre quelques précaution dans l’implémentation

Et donc à :

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMCIsIm5hbWUiOiJFcmljIFJJQ0hBUkQiLCJpYXQiOjE1MTYyMzkwMjJ9.wa7dCnRCNsQRoRMbrIjne0yY_GVkOWeaaJqKiPZOvh4

Vous répondez quoi ?

=> secret

Related