Skip to content

Commit

Permalink
Add authentication for backend operations
Browse files Browse the repository at this point in the history
  • Loading branch information
jschlyter committed Nov 29, 2024
1 parent e33cbaf commit b48827f
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 14 deletions.
37 changes: 33 additions & 4 deletions nodeman/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from datetime import datetime, timezone
from typing import Annotated

import argon2
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from fastapi import APIRouter, Header, HTTPException, Request, Response, status
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from jwcrypto.jwk import JWK
from jwcrypto.jws import JWS, InvalidJWSSignature
from opentelemetry import metrics, trace
Expand All @@ -22,6 +24,31 @@

router = APIRouter()

security = HTTPBasic()

password_hasher = argon2.PasswordHasher()


def get_current_username(
request: Request,
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
):
if password_hash := request.app.users.get(credentials.username):
print(password_hash)
try:
password_hasher.verify(hash=password_hash, password=credentials.password)
return credentials.username
except argon2.exceptions.VerifyMismatchError:
logger.warning("Invalid password for user %s", credentials.username)
else:
logger.warning("Unknown user %s", credentials.username)

raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)


@router.post(
"/api/v1/node",
Expand All @@ -31,7 +58,9 @@
},
tags=["backend"],
)
async def create_node(request: Request) -> NodeBootstrapInformation:
async def create_node(
request: Request, username: Annotated[str, Depends(get_current_username)]
) -> NodeBootstrapInformation:
secret = JWK.generate(kty="oct", size=256).k
node = TapirNode.create_next_node(domain=request.app.settings.nodes.domain)
logging.debug("Created node %s", node.name)
Expand All @@ -47,7 +76,7 @@ async def create_node(request: Request) -> NodeBootstrapInformation:
},
tags=["backend"],
)
def get_node_information(name: str) -> NodeInformation:
def get_node_information(name: str, username: Annotated[str, Depends(get_current_username)]) -> NodeInformation:
"""Get node information"""

node: TapirNode | None
Expand All @@ -65,7 +94,7 @@ def get_node_information(name: str) -> NodeInformation:
},
tags=["backend"],
)
def get_all_nodes() -> NodeCollection:
def get_all_nodes(username: Annotated[str, Depends(get_current_username)]) -> NodeCollection:
"""Get all nodes"""

return NodeCollection(nodes=[NodeInformation.from_db_model(node) for node in TapirNode.objects(deleted=None)])
Expand Down
1 change: 1 addition & 0 deletions nodeman/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def __init__(self, settings: Settings):

self.ca_client: CertificateAuthorityClient | None
self.ca_client = self.get_step_client(self.settings.step) if self.settings.step else None
self.users = {entry.username: entry.password for entry in settings.users}

@staticmethod
def get_step_client(settings: StepSettings) -> StepClient:
Expand Down
6 changes: 6 additions & 0 deletions nodeman/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,17 @@ class NodesSettings(BaseModel):
mqtt_topics: dict[str, str] = Field(default={})


class User(BaseModel):
username: str
password: str


class Settings(BaseSettings):
mongodb: MongoDB = Field(default=MongoDB())
step: StepSettings | None = None
# vault: VaultSettings = Field(default=VaultSettings())
otlp: OtlpSettings | None = None
users: list[User] = Field(default=[])

nodes: NodesSettings = Field(default=NodesSettings())

Expand Down
59 changes: 58 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pyyaml = "^6.0.1"
namesgenerator = "^0.3"
jwcrypto = "^1.5.6"
httpx = "^0.27.2"
argon2-cffi = "^23.1.0"

[tool.poetry.group.dev.dependencies]
pytest = "^8.2.0"
Expand Down
4 changes: 4 additions & 0 deletions tests/test.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ domain = "dev.dnstapir.se"
#trusted_keys = "trusted_keys.json"
mqtt_broker = "mqtts://localhost"

[[users]]
username = "username"
password = "$argon2id$v=19$m=65536,t=3,p=4$2UbbvL5YpSjGyeha++HE5g$o8iGuvAgrl0azPFDK79mCYQT10nqIGyU1XLipGwL4rc"

[nodes.mqtt_topics]
tem = "configuration/tem"
pop = "configuration/pop"
51 changes: 42 additions & 9 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from tests.utils import CaTestClient

ADMIN_TEST_NODE_COUNT = 100
BACKEND_CREDENTIALS = ("username", "password")

Settings.model_config = SettingsConfigDict(toml_file="tests/test.toml")

Expand All @@ -35,12 +36,16 @@ def get_test_client() -> TestClient:

def _test_enroll(data_key, x509_key) -> None:
client = get_test_client()

admin_client = get_test_client()
admin_client.auth = BACKEND_CREDENTIALS

server = ""

logging.basicConfig(level=logging.DEBUG)
logging.debug("Testing enrollment")

response = client.post(urljoin(server, "/api/v1/node"))
response = admin_client.post(urljoin(server, "/api/v1/node"))
assert response.status_code == 201
create_response = response.json()
name = create_response["name"]
Expand All @@ -49,7 +54,7 @@ def _test_enroll(data_key, x509_key) -> None:

node_url = urljoin(server, f"/api/v1/node/{name}")

response = client.get(node_url)
response = admin_client.get(node_url)
assert response.status_code == 200
node_information = response.json()
assert node_information["name"] == name
Expand Down Expand Up @@ -80,7 +85,7 @@ def _test_enroll(data_key, x509_key) -> None:
response = client.post(node_enroll_url, json=enrollment_request)
assert response.status_code == 400

response = client.get(node_url)
response = admin_client.get(node_url)
assert response.status_code == 200
node_information = response.json()
print(json.dumps(node_information, indent=4))
Expand All @@ -97,10 +102,10 @@ def _test_enroll(data_key, x509_key) -> None:
assert response.status_code == 200
_ = load_pem_public_key(response.text.encode())

response = client.delete(node_url)
response = admin_client.delete(node_url)
assert response.status_code == 204

response = client.delete(node_url)
response = admin_client.delete(node_url)
assert response.status_code == 404


Expand Down Expand Up @@ -149,7 +154,7 @@ def test_enroll_bad_hmac_signature() -> None:

logging.basicConfig(level=logging.DEBUG)

response = client.post(urljoin(server, "/api/v1/node"))
response = client.post(urljoin(server, "/api/v1/node"), auth=BACKEND_CREDENTIALS)
assert response.status_code == 201
create_response = response.json()
name = create_response["name"]
Expand All @@ -174,20 +179,24 @@ def test_enroll_bad_hmac_signature() -> None:
response = client.post(url, json=enrollment_request)
assert response.status_code == 401

response = client.delete(urljoin(server, f"/api/v1/node/{name}"))
response = client.delete(urljoin(server, f"/api/v1/node/{name}"), auth=BACKEND_CREDENTIALS)
assert response.status_code == 204


def test_enroll_bad_data_signature() -> None:
client = get_test_client()

admin_client = get_test_client()
admin_client.auth = BACKEND_CREDENTIALS

server = ""

kty = "OKP"
crv = "Ed25519"

logging.basicConfig(level=logging.DEBUG)

response = client.post(urljoin(server, "/api/v1/node"))
response = admin_client.post(urljoin(server, "/api/v1/node"))
assert response.status_code == 201
create_response = response.json()
name = create_response["name"]
Expand All @@ -214,12 +223,14 @@ def test_enroll_bad_data_signature() -> None:
response = client.post(url, json=enrollment_request)
assert response.status_code == 401

response = client.delete(urljoin(server, f"/api/v1/node/{name}"))
response = admin_client.delete(urljoin(server, f"/api/v1/node/{name}"))
assert response.status_code == 204


def test_admin() -> None:
client = get_test_client()
client.auth = BACKEND_CREDENTIALS

server = ""

for _ in range(ADMIN_TEST_NODE_COUNT):
Expand All @@ -242,8 +253,30 @@ def test_admin() -> None:
assert response.status_code == 204


def test_backend_authentication() -> None:
client = get_test_client()
server = ""

# correct password
response = client.get(urljoin(server, "/api/v1/nodes"), auth=BACKEND_CREDENTIALS)
assert response.status_code == 200

# no password
response = client.get(urljoin(server, "/api/v1/nodes"))
assert response.status_code == 401

# invalid user
response = client.get(urljoin(server, "/api/v1/nodes"), auth=("invalid", ""))
assert response.status_code == 401

# wrong password for existing user
response = client.get(urljoin(server, "/api/v1/nodes"), auth=(BACKEND_CREDENTIALS[0], "wrong"))
assert response.status_code == 401


def test_not_found() -> None:
client = get_test_client()
client.auth = BACKEND_CREDENTIALS
server = ""
name = str(uuid.uuid4())

Expand Down

0 comments on commit b48827f

Please sign in to comment.