Initial commit
This commit is contained in:
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
0
backend/api/management/__init__.py
Normal file
0
backend/api/management/__init__.py
Normal file
0
backend/api/management/commands/__init__.py
Normal file
0
backend/api/management/commands/__init__.py
Normal file
63
backend/api/management/commands/setup_oauth.py
Normal file
63
backend/api/management/commands/setup_oauth.py
Normal 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)')
|
||||||
40
backend/api/migrations/0001_initial.py
Normal file
40
backend/api/migrations/0001_initial.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/api/migrations/__init__.py
Normal file
0
backend/api/migrations/__init__.py
Normal file
24
backend/api/models.py
Normal file
24
backend/api/models.py
Normal 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
20
backend/api/urls.py
Normal 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
280
backend/api/views.py
Normal 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)
|
||||||
0
backend/config/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
177
backend/config/settings.py
Normal file
177
backend/config/settings.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Load a .env file if present (development convenience; production uses real env vars)
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(BASE_DIR / '.env')
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Core ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SECRET_KEY = os.environ.get(
|
||||||
|
'DJANGO_SECRET_KEY',
|
||||||
|
'django-insecure-dev-secret-key-change-in-production-xyz123',
|
||||||
|
)
|
||||||
|
|
||||||
|
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = [
|
||||||
|
h.strip()
|
||||||
|
for h in os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
||||||
|
if h.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'rest_framework',
|
||||||
|
'oauth2_provider',
|
||||||
|
'corsheaders',
|
||||||
|
'api',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
'oauth2_provider.middleware.OAuth2TokenMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# ── Auth redirects ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
LOGIN_URL = '/accounts/login/'
|
||||||
|
LOGIN_REDIRECT_URL = '/o/authorize/'
|
||||||
|
LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'http://localhost:5173/')
|
||||||
|
|
||||||
|
# ── Django REST Framework ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
|
'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
|
||||||
|
],
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Django OAuth Toolkit ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
OAUTH2_PROVIDER = {
|
||||||
|
'PKCE_REQUIRED': True,
|
||||||
|
'ACCESS_TOKEN_EXPIRE_SECONDS': 3600,
|
||||||
|
'REFRESH_TOKEN_EXPIRE_SECONDS': 60 * 60 * 24 * 7,
|
||||||
|
'ROTATE_REFRESH_TOKEN': True,
|
||||||
|
'SCOPES': {
|
||||||
|
'read': 'Read access',
|
||||||
|
'write': 'Write access',
|
||||||
|
},
|
||||||
|
'DEFAULT_SCOPES': ['read'],
|
||||||
|
'ALLOWED_REDIRECT_URI_SCHEMES': ['http', 'https'],
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
'oauth2_provider.backends.OAuth2Backend',
|
||||||
|
'django.contrib.auth.backends.ModelBackend',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── CORS ───────────────────────────────────────────────────────────────────────
|
||||||
|
# In production the frontend is co-located (same origin), so CORS isn't needed.
|
||||||
|
# Keep it configured here so the dev setup (Vite on :5173) still works.
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
o.strip()
|
||||||
|
for o in os.environ.get(
|
||||||
|
'CORS_ALLOWED_ORIGINS',
|
||||||
|
'http://localhost:5173,http://127.0.0.1:5173',
|
||||||
|
).split(',')
|
||||||
|
if o.strip()
|
||||||
|
]
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
CORS_ALLOW_HEADERS = [
|
||||||
|
'accept',
|
||||||
|
'accept-encoding',
|
||||||
|
'authorization',
|
||||||
|
'content-type',
|
||||||
|
'dnt',
|
||||||
|
'origin',
|
||||||
|
'user-agent',
|
||||||
|
'x-csrftoken',
|
||||||
|
'x-requested-with',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── WebAuthn / Touch ID ────────────────────────────────────────────────────────
|
||||||
|
# RP_ID must be the effective domain of the origin (no port, no scheme).
|
||||||
|
# WEBAUTHN_ORIGIN must be the exact origin browsers will use to reach the app.
|
||||||
|
|
||||||
|
WEBAUTHN_RP_ID = os.environ.get('WEBAUTHN_RP_ID', 'localhost')
|
||||||
|
WEBAUTHN_ORIGIN = os.environ.get('WEBAUTHN_ORIGIN', 'http://localhost:5173')
|
||||||
|
|
||||||
|
# ── Production HTTPS hardening ─────────────────────────────────────────────────
|
||||||
|
# These only activate when DEBUG=False. Assumes nginx terminates TLS and sets
|
||||||
|
# the X-Forwarded-Proto header before proxying to Gunicorn/Django.
|
||||||
|
|
||||||
|
if not DEBUG:
|
||||||
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
|
SECURE_HSTS_SECONDS = 31536000
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||||
|
SECURE_HSTS_PRELOAD = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
10
backend/config/urls.py
Normal file
10
backend/config/urls.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
# Django's built-in login/logout views — DOT redirects here when unauthenticated
|
||||||
|
path('accounts/', include('django.contrib.auth.urls')),
|
||||||
|
path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
|
||||||
|
path('api/', include('api.urls')),
|
||||||
|
]
|
||||||
6
backend/config/wsgi.py
Normal file
6
backend/config/wsgi.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import os
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
20
backend/manage.py
Normal file
20
backend/manage.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Django==4.2.11
|
||||||
|
djangorestframework==3.15.1
|
||||||
|
django-oauth-toolkit==2.4.0
|
||||||
|
django-cors-headers==4.3.1
|
||||||
|
webauthn>=2.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
102
backend/templates/oauth2_provider/base.html
Normal file
102
backend/templates/oauth2_provider/base.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{% block title %}OAuth2 Demo{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-center {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 32px rgba(0,0,0,.1);
|
||||||
|
padding: 3rem 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-center-heading {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
p { color: #6b7280; font-size: .95rem; margin-bottom: .75rem; }
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li {
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: .375rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li::before { content: "✓ "; color: #4f46e5; font-weight: 700; }
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: .75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: .75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #fff;
|
||||||
|
color: #374151;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { background: #f9fafb; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #4f46e5;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { background: #4338ca; border-color: #4338ca; }
|
||||||
|
|
||||||
|
.errorlist {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: .875rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { color: #dc2626; margin-bottom: .5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
133
backend/templates/registration/login.html
Normal file
133
backend/templates/registration/login.html
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sign in – OAuth2 Demo</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 32px rgba(0,0,0,.1);
|
||||||
|
padding: 3rem 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo { font-size: 3rem; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: .95rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errors {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
font-size: .875rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { margin-bottom: 1rem; text-align: left; }
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: .375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: .65rem .875rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #1a1a2e;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,.15); }
|
||||||
|
|
||||||
|
button[type="submit"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: .85rem;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: .5rem;
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"]:hover { background: #4338ca; }
|
||||||
|
|
||||||
|
.hint { margin-top: 1.5rem; font-size: .85rem; color: #9ca3af; }
|
||||||
|
.hint strong { color: #6b7280; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo">🔐</div>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p class="subtitle">Enter your credentials to authorise the application</p>
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="errors">
|
||||||
|
Invalid username or password. Please try again.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ next }}" />
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_username">Username</label>
|
||||||
|
<input type="text" id="id_username" name="username"
|
||||||
|
autofocus autocomplete="username" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_password">Password</label>
|
||||||
|
<input type="password" id="id_password" name="password"
|
||||||
|
autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="hint">Demo: <strong>admin</strong> / <strong>admin123</strong></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>OAuth2 Demo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1729
frontend/package-lock.json
generated
Normal file
1729
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "oauth-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.23.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
frontend/src/App.jsx
Normal file
23
frontend/src/App.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import CallbackPage from './pages/CallbackPage'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<LoginPage />} />
|
||||||
|
<Route path="/callback" element={<CallbackPage />} />
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
frontend/src/components/ProtectedRoute.jsx
Normal file
12
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ children }) {
|
||||||
|
const { accessToken } = useAuth()
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
17
frontend/src/config.js
Normal file
17
frontend/src/config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// OAuth2 / backend configuration
|
||||||
|
//
|
||||||
|
// AUTHORIZE_URL uses a relative path so it works in both environments:
|
||||||
|
// - Dev: Vite proxies /o → http://localhost:8000/o
|
||||||
|
// - Prod: nginx routes /o → Django directly (same origin, no proxy needed)
|
||||||
|
//
|
||||||
|
// REDIRECT_URI derives from the current page's origin at runtime, so it
|
||||||
|
// automatically resolves to the correct host in dev and production.
|
||||||
|
|
||||||
|
export const CLIENT_ID = 'react-oauth-client'
|
||||||
|
export const REDIRECT_URI = `${window.location.origin}/callback`
|
||||||
|
export const SCOPES = 'read'
|
||||||
|
|
||||||
|
export const AUTHORIZE_URL = '/o/authorize/'
|
||||||
|
export const TOKEN_URL = '/o/token/'
|
||||||
|
export const REVOKE_URL = '/o/revoke_token/'
|
||||||
|
export const API_BASE = '/api'
|
||||||
33
frontend/src/context/AuthContext.jsx
Normal file
33
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [accessToken, setAccessToken] = useState(
|
||||||
|
() => sessionStorage.getItem('access_token') ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
const setToken = useCallback((token) => {
|
||||||
|
sessionStorage.setItem('access_token', token)
|
||||||
|
setAccessToken(token)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearToken = useCallback(() => {
|
||||||
|
sessionStorage.removeItem('access_token')
|
||||||
|
sessionStorage.removeItem('pkce_verifier')
|
||||||
|
sessionStorage.removeItem('oauth_state')
|
||||||
|
setAccessToken(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ accessToken, setToken, clearToken }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
19
frontend/src/index.css
Normal file
19
frontend/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
color: #1a1a2e;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
16
frontend/src/main.jsx
Normal file
16
frontend/src/main.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
90
frontend/src/pages/CallbackPage.jsx
Normal file
90
frontend/src/pages/CallbackPage.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { CLIENT_ID, REDIRECT_URI, TOKEN_URL } from '../config'
|
||||||
|
import styles from './CallbackPage.module.css'
|
||||||
|
|
||||||
|
export default function CallbackPage() {
|
||||||
|
const { setToken } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const called = useRef(false) // prevent double-invocation in StrictMode
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (called.current) return
|
||||||
|
called.current = true
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const code = params.get('code')
|
||||||
|
const returnedState = params.get('state')
|
||||||
|
const error = params.get('error')
|
||||||
|
const errorDesc = params.get('error_description')
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('OAuth error:', error, errorDesc)
|
||||||
|
navigate(`/?error=${encodeURIComponent(errorDesc ?? error)}`, { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
navigate('/?error=missing_code', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedState = sessionStorage.getItem('oauth_state')
|
||||||
|
if (returnedState !== savedState) {
|
||||||
|
console.error('State mismatch — possible CSRF attack')
|
||||||
|
navigate('/?error=state_mismatch', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifier = sessionStorage.getItem('pkce_verifier')
|
||||||
|
if (!verifier) {
|
||||||
|
navigate('/?error=missing_verifier', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exchangeCode(code, verifier)
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function exchangeCode(code, verifier) {
|
||||||
|
try {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
code_verifier: verifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await fetch(TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(err.error_description ?? err.error ?? 'Token exchange failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
setToken(data.access_token)
|
||||||
|
|
||||||
|
// Clean up PKCE state
|
||||||
|
sessionStorage.removeItem('pkce_verifier')
|
||||||
|
sessionStorage.removeItem('oauth_state')
|
||||||
|
|
||||||
|
navigate('/dashboard', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Token exchange error:', err)
|
||||||
|
navigate(`/?error=${encodeURIComponent(err.message)}`, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<p className={styles.message}>Completing sign-in…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
frontend/src/pages/CallbackPage.module.css
Normal file
26
frontend/src/pages/CallbackPage.module.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #4f46e5;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
242
frontend/src/pages/DashboardPage.jsx
Normal file
242
frontend/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { API_BASE, REVOKE_URL, CLIENT_ID, REDIRECT_URI, SCOPES, AUTHORIZE_URL } from '../config'
|
||||||
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from '../utils/pkce'
|
||||||
|
import {
|
||||||
|
isWebAuthnSupported,
|
||||||
|
prepareRegistrationOptions,
|
||||||
|
serializeRegistrationCredential,
|
||||||
|
} from '../utils/webauthn'
|
||||||
|
import styles from './DashboardPage.module.css'
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { accessToken, clearToken } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false)
|
||||||
|
const [authUrl, setAuthUrl] = useState(null)
|
||||||
|
|
||||||
|
const [touchIdStatus, setTouchIdStatus] = useState(null) // 'registering' | 'success' | 'error'
|
||||||
|
const [touchIdMessage, setTouchIdMessage] = useState('')
|
||||||
|
|
||||||
|
const webAuthnAvailable = isWebAuthnSupported()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function buildAuthUrl() {
|
||||||
|
const verifier = generateCodeVerifier()
|
||||||
|
const challenge = await generateCodeChallenge(verifier)
|
||||||
|
const state = generateState()
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
scope: SCOPES,
|
||||||
|
state,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
})
|
||||||
|
setAuthUrl(`${AUTHORIZE_URL}?${params}`)
|
||||||
|
}
|
||||||
|
buildAuthUrl()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUser()
|
||||||
|
}, [accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function fetchUser() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/me/`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
setUser(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
setLoggingOut(true)
|
||||||
|
try {
|
||||||
|
await fetch(REVOKE_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ token: accessToken, client_id: CLIENT_ID }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Ignore revocation errors — clear locally regardless
|
||||||
|
} finally {
|
||||||
|
clearToken()
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegisterTouchId() {
|
||||||
|
setTouchIdStatus('registering')
|
||||||
|
setTouchIdMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get registration options from server
|
||||||
|
const beginRes = await fetch(`${API_BASE}/webauthn/register/begin/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
if (!beginRes.ok) {
|
||||||
|
const data = await beginRes.json()
|
||||||
|
throw new Error(data.detail || 'Failed to start registration.')
|
||||||
|
}
|
||||||
|
const options = await beginRes.json()
|
||||||
|
|
||||||
|
// 2. Ask browser / authenticator (Touch ID prompt)
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: prepareRegistrationOptions(options),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Send attestation to server
|
||||||
|
const completeRes = await fetch(`${API_BASE}/webauthn/register/complete/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(serializeRegistrationCredential(credential)),
|
||||||
|
})
|
||||||
|
const completeData = await completeRes.json()
|
||||||
|
if (!completeRes.ok) throw new Error(completeData.detail || 'Registration failed.')
|
||||||
|
|
||||||
|
setTouchIdStatus('success')
|
||||||
|
setTouchIdMessage('Touch ID registered! You can now sign in with Touch ID.')
|
||||||
|
// Refresh user to update has_touch_id flag
|
||||||
|
fetchUser()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
setTouchIdStatus('error')
|
||||||
|
setTouchIdMessage('Biometric prompt was dismissed.')
|
||||||
|
} else {
|
||||||
|
setTouchIdStatus('error')
|
||||||
|
setTouchIdMessage(err.message || 'Registration failed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.headerInner}>
|
||||||
|
<span className={styles.headerLogo}>🔐 OAuth2 Demo</span>
|
||||||
|
<button
|
||||||
|
className={styles.logoutBtn}
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={loggingOut}
|
||||||
|
>
|
||||||
|
{loggingOut ? 'Signing out…' : 'Sign out'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
<h1 className={styles.title}>Dashboard</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.errorBanner}>
|
||||||
|
Failed to load user info: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!user && !error && (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.avatar}>
|
||||||
|
{user.username?.[0]?.toUpperCase() ?? '?'}
|
||||||
|
</div>
|
||||||
|
<h2 className={styles.username}>{user.username}</h2>
|
||||||
|
{user.email && <p className={styles.email}>{user.email}</p>}
|
||||||
|
|
||||||
|
<dl className={styles.details}>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<dt>User ID</dt>
|
||||||
|
<dd>{user.id}</dd>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<dt>First name</dt>
|
||||||
|
<dd>{user.first_name || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<dt>Last name</dt>
|
||||||
|
<dd>{user.last_name || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<dt>Member since</dt>
|
||||||
|
<dd>{new Date(user.date_joined).toLocaleDateString()}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className={styles.tokenInfo}>
|
||||||
|
<p className={styles.tokenLabel}>Access Token (truncated)</p>
|
||||||
|
<code className={styles.tokenValue}>
|
||||||
|
{accessToken.slice(0, 20)}…
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Touch ID section ─────────────────────────────────── */}
|
||||||
|
{webAuthnAvailable && (
|
||||||
|
<div className={styles.touchIdSection}>
|
||||||
|
<p className={styles.touchIdLabel}>Touch ID / Biometric Login</p>
|
||||||
|
{user.has_touch_id ? (
|
||||||
|
<p className={styles.touchIdRegistered}>
|
||||||
|
Touch ID is registered for this account.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className={styles.touchIdBtn}
|
||||||
|
onClick={handleRegisterTouchId}
|
||||||
|
disabled={touchIdStatus === 'registering'}
|
||||||
|
>
|
||||||
|
{touchIdStatus === 'registering'
|
||||||
|
? 'Waiting for biometric…'
|
||||||
|
: user.has_touch_id
|
||||||
|
? 'Register another Touch ID'
|
||||||
|
: 'Register Touch ID'}
|
||||||
|
</button>
|
||||||
|
{touchIdStatus === 'success' && (
|
||||||
|
<p className={styles.touchIdSuccess}>{touchIdMessage}</p>
|
||||||
|
)}
|
||||||
|
{touchIdStatus === 'error' && (
|
||||||
|
<p className={styles.touchIdError}>{touchIdMessage}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authUrl && (
|
||||||
|
<div className={styles.qrSection}>
|
||||||
|
<p className={styles.qrLabel}>Authorize on another device</p>
|
||||||
|
<div className={styles.qrWrapper}>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={authUrl}
|
||||||
|
size={160}
|
||||||
|
bgColor="#ffffff"
|
||||||
|
fgColor="#1a1a2e"
|
||||||
|
level="M"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className={styles.qrHint}>
|
||||||
|
Scan to open the authorization server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
275
frontend/src/pages/DashboardPage.module.css
Normal file
275
frontend/src/pages/DashboardPage.module.css
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ───────────────────────────────────────────────────────────────── */
|
||||||
|
.header {
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerInner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerLogo {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #374151;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2.5rem 1.5rem;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.07);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Details table ────────────────────────────────────────────────────────── */
|
||||||
|
.details {
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow dt {
|
||||||
|
width: 130px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow dd {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Token info ───────────────────────────────────────────────────────────── */
|
||||||
|
.tokenInfo {
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenValue {
|
||||||
|
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #4f46e5;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Touch ID ─────────────────────────────────────────────────────────────── */
|
||||||
|
.touchIdSection {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdRegistered {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #16a34a;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1.5px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdBtn:hover {
|
||||||
|
border-color: #4f46e5;
|
||||||
|
background: #f5f4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdBtn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdSuccess {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdError {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── QR Code ──────────────────────────────────────────────────────────────── */
|
||||||
|
.qrSection {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrWrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrHint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── States ───────────────────────────────────────────────────────────────── */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #4f46e5;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBanner {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
199
frontend/src/pages/LoginPage.jsx
Normal file
199
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from '../utils/pkce'
|
||||||
|
import {
|
||||||
|
isWebAuthnSupported,
|
||||||
|
prepareAuthenticationOptions,
|
||||||
|
serializeAuthenticationCredential,
|
||||||
|
} from '../utils/webauthn'
|
||||||
|
import { CLIENT_ID, REDIRECT_URI, SCOPES, AUTHORIZE_URL } from '../config'
|
||||||
|
import styles from './LoginPage.module.css'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { accessToken } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const [touchIdUsername, setTouchIdUsername] = useState('')
|
||||||
|
const [touchIdLoading, setTouchIdLoading] = useState(false)
|
||||||
|
const [touchIdError, setTouchIdError] = useState(null)
|
||||||
|
|
||||||
|
const webAuthnAvailable = isWebAuthnSupported()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accessToken) navigate('/dashboard', { replace: true })
|
||||||
|
}, [accessToken, navigate])
|
||||||
|
|
||||||
|
/** Shared helper: once credentials are confirmed, redirect into the OAuth PKCE flow. */
|
||||||
|
async function redirectToOAuth() {
|
||||||
|
const verifier = generateCodeVerifier()
|
||||||
|
const challenge = await generateCodeChallenge(verifier)
|
||||||
|
const state = generateState()
|
||||||
|
|
||||||
|
sessionStorage.setItem('pkce_verifier', verifier)
|
||||||
|
sessionStorage.setItem('oauth_state', state)
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
scope: SCOPES,
|
||||||
|
state,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
})
|
||||||
|
|
||||||
|
window.location.href = `${AUTHORIZE_URL}?${params}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/login/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setError(data.detail || 'Login failed.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await redirectToOAuth()
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTouchIdLogin(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setTouchIdError(null)
|
||||||
|
setTouchIdLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get authentication options from server
|
||||||
|
const beginRes = await fetch('/api/webauthn/auth/begin/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: touchIdUsername }),
|
||||||
|
})
|
||||||
|
if (!beginRes.ok) {
|
||||||
|
const data = await beginRes.json()
|
||||||
|
throw new Error(data.detail || 'Touch ID not available for this account.')
|
||||||
|
}
|
||||||
|
const options = await beginRes.json()
|
||||||
|
|
||||||
|
// 2. Trigger biometric prompt
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: prepareAuthenticationOptions(options),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Send assertion to server
|
||||||
|
const completeRes = await fetch('/api/webauthn/auth/complete/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: touchIdUsername,
|
||||||
|
...serializeAuthenticationCredential(credential),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!completeRes.ok) {
|
||||||
|
const data = await completeRes.json()
|
||||||
|
throw new Error(data.detail || 'Touch ID verification failed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Identity confirmed — proceed to OAuth PKCE flow
|
||||||
|
await redirectToOAuth()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
setTouchIdError('Biometric prompt was dismissed or timed out.')
|
||||||
|
} else {
|
||||||
|
setTouchIdError(err.message || 'Touch ID sign-in failed.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setTouchIdLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.logo}>🔐</div>
|
||||||
|
<h1 className={styles.title}>OAuth2 Demo</h1>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Sign in with your credentials to authorize access.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ── Password login ─────────────────────────────────────── */}
|
||||||
|
<form onSubmit={handleLogin} className={styles.form}>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
<button className={styles.loginBtn} type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* ── Touch ID login (shown only when WebAuthn is supported) ─ */}
|
||||||
|
{webAuthnAvailable && (
|
||||||
|
<>
|
||||||
|
<div className={styles.divider}>
|
||||||
|
<span>or</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleTouchIdLogin} className={styles.form}>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={touchIdUsername}
|
||||||
|
onChange={e => setTouchIdUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="username webauthn"
|
||||||
|
/>
|
||||||
|
{touchIdError && <p className={styles.error}>{touchIdError}</p>}
|
||||||
|
<button
|
||||||
|
className={styles.touchIdBtn}
|
||||||
|
type="submit"
|
||||||
|
disabled={touchIdLoading}
|
||||||
|
>
|
||||||
|
{touchIdLoading ? 'Waiting for biometric…' : 'Sign in with Touch ID'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className={styles.hint}>
|
||||||
|
Demo credentials: <strong>admin</strong> / <strong>admin123</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
193
frontend/src/pages/LoginPage.module.css
Normal file
193
frontend/src/pages/LoginPage.module.css
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 3rem 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #dc2626;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBtn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1.5rem;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.1s;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBtn:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBtn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBtn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint strong {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider::before,
|
||||||
|
.divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1.5rem;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1.5px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s, transform 0.1s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdBtn::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.1em;
|
||||||
|
height: 1.1em;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234f46e5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'/%3E%3C/svg%3E");
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdBtn:hover {
|
||||||
|
border-color: #4f46e5;
|
||||||
|
background: #f5f4ff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdBtn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.touchIdBtn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
min-height: 212px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrPlaceholder {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrLabel {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
43
frontend/src/utils/pkce.js
Normal file
43
frontend/src/utils/pkce.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* PKCE (Proof Key for Code Exchange) utilities.
|
||||||
|
* Uses the Web Crypto API — no external dependencies needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically random code verifier (43-128 chars, base64url).
|
||||||
|
*/
|
||||||
|
export function generateCodeVerifier() {
|
||||||
|
const array = new Uint8Array(48) // 48 bytes → 64-char base64url string
|
||||||
|
crypto.getRandomValues(array)
|
||||||
|
return base64urlEncode(array)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the code challenge from the verifier (S256 method).
|
||||||
|
* @param {string} verifier
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export async function generateCodeChallenge(verifier) {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(verifier)
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||||
|
return base64urlEncode(new Uint8Array(digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random state parameter to prevent CSRF.
|
||||||
|
*/
|
||||||
|
export function generateState() {
|
||||||
|
const array = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(array)
|
||||||
|
return base64urlEncode(array)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function base64urlEncode(buffer) {
|
||||||
|
return btoa(String.fromCharCode(...buffer))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
}
|
||||||
94
frontend/src/utils/webauthn.js
Normal file
94
frontend/src/utils/webauthn.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/** Returns true when the browser supports the WebAuthn API. */
|
||||||
|
export function isWebAuthnSupported() {
|
||||||
|
return (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.PublicKeyCredential === 'function'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a base64url string to an ArrayBuffer. */
|
||||||
|
export function base64urlToBuffer(base64url) {
|
||||||
|
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=')
|
||||||
|
const binary = atob(padded)
|
||||||
|
const buffer = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i++) buffer[i] = binary.charCodeAt(i)
|
||||||
|
return buffer.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode an ArrayBuffer (or Uint8Array) as a base64url string. */
|
||||||
|
export function bufferToBase64url(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ''
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert server-provided registration options so the browser API can consume them.
|
||||||
|
* The server sends base64url strings; the browser expects ArrayBuffers.
|
||||||
|
*/
|
||||||
|
export function prepareRegistrationOptions(options) {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
challenge: base64urlToBuffer(options.challenge),
|
||||||
|
user: {
|
||||||
|
...options.user,
|
||||||
|
id: base64urlToBuffer(options.user.id),
|
||||||
|
},
|
||||||
|
excludeCredentials: (options.excludeCredentials ?? []).map(c => ({
|
||||||
|
...c,
|
||||||
|
id: base64urlToBuffer(c.id),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert server-provided authentication options so the browser API can consume them.
|
||||||
|
*/
|
||||||
|
export function prepareAuthenticationOptions(options) {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
challenge: base64urlToBuffer(options.challenge),
|
||||||
|
allowCredentials: (options.allowCredentials ?? []).map(c => ({
|
||||||
|
...c,
|
||||||
|
id: base64urlToBuffer(c.id),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the PublicKeyCredential returned by navigator.credentials.create()
|
||||||
|
* into a plain JSON object the server can parse.
|
||||||
|
*/
|
||||||
|
export function serializeRegistrationCredential(credential) {
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: bufferToBase64url(credential.rawId),
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
||||||
|
attestationObject: bufferToBase64url(credential.response.attestationObject),
|
||||||
|
},
|
||||||
|
type: credential.type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the PublicKeyCredential returned by navigator.credentials.get()
|
||||||
|
* into a plain JSON object the server can parse.
|
||||||
|
*/
|
||||||
|
export function serializeAuthenticationCredential(credential) {
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: bufferToBase64url(credential.rawId),
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
||||||
|
authenticatorData: bufferToBase64url(credential.response.authenticatorData),
|
||||||
|
signature: bufferToBase64url(credential.response.signature),
|
||||||
|
userHandle: credential.response.userHandle
|
||||||
|
? bufferToBase64url(credential.response.userHandle)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
type: credential.type,
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/vite.config.js
Normal file
21
frontend/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
// Proxy /api calls to Django — avoids CORS issues for API requests
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
// Proxy /o (OAuth endpoints) so token exchange POST avoids CORS preflight issues
|
||||||
|
'/o': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
28
setup.sh
Executable file
28
setup.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "── Setting up Django backend ──────────────────────────────────────────────"
|
||||||
|
cd backend
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py setup_oauth
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── Setting up React frontend ──────────────────────────────────────────────"
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── Done! ──────────────────────────────────────────────────────────────────"
|
||||||
|
echo ""
|
||||||
|
echo "Start the backend:"
|
||||||
|
echo " cd backend && source .venv/bin/activate && python manage.py runserver"
|
||||||
|
echo ""
|
||||||
|
echo "Start the frontend (new terminal):"
|
||||||
|
echo " cd frontend && npm run dev"
|
||||||
|
echo ""
|
||||||
|
echo "Open http://localhost:5173"
|
||||||
|
echo "Credentials: admin / admin123"
|
||||||
Reference in New Issue
Block a user