Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@ run:
poetry run python manage.py runserver 0.0.0.0:8000

test:
poetry run python manage.py test
poetry run python manage.py test

create-superuser:
DJANGO_SUPERUSER_PASSWORD=admin poetry run python manage.py createsuperuser --noinput --username=admin --email=admin@example.com
32 changes: 31 additions & 1 deletion auth_system/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'rest_framework_simplejwt.token_blacklist',
'drf_spectacular',
'users',
]
Expand All @@ -52,8 +53,38 @@
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}

# JWT settings
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'UPDATE_LAST_LOGIN': True,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'VERIFYING_KEY': None,
'AUTH_HEADER_TYPES': ('Bearer',),
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
}

# Email settings
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.gmail.com')
EMAIL_PORT = int(os.getenv('EMAIL_PORT', 587))
EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'True').lower() == 'true'
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'noreply@openhands.com')

# Spectacular settings
SPECTACULAR_SETTINGS = {
'TITLE': 'Authentication System API',
Expand All @@ -72,7 +103,6 @@
}
],
}

# Custom User model
AUTH_USER_MODEL = 'users.User'

Expand Down
1 change: 1 addition & 0 deletions auth_system/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('users.urls')),

# API Documentation URLs
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
Expand Down
30 changes: 28 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ gunicorn = "^21.2.0"
argon2-cffi = "^23.1.0"
djangorestframework = "^3.14.0"
djangorestframework-simplejwt = "^5.3.1"
pyotp = "^2.9.0"
django-ratelimit = "^4.1.0"
drf-spectacular = "^0.28.0"

[tool.poetry.group.dev.dependencies]
Expand Down
101 changes: 101 additions & 0 deletions users/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from .models import MFADevice

User = get_user_model()

class UserRegistrationSerializer(serializers.ModelSerializer):
password = serializers.CharField(
write_only=True,
required=True,
help_text="User's password (must meet complexity requirements)"
)
password_confirm = serializers.CharField(
write_only=True,
required=True,
help_text="Password confirmation (must match password)"
)

class Meta:
model = User
fields = ('email', 'username', 'password', 'password_confirm')
extra_kwargs = {
'email': {'help_text': "User's email address"},
'username': {'help_text': "User's desired username"}
}

def validate(self, data):
if data['password'] != data['password_confirm']:
raise serializers.ValidationError("Passwords do not match.")
try:
validate_password(data['password'])
except ValidationError as e:
raise serializers.ValidationError(str(e))
return data

def create(self, validated_data):
validated_data.pop('password_confirm')
user = User.objects.create_user(
email=validated_data['email'],
username=validated_data['username'],
password=validated_data['password']
)
return user

class UserLoginSerializer(serializers.Serializer):
email = serializers.EmailField(
help_text="User's email address"
)
password = serializers.CharField(
help_text="User's password"
)
mfa_code = serializers.CharField(
required=False,
help_text="6-digit MFA code (required if MFA is enabled)"
)

class PasswordResetRequestSerializer(serializers.Serializer):
email = serializers.EmailField(
help_text="Email address of the account to reset password"
)

class PasswordResetConfirmSerializer(serializers.Serializer):
token = serializers.CharField(
help_text="Password reset token received via email"
)
password = serializers.CharField(
write_only=True,
help_text="New password (must meet complexity requirements)"
)
password_confirm = serializers.CharField(
write_only=True,
help_text="New password confirmation (must match password)"
)

def validate(self, data):
if data['password'] != data['password_confirm']:
raise serializers.ValidationError("Passwords do not match.")
try:
validate_password(data['password'])
except ValidationError as e:
raise serializers.ValidationError(str(e))
return data

class MFAEnableSerializer(serializers.ModelSerializer):
"""
Serializer for enabling MFA. Returns the secret key and QR code URI.
"""
class Meta:
model = MFADevice
fields = ('secret_key',)
read_only_fields = ('secret_key',)
extra_kwargs = {
'secret_key': {'help_text': 'TOTP secret key for MFA setup'}
}

class MFAVerifySerializer(serializers.Serializer):
code = serializers.CharField(
help_text="6-digit TOTP verification code"
)
Empty file added users/tests/views/__init__.py
Empty file.
112 changes: 112 additions & 0 deletions users/tests/views/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import pyotp
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from users.models import MFADevice

User = get_user_model()

class AuthenticationTests(TestCase):
def setUp(self):
self.client = APIClient()
self.register_url = reverse('register')
self.login_url = reverse('login')
self.logout_url = reverse('logout')
self.password_reset_url = reverse('password_reset')
self.password_reset_confirm_url = reverse('password_reset_confirm')
self.mfa_enable_url = reverse('mfa_enable')
self.mfa_verify_url = reverse('mfa_verify')

self.user_data = {
'email': 'test@example.com',
'username': 'testuser',
'password': 'TestPass123!',
'password_confirm': 'TestPass123!'
}

def test_user_registration(self):
response = self.client.post(self.register_url, self.user_data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertTrue(User.objects.filter(email=self.user_data['email']).exists())

def test_user_login(self):
# Create user
User.objects.create_user(
email=self.user_data['email'],
username=self.user_data['username'],
password=self.user_data['password']
)

# Login
response = self.client.post(self.login_url, {
'email': self.user_data['email'],
'password': self.user_data['password']
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('access', response.data)
self.assertIn('refresh', response.data)

def test_user_logout(self):
# Create and login user
user = User.objects.create_user(
email=self.user_data['email'],
username=self.user_data['username'],
password=self.user_data['password']
)
response = self.client.post(self.login_url, {
'email': self.user_data['email'],
'password': self.user_data['password']
})
refresh_token = response.data['refresh']

# Logout
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {response.data["access"]}')
response = self.client.post(self.logout_url, {'refresh': refresh_token})
self.assertEqual(response.status_code, status.HTTP_200_OK)

def test_password_reset_request(self):
# Create user
User.objects.create_user(
email=self.user_data['email'],
username=self.user_data['username'],
password=self.user_data['password']
)

response = self.client.post(self.password_reset_url, {
'email': self.user_data['email']
})
self.assertEqual(response.status_code, status.HTTP_200_OK)

def test_mfa_enable_and_verify(self):
# Create and login user
user = User.objects.create_user(
email=self.user_data['email'],
username=self.user_data['username'],
password=self.user_data['password']
)
response = self.client.post(self.login_url, {
'email': self.user_data['email'],
'password': self.user_data['password']
})
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {response.data["access"]}')

# Enable MFA
response = self.client.post(self.mfa_enable_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('secret', response.data)
self.assertIn('qr_code_uri', response.data)

# Get MFA device
mfa_device = MFADevice.objects.get(user=user)
totp = pyotp.TOTP(mfa_device.secret_key)
code = totp.now()

# Verify MFA
response = self.client.post(self.mfa_verify_url, {'code': code})
self.assertEqual(response.status_code, status.HTTP_200_OK)

# Check user has MFA enabled
user.refresh_from_db()
self.assertTrue(user.two_factor_enabled)
14 changes: 14 additions & 0 deletions users/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from . import views

urlpatterns = [
path('auth/register/', views.RegisterView.as_view(), name='register'),
path('auth/login/', views.LoginView.as_view(), name='login'),
path('auth/logout/', views.LogoutView.as_view(), name='logout'),
path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('auth/password-reset/', views.PasswordResetView.as_view(), name='password_reset'),
path('auth/password-reset-confirm/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
path('auth/mfa/enable/', views.MFAEnableView.as_view(), name='mfa_enable'),
path('auth/mfa/verify/', views.MFAVerifyView.as_view(), name='mfa_verify'),
]
Loading