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
15 changes: 15 additions & 0 deletions flowbit-backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Profile,
AuditLog,
PasswordResetToken,
EmailVerificationToken,
Ticket,
)

Expand Down Expand Up @@ -273,3 +274,17 @@ def has_add_permission(self, request):

def has_change_permission(self, request, obj=None):
return False


@admin.register(EmailVerificationToken)
class EmailVerificationTokenAdmin(admin.ModelAdmin):
list_display = ('user', 'selector', 'expires_at', 'used_at', 'created_at')
search_fields = ('user__username', 'user__email', 'selector')
list_filter = ('expires_at', 'used_at', 'created_at')
readonly_fields = ('user', 'selector', 'expires_at', 'used_at', 'created_at')

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False
46 changes: 46 additions & 0 deletions flowbit-backend/core/migrations/0022_emailverificationtoken.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid


class Migration(migrations.Migration):

dependencies = [
("core", "0021_repeatticket_serial_number"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="EmailVerificationToken",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("selector", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("token_hash", models.CharField(max_length=128)),
("expires_at", models.DateTimeField()),
("used_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="email_verification_tokens",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="emailverificationtoken",
index=models.Index(fields=["selector"], name="core_emailv_selecto_3d1c2c_idx"),
),
migrations.AddIndex(
model_name="emailverificationtoken",
index=models.Index(fields=["expires_at"], name="core_emailv_expires_21b7a3_idx"),
),
]
44 changes: 44 additions & 0 deletions flowbit-backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2404,3 +2404,47 @@ def issue_for_user(cls, user, expiry_hours=2):
expires_at=timezone.now() + timezone.timedelta(hours=expiry_hours),
)
return reset_token, raw_token


class EmailVerificationToken(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_verification_tokens')
selector = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
token_hash = models.CharField(max_length=128)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['selector']),
models.Index(fields=['expires_at']),
]

def __str__(self):
return f"Email verification token for {self.user.username}"

@property
def is_active(self):
return self.used_at is None and self.expires_at > timezone.now()

def check_token(self, raw_token):
return self.is_active and check_password(raw_token, self.token_hash)

def mark_used(self, used_at=None):
if used_at is None:
used_at = timezone.now()
self.used_at = used_at
self.save(update_fields=['used_at'])

@classmethod
def issue_for_user(cls, user, expiry_hours=24):
cls.objects.filter(user=user, used_at__isnull=True).update(used_at=timezone.now())

raw_token = secrets.token_urlsafe(32)
verification_token = cls.objects.create(
user=user,
token_hash=make_password(raw_token),
expires_at=timezone.now() + timezone.timedelta(hours=expiry_hours),
)
return verification_token, raw_token
10 changes: 10 additions & 0 deletions flowbit-backend/core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,7 @@ def create(self, validated_data):
password=validated_data['password'],
first_name=first_name.strip(),
last_name=last_name.strip(),
is_active=False,
)
profile, _ = Profile.objects.get_or_create(user=user)
profile.phone_number = validated_data['phone_number'].strip()
Expand All @@ -1443,6 +1444,15 @@ class ForgotPasswordSerializer(serializers.Serializer):
email = serializers.EmailField()


class EmailVerificationConfirmSerializer(serializers.Serializer):
selector = serializers.UUIDField()
token = serializers.CharField(write_only=True)


class ResendVerificationSerializer(serializers.Serializer):
email = serializers.EmailField()


class ResetPasswordConfirmSerializer(serializers.Serializer):
selector = serializers.UUIDField()
token = serializers.CharField(write_only=True)
Expand Down
120 changes: 120 additions & 0 deletions flowbit-backend/core/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
UserNotification,
AuditLog,
PasswordResetToken,
EmailVerificationToken,
Profile,
Collaborator,
Ticket,
Expand Down Expand Up @@ -327,11 +328,17 @@ def test_register_creates_user_profile_and_returns_user_payload(self):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
created_user = User.objects.get(username='new_flow_user')
self.assertEqual(created_user.email, 'new-user@example.com')
self.assertFalse(created_user.is_active)
self.assertEqual(created_user.first_name, 'New')
self.assertEqual(created_user.last_name, 'Flow User')
self.assertEqual(created_user.profile.phone_number, '+44-7000-000001')
self.assertEqual(response.data['user']['phone_number'], '+44-7000-000001')
self.assertEqual(len(mail.outbox), 1)
self.assertIn('Selector:', mail.outbox[0].body)
self.assertIn('Token:', mail.outbox[0].body)
self.assertTrue(EmailVerificationToken.objects.filter(user=created_user).exists())
self.assertTrue(AuditLog.objects.filter(action='auth.register', target_id=created_user.id).exists())
self.assertTrue(AuditLog.objects.filter(action='auth.email_verification_requested', target_id=created_user.id).exists())

def test_register_rejects_duplicate_email(self):
response = self.client.post('/api/auth/register/', {
Expand Down Expand Up @@ -374,6 +381,91 @@ def test_login_returns_token_and_user_payload(self):
self.assertTrue(Token.objects.filter(user=self.user, key=response.data['token']).exists())
self.assertTrue(AuditLog.objects.filter(action='auth.login', target_id=self.user.id).exists())

def test_login_rejects_unverified_account_even_with_correct_password(self):
self.client.post('/api/auth/register/', {
'full_name': 'Pending User',
'username': 'pending_user',
'email': 'pending@example.com',
'phone_number': '+44-7000-000004',
'password': 'strong-pass-456',
'confirm_password': 'strong-pass-456',
}, format='json')

response = self.client.post('/api/auth/login/', {
'username': 'pending_user',
'password': 'strong-pass-456',
}, format='json')

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data['detail'], 'Verify your email before logging in.')

def test_verify_email_activates_user(self):
self.client.post('/api/auth/register/', {
'full_name': 'Verify User',
'username': 'verify_user',
'email': 'verify@example.com',
'phone_number': '+44-7000-000005',
'password': 'strong-pass-456',
'confirm_password': 'strong-pass-456',
}, format='json')

created_user = User.objects.get(username='verify_user')
body_lines = mail.outbox[0].body.splitlines()
selector = next(line.split(': ', 1)[1] for line in body_lines if line.startswith('Selector: '))
token_value = next(line.split(': ', 1)[1] for line in body_lines if line.startswith('Token: '))

response = self.client.post('/api/auth/verify-email/', {
'selector': selector,
'token': token_value,
}, format='json')

self.assertEqual(response.status_code, status.HTTP_200_OK)
created_user.refresh_from_db()
self.assertTrue(created_user.is_active)
verification_token = EmailVerificationToken.objects.get(user=created_user)
self.assertIsNotNone(verification_token.used_at)
self.assertTrue(AuditLog.objects.filter(action='auth.email_verified', target_id=created_user.id).exists())

def test_verify_email_rejects_invalid_token(self):
self.client.post('/api/auth/register/', {
'full_name': 'Verify User',
'username': 'verify_user_invalid',
'email': 'verify-invalid@example.com',
'phone_number': '+44-7000-000006',
'password': 'strong-pass-456',
'confirm_password': 'strong-pass-456',
}, format='json')

verification_token = EmailVerificationToken.objects.get(user__username='verify_user_invalid')
response = self.client.post('/api/auth/verify-email/', {
'selector': str(verification_token.selector),
'token': 'wrong-token',
}, format='json')

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data['detail'], 'Verification token is invalid or expired.')

def test_resend_verification_sends_new_email_for_inactive_user(self):
self.client.post('/api/auth/register/', {
'full_name': 'Resend User',
'username': 'resend_user',
'email': 'resend@example.com',
'phone_number': '+44-7000-000007',
'password': 'strong-pass-456',
'confirm_password': 'strong-pass-456',
}, format='json')

first_token = EmailVerificationToken.objects.get(user__username='resend_user')
response = self.client.post('/api/auth/resend-verification/', {
'email': 'resend@example.com',
}, format='json')

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(mail.outbox), 2)
first_token.refresh_from_db()
self.assertIsNotNone(first_token.used_at)
self.assertEqual(EmailVerificationToken.objects.filter(user__username='resend_user').count(), 2)

def test_login_accepts_email_address(self):
response = self.client.post('/api/auth/login/', {
'username': 'auth@example.com',
Expand Down Expand Up @@ -713,6 +805,34 @@ def test_reset_password_token_is_one_time_use(self):
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
self.assertEqual(second_response.status_code, status.HTTP_400_BAD_REQUEST)

def test_reset_password_does_not_activate_unverified_account(self):
self.client.post('/api/auth/register/', {
'full_name': 'Reset Pending User',
'username': 'reset_pending_user',
'email': 'reset-pending@example.com',
'phone_number': '+44-7000-000008',
'password': 'strong-pass-456',
'confirm_password': 'strong-pass-456',
}, format='json')

self.client.post('/api/auth/forgot-password/', {
'email': 'reset-pending@example.com',
}, format='json')

body_lines = mail.outbox[-1].body.splitlines()
selector = next(line.split(': ', 1)[1] for line in body_lines if line.startswith('Selector: '))
token_value = next(line.split(': ', 1)[1] for line in body_lines if line.startswith('Token: '))

response = self.client.post('/api/auth/reset-password/', {
'selector': selector,
'token': token_value,
'new_password': 'new-reset-pass-456',
}, format='json')

self.assertEqual(response.status_code, status.HTTP_200_OK)
pending_user = User.objects.get(username='reset_pending_user')
self.assertFalse(pending_user.is_active)


class RolePermissionTests(APITestCase):
def setUp(self):
Expand Down
4 changes: 4 additions & 0 deletions flowbit-backend/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
ProfileAvatarView,
ChangePasswordView,
ForgotPasswordView,
VerifyEmailView,
ResendVerificationView,
ResetPasswordConfirmView,
TicketListView,
TicketDetailView,
Expand Down Expand Up @@ -60,6 +62,8 @@
path('auth/avatar/', ProfileAvatarView.as_view(), name='auth-avatar'),
path('auth/change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
path('auth/forgot-password/', ForgotPasswordView.as_view(), name='auth-forgot-password'),
path('auth/verify-email/', VerifyEmailView.as_view(), name='auth-verify-email'),
path('auth/resend-verification/', ResendVerificationView.as_view(), name='auth-resend-verification'),
path('auth/reset-password/', ResetPasswordConfirmView.as_view(), name='auth-reset-password'),

path('reports/dashboard/', DashboardReportView.as_view(), name='report-dashboard'),
Expand Down
Loading
Loading