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>
|
||||
Reference in New Issue
Block a user