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.

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 à 👇

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

Chaque bloc est un objet json encodé en Base 64.
Header
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 !

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

(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 👇

(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.

(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 !

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

2. Création du client
Le client est notre applicatif ici une instance CTFd.

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

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

3. Création d’un utilisateur

Avec son attribut id:

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