diff --git a/flowbit-backend/core/admin.py b/flowbit-backend/core/admin.py
index 712f30a..97bc463 100644
--- a/flowbit-backend/core/admin.py
+++ b/flowbit-backend/core/admin.py
@@ -17,6 +17,7 @@
Profile,
AuditLog,
PasswordResetToken,
+ EmailVerificationToken,
Ticket,
)
@@ -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
diff --git a/flowbit-backend/core/migrations/0022_emailverificationtoken.py b/flowbit-backend/core/migrations/0022_emailverificationtoken.py
new file mode 100644
index 0000000..c706875
--- /dev/null
+++ b/flowbit-backend/core/migrations/0022_emailverificationtoken.py
@@ -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"),
+ ),
+ ]
diff --git a/flowbit-backend/core/models.py b/flowbit-backend/core/models.py
index 45ddd19..259329e 100644
--- a/flowbit-backend/core/models.py
+++ b/flowbit-backend/core/models.py
@@ -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
diff --git a/flowbit-backend/core/serializers.py b/flowbit-backend/core/serializers.py
index de9b30e..d683e3f 100644
--- a/flowbit-backend/core/serializers.py
+++ b/flowbit-backend/core/serializers.py
@@ -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()
@@ -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)
diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py
index e715367..b60cff5 100644
--- a/flowbit-backend/core/tests.py
+++ b/flowbit-backend/core/tests.py
@@ -28,6 +28,7 @@
UserNotification,
AuditLog,
PasswordResetToken,
+ EmailVerificationToken,
Profile,
Collaborator,
Ticket,
@@ -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/', {
@@ -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',
@@ -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):
diff --git a/flowbit-backend/core/urls.py b/flowbit-backend/core/urls.py
index 9fa2603..021ec66 100644
--- a/flowbit-backend/core/urls.py
+++ b/flowbit-backend/core/urls.py
@@ -26,6 +26,8 @@
ProfileAvatarView,
ChangePasswordView,
ForgotPasswordView,
+ VerifyEmailView,
+ ResendVerificationView,
ResetPasswordConfirmView,
TicketListView,
TicketDetailView,
@@ -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'),
diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py
index 256265c..87924de 100644
--- a/flowbit-backend/core/views.py
+++ b/flowbit-backend/core/views.py
@@ -45,6 +45,7 @@
AuditLog,
Profile,
PasswordResetToken,
+ EmailVerificationToken,
Collaborator,
Ticket,
RepeatTicket,
@@ -106,6 +107,8 @@
AccountDeletionSerializer,
ProfileAvatarSerializer,
ForgotPasswordSerializer,
+ EmailVerificationConfirmSerializer,
+ ResendVerificationSerializer,
ResetPasswordConfirmSerializer,
CollaboratorManageSerializer,
UserRoleUpdateSerializer,
@@ -214,6 +217,45 @@ def build_password_reset_email_body(reset_token, raw_token):
return "\n".join(body_lines)
+def build_email_verification_email_body(verification_token, raw_token):
+ frontend_url = getattr(settings, 'FRONTEND_EMAIL_VERIFICATION_URL', '').strip()
+ body_lines = [
+ "FlowBit email verification",
+ "",
+ "Verify your email address to activate your FlowBit account.",
+ "",
+ f"Selector: {verification_token.selector}",
+ f"Token: {raw_token}",
+ f"Expires At: {timezone.localtime(verification_token.expires_at).isoformat()}",
+ ]
+ if frontend_url:
+ body_lines.extend([
+ "",
+ f"Verify URL: {frontend_url}?selector={verification_token.selector}&token={raw_token}",
+ ])
+ return "\n".join(body_lines)
+
+
+def issue_and_send_email_verification(*, request, user):
+ expiry_hours = getattr(settings, 'EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24)
+ verification_token, raw_token = EmailVerificationToken.issue_for_user(user, expiry_hours=expiry_hours)
+ send_mail(
+ subject='FlowBit email verification',
+ message=build_email_verification_email_body(verification_token, raw_token),
+ from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@flowbit.local'),
+ recipient_list=[user.email],
+ fail_silently=True,
+ )
+ record_audit_log(
+ request,
+ 'auth.email_verification_requested',
+ target=user,
+ details=f"Email verification requested for '{user.username}'",
+ changes={'email': user.email, 'selector': str(verification_token.selector)},
+ )
+ return verification_token
+
+
def collaborator_snapshot(user):
return {
'id': user.id,
@@ -4699,6 +4741,60 @@ def post(self, request):
)
+class VerifyEmailView(APIView):
+ permission_classes = [AllowAny]
+
+ def post(self, request):
+ serializer = EmailVerificationConfirmSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ verification_token = EmailVerificationToken.objects.filter(
+ selector=serializer.validated_data['selector']
+ ).select_related('user').first()
+ if verification_token is None or not verification_token.check_token(serializer.validated_data['token']):
+ return Response(
+ {'detail': 'Verification token is invalid or expired.'},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ user = verification_token.user
+ if not user.is_active:
+ user.is_active = True
+ user.save(update_fields=['is_active'])
+ verification_token.mark_used()
+
+ record_audit_log(
+ request,
+ 'auth.email_verified',
+ target=user,
+ details=f"Email verified for '{user.username}'",
+ changes={'email': user.email, 'selector': str(verification_token.selector)},
+ )
+
+ return Response(
+ {'message': 'Email verified successfully. You can now log in.'},
+ status=status.HTTP_200_OK,
+ )
+
+
+class ResendVerificationView(APIView):
+ permission_classes = [AllowAny]
+
+ def post(self, request):
+ serializer = ResendVerificationSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ email = serializer.validated_data['email'].strip().lower()
+ user = User.objects.filter(email__iexact=email).first()
+ if user and not user.is_active and user.has_usable_password():
+ issue_and_send_email_verification(request=request, user=user)
+
+ return Response(
+ {'message': 'If the email exists, a verification message has been sent.'},
+ status=status.HTTP_200_OK,
+ )
+
+
class ResetPasswordConfirmView(APIView):
permission_classes = [AllowAny]
@@ -4747,6 +4843,7 @@ def post(self, request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
+ issue_and_send_email_verification(request=request, user=user)
record_audit_log(
request,
@@ -4762,7 +4859,7 @@ def post(self, request):
return Response(
{
- 'message': 'Account created successfully. Please log in to continue.',
+ 'message': 'Account created successfully. Check your email to verify your account before logging in.',
'user': UserProfileSerializer(user).data,
},
status=status.HTTP_201_CREATED,
@@ -4799,6 +4896,12 @@ def post(self, request):
used_master_override = True
if user is None:
+ inactive_user = User.objects.filter(username__iexact=auth_username).first()
+ if inactive_user and not inactive_user.is_active and inactive_user.has_usable_password() and inactive_user.check_password(password):
+ return Response(
+ {'detail': 'Verify your email before logging in.'},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
return Response(
{'detail': 'Invalid username or password.'},
status=status.HTTP_400_BAD_REQUEST,
diff --git a/flowbit-backend/flowbit_backend/settings.py b/flowbit-backend/flowbit_backend/settings.py
index 8500266..5f2e048 100644
--- a/flowbit-backend/flowbit_backend/settings.py
+++ b/flowbit-backend/flowbit_backend/settings.py
@@ -189,6 +189,8 @@
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@flowbit.local')
FRONTEND_PASSWORD_RESET_URL = config('FRONTEND_PASSWORD_RESET_URL', default='')
PASSWORD_RESET_TOKEN_EXPIRY_HOURS = config('PASSWORD_RESET_TOKEN_EXPIRY_HOURS', cast=int, default=2)
+FRONTEND_EMAIL_VERIFICATION_URL = config('FRONTEND_EMAIL_VERIFICATION_URL', default='')
+EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS = config('EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', cast=int, default=24)
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
REST_FRAMEWORK = {
diff --git a/flowbit-frontend/src/app/verify-email/page.tsx b/flowbit-frontend/src/app/verify-email/page.tsx
new file mode 100644
index 0000000..d5319ac
--- /dev/null
+++ b/flowbit-frontend/src/app/verify-email/page.tsx
@@ -0,0 +1,14 @@
+import { VerifyEmailPage } from "@/components/auth/verify-email-page";
+
+type VerifyEmailRouteProps = {
+ searchParams: Promise<{
+ selector?: string;
+ token?: string;
+ }>;
+};
+
+export default async function VerifyEmailRoute({ searchParams }: VerifyEmailRouteProps) {
+ const params = await searchParams;
+
+ return
Need another verification email?
+Enter the email address for this account and FlowBit will send a fresh verification link.
+{resendMessage}
: null} +- After your account is created, you will return to the login flow and sign in with your new account details. + After your account is created, FlowBit will send a verification link to your email address before login is allowed.
Verify Email
++ {isSubmitting ? "Verifying your email..." : message ? "Email verified" : "Verification failed"} +
++ {isSubmitting + ? "FlowBit is checking your verification link now." + : message || errorMessage} +
+