feat: add EncryptID passkey auth backend for DRF
Add EncryptID as a DRF authentication class: - Add encryptid_auth.py with EncryptIDAuthentication backend - Verifies Bearer tokens against EncryptID server - Auto-creates Django User from DID claims - Add ENCRYPTID_SERVER_URL and ENCRYPTID_JWT_SECRET settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5aae2776ed
commit
e8ad0f7f31
|
|
@ -0,0 +1,221 @@
|
||||||
|
"""
|
||||||
|
EncryptID Django / DRF Authentication Backend
|
||||||
|
|
||||||
|
Usage with Django REST Framework:
|
||||||
|
|
||||||
|
# settings.py
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
|
'encryptid_middleware.EncryptIDAuthentication',
|
||||||
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
|
'rest_framework.authentication.TokenAuthentication',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
ENCRYPTID_SERVER_URL = 'https://encryptid.jeffemmett.com'
|
||||||
|
ENCRYPTID_JWT_SECRET = None # Set for local verification; None = remote verify
|
||||||
|
|
||||||
|
Usage standalone (Django middleware):
|
||||||
|
|
||||||
|
# settings.py
|
||||||
|
MIDDLEWARE = [
|
||||||
|
...
|
||||||
|
'encryptid_middleware.EncryptIDMiddleware',
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import URLError
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JWT helpers (no PyJWT dependency for basic HMAC-SHA256)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _b64decode(s: str) -> bytes:
|
||||||
|
"""Base64url decode with padding."""
|
||||||
|
s += '=' * (-len(s) % 4)
|
||||||
|
return base64.urlsafe_b64decode(s)
|
||||||
|
|
||||||
|
|
||||||
|
def _b64encode(data: bytes) -> str:
|
||||||
|
"""Base64url encode without padding."""
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_jwt_local(token: str, secret: str) -> Optional[dict]:
|
||||||
|
"""Verify a HS256 JWT locally and return claims, or None."""
|
||||||
|
try:
|
||||||
|
parts = token.split('.')
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
header = json.loads(_b64decode(parts[0]))
|
||||||
|
if header.get('alg') != 'HS256':
|
||||||
|
return None
|
||||||
|
|
||||||
|
signing_input = f'{parts[0]}.{parts[1]}'.encode()
|
||||||
|
expected_sig = hmac.new(
|
||||||
|
secret.encode(), signing_input, hashlib.sha256
|
||||||
|
).digest()
|
||||||
|
actual_sig = _b64decode(parts[2])
|
||||||
|
|
||||||
|
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||||
|
return None
|
||||||
|
|
||||||
|
claims = json.loads(_b64decode(parts[1]))
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
if claims.get('exp') and claims['exp'] < time.time():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return claims
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_jwt_remote(token: str, server_url: str) -> Optional[dict]:
|
||||||
|
"""Verify a JWT by calling the EncryptID server."""
|
||||||
|
try:
|
||||||
|
req = Request(
|
||||||
|
f'{server_url.rstrip("/")}/api/session/verify',
|
||||||
|
data=json.dumps({'token': token}).encode(),
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
with urlopen(req, timeout=5) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
if data.get('valid'):
|
||||||
|
return data.get('claims', {})
|
||||||
|
return None
|
||||||
|
except (URLError, json.JSONDecodeError, Exception):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_encryptid_token(token: str) -> Optional[dict]:
|
||||||
|
"""Verify an EncryptID JWT using configured method."""
|
||||||
|
secret = getattr(settings, 'ENCRYPTID_JWT_SECRET', None)
|
||||||
|
if secret:
|
||||||
|
return verify_jwt_local(token, secret)
|
||||||
|
|
||||||
|
server_url = getattr(
|
||||||
|
settings, 'ENCRYPTID_SERVER_URL', 'https://encryptid.jeffemmett.com'
|
||||||
|
)
|
||||||
|
return verify_jwt_remote(token, server_url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_or_create_user(claims: dict):
|
||||||
|
"""Find or create a Django User from EncryptID claims."""
|
||||||
|
did = claims.get('did') or claims.get('sub', '')
|
||||||
|
username = claims.get('username', '')
|
||||||
|
|
||||||
|
# Try to find by DID first (stored in email field as convention)
|
||||||
|
try:
|
||||||
|
return User.objects.get(email=did)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try by username
|
||||||
|
if username:
|
||||||
|
try:
|
||||||
|
return User.objects.get(username=username)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
safe_username = username or did[:30]
|
||||||
|
# Ensure unique username
|
||||||
|
base = safe_username
|
||||||
|
counter = 1
|
||||||
|
while User.objects.filter(username=safe_username).exists():
|
||||||
|
safe_username = f'{base}_{counter}'
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
return User.objects.create_user(
|
||||||
|
username=safe_username,
|
||||||
|
email=did, # Store DID in email field
|
||||||
|
password=None, # No password — passkey-only
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Django REST Framework Authentication Backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
try:
|
||||||
|
from rest_framework.authentication import BaseAuthentication
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
class EncryptIDAuthentication(BaseAuthentication):
|
||||||
|
"""
|
||||||
|
DRF authentication backend for EncryptID JWT tokens.
|
||||||
|
|
||||||
|
Accepts: Authorization: Bearer <encryptid-jwt>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
||||||
|
if not auth_header.startswith('Bearer '):
|
||||||
|
return None # Not our token — let other backends try
|
||||||
|
|
||||||
|
token = auth_header[7:]
|
||||||
|
claims = verify_encryptid_token(token)
|
||||||
|
if not claims:
|
||||||
|
raise AuthenticationFailed('Invalid or expired EncryptID token')
|
||||||
|
|
||||||
|
user = get_or_create_user(claims)
|
||||||
|
|
||||||
|
# Attach claims to request for downstream use
|
||||||
|
request.encryptid_claims = claims
|
||||||
|
return (user, token)
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
return 'Bearer realm="encryptid"'
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# DRF not installed — skip
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Django Middleware (for non-DRF views)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class EncryptIDMiddleware:
|
||||||
|
"""
|
||||||
|
Django middleware that attaches EncryptID claims to request.
|
||||||
|
|
||||||
|
Does NOT enforce authentication — just populates request.encryptid_claims
|
||||||
|
and request.encryptid_user if a valid Bearer token is present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
request.encryptid_claims = None
|
||||||
|
request.encryptid_user = None
|
||||||
|
|
||||||
|
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
||||||
|
if auth_header.startswith('Bearer '):
|
||||||
|
token = auth_header[7:]
|
||||||
|
claims = verify_encryptid_token(token)
|
||||||
|
if claims:
|
||||||
|
request.encryptid_claims = claims
|
||||||
|
request.encryptid_user = get_or_create_user(claims)
|
||||||
|
|
||||||
|
return self.get_response(request)
|
||||||
|
|
@ -111,6 +111,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
# REST Framework
|
# REST Framework
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
|
'config.encryptid_auth.EncryptIDAuthentication',
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
'rest_framework.authentication.TokenAuthentication',
|
'rest_framework.authentication.TokenAuthentication',
|
||||||
],
|
],
|
||||||
|
|
@ -148,6 +149,10 @@ CELERY_BEAT_SCHEDULE = {
|
||||||
# rfiles-specific settings
|
# rfiles-specific settings
|
||||||
SHARE_BASE_URL = os.environ.get('SHARE_BASE_URL', 'https://rfiles.online')
|
SHARE_BASE_URL = os.environ.get('SHARE_BASE_URL', 'https://rfiles.online')
|
||||||
|
|
||||||
|
# EncryptID (passkey auth)
|
||||||
|
ENCRYPTID_SERVER_URL = os.environ.get('ENCRYPTID_SERVER_URL', 'https://encryptid.jeffemmett.com')
|
||||||
|
ENCRYPTID_JWT_SECRET = os.environ.get('ENCRYPTID_JWT_SECRET', None) # Set for local JWT verification
|
||||||
|
|
||||||
# Security (production)
|
# Security (production)
|
||||||
if not DEBUG:
|
if not DEBUG:
|
||||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue