diff --git a/Dockerfile b/Dockerfile index 0ad75fc..07acc73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,4 +4,5 @@ LABEL org.opencontainers.image.source=https://github.com/Quorra-Auth/server COPY . /src RUN pip install /src HEALTHCHECK CMD curl -f http://localhost:8080/health || exit 1 +ENV PYTHONUNBUFFERED=1 ENTRYPOINT ["quorra-server"] \ No newline at end of file diff --git a/hurl/onboard_first_user.hurl b/hurl/onboard_first_user.hurl deleted file mode 100644 index d779440..0000000 --- a/hurl/onboard_first_user.hurl +++ /dev/null @@ -1,19 +0,0 @@ -GET http://localhost:8080/usermgmt/onboard -HTTP 201 -[Captures] -onboarding_token: jsonpath "$.link_id" - -# Check that another onboarding link can't be created -GET http://localhost:8080/usermgmt/onboard -HTTP 403 - -# Finalize the user registration -GET http://localhost:8080/usermgmt/register/{{onboarding_token}} -200 - - -# Will be used for QR polling -#GET http://localhost:8080/usermgmt/onboard -#[Options] -#retry: -1 -#HTTP 201 \ No newline at end of file diff --git a/hurl/user_onboarding.hurl b/hurl/user_onboarding.hurl new file mode 100644 index 0000000..64418bd --- /dev/null +++ b/hurl/user_onboarding.hurl @@ -0,0 +1,54 @@ +# Wait for the main service healthcheck +GET http://localhost:8080/health +[Options] +retry: -1 +HTTP 200 + +# Wait for crypto support healthcheck +GET http://localhost:8085/health +[Options] +retry: -1 +HTTP 200 + +# Create one onboarding link +GET http://localhost:8080/onboarding/init +HTTP 201 +[Captures] +onboarding_token: jsonpath "$.link_id" + +# Send the user details +POST http://localhost:8080/onboarding/register +{ + "link_id": "{{ onboarding_token }}", + "username": "hurl", + "email": "hurl@hurl.dev" +} +HTTP 200 +[Captures] +mobile_registration_link: jsonpath "$.link" + +# Get the mobile token +POST http://localhost:8085/utils/parameter +{ + "uri": "{{ mobile_registration_link }}", + "parameter": "t" +} +HTTP 200 +[Captures] +mobile_registration_token: jsonpath "$.result" + +# Generate a new key pair +GET http://localhost:8085/ed25519/generate +HTTP 200 +[Captures] +pubkey: jsonpath "$.public_key" +privkey: jsonpath "$.private_key" + +# Register a new authenticator device +POST http://localhost:8080/mobile/register +x-registration-token: {{ mobile_registration_token }} +{ + "pubkey": "{{ pubkey }}", + "name": "hurl" +} +HTTP 201 \ No newline at end of file diff --git a/quorra/classes.py b/quorra/classes.py index 8551dc6..dadd5a8 100644 --- a/quorra/classes.py +++ b/quorra/classes.py @@ -1,10 +1,13 @@ from sqlmodel import Field, SQLModel -from pydantic import BaseModel +from pydantic import BaseModel, field_serializer, PrivateAttr, computed_field from enum import Enum from typing import Literal - from datetime import datetime +from uuid import uuid4 + +from .database import vk + # Responses class GenericResponse(BaseModel): @@ -93,3 +96,123 @@ class TokenResponse(BaseModel): access_token: str = "dummy-access-token" token_type: Literal["Bearer"] = "Bearer" id_token: str + + +class TransactionTypes(str, Enum): + onboarding = "onboarding" + oidc_login = "oidc-login" + +class TransactionCreateRequest(BaseModel): + tx_type: TransactionTypes + +class TransactionUpdateRequest(TransactionCreateRequest): + tx_id: str + data: dict + +class Transaction(BaseModel): + tx_type: TransactionTypes + tx_id: str | None = None + + _data_key: str + _state_key: str + _private_data_key: str + _expiry: int = 5 + + def __init__(self, **data): + super().__init__(**data) + base_key : str = "{}:{}".format(self.tx_type.value, self.tx_id) + # Apparently Pydantic intercepts normal assignments, so... + object.__setattr__(self, "_data_key", base_key) + object.__setattr__(self, "_state_key", base_key + ":state") + object.__setattr__(self, "_private_data_key", base_key + ":private-data") + + @computed_field + @property + def state(self) -> str: + return vk.get(self._state_key) + + def _cleanup_dict(self, d: dict) -> dict: + """Workaround - Valkey won't store an empty dict""" + if "empty" in d: + return {} + return d + + @computed_field + @property + def data(self) -> dict: + return self._cleanup_dict(dict(vk.hgetall(self._data_key))) + + @property + def _private_data(self) -> dict: + return self._cleanup_dict(dict(vk.hgetall(self._private_data_key))) + + @field_serializer("state", "data") + def serialize_computed_fields(self, value, _info): + return value + + @classmethod + def load(cls, tx_type: str, tx_id: str) -> "Transaction | None": + if not vk.exists("{}:{}".format(tx_type, tx_id)): + return None + return cls(tx_id=tx_id, tx_type=tx_type) + + @classmethod + def new(cls, tx_type: str) -> "Transaction": + tx = cls(tx_id=str(uuid4()), tx_type=tx_type) + tx.save_state("created") + tx.save_data({}, True) + tx.save_data({}, False) + return tx + + def save_state(self, state: str): + vk.set(self._state_key, state) + vk.expire(self._state_key, self._expiry) + + def save_data(self, data: dict, public: bool): + if public: + key = self._data_key + else: + key = self._private_data_key + self._save_data(key, data) + + def _save_data(self, key: str, data: dict): + if len(data) == 0: + data = {"empty": ""} + vk.hset(key, mapping=data) + vk.expire(key, self._expiry) + + def add_data(self, data: dict, public: bool): + if public: + key = self._data_key + else: + key = self._private_data_key + self._add_data(key, data) + + def _add_data(self, key: str, data: dict): + orig = vk.hgetall(self._data_key) + new = orig | data + vk.hset(key, mapping=new) + vk.expire(key, self._expiry) + + def prolong(self): + for key in [self._data_key, self._state_key, self._private_data_key]: + vk.expire(key, self._expiry) + + def delete(self): + vk.delete(self._state_key) + vk.delete(self._data_key) + +class OnboardingTransactionStates(str, Enum): + created = "created" + filled = "user-info-filled" + finished = "finished" + +class OnboardingTransaction(Transaction): + def transition(self, to_state: str) -> bool: + if self.state == OnboardingTransactionStates.created.value and to_state == OnboardingTransactionStates.filled.value: + self.save_state(to_state) + return True + elif self.state == OnboardingTransactionStates.filled.value and to_state == OnboardingTransactionStates.finished.value: + self.save_state(to_state) + return True + return False diff --git a/quorra/config.py b/quorra/config.py index 6803dc7..2fa6db7 100644 --- a/quorra/config.py +++ b/quorra/config.py @@ -23,7 +23,7 @@ def load_config(): else: print("Config file not found!") print("Using defaults") - default_config["server"] = {"address": "http://localhost:8080"} + default_config["server"] = {"address": "http://localhost:8080", "registrations": False} default_config["oidc"] = {"clients": []} default_config["database"] = {} default_config["database"]["sql"] = {"string": "sqlite:///database.db"} diff --git a/quorra/main.py b/quorra/main.py index 4f783c9..5d33bdb 100644 --- a/quorra/main.py +++ b/quorra/main.py @@ -13,6 +13,7 @@ from .routers import mobile from .routers import login from .routers import oidc +from .routers import tx from .database import engine from .database import SessionDep @@ -45,6 +46,7 @@ async def healthcheck(session: SessionDep): app.include_router(login.router, prefix="/login", tags=["Login session management"]) app.include_router(mobile.router, prefix="/mobile", tags=["Mobile endpoints"]) app.include_router(oidc.router, prefix="/oidc", tags=["OIDC"]) +app.include_router(tx.router, prefix="/tx", tags=["Transaction management"]) fe_dir = importlib.resources.files("quorra") / "fe" app.mount("/fe", StaticFiles(directory=fe_dir), name="static") diff --git a/quorra/routers/onboarding.py b/quorra/routers/onboarding.py index ac331af..36269ba 100644 --- a/quorra/routers/onboarding.py +++ b/quorra/routers/onboarding.py @@ -8,7 +8,7 @@ from uuid import uuid4 import json -from ..classes import OnboardingLink +from ..classes import OnboardingLink, User from ..classes import RegistrationRequest, RegistrationResponse from ..classes import ErrorResponse @@ -18,21 +18,24 @@ from ..utils import generate_qr from ..utils import QRCodeResponse from ..config import server_url +from ..config import config router = APIRouter() -# TODO: Configurable parameter for allowing open self-registrations # TODO: Should probably return the server URL as well @router.get("/init", status_code=201, response_model=OnboardingLink, responses={403: {"model": ErrorResponse}}) async def onboard(session: SessionDep, x_self_service_token: Annotated[str | None, Header()] = None) -> OnboardingLink: authenticated: bool = False + # Bypass for when self-registrations are open + if config["server"]["registrations"]: + authenticated = True # If users and onboarding links are empty, we allow it - # TODO: Check if users are empty - if len(session.exec(select(OnboardingLink)).all()) == 0: + elif len(session.exec(select(OnboardingLink)).all()) == 0 and len(session.exec(select(User)).all()) == 0: authenticated = True - # If a valid self-service token is presented, we allow it - if x_self_service_token is not None: + # TODO: If a valid self-service token is presented, we allow it + # garbage condition for now + elif x_self_service_token is not None and authenticated: authenticated = True if not authenticated: raise HTTPException(status_code=403, detail="x-self-service-token missing or invalid") diff --git a/quorra/routers/tx.py b/quorra/routers/tx.py new file mode 100644 index 0000000..e23f858 --- /dev/null +++ b/quorra/routers/tx.py @@ -0,0 +1,44 @@ +from typing import Annotated + +from fastapi import APIRouter, Header +from sqlmodel import select + +from fastapi import HTTPException +from fastapi.responses import StreamingResponse + +from uuid import uuid4 +import json +import base64 + +from ..classes import Transaction, TransactionCreateRequest, TransactionUpdateRequest +from ..classes import TransactionTypes +from ..classes import ErrorResponse + +from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives import serialization +from cryptography.exceptions import InvalidSignature + +from ..database import SessionDep +from ..database import vk + +from ..utils import generate_qr +from ..config import server_url + + +router = APIRouter() + + +@router.get("/transaction", status_code=200, response_model=Transaction, responses={401: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}) +async def get_transaction(tx_type: TransactionTypes, tx_id: str, session: SessionDep): + """Get transaction.""" + tx = Transaction.load(tx_type.value, tx_id) + if tx is None: + raise HTTPException(status_code=404, detail="Transaction not found") + tx.prolong() + return tx + +@router.post("/transaction", status_code=201, response_model=Transaction, responses={401: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}) +async def create_transaction(rq: TransactionCreateRequest, session: SessionDep): + """Start a new transaction.""" + tx = Transaction.new(rq.tx_type.value) + return tx