Skip to content
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ const { handleSignIn } = useSiwe({
const data = await response.json();
return data.nonce;
},
verifySignature: async (message, signature) => {
verifySignature: async (message, _nonce, signature) => {
const response = await fetch("/auth/siwe/verify/", {
method: "POST",
credentials: "include",
Expand Down Expand Up @@ -208,6 +208,7 @@ All settings live under `SIWE_DJANGO`.
| `AUTO_CREATE_USERS` | `True` | Create a user when a new wallet signs in. |
| `USER_FACTORY` | built-in | Dotted path for custom user creation. |
| `RATE_LIMITS` | `{}` | Optional per-view limits like `{ "verify": "5/m" }`. |
| `RATE_LIMIT_TRUST_X_FORWARDED_FOR` | `False` | Use the first `X-Forwarded-For` address for rate limits. Enable only behind a trusted proxy that strips client-supplied forwarding headers. |
| `TOKEN_GATES` | `[]` | Optional group sync gates. |
| `SYNC_TOKEN_GATES_ON_LOGIN` | `True` | Sync token gates after login/linking. |

Expand Down
4 changes: 4 additions & 0 deletions src/siwe_django/drf/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from django.contrib.auth import login as auth_login
from django.contrib.auth import logout as auth_logout
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
Expand Down Expand Up @@ -32,6 +34,7 @@ def _error(error: SiweAuthError) -> Response:
)


@method_decorator(ensure_csrf_cookie, name="dispatch")
class NonceView(APIView):
authentication_classes = []
permission_classes = []
Expand All @@ -50,6 +53,7 @@ def get(self, request):
)


@method_decorator(csrf_protect, name="dispatch")
class VerifyView(APIView):
authentication_classes = []
permission_classes = []
Expand Down
18 changes: 11 additions & 7 deletions src/siwe_django/ethid.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ def _fetch_json(path: str, *, fresh: bool | None = None) -> dict[str, Any]:
return data if isinstance(data, dict) else {}


def _fetch_profile_part(path: str, *, fresh: bool | None = None) -> dict[str, Any]:
try:
return _fetch_json(path, fresh=fresh)
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, OSError):
logger.exception("EthID profile part lookup failed for %s.", path)
return {}


def _as_int(value: Any) -> int:
try:
return int(value or 0)
Expand Down Expand Up @@ -128,13 +136,9 @@ def fetch_ethid_profile(
address_or_name: str, *, fresh: bool | None = None
) -> EthIDProfile:
encoded = quote(address_or_name, safe="")
try:
simple = _fetch_json(f"users/{encoded}/simple-profile", fresh=fresh)
details = _fetch_json(f"users/{encoded}/details", fresh=fresh)
ens = _fetch_json(f"users/{encoded}/ens", fresh=fresh)
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, OSError):
logger.exception("EthID profile lookup failed for %s.", address_or_name)
return EthIDProfile()
simple = _fetch_profile_part(f"users/{encoded}/simple-profile", fresh=fresh)
details = _fetch_profile_part(f"users/{encoded}/details", fresh=fresh)
ens = _fetch_profile_part(f"users/{encoded}/ens", fresh=fresh)
return _merge_profile(simple, details, ens)


Expand Down
21 changes: 14 additions & 7 deletions src/siwe_django/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def default_user_factory(identity: SiweIdentity, request=None):
if username_field == "email":
value = f"{identity.address.lower()}@ethereum.local"
else:
value = f"siwe_{identity.chain_id}_{identity.address[2:].lower()}"
value = f"siwe_{identity.address[2:].lower()}"

field = UserModel._meta.get_field(username_field)
max_length = getattr(field, "max_length", None)
Expand All @@ -229,7 +229,8 @@ def _user_factory():

def _update_user_identity_fields(user, identity: SiweIdentity) -> None:
changed = []
for field, value in (
has_identity_profile = bool(identity.identity_profile)
fields = [
("ethereum_address", identity.address),
("chain_id", identity.chain_id),
("ens_name", identity.ens_name),
Expand All @@ -241,12 +242,18 @@ def _update_user_identity_fields(user, identity: SiweIdentity) -> None:
("identity_avatar", identity.identity_avatar),
("identity_url", identity.identity_url),
("identity_profile", identity.identity_profile or {}),
("followers_count", identity.followers_count),
("following_count", identity.following_count),
):
]
if has_identity_profile:
fields.extend(
[
("followers_count", identity.followers_count),
("following_count", identity.following_count),
]
)
for field, value in fields:
if (
hasattr(user, field)
and value not in ("", None)
and value not in ("", None, {})
and getattr(user, field) != value
):
setattr(user, field, value)
Expand Down Expand Up @@ -446,7 +453,7 @@ def eth_identity_kit_nonce_payload(nonce: SiweNonce) -> dict[str, Any]:
def get_public_identity_profile(
address_or_name: str, *, fresh: bool | None = None
) -> dict[str, Any]:
if not (get_setting("ETHID_ENABLED") or get_setting("ETHID_PROFILE_PROXY_ENABLED")):
if not get_setting("ETHID_PROFILE_PROXY_ENABLED"):
raise SiweAuthError("EthID profile lookups are disabled.", status_code=404)
profile = fetch_ethid_profile(address_or_name, fresh=fresh)
if profile.is_empty:
Expand Down
1 change: 1 addition & 0 deletions src/siwe_django/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"AUTO_CREATE_USERS": True,
"USER_FACTORY": "siwe_django.services.default_user_factory",
"RATE_LIMITS": {},
"RATE_LIMIT_TRUST_X_FORWARDED_FOR": False,
"TOKEN_GATES": [],
"SYNC_TOKEN_GATES_ON_LOGIN": True,
}
Expand Down
2 changes: 1 addition & 1 deletion src/siwe_django/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def _error_response(error: SiweAuthError) -> JsonResponse:

def _client_ip(request: HttpRequest) -> str:
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
if forwarded:
if forwarded and get_setting("RATE_LIMIT_TRUST_X_FORWARDED_FOR"):
return forwarded.split(",", 1)[0].strip()
return request.META.get("REMOTE_ADDR", "")

Expand Down
39 changes: 39 additions & 0 deletions tests/test_drf.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,45 @@ def test_drf_nonce_and_verify():
assert SiweWallet.objects.count() == 1


@pytest.mark.django_db
def test_drf_verify_enforces_csrf():
client = APIClient(enforce_csrf_checks=True)
nonce = client.get("/siwe-drf/nonce/").json()["nonce"]

from eth_account import Account

account = Account.create()
message = build_message(account, nonce)
response = client.post(
"/siwe-drf/verify/",
{"message": message, "signature": sign_message(account, message)},
format="json",
)

assert response.status_code == 403


@pytest.mark.django_db
def test_drf_verify_accepts_csrf_token_from_nonce():
client = APIClient(enforce_csrf_checks=True)
nonce = client.get("/siwe-drf/nonce/").json()["nonce"]
csrf_token = client.cookies["csrftoken"].value

from eth_account import Account

account = Account.create()
message = build_message(account, nonce)
response = client.post(
"/siwe-drf/verify/",
{"message": message, "signature": sign_message(account, message)},
format="json",
HTTP_X_CSRFTOKEN=csrf_token,
)

assert response.status_code == 200
assert response.json()["wallet"]["address"] == account.address


@pytest.mark.django_db
def test_drf_me_requires_authentication():
client = APIClient()
Expand Down
76 changes: 74 additions & 2 deletions tests/test_enrichment.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from urllib.error import HTTPError

import pytest
from django.test import override_settings

from siwe_django.ens import ENSProfile
from siwe_django.ethid import EthIDProfile
from siwe_django.ethid import EthIDProfile, fetch_ethid_profile
from siwe_django.gates import sync_wallet_groups
from siwe_django.models import SiweWallet
from siwe_django.models import EthereumUser, SiweWallet
from siwe_django.services import SiweIdentity, _update_user_identity_fields

from .helpers import post_json, signed_payload

Expand Down Expand Up @@ -151,3 +154,72 @@ def test_public_profile_endpoint_uses_ethid(client, mocker):
assert response.json()["profile"]["displayName"] == "vitalik.eth"
assert response.json()["profile"]["ens"]["records"] == {"name": "Vitalik"}
mock.assert_called_once_with("vitalik.eth", fresh=True)


@pytest.mark.django_db
@override_settings(
SIWE_DJANGO={
"ETHID_ENABLED": True,
"ETHID_PROFILE_PROXY_ENABLED": False,
}
)
def test_public_profile_endpoint_respects_proxy_setting(client, mocker):
mock = mocker.patch("siwe_django.services.fetch_ethid_profile")

response = client.get("/siwe/profile/vitalik.eth/")

assert response.status_code == 404
assert response.json()["error"] == "siwe_error"
mock.assert_not_called()


def test_ethid_profile_keeps_partial_data_when_endpoint_404s(mocker):
def fake_fetch(path, *, fresh=None):
if path.endswith("/details"):
raise HTTPError(
url="https://example.com/details",
code=404,
msg="Not Found",
hdrs=None,
fp=None,
)
if path.endswith("/simple-profile"):
return {
"address": "0x0000000000000000000000000000000000000001",
"display_name": "alice.eth",
"avatar": "https://example.com/avatar.png",
"followers_count": 12,
}
return {"ens": {"name": "alice.eth", "records": {"com.github": "alice"}}}

mocker.patch("siwe_django.ethid._fetch_json", side_effect=fake_fetch)

profile = fetch_ethid_profile("alice.eth")

assert profile.address == "0x0000000000000000000000000000000000000001"
assert profile.display_name == "alice.eth"
assert profile.followers_count == 12
assert profile.ens_records == {"com.github": "alice"}
assert profile.raw["details"] == {}


@pytest.mark.django_db
def test_user_identity_counts_survive_missing_profile_payload():
user = EthereumUser.objects.create_user(
ethereum_address="0x0000000000000000000000000000000000000001",
identity_profile={"simple_profile": {"display_name": "alice.eth"}},
followers_count=25,
following_count=7,
)
identity = SiweIdentity(
address="0x0000000000000000000000000000000000000001",
chain_id=1,
caip10="eip155:1:0x0000000000000000000000000000000000000001",
)

_update_user_identity_fields(user, identity)

user.refresh_from_db()
assert user.identity_profile == {"simple_profile": {"display_name": "alice.eth"}}
assert user.followers_count == 25
assert user.following_count == 7
34 changes: 34 additions & 0 deletions tests/test_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,37 @@ def test_nonce_rate_limit(client):

assert response.status_code == 429
assert response.json()["error"] == "rate_limited"


@pytest.mark.django_db
@override_settings(SIWE_DJANGO={"RATE_LIMITS": {"nonce": "1/m"}})
def test_nonce_rate_limit_ignores_x_forwarded_for_by_default(client):
cache.clear()

assert (
client.get("/siwe/nonce/", HTTP_X_FORWARDED_FOR="203.0.113.10").status_code
== 200
)
response = client.get("/siwe/nonce/", HTTP_X_FORWARDED_FOR="203.0.113.11")

assert response.status_code == 429
assert response.json()["error"] == "rate_limited"


@pytest.mark.django_db
@override_settings(
SIWE_DJANGO={
"RATE_LIMITS": {"nonce": "1/m"},
"RATE_LIMIT_TRUST_X_FORWARDED_FOR": True,
}
)
def test_nonce_rate_limit_can_trust_x_forwarded_for(client):
cache.clear()

assert (
client.get("/siwe/nonce/", HTTP_X_FORWARDED_FOR="203.0.113.10").status_code
== 200
)
response = client.get("/siwe/nonce/", HTTP_X_FORWARDED_FOR="203.0.113.11")

assert response.status_code == 200
26 changes: 26 additions & 0 deletions tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,32 @@ def test_verify_logs_in_and_creates_user_wallet(client, django_user_model):
assert me.json()["wallet"]["caip10"].startswith("eip155:1:")


@pytest.mark.django_db
def test_default_user_factory_reuses_user_across_chains(client, django_user_model):
account = Account.create()

first = signed_payload(client, account, chain_id=1)
first_response = post_json(
client,
"/siwe/verify/",
{"message": first["message"], "signature": first["signature"]},
)
second = signed_payload(client, account, chain_id=11155111)
second_response = post_json(
client,
"/siwe/verify/",
{"message": second["message"], "signature": second["signature"]},
)

assert first_response.status_code == 200
assert second_response.status_code == 200
assert django_user_model.objects.count() == 1
user = django_user_model.objects.get()
assert user.username == f"siwe_{account.address[2:].lower()}"
assert SiweWallet.objects.count() == 2
assert set(SiweWallet.objects.values_list("user_id", flat=True)) == {user.pk}


@pytest.mark.django_db
def test_nonce_response_includes_eth_identity_kit_metadata(client):
response = client.get("/siwe/nonce/")
Expand Down
Loading