From 97d3c334fd6e5cc0da8d15a6192d03453ba18430 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Mon, 19 Aug 2024 17:47:48 -0500 Subject: [PATCH 1/9] Add authentication endpoint with client key authentication which returns a JWT --- server/pyproject.toml | 1 + server/src/api/v1.py | 48 ++++++++++++++++++++++++++++++++++++++- server/tests/test_v1.py | 50 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/server/pyproject.toml b/server/pyproject.toml index be6df683..660992e7 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -18,6 +18,7 @@ sentry-sdk = { extras = ["flask"], version = "^2.0.1" } requests = "^2.31.0" urllib3 = "^2.2.1" pymongo = "<4.9.0" +pyjwt = "^2.8.0" [tool.poetry.dev-dependencies] pytest = "^8.1.2" diff --git a/server/src/api/v1.py b/server/src/api/v1.py index 1ccf4494..c60f315f 100644 --- a/server/src/api/v1.py +++ b/server/src/api/v1.py @@ -18,7 +18,8 @@ """ import uuid -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta +import secrets import pkg_resources from apiflask import APIBlueprint, abort @@ -31,6 +32,9 @@ from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry +import jwt + + from src import database from . import schemas @@ -652,3 +656,45 @@ def queue_wait_time_percentiles_get(): queue["wait_times"] ) return queue_percentile_data + + +def generate_token(permissions, secret_key): + """Generates JWT token with queue permission given a secret key""" + expiration_time = datetime.utcnow() + timedelta(seconds=2) + token_payload = { + "exp": expiration_time, + "iat": datetime.now(timezone.utc), # Issued at time + "sub": "access_token", + "permissions": permissions, + } + token = jwt.encode(token_payload, secret_key, algorithm="HS256") + return token + + +def validate_client_key_pair(client_id: str, client_key: str): + """ + Checks client_id and key pair for validity and returns their permissions + """ + client_permissions_entry = database.mongo.db.client_permissions.find_one( + { + "client_id": client_id, + "client_secret_hash": client_key, + } + ) + if client_permissions_entry is None: + return {} + + permissions = client_permissions_entry["permissions"] + return permissions + + +SECRET_KEY = secrets.token_hex(20) + + +@v1.get("/authenticate/token/") +def authenticate_client_get(client_id: str): + """Get JWT with priority and queue permissions""" + client_key = request.headers.get("client-key") + allowed_resources = validate_client_key_pair(client_id, client_key) + token = generate_token(allowed_resources, SECRET_KEY) + return token diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py index 99c287c9..7a6daf5b 100644 --- a/server/tests/test_v1.py +++ b/server/tests/test_v1.py @@ -21,6 +21,7 @@ from io import BytesIO import json import os +import jwt from src.api import v1 @@ -725,3 +726,52 @@ def test_get_queue_wait_times(mongo_app): assert len(output.json) == 2 assert output.json["queue1"]["50"] == 3.0 assert output.json["queue2"]["50"] == 30.0 + + +def test_generate_token(): + """Tests JWT generation with client permissions""" + permissions = [ + { + "max_priority": 100, + "queue_name": "myqueue", + }, + { + "max_priority": 200, + "queue_name": "myqueue2", + }, + ] + secret_key = "my_secret_key" + token = v1.generate_token(permissions, secret_key) + decoded_token = jwt.decode(token, secret_key, algorithms="HS256") + assert decoded_token["permissions"] == permissions + + +def test_authenticate_client_get(mongo_app): + """Tests authentication endpoint which returns JWT with permissions""" + app, mongo = mongo_app + client_id = "my_client_id" + client_key = "my_client_key" + permissions = [ + { + "max_priority": 100, + "queue_name": "myqueue", + }, + { + "max_priority": 200, + "queue_name": "myqueue2", + }, + ] + mongo.client_permissions.insert_one( + { + "client_id": client_id, + "client_secret_hash": client_key, + "permissions": permissions, + } + ) + output = app.get( + f"/v1/authenticate/token/{client_id}", + headers={"client-key": client_key}, + ) + token = output.data + decoded_token = jwt.decode(token, v1.SECRET_KEY, algorithms="HS256") + assert decoded_token["permissions"] == permissions From aae40c913bb9a67b01a62e69a53123503002f59a Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Mon, 9 Sep 2024 16:42:15 -0500 Subject: [PATCH 2/9] Add client_key hashing --- server/pyproject.toml | 1 + server/src/api/v1.py | 14 ++++++++++---- server/tests/test_v1.py | 6 +++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/server/pyproject.toml b/server/pyproject.toml index 660992e7..7ec956c5 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -19,6 +19,7 @@ requests = "^2.31.0" urllib3 = "^2.2.1" pymongo = "<4.9.0" pyjwt = "^2.8.0" +bcrypt = "^4.2.0" [tool.poetry.dev-dependencies] pytest = "^8.1.2" diff --git a/server/src/api/v1.py b/server/src/api/v1.py index c60f315f..17a871df 100644 --- a/server/src/api/v1.py +++ b/server/src/api/v1.py @@ -33,7 +33,7 @@ from urllib3.util.retry import Retry import jwt - +import bcrypt from src import database from . import schemas @@ -675,15 +675,19 @@ def validate_client_key_pair(client_id: str, client_key: str): """ Checks client_id and key pair for validity and returns their permissions """ + if client_key is None: + return None + client_key_bytes = client_key.encode("utf-8") client_permissions_entry = database.mongo.db.client_permissions.find_one( { "client_id": client_id, - "client_secret_hash": client_key, } ) - if client_permissions_entry is None: - return {} + if client_permissions_entry is None or not bcrypt.checkpw( + client_key_bytes, client_permissions_entry["client_secret_hash"] + ): + return None permissions = client_permissions_entry["permissions"] return permissions @@ -696,5 +700,7 @@ def authenticate_client_get(client_id: str): """Get JWT with priority and queue permissions""" client_key = request.headers.get("client-key") allowed_resources = validate_client_key_pair(client_id, client_key) + if allowed_resources is None: + return "Invalid client id or client key", 401 token = generate_token(allowed_resources, SECRET_KEY) return token diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py index 7a6daf5b..358f6eff 100644 --- a/server/tests/test_v1.py +++ b/server/tests/test_v1.py @@ -21,7 +21,9 @@ from io import BytesIO import json import os + import jwt +import bcrypt from src.api import v1 @@ -751,6 +753,8 @@ def test_authenticate_client_get(mongo_app): app, mongo = mongo_app client_id = "my_client_id" client_key = "my_client_key" + client_salt = bcrypt.gensalt() + client_key_hash = bcrypt.hashpw(client_key.encode("utf-8"), client_salt) permissions = [ { "max_priority": 100, @@ -764,7 +768,7 @@ def test_authenticate_client_get(mongo_app): mongo.client_permissions.insert_one( { "client_id": client_id, - "client_secret_hash": client_key, + "client_secret_hash": client_key_hash, "permissions": permissions, } ) From 5c2b7299423bcc62f8bfa3f2408363dc17fc3bd6 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Tue, 10 Sep 2024 17:50:00 -0500 Subject: [PATCH 3/9] Return 401 error code with invalid credentials on authentication endpoint --- server/tests/test_v1.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py index 358f6eff..305c01bb 100644 --- a/server/tests/test_v1.py +++ b/server/tests/test_v1.py @@ -779,3 +779,19 @@ def test_authenticate_client_get(mongo_app): token = output.data decoded_token = jwt.decode(token, v1.SECRET_KEY, algorithms="HS256") assert decoded_token["permissions"] == permissions + + +def test_authenticate_invalid_credentials(mongo_app): + """ + Tests that authentication endpoint returns 401 error code + when receiving invalid credentials + """ + app, _ = mongo_app + client_id = "my_client_id" + client_key = "my_client_key" + + output = app.get( + f"/v1/authenticate/token/{client_id}", + headers={"client-key": client_key}, + ) + assert output.status_code == 401 From 7b014de4e8102d5a1c5793f7ff2fe2d9c85c2045 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Mon, 16 Sep 2024 16:11:54 -0500 Subject: [PATCH 4/9] Get secret key from charm config --- server/charm/config.yaml | 4 ++++ server/charm/src/charm.py | 6 +++++- server/src/api/v1.py | 4 ++-- server/tests/test_v1.py | 2 ++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/server/charm/config.yaml b/server/charm/config.yaml index 62c18a8b..4d181318 100644 --- a/server/charm/config.yaml +++ b/server/charm/config.yaml @@ -11,3 +11,7 @@ options: default: 100 description: Maximum number of concurrent connections to the database type: int + jwt_signing_key: + default: "" + description: Secret key used for signing authorization tokens + type: string diff --git a/server/charm/src/charm.py b/server/charm/src/charm.py index a66aeaf6..de46615a 100755 --- a/server/charm/src/charm.py +++ b/server/charm/src/charm.py @@ -191,7 +191,10 @@ def _pebble_layer(self): @property def app_environment(self) -> dict: - """Get dict of env data for the mongodb credentials""" + """ + Get dict of env data for the mongodb credentials + and other config variables + """ db_data = self.fetch_mongodb_relation_data() env = { "MONGODB_HOST": db_data.get("db_host"), @@ -200,6 +203,7 @@ def app_environment(self) -> dict: "MONGODB_PASSWORD": db_data.get("db_password"), "MONGODB_DATABASE": db_data.get("db_database"), "MONGODB_MAX_POOL_SIZE": str(self.config["max_pool_size"]), + "JWT_SIGNING_KEY": self.config["jwt_signing_key"], } return env diff --git a/server/src/api/v1.py b/server/src/api/v1.py index 17a871df..91298a97 100644 --- a/server/src/api/v1.py +++ b/server/src/api/v1.py @@ -17,9 +17,9 @@ Testflinger v1 API """ +import os import uuid from datetime import datetime, timezone, timedelta -import secrets import pkg_resources from apiflask import APIBlueprint, abort @@ -692,7 +692,7 @@ def validate_client_key_pair(client_id: str, client_key: str): return permissions -SECRET_KEY = secrets.token_hex(20) +SECRET_KEY = os.environ.get("JWT_SIGNING_KEY") @v1.get("/authenticate/token/") diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py index 305c01bb..ba5f37b9 100644 --- a/server/tests/test_v1.py +++ b/server/tests/test_v1.py @@ -751,6 +751,7 @@ def test_generate_token(): def test_authenticate_client_get(mongo_app): """Tests authentication endpoint which returns JWT with permissions""" app, mongo = mongo_app + v1.SECRET_KEY = "my_secret_key" client_id = "my_client_id" client_key = "my_client_key" client_salt = bcrypt.gensalt() @@ -776,6 +777,7 @@ def test_authenticate_client_get(mongo_app): f"/v1/authenticate/token/{client_id}", headers={"client-key": client_key}, ) + assert output.status_code == 200 token = output.data decoded_token = jwt.decode(token, v1.SECRET_KEY, algorithms="HS256") assert decoded_token["permissions"] == permissions From 2567483c6acd3bc2894d3e519a38f78aacd441a0 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Tue, 17 Sep 2024 16:02:04 -0500 Subject: [PATCH 5/9] Add new fixture to provide permissions in mongodb and add new tests for invalid credentials --- server/tests/conftest.py | 36 +++++++++++++++++-- server/tests/test_v1.py | 76 +++++++++++++++------------------------- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/server/tests/conftest.py b/server/tests/conftest.py index fd2e5f55..249ace8e 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -21,6 +21,7 @@ import pytest import mongomock from mongomock.gridfs import enable_gridfs_integration +import bcrypt from src import database, application @@ -44,8 +45,8 @@ def start_session(self, *args, **kwargs): return super().start_session(*args, **kwargs) -@pytest.fixture -def mongo_app(): +@pytest.fixture(name="mongo_app") +def mongo_app_fixture(): """Create a pytest fixture for database and app""" mock_mongo = MongoClientMock() database.mongo = mock_mongo @@ -58,3 +59,34 @@ def testapp(): """pytest fixture for just the app""" app = application.create_flask_app(TestingConfig) yield app + + +@pytest.fixture +def mongo_app_with_permissions(mongo_app): + """ + Pytest fixture that adds permissions + to the mock db for priority + """ + app, mongo = mongo_app + client_id = "my_client_id" + client_key = "my_client_key" + client_salt = bcrypt.gensalt() + client_key_hash = bcrypt.hashpw(client_key.encode("utf-8"), client_salt) + permissions = [ + { + "max_priority": 100, + "queue_name": "myqueue", + }, + { + "max_priority": 200, + "queue_name": "myqueue2", + }, + ] + mongo.client_permissions.insert_one( + { + "client_id": client_id, + "client_secret_hash": client_key_hash, + "permissions": permissions, + } + ) + yield app, mongo, client_id, client_key, permissions diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py index ba5f37b9..317a5390 100644 --- a/server/tests/test_v1.py +++ b/server/tests/test_v1.py @@ -23,7 +23,6 @@ import os import jwt -import bcrypt from src.api import v1 @@ -730,67 +729,48 @@ def test_get_queue_wait_times(mongo_app): assert output.json["queue2"]["50"] == 30.0 -def test_generate_token(): - """Tests JWT generation with client permissions""" - permissions = [ - { - "max_priority": 100, - "queue_name": "myqueue", - }, - { - "max_priority": 200, - "queue_name": "myqueue2", - }, - ] - secret_key = "my_secret_key" - token = v1.generate_token(permissions, secret_key) - decoded_token = jwt.decode(token, secret_key, algorithms="HS256") - assert decoded_token["permissions"] == permissions - - -def test_authenticate_client_get(mongo_app): +def test_authenticate_client_get(mongo_app_with_permissions): """Tests authentication endpoint which returns JWT with permissions""" - app, mongo = mongo_app + app, _, client_id, client_key, permissions = mongo_app_with_permissions v1.SECRET_KEY = "my_secret_key" - client_id = "my_client_id" - client_key = "my_client_key" - client_salt = bcrypt.gensalt() - client_key_hash = bcrypt.hashpw(client_key.encode("utf-8"), client_salt) - permissions = [ - { - "max_priority": 100, - "queue_name": "myqueue", - }, - { - "max_priority": 200, - "queue_name": "myqueue2", - }, - ] - mongo.client_permissions.insert_one( - { - "client_id": client_id, - "client_secret_hash": client_key_hash, - "permissions": permissions, - } - ) output = app.get( f"/v1/authenticate/token/{client_id}", headers={"client-key": client_key}, ) assert output.status_code == 200 token = output.data - decoded_token = jwt.decode(token, v1.SECRET_KEY, algorithms="HS256") + decoded_token = jwt.decode( + token, + v1.SECRET_KEY, + algorithms="HS256", + options={"require": ["exp", "iat", "sub", "permissions"]}, + ) assert decoded_token["permissions"] == permissions -def test_authenticate_invalid_credentials(mongo_app): +def test_authenticate_invalid_client_id(mongo_app_with_permissions): """ Tests that authentication endpoint returns 401 error code - when receiving invalid credentials + when receiving invalid client key """ - app, _ = mongo_app - client_id = "my_client_id" - client_key = "my_client_key" + app, _, _, client_key, _ = mongo_app_with_permissions + v1.SECRET_KEY = "my_secret_key" + client_id = "my_wrong_id" + output = app.get( + f"/v1/authenticate/token/{client_id}", + headers={"client-key": client_key}, + ) + assert output.status_code == 401 + + +def test_authenticate_invalid_client_key(mongo_app_with_permissions): + """ + Tests that authentication endpoint returns 401 error code + when receiving invalid client key + """ + app, _, client_id, _, _ = mongo_app_with_permissions + v1.SECRET_KEY = "my_secret_key" + client_key = "my_wrong_key" output = app.get( f"/v1/authenticate/token/{client_id}", From 04bcbb7390f2d62ff72d7a9373eca2effedc8929 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Tue, 17 Sep 2024 16:24:14 -0500 Subject: [PATCH 6/9] Add documentation for new endpoint and environment variable --- docs/reference/testflinger-server-conf.rst | 5 ++++- server/README.rst | 26 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/reference/testflinger-server-conf.rst b/docs/reference/testflinger-server-conf.rst index aa95cf88..f013e14e 100644 --- a/docs/reference/testflinger-server-conf.rst +++ b/docs/reference/testflinger-server-conf.rst @@ -23,6 +23,8 @@ The configuration values of Testflinger servers are read from environment variab - MongoDB port to connect to (Default: 27017) * - ``MONGODB_URI`` - URI for connecting to MongoDB (used instead of the above config options). For example: ``mongodb://user:pass@host:27017/dbname`` + * - ``JWT_SIGNING_KEY`` + - Secret key used for signing tokens with permissions for authenticated clients Example configuration @@ -37,4 +39,5 @@ Example configuration MONGODB_PASSWORD="testflinger" MONGODB_DATABASE="testflinger_db" MONGODB_HOST="mongo" - MONGODB_URI="mongodb://mongo:27017/testflinger_db" \ No newline at end of file + MONGODB_URI="mongodb://mongo:27017/testflinger_db" + JWT_SIGNING_KEY="my_secret_key" diff --git a/server/README.rst b/server/README.rst index 51fe017e..ef791ded 100644 --- a/server/README.rst +++ b/server/README.rst @@ -398,3 +398,29 @@ The job_status_webhook parameter is required for this endpoint. Other parameters .. code-block:: console $ curl http://localhost:8000/v1/queues/wait_times?queue=foo\&queue=bar + +**[GET] /v1/authenticate/token/** - Authenticate client key and return JWT with permissions + +- Parameters: + + - client_id (string): Client identifier + +- Headers: + + - client-key (string): unique secret key for client + +- Status Codes: + + - HTTP 200 (OK) + - HTTP 401 (Unauthorized) - Invalid client_id or client-key + +- Returns: + + Signed JWT with permissions for client + +- Example: + + .. code-block:: console + + $ curl http://localhost:8000/v1/authenticate/token/101 \ + -X GET --header "client-key: ABCDEF12345" From 39addbe797bc50cc872dea6365c2f61bff917b88 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Thu, 26 Sep 2024 15:53:34 -0500 Subject: [PATCH 7/9] Change endpoint to post and use basic authorization scheme --- server/src/api/v1.py | 21 +++++++++++++++------ server/tests/conftest.py | 22 ++++++++++------------ server/tests/test_v1.py | 33 +++++++++++++++++++++++---------- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/server/src/api/v1.py b/server/src/api/v1.py index 91298a97..ca7b2fdc 100644 --- a/server/src/api/v1.py +++ b/server/src/api/v1.py @@ -675,8 +675,6 @@ def validate_client_key_pair(client_id: str, client_key: str): """ Checks client_id and key pair for validity and returns their permissions """ - if client_key is None: - return None client_key_bytes = client_key.encode("utf-8") client_permissions_entry = database.mongo.db.client_permissions.find_one( { @@ -685,7 +683,8 @@ def validate_client_key_pair(client_id: str, client_key: str): ) if client_permissions_entry is None or not bcrypt.checkpw( - client_key_bytes, client_permissions_entry["client_secret_hash"] + client_key_bytes, + client_permissions_entry["client_secret_hash"].encode("utf8"), ): return None permissions = client_permissions_entry["permissions"] @@ -695,10 +694,20 @@ def validate_client_key_pair(client_id: str, client_key: str): SECRET_KEY = os.environ.get("JWT_SIGNING_KEY") -@v1.get("/authenticate/token/") -def authenticate_client_get(client_id: str): +@v1.post("/oauth2/token") +def authenticate_client_post(): """Get JWT with priority and queue permissions""" - client_key = request.headers.get("client-key") + auth_header = request.authorization + if auth_header is None: + return "No authorization header specified", 401 + client_id = auth_header["username"] + client_key = auth_header["password"] + if client_id is None or client_key is None: + return ( + "Client id and key must be specified in authorization header", + 401, + ) + allowed_resources = validate_client_key_pair(client_id, client_key) if allowed_resources is None: return "Invalid client id or client key", 401 diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 249ace8e..bdf2a3f6 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -16,7 +16,6 @@ """ Fixtures for testing """ - from dataclasses import dataclass import pytest import mongomock @@ -71,17 +70,16 @@ def mongo_app_with_permissions(mongo_app): client_id = "my_client_id" client_key = "my_client_key" client_salt = bcrypt.gensalt() - client_key_hash = bcrypt.hashpw(client_key.encode("utf-8"), client_salt) - permissions = [ - { - "max_priority": 100, - "queue_name": "myqueue", - }, - { - "max_priority": 200, - "queue_name": "myqueue2", - }, - ] + client_key_hash = bcrypt.hashpw( + client_key.encode("utf-8"), client_salt + ).decode("utf-8") + + permissions = { + "max_priority": { + "myqueue": 100, + "myqueue2": 200, + } + } mongo.client_permissions.insert_one( { "client_id": client_id, diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py index 317a5390..16c0c1b0 100644 --- a/server/tests/test_v1.py +++ b/server/tests/test_v1.py @@ -21,6 +21,7 @@ from io import BytesIO import json import os +import base64 import jwt @@ -729,13 +730,25 @@ def test_get_queue_wait_times(mongo_app): assert output.json["queue2"]["50"] == 30.0 -def test_authenticate_client_get(mongo_app_with_permissions): +def create_auth_header(client_id: str, client_key: str) -> dict: + """ + Creates authorization header with base64 encoded client_id + and client key using the Basic scheme + """ + id_key_pair = f"{client_id}:{client_key}" + base64_encoded_pair = base64.b64encode(id_key_pair.encode("utf-8")).decode( + "utf-8" + ) + return {"Authorization": f"Basic {base64_encoded_pair}"} + + +def test_authenticate_client_post(mongo_app_with_permissions): """Tests authentication endpoint which returns JWT with permissions""" app, _, client_id, client_key, permissions = mongo_app_with_permissions v1.SECRET_KEY = "my_secret_key" - output = app.get( - f"/v1/authenticate/token/{client_id}", - headers={"client-key": client_key}, + output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), ) assert output.status_code == 200 token = output.data @@ -756,9 +769,9 @@ def test_authenticate_invalid_client_id(mongo_app_with_permissions): app, _, _, client_key, _ = mongo_app_with_permissions v1.SECRET_KEY = "my_secret_key" client_id = "my_wrong_id" - output = app.get( - f"/v1/authenticate/token/{client_id}", - headers={"client-key": client_key}, + output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), ) assert output.status_code == 401 @@ -772,8 +785,8 @@ def test_authenticate_invalid_client_key(mongo_app_with_permissions): v1.SECRET_KEY = "my_secret_key" client_key = "my_wrong_key" - output = app.get( - f"/v1/authenticate/token/{client_id}", - headers={"client-key": client_key}, + output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), ) assert output.status_code == 401 From e27259c3ba2dcc9e7b7e43cdfdd28c133966ed96 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Mon, 30 Sep 2024 16:23:10 -0500 Subject: [PATCH 8/9] Use max_priority instead of permissions in JWT token --- server/src/api/v1.py | 9 +++++---- server/tests/conftest.py | 12 +++++------- server/tests/test_v1.py | 6 +++--- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/server/src/api/v1.py b/server/src/api/v1.py index ca7b2fdc..a8338b6a 100644 --- a/server/src/api/v1.py +++ b/server/src/api/v1.py @@ -658,15 +658,16 @@ def queue_wait_time_percentiles_get(): return queue_percentile_data -def generate_token(permissions, secret_key): +def generate_token(max_priority, secret_key): """Generates JWT token with queue permission given a secret key""" expiration_time = datetime.utcnow() + timedelta(seconds=2) token_payload = { "exp": expiration_time, "iat": datetime.now(timezone.utc), # Issued at time "sub": "access_token", - "permissions": permissions, + "max_priority": max_priority, } + token = jwt.encode(token_payload, secret_key, algorithm="HS256") return token @@ -687,8 +688,8 @@ def validate_client_key_pair(client_id: str, client_key: str): client_permissions_entry["client_secret_hash"].encode("utf8"), ): return None - permissions = client_permissions_entry["permissions"] - return permissions + max_priority = client_permissions_entry["max_priority"] + return max_priority SECRET_KEY = os.environ.get("JWT_SIGNING_KEY") diff --git a/server/tests/conftest.py b/server/tests/conftest.py index bdf2a3f6..39b4baa4 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -74,17 +74,15 @@ def mongo_app_with_permissions(mongo_app): client_key.encode("utf-8"), client_salt ).decode("utf-8") - permissions = { - "max_priority": { - "myqueue": 100, - "myqueue2": 200, - } + max_priority = { + "myqueue": 100, + "myqueue2": 200, } mongo.client_permissions.insert_one( { "client_id": client_id, "client_secret_hash": client_key_hash, - "permissions": permissions, + "max_priority": max_priority, } ) - yield app, mongo, client_id, client_key, permissions + yield app, mongo, client_id, client_key, max_priority diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py index 16c0c1b0..0faa9166 100644 --- a/server/tests/test_v1.py +++ b/server/tests/test_v1.py @@ -744,7 +744,7 @@ def create_auth_header(client_id: str, client_key: str) -> dict: def test_authenticate_client_post(mongo_app_with_permissions): """Tests authentication endpoint which returns JWT with permissions""" - app, _, client_id, client_key, permissions = mongo_app_with_permissions + app, _, client_id, client_key, max_priority = mongo_app_with_permissions v1.SECRET_KEY = "my_secret_key" output = app.post( "/v1/oauth2/token", @@ -756,9 +756,9 @@ def test_authenticate_client_post(mongo_app_with_permissions): token, v1.SECRET_KEY, algorithms="HS256", - options={"require": ["exp", "iat", "sub", "permissions"]}, + options={"require": ["exp", "iat", "sub", "max_priority"]}, ) - assert decoded_token["permissions"] == permissions + assert decoded_token["max_priority"] == max_priority def test_authenticate_invalid_client_id(mongo_app_with_permissions): From 12c8e433bb6062347edb4663e73884dd65e1b084 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Tue, 1 Oct 2024 12:10:19 -0500 Subject: [PATCH 9/9] Add minor changes to clean up authentication code --- server/README.rst | 13 +++++-------- server/devel/docker-compose.override.yml | 1 + server/src/api/v1.py | 8 +++----- server/tests/conftest.py | 4 ++++ server/tests/test_v1.py | 11 ++++------- 5 files changed, 17 insertions(+), 20 deletions(-) diff --git a/server/README.rst b/server/README.rst index ef791ded..864aaec9 100644 --- a/server/README.rst +++ b/server/README.rst @@ -399,15 +399,12 @@ The job_status_webhook parameter is required for this endpoint. Other parameters $ curl http://localhost:8000/v1/queues/wait_times?queue=foo\&queue=bar -**[GET] /v1/authenticate/token/** - Authenticate client key and return JWT with permissions - -- Parameters: - - - client_id (string): Client identifier +**[POST] /v1/oauth2/token** - Authenticate client key and return JWT with permissions - Headers: - - client-key (string): unique secret key for client + - Basic Authorization: client_id:client_key (Base64 Encoded) + - Status Codes: @@ -422,5 +419,5 @@ The job_status_webhook parameter is required for this endpoint. Other parameters .. code-block:: console - $ curl http://localhost:8000/v1/authenticate/token/101 \ - -X GET --header "client-key: ABCDEF12345" + $ curl http://localhost:8000/v1/oauth2/token \ + -X GET --header "Authorization: Basic ABCDEF12345" diff --git a/server/devel/docker-compose.override.yml b/server/devel/docker-compose.override.yml index 8cb93676..6661295d 100644 --- a/server/devel/docker-compose.override.yml +++ b/server/devel/docker-compose.override.yml @@ -12,6 +12,7 @@ services: - MONGODB_DATABASE=testflinger_db - MONGODB_HOST=mongo - MONGODB_AUTH_SOURCE=admin + - JWT_SIGNING_KEY=my_secret_key volumes: - .:/srv/testflinger diff --git a/server/src/api/v1.py b/server/src/api/v1.py index a8338b6a..7068c94b 100644 --- a/server/src/api/v1.py +++ b/server/src/api/v1.py @@ -692,11 +692,8 @@ def validate_client_key_pair(client_id: str, client_key: str): return max_priority -SECRET_KEY = os.environ.get("JWT_SIGNING_KEY") - - @v1.post("/oauth2/token") -def authenticate_client_post(): +def retrieve_token(): """Get JWT with priority and queue permissions""" auth_header = request.authorization if auth_header is None: @@ -712,5 +709,6 @@ def authenticate_client_post(): allowed_resources = validate_client_key_pair(client_id, client_key) if allowed_resources is None: return "Invalid client id or client key", 401 - token = generate_token(allowed_resources, SECRET_KEY) + secret_key = os.environ.get("JWT_SIGNING_KEY") + token = generate_token(allowed_resources, secret_key) return token diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 39b4baa4..b44709f1 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -16,6 +16,9 @@ """ Fixtures for testing """ + +import os + from dataclasses import dataclass import pytest import mongomock @@ -66,6 +69,7 @@ def mongo_app_with_permissions(mongo_app): Pytest fixture that adds permissions to the mock db for priority """ + os.environ["JWT_SIGNING_KEY"] = "my_secret_key" app, mongo = mongo_app client_id = "my_client_id" client_key = "my_client_key" diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py index 0faa9166..1ac40bf0 100644 --- a/server/tests/test_v1.py +++ b/server/tests/test_v1.py @@ -742,10 +742,9 @@ def create_auth_header(client_id: str, client_key: str) -> dict: return {"Authorization": f"Basic {base64_encoded_pair}"} -def test_authenticate_client_post(mongo_app_with_permissions): +def test_retrieve_token(mongo_app_with_permissions): """Tests authentication endpoint which returns JWT with permissions""" app, _, client_id, client_key, max_priority = mongo_app_with_permissions - v1.SECRET_KEY = "my_secret_key" output = app.post( "/v1/oauth2/token", headers=create_auth_header(client_id, client_key), @@ -754,20 +753,19 @@ def test_authenticate_client_post(mongo_app_with_permissions): token = output.data decoded_token = jwt.decode( token, - v1.SECRET_KEY, + os.environ.get("JWT_SIGNING_KEY"), algorithms="HS256", options={"require": ["exp", "iat", "sub", "max_priority"]}, ) assert decoded_token["max_priority"] == max_priority -def test_authenticate_invalid_client_id(mongo_app_with_permissions): +def test_retrieve_token_invalid_client_id(mongo_app_with_permissions): """ Tests that authentication endpoint returns 401 error code when receiving invalid client key """ app, _, _, client_key, _ = mongo_app_with_permissions - v1.SECRET_KEY = "my_secret_key" client_id = "my_wrong_id" output = app.post( "/v1/oauth2/token", @@ -776,13 +774,12 @@ def test_authenticate_invalid_client_id(mongo_app_with_permissions): assert output.status_code == 401 -def test_authenticate_invalid_client_key(mongo_app_with_permissions): +def test_retrieve_token_invalid_client_key(mongo_app_with_permissions): """ Tests that authentication endpoint returns 401 error code when receiving invalid client key """ app, _, client_id, _, _ = mongo_app_with_permissions - v1.SECRET_KEY = "my_secret_key" client_key = "my_wrong_key" output = app.post(