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
95 changes: 95 additions & 0 deletions flowbit-backend/core/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,27 @@ def test_register_rejects_password_mismatch(self):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('confirm_password', response.data)

@patch('core.views.send_mail', side_effect=Exception('smtp down'))
def test_register_returns_operational_error_when_verification_email_fails(self, mock_send_mail):
response = self.client.post('/api/auth/register/', {
'full_name': 'Delivery Failure User',
'username': 'delivery_failure_user',
'email': 'delivery-failure@example.com',
'phone_number': '+44-7000-000009',
'password': 'strong-pass-456',
'confirm_password': 'strong-pass-456',
}, format='json')

self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
self.assertEqual(
response.data['detail'],
'Account created, but we could not send the verification email right now. Please try resending verification shortly.',
)
created_user = User.objects.get(username='delivery_failure_user')
self.assertFalse(created_user.is_active)
self.assertTrue(AuditLog.objects.filter(action='auth.register', target_id=created_user.id).exists())
self.assertTrue(AuditLog.objects.filter(action='auth.email_delivery_failed', target_id=created_user.id).exists())

def test_login_returns_token_and_user_payload(self):
response = self.client.post('/api/auth/login/', {
'username': 'auth_user',
Expand Down Expand Up @@ -446,6 +467,7 @@ def test_verify_email_rejects_invalid_token(self):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data['detail'], 'Verification token is invalid or expired.')

@override_settings(EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=0)
def test_resend_verification_sends_new_email_for_inactive_user(self):
self.client.post('/api/auth/register/', {
'full_name': 'Resend User',
Expand All @@ -467,6 +489,61 @@ def test_resend_verification_sends_new_email_for_inactive_user(self):
self.assertIsNotNone(first_token.used_at)
self.assertEqual(EmailVerificationToken.objects.filter(user__username='resend_user').count(), 2)

def test_resend_verification_returns_generic_message_for_unknown_email(self):
response = self.client.post('/api/auth/resend-verification/', {
'email': 'missing-verify@example.com',
}, format='json')

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['message'], 'If the email exists, a verification message has been sent.')
self.assertEqual(len(mail.outbox), 0)

@override_settings(EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=60)
def test_resend_verification_is_rate_limited_when_requested_too_soon(self):
self.client.post('/api/auth/register/', {
'full_name': 'Resend Limited User',
'username': 'resend_limited_user',
'email': 'resend-limited@example.com',
'phone_number': '+44-7000-000010',
'password': 'strong-pass-456',
'confirm_password': 'strong-pass-456',
}, format='json')

response = self.client.post('/api/auth/resend-verification/', {
'email': 'resend-limited@example.com',
}, format='json')

self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
self.assertIn('Please wait', response.data['detail'])
self.assertTrue(
AuditLog.objects.filter(action='auth.email_verification_resend_rate_limited').exists()
)

@patch('core.views.send_mail', side_effect=Exception('smtp down'))
@override_settings(EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=0)
def test_resend_verification_returns_operational_error_when_email_fails(self, mock_send_mail):
self.client.post('/api/auth/register/', {
'full_name': 'Resend Failure User',
'username': 'resend_failure_user',
'email': 'resend-failure@example.com',
'phone_number': '+44-7000-000011',
'password': 'strong-pass-456',
'confirm_password': 'strong-pass-456',
}, format='json')

response = self.client.post('/api/auth/resend-verification/', {
'email': 'resend-failure@example.com',
}, format='json')

self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
self.assertEqual(
response.data['detail'],
'We could not send the verification email right now. Please try again shortly.',
)
self.assertTrue(
AuditLog.objects.filter(action='auth.email_delivery_failed', target_id=User.objects.get(username='resend_failure_user').id).exists()
)

def test_login_accepts_email_address(self):
response = self.client.post('/api/auth/login/', {
'username': 'auth@example.com',
Expand Down Expand Up @@ -783,6 +860,24 @@ def test_forgot_password_returns_generic_message_for_unknown_email(self):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(mail.outbox), 0)

@patch('core.views.send_mail', side_effect=Exception('smtp down'))
def test_forgot_password_returns_operational_error_when_email_fails(self, mock_send_mail):
response = self.client.post('/api/auth/forgot-password/', {
'email': 'auth@example.com',
}, format='json')

self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
self.assertEqual(
response.data['detail'],
'We could not send the password reset email right now. Please try again shortly.',
)
self.assertTrue(
AuditLog.objects.filter(action='auth.email_delivery_failed', target_id=self.user.id).exists()
)
self.assertFalse(
AuditLog.objects.filter(action='auth.password_reset_requested', target_id=self.user.id).exists()
)

def test_reset_password_completes_with_valid_token(self):
self.client.post('/api/auth/forgot-password/', {
'email': 'auth@example.com',
Expand Down
114 changes: 96 additions & 18 deletions flowbit-backend/core/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
# All imports organized in ONE place at the top
import csv
import logging
from datetime import datetime, time
from decimal import Decimal, InvalidOperation
from io import BytesIO
from itertools import permutations

from rest_framework import viewsets, generics, status, mixins
from rest_framework.permissions import AllowAny
from rest_framework.exceptions import ValidationError as DRFValidationError
Expand All @@ -19,11 +26,6 @@
from django.db.models.functions import Coalesce, Greatest
from django.core.exceptions import ValidationError
from rest_framework.authtoken.models import Token
from decimal import Decimal, InvalidOperation
from datetime import datetime, time
import csv
from io import BytesIO
from itertools import permutations
from reportlab.lib.pagesizes import letter
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
Expand Down Expand Up @@ -117,6 +119,8 @@
TicketReceiptPdfSerializer,
)

logger = logging.getLogger(__name__)


def parse_period_value(value):
if not value:
Expand Down Expand Up @@ -236,15 +240,40 @@ def build_email_verification_email_body(verification_token, raw_token):
return "\n".join(body_lines)


class AuthEmailDeliveryError(Exception):
pass


def send_auth_email(*, request, user, subject, message, audit_action):
try:
send_mail(
subject=subject,
message=message,
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@flowbit.local'),
recipient_list=[user.email],
fail_silently=False,
)
except Exception as exc:
logger.exception("Failed to send auth email '%s' to %s", audit_action, user.email)
record_audit_log(
request,
'auth.email_delivery_failed',
target=user,
details=f"Auth email delivery failed for '{user.username}'",
changes={'email': user.email, 'category': audit_action, 'error': str(exc)},
)
raise AuthEmailDeliveryError from exc


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(
send_auth_email(
request=request,
user=user,
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,
audit_action='email_verification',
)
record_audit_log(
request,
Expand All @@ -256,6 +285,19 @@ def issue_and_send_email_verification(*, request, user):
return verification_token


def get_email_verification_resend_wait_seconds(user):
cooldown_seconds = max(getattr(settings, 'EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS', 60), 0)
if cooldown_seconds == 0:
return 0

latest_token = EmailVerificationToken.objects.filter(user=user).order_by('-created_at').first()
if latest_token is None:
return 0

elapsed_seconds = (timezone.now() - latest_token.created_at).total_seconds()
return max(int(cooldown_seconds - elapsed_seconds), 0)


def collaborator_snapshot(user):
return {
'id': user.id,
Expand Down Expand Up @@ -4720,13 +4762,19 @@ def post(self, request):
if user and user.has_usable_password():
expiry_hours = getattr(settings, 'PASSWORD_RESET_TOKEN_EXPIRY_HOURS', 2)
reset_token, raw_token = PasswordResetToken.issue_for_user(user, expiry_hours=expiry_hours)
send_mail(
subject='FlowBit password reset',
message=build_password_reset_email_body(reset_token, raw_token),
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@flowbit.local'),
recipient_list=[user.email],
fail_silently=True,
)
try:
send_auth_email(
request=request,
user=user,
subject='FlowBit password reset',
message=build_password_reset_email_body(reset_token, raw_token),
audit_action='password_reset',
)
except AuthEmailDeliveryError:
return Response(
{'detail': 'We could not send the password reset email right now. Please try again shortly.'},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
record_audit_log(
request,
'auth.password_reset_requested',
Expand Down Expand Up @@ -4787,7 +4835,27 @@ def post(self, request):
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)
wait_seconds = get_email_verification_resend_wait_seconds(user)
if wait_seconds > 0:
record_audit_log(
request,
'auth.email_verification_resend_rate_limited',
target=user,
details=f"Verification resend blocked for '{user.username}'",
changes={'email': user.email, 'wait_seconds': wait_seconds},
)
return Response(
{'detail': f'Please wait {wait_seconds} seconds before requesting another verification email.'},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)

try:
issue_and_send_email_verification(request=request, user=user)
except AuthEmailDeliveryError:
return Response(
{'detail': 'We could not send the verification email right now. Please try again shortly.'},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)

return Response(
{'message': 'If the email exists, a verification message has been sent.'},
Expand Down Expand Up @@ -4843,7 +4911,6 @@ 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,
Expand All @@ -4857,6 +4924,17 @@ def post(self, request):
},
)

try:
issue_and_send_email_verification(request=request, user=user)
except AuthEmailDeliveryError:
return Response(
{
'detail': 'Account created, but we could not send the verification email right now. Please try resending verification shortly.',
'user': UserProfileSerializer(user).data,
},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)

return Response(
{
'message': 'Account created successfully. Check your email to verify your account before logging in.',
Expand Down
8 changes: 8 additions & 0 deletions flowbit-backend/flowbit_backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,19 @@

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
GOOGLE_OAUTH_CLIENT_ID = config('GOOGLE_OAUTH_CLIENT_ID', default='')
EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend')
EMAIL_HOST = config('EMAIL_HOST', default='localhost')
EMAIL_PORT = config('EMAIL_PORT', cast=int, default=25)
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
EMAIL_USE_TLS = config('EMAIL_USE_TLS', cast=bool, default=False)
EMAIL_USE_SSL = config('EMAIL_USE_SSL', cast=bool, default=False)
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)
EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS = config('EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS', cast=int, default=60)
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

REST_FRAMEWORK = {
Expand Down
14 changes: 11 additions & 3 deletions flowbit-frontend/src/components/auth/auth-input.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useId, useState, type ChangeEventHandler, type HTMLAttributes, type KeyboardEventHandler } from "react";
import { useState, type ChangeEventHandler, type HTMLAttributes, type KeyboardEventHandler } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
import { Input } from "@/components/ui/input";
Expand All @@ -19,6 +19,8 @@ type AuthInputProps = {
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
disabled?: boolean;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
hideErrorMessage?: boolean;
id?: string;
};

export function AuthInput({
Expand All @@ -27,10 +29,16 @@ export function AuthInput({
placeholder,
error,
hint,
hideErrorMessage = false,
className,
id,
...props
}: AuthInputProps & { className?: string }) {
const inputId = useId();
const inputId =
id ||
(props.name
? `auth-input-${props.name}`
: `auth-input-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`);
const descriptionId = `${inputId}-description`;
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === "password";
Expand Down Expand Up @@ -60,7 +68,7 @@ export function AuthInput({
</button>
) : null}
</div>
{error ? (
{error && !hideErrorMessage ? (
<p id={descriptionId} className="mt-2 text-sm text-red-700">
{error}
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { requestPasswordReset } from "@/lib/auth-client";
const recoveryNotes = [
"Enter the email address linked to your account.",
"If the address is recognized, you will receive instructions to reset your password.",
"If the email does not arrive, check your spam or junk folder before trying again.",
"After resetting your password, sign in again to continue to your workspace.",
];

Expand Down
Loading
Loading