Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
19 changes: 0 additions & 19 deletions hurl/onboard_first_user.hurl

This file was deleted.

54 changes: 54 additions & 0 deletions hurl/user_onboarding.hurl
Original file line number Diff line number Diff line change
@@ -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
127 changes: 125 additions & 2 deletions quorra/classes.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion quorra/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
2 changes: 2 additions & 0 deletions quorra/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
15 changes: 9 additions & 6 deletions quorra/routers/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
Expand Down
44 changes: 44 additions & 0 deletions quorra/routers/tx.py
Original file line number Diff line number Diff line change
@@ -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