OAuth2 Authentisierung mit Service-Schlüsseln und Tokens¶
Diese Authentisierungs-Methode erlaubt Maschine-zu-Maschine Authentisierung durch den Einsatz von Service-Schlüsseln, signierten Requests zur Token-Anforderung und kurzlebigen Access-Tokens.
Diese Methode eignet sich für die Authentisierung von Service-Applikationen (z.B. Fachanwendungen), welche die Authentisierung selbstständig und ohne menschliche Interaktion durchführen sollen.
Übersicht¶
Die Authentisierung der Requests erfolgt mittels kurzlebigen Access-Tokens. Diese haben eine beschränkte Gültigkeitsdauer (default: eine Stunde), und müssen von der Service-Applikation regelmässig neu bezogen werden.
Das Beziehen eines Access-Tokens erfolgt mit einem sogenannten “JWT Authorization Grant” - eine signierte Anforderung, mit der die Service-Applikation von GEVER ein neues Access-Token verlangt.
Dieser Authorization Grant muss mit einem privaten Schlüssel signiert werden, der an einen bestimmten Benutzer gebunden ist und zuvor (einmalig) in GEVER über die Benutzeroberfläche ausgestellt wurde.
Authentisierungs-Flow¶
Der Prozess umfasst dementsprechend vier Schritte:
- Ausstellen eines Service-Schlüssels für einen Benutzer in GEVER, und hinterlegen dieses Schlüssels in der Service-Applikation.
- Die Service-Applikation erstellt einen JWT Authorization Grant, um ein Access-Token anzufordern, und signiert diesen Grant mit dem privaten Schlüssel.
- Die Service-Applikation bezieht dann über den Token-Endpoint in GEVER mit diesem JWT Grant ein Access Token.
- Für Requests auf GEVER verwendet die Service-Applikation dann dieses Access-Token um sich zu authentisieren (bis es abläuft, in welchem Fall ein neuer JWT Grant erstellt werden muss um erneut ein Token zu beziehen).
Wenn der Benutzer bereits einen Service-Schlüssel ausgestellt und für die Service-Applikation hinterlegt hat, sieht der Authentisierungs-Flow folgendermassen aus:
Verwalten von Service-Schlüsseln¶
Service-Schlüssel für einen Account können über die Benutzeroberfläche von GEVER verwaltet werden. Für Accounts, für welche das Ausstellen von Schlüsseln erlaubt wurde, kann über die Aktion Service-Schlüssel verwalten im Menü für persönliche Einstellungen das Management-Interface aufgerufen werden.
Im Management-Interface werden die vorhandenen Schlüssel aufgelistet, und Details zu den mit den Schlüsseln assozierten Metadaten und Einstellungen.
Über die Aktion Neuen Service-Schlüssel ausstellen kann ein neuer Schlüssel erzeugt werden. Dem Schlüssel muss mindestens ein Titel zugewiesen werden, der den Verwendungsweck beschreiben sollte.
Optional kann ein IP-Range definiert werden, von welchem aus mit diesem Schlüssel bezogene Access Tokens zur Authentisierung verwendet werden dürfen.
Nach dem Erzeugen wird der private Teil des Schlüssels genau einmal dargestellt, und muss gespeichert werden. Der öffentliche Schlüssel verbleibt auf dem Server, und der private Schlüssel wird auf dem Dateisystem so hinterlegt, dass er nur für die Service-Applikation zugänglich ist.
Bei der Erstellung eines Schlüssels wird diesem eine eindeutige Client-ID vergeben. Diese Client-ID identifiziert die Service-Applikation, und ein Schlüssel sollte daher jeweils nur für eine Applikation verwendet werden.
Über das Bearbeitungsformular können Titel und IP-Range für bereits erstellte Schlüssel angepasst werden. Änderungen am erlaubten IP-Range sind sofort wirksam, und gelten auch für Access Tokens welche bereits mit diesem Schlüssel ausgestellt wurden.
In der Übersicht des Management-Interfaces wird der Zeitpunkt dargestellt, zu welchem ein Schlüssel das letzte Mal verwendet wurde, um ein Access Token zu beziehen.
Ein Klick auf dieses Datum zeigt ausführliche Logs über die letzten Verwendungen des Schlüssels an. Eine Verwendung wird immer dann protokolliert, wenn ein mit dem entsprechenden Schlüssel signierter JWT Authorization Grant benutzt wird, um ein Access Token zu beziehen.
JWT Authorization Grant erstellen¶
Um ein Access Token zu beziehen, erstellt die Service-Applikation einen JWT Authorization Grant, und signiert diesen mit ihrem privaten Schlüssel.
Ein Authorization Grant ist ein JWT (JSON Web Token) mit einem vordefinierten Satz an Claims, welche sich von Zeitstempeln abgesehen alle aus dem Service-Key ableiten lassen.
Das JWT muss folgende Claims enthalten:
Name | Beschreibung |
---|---|
iss | Issuer - die client_id aus dem Service-Key |
aud | Audience - die token_uri aus dem Service-Key |
sub | Subject - die user_id aus dem Service-Key |
iat | Die Zeit zu welcher der Grant ausgestellt wurde, angegeben als Unix-Timestamp [1] |
exp | Die Ablauf-Zeit des JWTs, als Unix-Timestamp [1]. Maximum: 1 Tag, Empfehlung: 1 Stunde |
[1] | (1, 2) Sekunden seit Epoch (00:00:00 UTC, 1. Januar, 1970). |
Das JWT muss dann mit dem privaten Schlüssel signiert werden. Der einzige
unterstützte Signatur-Algorithmus ist RS256
(RSA Signatur mit SHA256)).
Für .NET Applikationen existiert eine Library Jwt.Net welche für das Erstellen und Signieren von JWTs verwendet werden kann.
Beispiel in Python:
import json
import jwt
import time
# Load saved key from filesystem
service_key = json.load(open('my_saved_key.json', 'rb'))
private_key = service_key['private_key'].encode('utf-8')
claim_set = {
"iss": service_key['client_id'],
"sub": service_key['user_id'],
"aud": service_key['token_uri'],
"iat": int(time.time()),
"exp": int(time.time() + (60 * 60)),
}
grant = jwt.encode(claim_set, private_key, algorithm='RS256')
Access Token beziehen¶
Um ein Access Token zu beziehen, macht die Client-Applikation einen Token-Request, um das zuvor erstellte und signierte JWT gegen ein Token einzutauschen.
Der Token Request muss auf die im Service-Key angegebene token_uri
gemacht werden. Dieser Request muss ein POST
Request mit dem
Content-Type: application/x-www-form-urlencoded
sein, und als Body die
form-encodeten Parameter enthalten.
Zwei Parameter werden benötigt:
Name | Beschreibung |
---|---|
grant_type | Muss immer urn:ietf:params:oauth:grant-type:jwt-bearer sein |
assertion | Der JWT Authorization Grant |
Der Token Endpoint antwortet dann mit einer Token Response, welche das Access Token enthält:
{
"access_token": "<token>",
"expires_in": 3600,
"token_type": "Bearer"
}
Diese Response ist vom Content-Type: application/json
und enthält einen
JSON encodeten Body.
Beispiel in Python:
import requests
GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
payload = {'grant_type': GRANT_TYPE, 'assertion': grant}
response = requests.post(service_key['token_uri'], data=payload)
token = response.json()['access_token']
Im Fehlerfall antwortet der Token Endpoint mit einem JSON-Dictionary, das Details zum Fehler enthält:
{
"error": "invalid_grant",
"error_description": "<Fehlerbeschreibung>"
}
Access Token zur Authentisierung verwenden¶
Die Client-Applikation kann dann das erhaltene Access Token verwenden, um
Requests zu Authentisieren. Das Token muss im HTTP Authorization
Header
als Bearer
Token gesendet werden.
Wenn das Token abgelaufen ist, muss die Client-Applikation einen neuen JWT Grant erstellen und signieren, und damit ein neues Token beziehen.
Beispiel in Python:
with requests.Session() as session:
session.headers.update({'Authorization': 'Bearer %s' % token})
response = session.get('http://localhost:8080/Plone/')
# ...
Wenn das von der Client-Applikation gesendete Token abgelaufen ist, sendet der Server eine entsprechende Fehler-Response:
{
"error": "invalid_token",
"error_description": "Access token expired"
}
Der Client muss in diesem Fall ein neues JWT erstellen und signieren, und den zuvor gescheiterten Request mit den ursprünglichen Parametern, aber dem neuen Token, wiederholen.
Empfohlene Client-Implementierung¶
Die oben beschriebenen Schritte stellen den einfachen Fall dar, dass sich ein Client genau einmal authentisieren soll.
Für einen Client, der kontinuierlich authentisierte Requests durchführen soll, muss eine gewisse Logik implementiert werden um das Token regelmässig zu erneuern.
Diese Logik sollte ungefähr so implementiert werden:
Der Client sollte, statt versuchen die Ablaufzeit des Tokens vorherzusagen, damit rechnen dass jeder Request aufgrund eines abgelaufenen Tokens scheitern kann. In diesem Fall soll er ein neues Token beziehen, und den Request mit dem neuen Token wiederholen.
Um dies Umzusetzen, empfehlen wir das durchführen der Requests an eine Klasse zu delegieren, welche diese ganze Retry-Logik enthält, und aus der Business-Logik der Client-Applikation nicht direkt Requests abzusetzen.
Beim Durchführen von Requests um neue Tokens zu beziehen muss auf zwei Dinge geachtet werden:
- Diese Requests sollen keinen
Authorization
Header enthalten. Sonst scheitern sie u.U. wenn aus Versehen ein abgelaufenes Token mitgesendet wird. - Diese Requests müssen wie oben beschrieben mit dem
Content-Type: application/x-www-form-urlencoded
durchgeführt werden, während Requests auf die GEVER API denContent-Type: application/json
haben müssen.
Aus diesen Gründen ist es empfehlenswert, für “normale” Requests und Requests zur Token-Erneuerung unterschiedliche Sessions (persistente HTTP-Verbindungen) zu verwenden.
Eine Beispiel-Implementation in Python für einen kontinuierlich authentisierenden Client:
import json
import jwt
import requests
import time
KEY_PATH = './my_key.json'
GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
class Client(object):
def __init__(self):
self.session = requests.Session()
def request(self, method, url, **kwargs):
# First request will always need to obtain a token first
if 'Authorization' not in self.session.headers:
self.obtain_token()
# Optimistically attempt to dispatch reqest
response = self.session.request(method, url, **kwargs)
if self.token_has_expired(response):
# We got an 'Access token expired' response => refresh token
self.obtain_token()
# Re-dispatch the request that previously failed
response = self.session.request(method, url, **kwargs)
return response
def token_has_expired(self, response):
status = response.status_code
content_type = response.headers['Content-Type']
if status == 401 and content_type == 'application/json':
body = response.json()
if body.get('error_description') == 'Access token expired':
return True
return False
def obtain_token(self):
print "Obtaining token..."
private_key, client_id, user_id, token_uri = self.load_private_key()
iat = int(time.time())
exp = iat + (60 * 60)
claim_set = {
"iss": client_id,
"sub": user_id,
"aud": token_uri,
"exp": exp,
"iat": iat,
}
grant_token = jwt.encode(claim_set, private_key, algorithm='RS256')
payload = {'grant_type': GRANT_TYPE, 'assertion': grant_token}
response = requests.post(token_uri, data=payload)
token = response.json()['access_token']
# Update session with fresh token
self.session.headers.update({'Authorization': 'Bearer %s' % token})
def load_private_key(self):
keydata = json.load(open(KEY_PATH, "rb"))
private_key = keydata['private_key'].encode('utf-8')
client_id = keydata['client_id']
user_id = keydata['user_id']
token_uri = keydata['token_uri']
return private_key, client_id, user_id, token_uri
def main():
client = Client()
# Issue a series of API requests an an example
client.session.headers.update({'Accept': 'application/json'})
for i in range(10):
response = client.request('GET', 'http://localhost:8080/fd/')
print response.status_code
time.sleep(1)
if __name__ == '__main__':
main()