Skip to content

Commit

Permalink
Add endpoints to request nonce and message to sign (#10)
Browse files Browse the repository at this point in the history
* Add Redis configuration

* Add get nonce endpoint

* Add request message to sign endpoint

* Fix README CI badge

* Apply suggestion

Co-authored-by: Uxío <Uxio0@users.noreply.github.com>

* Apply PR suggestions

* Fix test

---------

Co-authored-by: Uxío <Uxio0@users.noreply.github.com>
  • Loading branch information
falvaradorodriguez and Uxio0 authored Aug 2, 2024
1 parent 906406b commit c9d9172
Show file tree
Hide file tree
Showing 30 changed files with 544 additions and 75 deletions.
1 change: 1 addition & 0 deletions .env.docker
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
REDIS_URL=redis://redis:6379/0
2 changes: 1 addition & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1 +1 @@
SECRET_KEY=kamehameha
REDIS_URL=redis://localhost:6379/0
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
REDIS_URL=redis://localhost:6379/0
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ jobs:
strategy:
matrix:
python-version: ["3.12"]
services:
redis:
image: redis
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
![Build Status](https://github.com/safe-global/safe-auth-service/workflows/Python%20CI/badge.svg?branch=main)
[![Python CI](https://github.com/safe-global/safe-auth-service/actions/workflows/ci.yml/badge.svg)](https://github.com/safe-global/safe-auth-service/actions/workflows/ci.yml)
[![Coverage Status](https://coveralls.io/repos/github/safe-global/safe-auth-service/badge.svg?branch=main)](https://coveralls.io/github/safe-global/safe-auth-service?branch=main)
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
![Python 3.12](https://img.shields.io/badge/Python-3.12-blue.svg)
Expand Down
1 change: 1 addition & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VERSION = "0.0.0"
10 changes: 10 additions & 0 deletions app/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from functools import cache

from redis import Redis

from .config import settings


@cache
def get_redis() -> Redis:
return Redis.from_url(settings.REDIS_URL)
24 changes: 24 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
Base settings file for FastApi application.
"""

import os

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=os.environ.get("ENV_FILE", ".env"),
env_file_encoding="utf-8",
extra="allow",
case_sensitive=True,
)
REDIS_URL: str = "redis://"
NONCE_TTL_SECONDS: int = 60 * 10
DEFAULT_SIWE_MESSAGE_STATEMENT: str = (
"Welcome to Safe! I accept the Terms of Use: https://safe.global/terms."
)


settings = Settings()
58 changes: 12 additions & 46 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,55 +1,21 @@
from typing import Any, Literal

from fastapi import FastAPI
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from .version import VERSION
from . import VERSION
from .routers import about, auth, default

app = FastAPI(
title="Safe Auth Service",
description="API to grant JWT tokens for using across the Safe Core{API} infrastructure.",
version=VERSION,
docs_url=None,
redoc_url=None,
)
app.mount("/static", StaticFiles(directory="static"), name="static")


class About(BaseModel):
version: str


@app.get("/docs", include_in_schema=False)
async def swagger_ui_html():
return get_swagger_ui_html(
openapi_url=str(app.openapi_url),
title=app.title + " - Swagger UI",
swagger_favicon_url="/static/favicon.ico",
)


@app.get("/redoc", include_in_schema=False)
async def redoc_html():
return get_redoc_html(
openapi_url=str(app.openapi_url),
title=app.title + " - ReDoc",
redoc_js_url="https://unpkg.com/redoc@next/bundles/redoc.standalone.js",
redoc_favicon_url="/static/favicon.ico",
)


@app.get("/", include_in_schema=False)
async def home() -> RedirectResponse:
return RedirectResponse(url="/docs")


@app.get("/v1/about", response_model=About)
async def about() -> Any:
return {"version": VERSION}


@app.get("/health")
async def health() -> Literal["OK"]:
return "OK"
# Router configuration
api_v1_router = APIRouter(
prefix="/api/v1",
)
api_v1_router.include_router(about.router)
api_v1_router.include_router(auth.router)
app.include_router(api_v1_router)
app.include_router(default.router)
31 changes: 31 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import Optional

from pydantic import AnyUrl, BaseModel, Field, field_validator

from gnosis.eth.utils import fast_is_checksum_address


class About(BaseModel):
version: str


class Nonce(BaseModel):
nonce: str = Field(min_length=8, pattern=r"^[A-Za-z0-9]{8,}$")


class SiweMessageRequest(BaseModel):
domain: str = Field(pattern="^[^/?#]+$", examples=["domain.com"])
address: str
chain_id: int
uri: AnyUrl
statement: Optional[str] = Field(default=None)

@field_validator("address")
def validate_address(cls, value):
if not fast_is_checksum_address(value):
raise ValueError("Invalid Ethereum address")
return value


class SiweMessage(BaseModel):
message: str
Empty file added app/routers/__init__.py
Empty file.
14 changes: 14 additions & 0 deletions app/routers/about.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from fastapi import APIRouter

from .. import VERSION
from ..models import About

router = APIRouter(
prefix="/about",
tags=["About"],
)


@router.get("", response_model=About)
async def about() -> "About":
return About(version=VERSION)
29 changes: 29 additions & 0 deletions app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from fastapi import APIRouter

from ..models import Nonce, SiweMessage, SiweMessageRequest
from ..services.message_service import create_siwe_message
from ..services.nonce_service import generate_nonce

router = APIRouter(
prefix="/auth",
tags=["Authentication"],
)


@router.get("/nonce", response_model=Nonce)
async def get_nonce() -> "Nonce":
return Nonce(nonce=generate_nonce())


@router.post("/messages", response_model=SiweMessage)
async def request_siwe_message(
siwe_message_request: SiweMessageRequest,
) -> "SiweMessage":
siwe_message = create_siwe_message(
domain=siwe_message_request.domain,
address=siwe_message_request.address,
chain_id=siwe_message_request.chain_id,
uri=str(siwe_message_request.uri),
statement=siwe_message_request.statement,
)
return SiweMessage(message=siwe_message)
36 changes: 36 additions & 0 deletions app/routers/default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Literal

from fastapi import APIRouter
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import RedirectResponse

router = APIRouter()


@router.get("/docs", include_in_schema=False)
async def swagger_ui_html():
return get_swagger_ui_html(
openapi_url="/openapi.json",
title="Safe Auth Service - Swagger UI",
swagger_favicon_url="/static/favicon.ico",
)


@router.get("/redoc", include_in_schema=False)
async def redoc_html():
return get_redoc_html(
openapi_url="/openapi.json",
title="Safe Auth Service - ReDoc",
redoc_js_url="https://unpkg.com/redoc@next/bundles/redoc.standalone.js",
redoc_favicon_url="/static/favicon.ico",
)


@router.get("/health", include_in_schema=False)
async def health() -> Literal["OK"]:
return "OK"


@router.get("/", include_in_schema=False)
async def home() -> RedirectResponse:
return RedirectResponse(url="/docs")
40 changes: 40 additions & 0 deletions app/services/message_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from datetime import UTC, datetime, timedelta

from siwe.siwe import ISO8601Datetime, SiweMessage, VersionEnum

from gnosis.eth.utils import fast_to_checksum_address

from ..config import settings
from .nonce_service import generate_nonce


def create_siwe_message(
domain: str, address: str, chain_id: int, uri: str, statement=None
) -> str:
"""
Creates a new Sign-in with Ethereum (EIP-4361) message.
:param domain: The domain that is requesting the signing. Its value MUST be an RFC 3986 authority.
:param address: The Ethereum address performing the signing.
:param chain_id: The Chain ID to which the session is bound.
:param uri: An RFC 3986 URI referring to the resource that is the subject of the signing.
:param statement: OPTIONAL. A human-readable assertion to show in the message that the user will sign.
:return: EIP-4361 formatted message, ready for EIP-191 signing.
"""
nonce = generate_nonce()

message = SiweMessage(
domain=domain,
address=fast_to_checksum_address(address),
statement=statement or settings.DEFAULT_SIWE_MESSAGE_STATEMENT,
uri=uri,
version=VersionEnum.one,
chain_id=chain_id,
nonce=nonce,
issued_at=ISO8601Datetime.from_datetime(datetime.now(UTC)),
valid_until=ISO8601Datetime.from_datetime(
datetime.now(UTC) + timedelta(seconds=settings.NONCE_TTL_SECONDS)
),
)

return message.prepare_message()
16 changes: 16 additions & 0 deletions app/services/nonce_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import siwe

from ..cache import get_redis
from ..config import settings


def generate_nonce() -> str:
"""
Generates a new nonce to be used in the Sign-in with Ethereum process (EIP-4361).
The nonce is cached for future validation. The cache lifetime is configured by NONCE_TTL_SECONDS key.
:return: Alphanumeric random character string of at least 8 characters.
"""
nonce = siwe.generate_nonce()
get_redis().set(nonce, nonce, ex=settings.NONCE_TTL_SECONDS)
return nonce
25 changes: 0 additions & 25 deletions app/test_main.py

This file was deleted.

Empty file added app/tests/__init__.py
Empty file.
Empty file added app/tests/routers/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions app/tests/routers/test_about.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import unittest

from fastapi.testclient import TestClient

from ... import VERSION
from ...main import app


class TestRouterAbout(unittest.TestCase):
client: TestClient

@classmethod
def setUpClass(cls):
cls.client = TestClient(app)

def test_view_about(self):
response = self.client.get("/api/v1/about")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"version": VERSION})
Loading

0 comments on commit c9d9172

Please sign in to comment.