Add email verification, admin approval, and user profile enhancements

Implement comprehensive email verification and admin approval system for user registration. Users must verify their email before logging in, and admins must approve new users before they can access the system.

Major features:
- Email verification with UUID tokens (24hr expiry, one-time use)
- Admin approval workflow via Django admin interface
- Custom authentication backend enforcing verification and approval
- Password change functionality for authenticated users
- Enhanced profile page with proper styling and theme support
- Collect first name, last name, and timezone during registration
- Profile link added to header navigation

Email notifications:
- Verification email sent after registration
- Admin notification when users need approval
- Approval notification sent to users

Security features:
- Cryptographically secure UUID tokens
- Token expiration and one-time use enforcement
- Email enumeration protection
- Custom JWT token validation for API access
- Existing users auto-approved via data migration

Templates added:
- Email templates (verification, approval, admin notification)
- Web templates (password change, verification pages)
- Enhanced profile page with dark/light mode support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Keith Smith
2025-12-22 18:13:38 -07:00
co-authored by Claude Sonnet 4.5
parent 87afc4e80c
commit 21d9d01885
24 changed files with 1269 additions and 73 deletions
+53 -3
View File
@@ -11,16 +11,30 @@ from .models import User, DeviceToken
class UserAdmin(BaseUserAdmin):
"""Admin configuration for User model."""
list_display = ['email', 'username', 'first_name', 'last_name', 'is_staff', 'created_at']
list_filter = ['is_staff', 'is_superuser', 'is_active', 'created_at']
list_display = [
'email', 'username', 'first_name', 'last_name',
'email_verified', 'is_approved', 'is_staff', 'created_at'
]
list_filter = [
'email_verified', 'is_approved', 'is_staff',
'is_superuser', 'is_active', 'created_at'
]
search_fields = ['email', 'username', 'first_name', 'last_name']
ordering = ['-created_at']
actions = ['approve_users', 'unapprove_users']
fieldsets = BaseUserAdmin.fieldsets + (
('Profile', {'fields': ('timezone', 'avatar')}),
('Preferences', {'fields': ('default_reminder_minutes', 'email_notifications', 'push_notifications')}),
('Preferences', {
'fields': ('default_reminder_minutes', 'email_notifications', 'push_notifications')
}),
('Verification & Approval', {
'fields': ('email_verified', 'email_verified_at', 'is_approved', 'approved_by', 'approved_at')
}),
)
readonly_fields = ['email_verified_at', 'approved_by', 'approved_at']
add_fieldsets = (
(None, {
'classes': ('wide',),
@@ -28,6 +42,42 @@ class UserAdmin(BaseUserAdmin):
}),
)
def approve_users(self, request, queryset):
"""Approve selected users."""
from django.utils import timezone
from .utils import send_approval_notification
count = 0
for user in queryset.filter(is_approved=False):
user.is_approved = True
user.approved_by = request.user
user.approved_at = timezone.now()
user.save(update_fields=['is_approved', 'approved_by', 'approved_at'])
# Send notification email
if user.email_notifications:
try:
send_approval_notification(user)
except Exception:
pass # Don't fail approval if email fails
count += 1
self.message_user(request, f'{count} user(s) approved successfully.')
approve_users.short_description = "Approve selected users"
def unapprove_users(self, request, queryset):
"""Remove approval from selected users."""
count = queryset.filter(is_approved=True).update(
is_approved=False,
approved_by=None,
approved_at=None
)
self.message_user(request, f'{count} user(s) unapproved.')
unapprove_users.short_description = "Remove approval from selected users"
@admin.register(DeviceToken)
class DeviceTokenAdmin(admin.ModelAdmin):
+45
View File
@@ -0,0 +1,45 @@
"""
Custom authentication backends for KeepItGoing.
"""
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model
User = get_user_model()
class EmailVerifiedApprovedBackend(ModelBackend):
"""
Authentication backend that requires email verification and admin approval.
This backend extends Django's ModelBackend to add additional checks:
- User must have verified their email address
- User must be approved by an admin
If authentication fails due to these checks, helpful error messages
are stored in the session for the web UI.
"""
def authenticate(self, request, username=None, password=None, **kwargs):
# Call parent to do the actual authentication
user = super().authenticate(request, username=username, password=password, **kwargs)
if user is None:
return None
# Check email verification
if not user.email_verified:
# Store reason for failed login (for web UI)
if request:
request.session['login_error'] = 'email_not_verified'
request.session['login_email'] = user.email
return None
# Check admin approval
if not user.is_approved:
if request:
request.session['login_error'] = 'not_approved'
request.session['login_email'] = user.email
return None
return user
@@ -0,0 +1,87 @@
# Generated by Django 5.2.9 on 2025-12-23 00:46
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
from django.utils import timezone
def approve_existing_users(apps, schema_editor):
"""
Auto-approve and mark as verified all existing users.
This ensures existing users can continue logging in.
"""
User = apps.get_model('users', 'User')
now = timezone.now()
User.objects.all().update(
email_verified=True,
email_verified_at=now,
is_approved=True,
approved_at=now
)
def reverse_approval(apps, schema_editor):
"""Reverse the auto-approval."""
User = apps.get_model('users', 'User')
User.objects.all().update(
email_verified=False,
email_verified_at=None,
is_approved=False,
approved_by=None,
approved_at=None
)
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='approved_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='user',
name='approved_by',
field=models.ForeignKey(blank=True, limit_choices_to={'is_staff': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_users', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='email_verified',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='user',
name='email_verified_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='user',
name='is_approved',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='EmailVerificationToken',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('is_used', models.BooleanField(default=False)),
('used_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='verification_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'email_verification_tokens',
'ordering': ['-created_at'],
},
),
migrations.RunPython(approve_existing_users, reverse_approval),
]
+41
View File
@@ -25,6 +25,22 @@ class User(AbstractUser):
email_notifications = models.BooleanField(default=True)
push_notifications = models.BooleanField(default=True)
# Email verification
email_verified = models.BooleanField(default=False)
email_verified_at = models.DateTimeField(null=True, blank=True)
# Admin approval
is_approved = models.BooleanField(default=False)
approved_by = models.ForeignKey(
'self',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='approved_users',
limit_choices_to={'is_staff': True}
)
approved_at = models.DateTimeField(null=True, blank=True)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -67,3 +83,28 @@ class DeviceToken(models.Model):
def __str__(self):
return f"{self.user.email} - {self.platform} - {self.device_name}"
class EmailVerificationToken(models.Model):
"""
Token for email verification.
Uses UUID tokens with expiration for security.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='verification_tokens')
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
used_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = 'email_verification_tokens'
ordering = ['-created_at']
def is_expired(self):
from django.utils import timezone
return timezone.now() > self.expires_at
def __str__(self):
return f"Verification token for {self.user.email}"
+1 -1
View File
@@ -41,7 +41,7 @@ class UserRegistrationSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['email', 'username', 'password', 'password_confirm', 'first_name', 'last_name']
fields = ['email', 'username', 'password', 'password_confirm', 'first_name', 'last_name', 'timezone']
def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']:
+7 -5
View File
@@ -3,21 +3,20 @@ User URLs for KeepItGoing.
"""
from django.urls import path
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
from rest_framework_simplejwt.views import TokenRefreshView
from . import views
# API URLs (to be included under /api/users/)
api_urlpatterns = [
path('register/', views.RegisterAPIView.as_view(), name='api-register'),
path('verify-email/', views.VerifyEmailAPIView.as_view(), name='api-verify-email'),
path('resend-verification/', views.ResendVerificationEmailAPIView.as_view(), name='api-resend-verification'),
path('profile/', views.UserProfileAPIView.as_view(), name='api-profile'),
path('change-password/', views.ChangePasswordAPIView.as_view(), name='api-change-password'),
path('devices/', views.DeviceTokenAPIView.as_view(), name='api-devices'),
path('devices/<uuid:pk>/', views.DeviceTokenDeleteAPIView.as_view(), name='api-device-delete'),
path('token/', TokenObtainPairView.as_view(), name='token-obtain-pair'),
path('token/', views.TokenObtainPairView.as_view(), name='token-obtain-pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'),
]
@@ -26,5 +25,8 @@ web_urlpatterns = [
path('login/', views.LoginView.as_view(), name='login'),
path('logout/', views.LogoutView.as_view(), name='logout'),
path('register/', views.RegisterView.as_view(), name='register'),
path('verify-email/<uuid:token>/', views.VerifyEmailWebView.as_view(), name='verify-email'),
path('resend-verification/', views.ResendVerificationWebView.as_view(), name='resend-verification'),
path('profile/', views.ProfileView.as_view(), name='profile'),
path('change-password/', views.ChangePasswordWebView.as_view(), name='change-password'),
]
+128
View File
@@ -0,0 +1,128 @@
"""
Utility functions for user management.
"""
from datetime import timedelta
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils import timezone
from .models import EmailVerificationToken
TOKEN_EXPIRY_HOURS = getattr(settings, 'EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24)
def generate_verification_token(user):
"""
Generate a new verification token for user.
Invalidates any existing unused tokens before creating a new one.
"""
# Invalidate any existing unused tokens
EmailVerificationToken.objects.filter(
user=user,
is_used=False
).update(is_used=True)
# Create new token
token = EmailVerificationToken.objects.create(
user=user,
expires_at=timezone.now() + timedelta(hours=TOKEN_EXPIRY_HOURS)
)
return token
def send_verification_email(user, request=None):
"""
Send email verification link to user.
"""
token = generate_verification_token(user)
# Build verification URL
if request:
domain = request.get_host()
protocol = 'https' if request.is_secure() else 'http'
else:
domain = settings.SITE_DOMAIN
protocol = 'https' if not settings.DEBUG else 'http'
verification_url = f"{protocol}://{domain}/verify-email/{token.token}/"
# Render email from template
context = {
'user': user,
'verification_url': verification_url,
'expiry_hours': TOKEN_EXPIRY_HOURS,
}
html_message = render_to_string('emails/verify_email.html', context)
text_message = render_to_string('emails/verify_email.txt', context)
send_mail(
subject='Verify your KeepItGoing email',
message=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
html_message=html_message,
fail_silently=False,
)
def send_approval_notification(user):
"""
Notify user their account has been approved.
"""
protocol = 'https' if not settings.DEBUG else 'http'
login_url = f"{protocol}://{settings.SITE_DOMAIN}/login/"
context = {
'user': user,
'login_url': login_url,
}
html_message = render_to_string('emails/account_approved.html', context)
text_message = render_to_string('emails/account_approved.txt', context)
send_mail(
subject='Your KeepItGoing account has been approved',
message=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
html_message=html_message,
fail_silently=False,
)
def notify_admins_new_user(user):
"""
Notify all staff users about new registration awaiting approval.
"""
from django.contrib.auth import get_user_model
User = get_user_model()
# Get all staff users
staff_users = User.objects.filter(is_staff=True, is_active=True)
staff_emails = [u.email for u in staff_users if u.email]
if not staff_emails:
return
protocol = 'https' if not settings.DEBUG else 'http'
admin_url = f"{protocol}://{settings.SITE_DOMAIN}/admin/users/user/{user.id}/change/"
context = {
'user': user,
'admin_url': admin_url,
}
html_message = render_to_string('emails/admin_new_user.html', context)
text_message = render_to_string('emails/admin_new_user.txt', context)
send_mail(
subject=f'New user registration: {user.email}',
message=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=staff_emails,
html_message=html_message,
fail_silently=True, # Don't fail registration if admin email fails
)
+336 -14
View File
@@ -4,11 +4,15 @@ User views for KeepItGoing.
from django.contrib.auth import get_user_model, login, logout
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views import View
from rest_framework import generics, permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.exceptions import AuthenticationFailed
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.views import TokenObtainPairView as BaseTokenObtainPairView
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from .serializers import (
UserSerializer,
@@ -16,7 +20,7 @@ from .serializers import (
ChangePasswordSerializer,
DeviceTokenSerializer,
)
from .models import DeviceToken
from .models import DeviceToken, EmailVerificationToken
User = get_user_model()
@@ -37,15 +41,16 @@ class RegisterAPIView(generics.CreateAPIView):
serializer.is_valid(raise_exception=True)
user = serializer.save()
# Generate tokens
refresh = RefreshToken.for_user(user)
# Send verification email
from .utils import send_verification_email
send_verification_email(user, request)
# DO NOT generate tokens - user must verify email first
return Response({
'user': UserSerializer(user).data,
'tokens': {
'refresh': str(refresh),
'access': str(refresh.access_token),
}
'message': 'Registration successful! Please check your email to verify your account.',
'email': user.email,
'email_verified': False,
'is_approved': False,
}, status=status.HTTP_201_CREATED)
@@ -97,6 +102,131 @@ class DeviceTokenDeleteAPIView(generics.DestroyAPIView):
return DeviceToken.objects.filter(user=self.request.user)
class VerifyEmailAPIView(APIView):
"""API endpoint for email verification."""
permission_classes = [permissions.AllowAny]
def post(self, request):
token_value = request.data.get('token')
if not token_value:
return Response(
{'error': 'Token is required.'},
status=status.HTTP_400_BAD_REQUEST
)
try:
token = EmailVerificationToken.objects.get(token=token_value)
except EmailVerificationToken.DoesNotExist:
return Response(
{'error': 'Invalid verification token.'},
status=status.HTTP_400_BAD_REQUEST
)
if token.is_used:
return Response(
{'error': 'This verification link has already been used.'},
status=status.HTTP_400_BAD_REQUEST
)
if token.is_expired():
return Response(
{'error': 'This verification link has expired. Please request a new one.'},
status=status.HTTP_400_BAD_REQUEST
)
# Mark email as verified
user = token.user
user.email_verified = True
user.email_verified_at = timezone.now()
user.save(update_fields=['email_verified', 'email_verified_at'])
# Mark token as used
token.is_used = True
token.used_at = timezone.now()
token.save(update_fields=['is_used', 'used_at'])
# Notify admins if not yet approved
if not user.is_approved:
from .utils import notify_admins_new_user
notify_admins_new_user(user)
return Response({
'message': 'Email verified successfully.',
'email_verified': True,
'is_approved': user.is_approved,
})
class ResendVerificationEmailAPIView(APIView):
"""API endpoint to resend verification email."""
permission_classes = [permissions.AllowAny]
def post(self, request):
email = request.data.get('email')
if not email:
return Response(
{'error': 'Email is required.'},
status=status.HTTP_400_BAD_REQUEST
)
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
# Don't reveal if email exists
return Response({
'message': 'If that email is registered, a verification link has been sent.'
})
if user.email_verified:
return Response(
{'error': 'Email is already verified.'},
status=status.HTTP_400_BAD_REQUEST
)
from .utils import send_verification_email
send_verification_email(user, request)
return Response({
'message': 'Verification email sent.'
})
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
"""Custom JWT token serializer that checks verification and approval."""
def validate(self, attrs):
# Authenticate user
data = super().validate(attrs)
# Check email verification
if not self.user.email_verified:
raise AuthenticationFailed({
'error': 'email_not_verified',
'message': 'Please verify your email address before logging in.',
'email': self.user.email,
})
# Check admin approval
if not self.user.is_approved:
raise AuthenticationFailed({
'error': 'not_approved',
'message': 'Your account is pending admin approval.',
'email': self.user.email,
})
return data
class TokenObtainPairView(BaseTokenObtainPairView):
"""Custom JWT token view using our custom serializer."""
serializer_class = CustomTokenObtainPairSerializer
# =============================================================================
# Web Views (Django Templates)
# =============================================================================
@@ -107,7 +237,27 @@ class LoginView(View):
def get(self, request):
if request.user.is_authenticated:
return redirect('dashboard')
return render(request, 'users/login.html')
# Check for special error conditions from authentication backend
error = None
email = None
can_resend = False
if 'login_error' in request.session:
error_type = request.session.pop('login_error')
email = request.session.pop('login_email', None)
if error_type == 'email_not_verified':
error = 'Please verify your email address before logging in.'
can_resend = True
elif error_type == 'not_approved':
error = 'Your account is pending admin approval.'
return render(request, 'users/login.html', {
'error': error,
'email': email,
'can_resend': can_resend,
})
def post(self, request):
from django.contrib.auth import authenticate
@@ -122,8 +272,26 @@ class LoginView(View):
next_url = request.GET.get('next', 'dashboard')
return redirect(next_url)
# Check if error info was set by authentication backend
error = 'Invalid email or password.'
can_resend = False
if 'login_error' in request.session:
error_type = request.session.pop('login_error')
email_from_session = request.session.pop('login_email', None)
if error_type == 'email_not_verified':
error = 'Please verify your email address before logging in.'
can_resend = True
email = email_from_session
elif error_type == 'not_approved':
error = 'Your account is pending admin approval.'
email = email_from_session
return render(request, 'users/login.html', {
'error': 'Invalid email or password.'
'error': error,
'email': email,
'can_resend': can_resend,
})
@@ -152,6 +320,9 @@ class RegisterView(View):
username = request.POST.get('username')
password = request.POST.get('password')
password_confirm = request.POST.get('password_confirm')
first_name = request.POST.get('first_name', '')
last_name = request.POST.get('last_name', '')
timezone = request.POST.get('timezone', 'UTC')
errors = {}
@@ -165,15 +336,32 @@ class RegisterView(View):
errors['username'] = 'Username already taken.'
if errors:
return render(request, 'users/register.html', {'errors': errors})
return render(request, 'users/register.html', {
'errors': errors,
'email': email,
'username': username,
'first_name': first_name,
'last_name': last_name,
'timezone': timezone,
})
user = User.objects.create_user(
email=email,
username=username,
password=password
password=password,
first_name=first_name,
last_name=last_name,
timezone=timezone
)
login(request, user)
return redirect('dashboard')
# Send verification email
from .utils import send_verification_email
send_verification_email(user, request)
# DO NOT auto-login
return render(request, 'users/register_success.html', {
'email': user.email
})
class ProfileView(View):
@@ -200,3 +388,137 @@ class ProfileView(View):
return render(request, 'users/profile.html', {
'success': 'Profile updated successfully.'
})
class VerifyEmailWebView(View):
"""Web view for email verification via link."""
def get(self, request, token):
try:
token_obj = EmailVerificationToken.objects.get(token=token)
except EmailVerificationToken.DoesNotExist:
return render(request, 'users/verify_email.html', {
'success': False,
'error': 'Invalid verification link.'
})
if token_obj.is_used:
return render(request, 'users/verify_email.html', {
'success': False,
'error': 'This verification link has already been used.'
})
if token_obj.is_expired():
return render(request, 'users/verify_email.html', {
'success': False,
'error': 'This verification link has expired.',
'can_resend': True,
'email': token_obj.user.email,
})
# Verify email
user = token_obj.user
user.email_verified = True
user.email_verified_at = timezone.now()
user.save(update_fields=['email_verified', 'email_verified_at'])
token_obj.is_used = True
token_obj.used_at = timezone.now()
token_obj.save(update_fields=['is_used', 'used_at'])
# Notify admins
if not user.is_approved:
from .utils import notify_admins_new_user
notify_admins_new_user(user)
return render(request, 'users/verify_email.html', {
'success': True,
'is_approved': user.is_approved,
})
class ResendVerificationWebView(View):
"""Web view for resending verification email."""
def get(self, request):
return render(request, 'users/resend_verification.html')
def post(self, request):
email = request.POST.get('email')
if not email:
return render(request, 'users/resend_verification.html', {
'error': 'Email is required.'
})
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
# Don't reveal if email exists
return render(request, 'users/resend_verification.html', {
'success': True,
'message': 'If that email is registered, a verification link has been sent.'
})
if user.email_verified:
return render(request, 'users/resend_verification.html', {
'error': 'Email is already verified.'
})
from .utils import send_verification_email
send_verification_email(user, request)
return render(request, 'users/resend_verification.html', {
'success': True,
'message': 'Verification email sent. Please check your inbox.'
})
class ChangePasswordWebView(View):
"""Web view for changing password."""
def get(self, request):
if not request.user.is_authenticated:
return redirect('login')
return render(request, 'users/change_password.html')
def post(self, request):
if not request.user.is_authenticated:
return redirect('login')
old_password = request.POST.get('old_password')
new_password = request.POST.get('new_password')
confirm_password = request.POST.get('confirm_password')
errors = {}
# Validate old password
if not request.user.check_password(old_password):
errors['old_password'] = 'Current password is incorrect.'
# Validate new passwords match
if new_password != confirm_password:
errors['confirm_password'] = 'New passwords do not match.'
# Validate password length
if len(new_password) < 8:
errors['new_password'] = 'Password must be at least 8 characters long.'
# Validate new password is different from old
if old_password == new_password:
errors['new_password'] = 'New password must be different from current password.'
if errors:
return render(request, 'users/change_password.html', {'errors': errors})
# Change the password
request.user.set_password(new_password)
request.user.save()
# Re-login the user so their session isn't invalidated
from django.contrib.auth import update_session_auth_hash
update_session_auth_hash(request, request.user)
return render(request, 'users/change_password.html', {
'success': 'Password changed successfully!'
})