Initial commit

This commit is contained in:
Pat McNeely
2026-03-20 15:09:41 -04:00
commit d1f7052a58
36 changed files with 3974 additions and 0 deletions

0
backend/api/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,63 @@
"""
Management command to create the OAuth2 Application record and a demo user.
Usage:
python manage.py setup_oauth
"""
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from oauth2_provider.models import Application
User = get_user_model()
CLIENT_ID = 'react-oauth-client'
REDIRECT_URI = 'http://localhost:5173/callback'
class Command(BaseCommand):
help = 'Create the OAuth2 Application and a demo superuser'
def handle(self, *args, **kwargs):
# ── Demo user ──────────────────────────────────────────────────────────
user, user_created = User.objects.get_or_create(
username='admin',
defaults={
'email': 'admin@example.com',
'is_staff': True,
'is_superuser': True,
},
)
if user_created:
user.set_password('admin123')
user.save()
self.stdout.write(self.style.SUCCESS('Created superuser: admin / admin123'))
else:
self.stdout.write('Superuser "admin" already exists — skipping.')
# ── OAuth2 Application ─────────────────────────────────────────────────
app, app_created = Application.objects.get_or_create(
client_id=CLIENT_ID,
defaults={
'name': 'React Frontend',
'user': user,
'client_type': Application.CLIENT_PUBLIC,
'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE,
'redirect_uris': REDIRECT_URI,
'skip_authorization': False,
},
)
if not app_created:
# Ensure redirect URI is current if re-running
if REDIRECT_URI not in app.redirect_uris.split():
app.redirect_uris = REDIRECT_URI
app.save()
self.stdout.write('OAuth2 Application already exists — skipping.')
else:
self.stdout.write(self.style.SUCCESS(f'Created OAuth2 Application: {app.name}'))
self.stdout.write('')
self.stdout.write(self.style.SUCCESS('── OAuth2 Application Details ──'))
self.stdout.write(f' Client ID : {app.client_id}')
self.stdout.write(f' Redirect URI : {app.redirect_uris}')
self.stdout.write(f' Grant type : {app.authorization_grant_type}')
self.stdout.write(f' PKCE required: True (enforced in settings)')

View File

@@ -0,0 +1,40 @@
# Generated by Django 4.2.11 on 2026-03-17 19:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WebAuthnCredential',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('credential_id', models.TextField(unique=True)),
('public_key', models.BinaryField()),
('sign_count', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webauthn_credentials', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='WebAuthnChallenge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('challenge', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webauthn_challenges', to=settings.AUTH_USER_MODEL)),
],
options={
'indexes': [models.Index(fields=['user', 'created_at'], name='api_webauth_user_id_ed8a52_idx')],
},
),
]

View File

24
backend/api/models.py Normal file
View File

@@ -0,0 +1,24 @@
from django.db import models
from django.conf import settings
class WebAuthnCredential(models.Model):
"""Stores a registered WebAuthn (passkey/biometric) credential for a user."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='webauthn_credentials')
credential_id = models.TextField(unique=True) # base64url-encoded
public_key = models.BinaryField() # COSE-encoded public key bytes
sign_count = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"WebAuthnCredential(user={self.user.username})"
class WebAuthnChallenge(models.Model):
"""Temporary storage for a pending WebAuthn challenge (expires after use)."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='webauthn_challenges')
challenge = models.TextField() # base64url-encoded random bytes
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [models.Index(fields=['user', 'created_at'])]

20
backend/api/urls.py Normal file
View File

@@ -0,0 +1,20 @@
from django.urls import path
from .views import (
LoginView, MeView, PingView,
WebAuthnRegisterBeginView, WebAuthnRegisterCompleteView,
WebAuthnAuthBeginView, WebAuthnAuthCompleteView,
)
urlpatterns = [
path('login/', LoginView.as_view(), name='login'),
path('me/', MeView.as_view(), name='me'),
path('ping/', PingView.as_view(), name='ping'),
# WebAuthn (Touch ID) — registration (requires OAuth token)
path('webauthn/register/begin/', WebAuthnRegisterBeginView.as_view(), name='webauthn-register-begin'),
path('webauthn/register/complete/', WebAuthnRegisterCompleteView.as_view(), name='webauthn-register-complete'),
# WebAuthn (Touch ID) — authentication (public)
path('webauthn/auth/begin/', WebAuthnAuthBeginView.as_view(), name='webauthn-auth-begin'),
path('webauthn/auth/complete/', WebAuthnAuthCompleteView.as_view(), name='webauthn-auth-complete'),
]

280
backend/api/views.py Normal file
View File

@@ -0,0 +1,280 @@
import json
from datetime import timedelta
from django.conf import settings
from django.contrib.auth import authenticate, get_user_model
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import status
from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope
import webauthn
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
UserVerificationRequirement,
PublicKeyCredentialDescriptor,
RegistrationCredential,
AuthenticatorAttestationResponse,
AuthenticationCredential,
AuthenticatorAssertionResponse,
)
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
from webauthn.helpers.exceptions import InvalidCBORData, InvalidAuthenticatorDataStructure
from .models import WebAuthnCredential, WebAuthnChallenge
User = get_user_model()
RP_NAME = "OAuth2 Demo"
CHALLENGE_TTL = timedelta(minutes=5)
def _rp_id():
return settings.WEBAUTHN_RP_ID
def _origin():
return settings.WEBAUTHN_ORIGIN
class MeView(APIView):
"""Return the authenticated user's profile."""
permission_classes = [IsAuthenticated, TokenHasReadWriteScope]
required_scopes = ['read']
def get(self, request):
user = request.user
return Response({
'id': user.id,
'username': user.username,
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
'date_joined': user.date_joined.isoformat(),
'has_touch_id': user.webauthn_credentials.exists(),
})
class LoginView(APIView):
"""Validate username/password credentials (no session or token issued)."""
permission_classes = []
authentication_classes = []
def post(self, request):
username = request.data.get('username', '').strip()
password = request.data.get('password', '')
if not username or not password:
return Response({'detail': 'Username and password required.'}, status=status.HTTP_400_BAD_REQUEST)
user = authenticate(request, username=username, password=password)
if user is None:
return Response({'detail': 'Invalid credentials.'}, status=status.HTTP_401_UNAUTHORIZED)
return Response({'username': user.username}, status=status.HTTP_200_OK)
class PingView(APIView):
"""Health check — no auth required."""
permission_classes = []
authentication_classes = []
def get(self, request):
return Response({'status': 'ok'})
# ── WebAuthn Registration ───────────────────────────────────────────────────
class WebAuthnRegisterBeginView(APIView):
"""Begin WebAuthn credential registration (requires existing OAuth token)."""
permission_classes = [IsAuthenticated, TokenHasReadWriteScope]
required_scopes = ['read']
def post(self, request):
user = request.user
# Remove stale challenges for this user
WebAuthnChallenge.objects.filter(user=user).delete()
existing_creds = WebAuthnCredential.objects.filter(user=user)
options = webauthn.generate_registration_options(
rp_id=_rp_id(),
rp_name=RP_NAME,
user_id=str(user.pk).encode(),
user_name=user.username,
user_display_name=user.get_full_name() or user.username,
authenticator_selection=AuthenticatorSelectionCriteria(
user_verification=UserVerificationRequirement.REQUIRED,
),
exclude_credentials=[
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c.credential_id))
for c in existing_creds
],
)
WebAuthnChallenge.objects.create(
user=user,
challenge=bytes_to_base64url(options.challenge),
)
return Response(json.loads(webauthn.options_to_json(options)))
class WebAuthnRegisterCompleteView(APIView):
"""Complete WebAuthn credential registration."""
permission_classes = [IsAuthenticated, TokenHasReadWriteScope]
required_scopes = ['read']
def post(self, request):
user = request.user
try:
challenge_obj = WebAuthnChallenge.objects.filter(user=user).latest('created_at')
except WebAuthnChallenge.DoesNotExist:
return Response({'detail': 'No pending challenge. Begin registration first.'}, status=status.HTTP_400_BAD_REQUEST)
if timezone.now() - challenge_obj.created_at > CHALLENGE_TTL:
challenge_obj.delete()
return Response({'detail': 'Challenge expired. Please try again.'}, status=status.HTTP_400_BAD_REQUEST)
data = request.data
try:
credential = RegistrationCredential(
id=data['id'],
raw_id=base64url_to_bytes(data['rawId']),
response=AuthenticatorAttestationResponse(
client_data_json=base64url_to_bytes(data['response']['clientDataJSON']),
attestation_object=base64url_to_bytes(data['response']['attestationObject']),
),
type=data['type'],
)
verified = webauthn.verify_registration_response(
credential=credential,
expected_challenge=base64url_to_bytes(challenge_obj.challenge),
expected_rp_id=_rp_id(),
expected_origin=_origin(),
require_user_verification=True,
)
except (KeyError, ValueError, InvalidCBORData, InvalidAuthenticatorDataStructure, Exception) as exc:
return Response({'detail': f'Verification failed: {exc}'}, status=status.HTTP_400_BAD_REQUEST)
finally:
challenge_obj.delete()
WebAuthnCredential.objects.update_or_create(
credential_id=bytes_to_base64url(verified.credential_id),
defaults={
'user': user,
'public_key': verified.credential_public_key,
'sign_count': verified.sign_count,
},
)
return Response({'detail': 'Touch ID registered successfully.'})
# ── WebAuthn Authentication ─────────────────────────────────────────────────
class WebAuthnAuthBeginView(APIView):
"""Begin WebAuthn authentication (no existing token required)."""
permission_classes = []
authentication_classes = []
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'detail': 'Username required.'}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response({'detail': 'No Touch ID registered for this account.'}, status=status.HTTP_404_NOT_FOUND)
credentials = WebAuthnCredential.objects.filter(user=user)
if not credentials.exists():
return Response({'detail': 'No Touch ID registered for this account.'}, status=status.HTTP_404_NOT_FOUND)
# Remove stale challenges
WebAuthnChallenge.objects.filter(user=user).delete()
options = webauthn.generate_authentication_options(
rp_id=_rp_id(),
allow_credentials=[
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c.credential_id))
for c in credentials
],
user_verification=UserVerificationRequirement.REQUIRED,
)
WebAuthnChallenge.objects.create(
user=user,
challenge=bytes_to_base64url(options.challenge),
)
return Response(json.loads(webauthn.options_to_json(options)))
class WebAuthnAuthCompleteView(APIView):
"""Complete WebAuthn authentication — returns username on success (same as /api/login/)."""
permission_classes = []
authentication_classes = []
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'detail': 'Username required.'}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
try:
challenge_obj = WebAuthnChallenge.objects.filter(user=user).latest('created_at')
except WebAuthnChallenge.DoesNotExist:
return Response({'detail': 'No pending challenge. Begin authentication first.'}, status=status.HTTP_400_BAD_REQUEST)
if timezone.now() - challenge_obj.created_at > CHALLENGE_TTL:
challenge_obj.delete()
return Response({'detail': 'Challenge expired. Please try again.'}, status=status.HTTP_400_BAD_REQUEST)
data = request.data
credential_id = data.get('id')
try:
stored_cred = WebAuthnCredential.objects.get(credential_id=credential_id, user=user)
except WebAuthnCredential.DoesNotExist:
return Response({'detail': 'Credential not found.'}, status=status.HTTP_400_BAD_REQUEST)
try:
credential = AuthenticationCredential(
id=data['id'],
raw_id=base64url_to_bytes(data['rawId']),
response=AuthenticatorAssertionResponse(
client_data_json=base64url_to_bytes(data['response']['clientDataJSON']),
authenticator_data=base64url_to_bytes(data['response']['authenticatorData']),
signature=base64url_to_bytes(data['response']['signature']),
user_handle=base64url_to_bytes(data['response']['userHandle']) if data['response'].get('userHandle') else None,
),
type=data['type'],
)
verified = webauthn.verify_authentication_response(
credential=credential,
expected_challenge=base64url_to_bytes(challenge_obj.challenge),
expected_rp_id=_rp_id(),
expected_origin=_origin(),
credential_public_key=bytes(stored_cred.public_key),
credential_current_sign_count=stored_cred.sign_count,
require_user_verification=True,
)
except Exception as exc:
return Response({'detail': f'Verification failed: {exc}'}, status=status.HTTP_401_UNAUTHORIZED)
finally:
challenge_obj.delete()
# Update replay-attack counter
stored_cred.sign_count = verified.new_sign_count
stored_cred.save(update_fields=['sign_count'])
return Response({'username': user.username}, status=status.HTTP_200_OK)