Skip to content

Commit

Permalink
Merge pull request #335 from canonical/CERTTF-370-Create-new-endpoint…
Browse files Browse the repository at this point in the history
…-in-Testflinger-Server-to-authenticate-clients-and-distribute-tokens-for-submitting-jobs-with-priority

Create new endpoint in Testflinger Server to authenticate clients and distribute tokens for submitting jobs with priority
  • Loading branch information
val500 authored Oct 2, 2024
2 parents b880b80 + 12c8e43 commit 7e2e334
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 5 deletions.
5 changes: 4 additions & 1 deletion docs/reference/testflinger-server-conf.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,4 +39,5 @@ Example configuration
MONGODB_PASSWORD="testflinger"
MONGODB_DATABASE="testflinger_db"
MONGODB_HOST="mongo"
MONGODB_URI="mongodb://mongo:27017/testflinger_db"
MONGODB_URI="mongodb://mongo:27017/testflinger_db"
JWT_SIGNING_KEY="my_secret_key"
23 changes: 23 additions & 0 deletions server/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -398,3 +398,26 @@ 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
**[POST] /v1/oauth2/token** - Authenticate client key and return JWT with permissions

- Headers:

- Basic Authorization: client_id:client_key (Base64 Encoded)


- 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/oauth2/token \
-X GET --header "Authorization: Basic ABCDEF12345"
4 changes: 4 additions & 0 deletions server/charm/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion server/charm/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions server/devel/docker-compose.override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ sentry-sdk = { extras = ["flask"], version = "^2.0.1" }
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"
Expand Down
62 changes: 61 additions & 1 deletion server/src/api/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
Testflinger v1 API
"""

import os
import uuid
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta

import pkg_resources
from apiflask import APIBlueprint, abort
Expand All @@ -31,6 +32,9 @@
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

import jwt
import bcrypt

from src import database
from . import schemas

Expand Down Expand Up @@ -652,3 +656,59 @@ def queue_wait_time_percentiles_get():
queue["wait_times"]
)
return queue_percentile_data


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",
"max_priority": max_priority,
}

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_key_bytes = client_key.encode("utf-8")
client_permissions_entry = database.mongo.db.client_permissions.find_one(
{
"client_id": client_id,
}
)

if client_permissions_entry is None or not bcrypt.checkpw(
client_key_bytes,
client_permissions_entry["client_secret_hash"].encode("utf8"),
):
return None
max_priority = client_permissions_entry["max_priority"]
return max_priority


@v1.post("/oauth2/token")
def retrieve_token():
"""Get JWT with priority and queue permissions"""
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
secret_key = os.environ.get("JWT_SIGNING_KEY")
token = generate_token(allowed_resources, secret_key)
return token
36 changes: 34 additions & 2 deletions server/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@
Fixtures for testing
"""

import os

from dataclasses import dataclass
import pytest
import mongomock
from mongomock.gridfs import enable_gridfs_integration
import bcrypt

from src import database, application

Expand All @@ -44,8 +47,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
Expand All @@ -58,3 +61,32 @@ 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
"""
os.environ["JWT_SIGNING_KEY"] = "my_secret_key"
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
).decode("utf-8")

max_priority = {
"myqueue": 100,
"myqueue2": 200,
}
mongo.client_permissions.insert_one(
{
"client_id": client_id,
"client_secret_hash": client_key_hash,
"max_priority": max_priority,
}
)
yield app, mongo, client_id, client_key, max_priority
62 changes: 62 additions & 0 deletions server/tests/test_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
from io import BytesIO
import json
import os
import base64

import jwt

from src.api import v1

Expand Down Expand Up @@ -725,3 +728,62 @@ 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 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_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
output = app.post(
"/v1/oauth2/token",
headers=create_auth_header(client_id, client_key),
)
assert output.status_code == 200
token = output.data
decoded_token = jwt.decode(
token,
os.environ.get("JWT_SIGNING_KEY"),
algorithms="HS256",
options={"require": ["exp", "iat", "sub", "max_priority"]},
)
assert decoded_token["max_priority"] == max_priority


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
client_id = "my_wrong_id"
output = app.post(
"/v1/oauth2/token",
headers=create_auth_header(client_id, client_key),
)
assert output.status_code == 401


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
client_key = "my_wrong_key"

output = app.post(
"/v1/oauth2/token",
headers=create_auth_header(client_id, client_key),
)
assert output.status_code == 401

0 comments on commit 7e2e334

Please sign in to comment.