feat: 改为密码注册和多种密码登入方式,新增密码重置功能

后端改动:
- RegisterSerializer: 改为邮箱+用户名+手机号+密码注册
- CustomTokenObtainPairView: 支持三种登入方式
  * 邮箱验证码登入(验证码有效10分钟,失败5次锁定)
  * 邮箱+密码登入
  * 用户名+密码登入
- 新增 PasswordLoginSerializer: 支持邮箱或用户名登入
- 新增 ResetPasswordSerializer: 请求密码重置
- 新增 ConfirmResetPasswordSerializer: 确认密码重置
- 新增 RequestResetPasswordView: 发送密码重置验证码
- 新增 ConfirmResetPasswordView: 重置密码
- 更新 URLs: 添加 /reset-password/ 和 /confirm-reset-password/

功能特性:
- 注册时需设置密码
- 登入可用邮箱或用户名 + 密码(邮箱和用户名对应同一密码)
- 保留邮箱验证码快速登入
- 忘记密码可通过邮箱验证码重置

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
TianyangZhang 2026-03-25 16:14:57 +08:00
parent b6d5a51c3d
commit 8a5ed86421
3 changed files with 180 additions and 44 deletions

View File

@ -6,11 +6,11 @@ User = get_user_model()
class RegisterSerializer(serializers.Serializer): class RegisterSerializer(serializers.Serializer):
"""邮箱验证码注册 serializer""" """码注册 serializer"""
username = serializers.CharField(max_length=150) username = serializers.CharField(max_length=150)
email = serializers.EmailField() email = serializers.EmailField()
phone = serializers.CharField(max_length=20) phone = serializers.CharField(max_length=20)
code = serializers.CharField(max_length=6, min_length=6) password = serializers.CharField(write_only=True, min_length=6)
def validate_username(self, value): def validate_username(self, value):
"""验证用户名是否已存在""" """验证用户名是否已存在"""
@ -24,39 +24,15 @@ class RegisterSerializer(serializers.Serializer):
raise serializers.ValidationError('邮箱已被注册') raise serializers.ValidationError('邮箱已被注册')
return value return value
def validate(self, attrs):
"""验证邮箱和验证码"""
email = attrs.get('email')
code = attrs.get('code')
# 检查验证码
try:
vc = VerificationCode.objects.filter(email=email).latest('created_at')
except VerificationCode.DoesNotExist:
raise serializers.ValidationError({'code': '请先获取验证码'})
# 检查验证码是否有效
if not vc.is_valid():
raise serializers.ValidationError({'code': '验证码已过期或已使用'})
# 验证码是否正确
if vc.code != code:
vc.increment_attempts()
raise serializers.ValidationError({'code': '验证码错误'})
attrs['vc'] = vc
return attrs
def create(self, validated_data): def create(self, validated_data):
"""创建用户并标记验证码为已使用""" """创建用户"""
vc = validated_data.pop('vc')
user = User.objects.create_user( user = User.objects.create_user(
username=validated_data['username'], username=validated_data['username'],
email=validated_data['email'], email=validated_data['email'],
phone=validated_data['phone'], phone=validated_data['phone'],
password=validated_data['password'],
role='seeker' role='seeker'
) )
vc.mark_as_verified()
return user return user
@ -94,7 +70,7 @@ class SendCodeSerializer(serializers.Serializer):
return value return value
class LoginSerializer(serializers.Serializer): class CodeLoginSerializer(serializers.Serializer):
"""邮箱验证码登入 serializer""" """邮箱验证码登入 serializer"""
email = serializers.EmailField() email = serializers.EmailField()
code = serializers.CharField(max_length=6, min_length=6) code = serializers.CharField(max_length=6, min_length=6)
@ -134,3 +110,90 @@ class LoginSerializer(serializers.Serializer):
attrs['user'] = user attrs['user'] = user
attrs['vc'] = vc attrs['vc'] = vc
return attrs return attrs
class PasswordLoginSerializer(serializers.Serializer):
"""邮箱/用户名 + 密码登入 serializer"""
username = serializers.CharField(required=False, allow_blank=True)
email = serializers.EmailField(required=False, allow_blank=True)
password = serializers.CharField()
def validate(self, attrs):
"""验证用户名/邮箱和密码"""
username = attrs.get('username')
email = attrs.get('email')
password = attrs.get('password')
if not username and not email:
raise serializers.ValidationError('请输入用户名或邮箱')
# 查找用户
user = None
if username:
user = User.objects.filter(username=username).first()
elif email:
user = User.objects.filter(email=email).first()
if not user:
raise serializers.ValidationError('用户不存在')
# 验证密码
if not user.check_password(password):
raise serializers.ValidationError('密码错误')
attrs['user'] = user
return attrs
class ResetPasswordSerializer(serializers.Serializer):
"""请求密码重置 serializer"""
email = serializers.EmailField()
def validate_email(self, value):
"""验证邮箱是否存在"""
if not User.objects.filter(email=value).exists():
raise serializers.ValidationError('该邮箱未在系统中注册')
return value
class ConfirmResetPasswordSerializer(serializers.Serializer):
"""确认密码重置 serializer"""
email = serializers.EmailField()
code = serializers.CharField(max_length=6, min_length=6)
new_password = serializers.CharField(write_only=True, min_length=6)
def validate(self, attrs):
"""验证邮箱、验证码和新密码"""
email = attrs.get('email')
code = attrs.get('code')
# 检查用户是否存在
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
raise serializers.ValidationError('用户不存在')
# 检查验证码
try:
vc = VerificationCode.objects.filter(email=email).latest('created_at')
except VerificationCode.DoesNotExist:
raise serializers.ValidationError({'code': '请先获取验证码'})
# 检查是否被锁定
if vc.locked_until:
from django.utils import timezone
if timezone.now() < vc.locked_until:
raise serializers.ValidationError('验证码错误次数过多请10分钟后重试')
# 检查验证码是否有效
if not vc.is_valid():
raise serializers.ValidationError({'code': '验证码已过期或已使用'})
# 验证码是否正确
if vc.code != code:
vc.increment_attempts()
raise serializers.ValidationError({'code': '验证码错误'})
attrs['user'] = user
attrs['vc'] = vc
return attrs

View File

@ -1,12 +1,18 @@
from django.urls import path from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView from rest_framework_simplejwt.views import TokenRefreshView
from .views import RegisterView, MeView, UserManageViewSet, UserDetailView, SendCodeView, CustomTokenObtainPairView from .views import (
RegisterView, MeView, UserManageViewSet, UserDetailView,
SendCodeView, CustomTokenObtainPairView,
RequestResetPasswordView, ConfirmResetPasswordView
)
urlpatterns = [ urlpatterns = [
path('register/', RegisterView.as_view()), path('register/', RegisterView.as_view()),
path('send-code/', SendCodeView.as_view()), path('send-code/', SendCodeView.as_view()),
path('login/', CustomTokenObtainPairView.as_view()), path('login/', CustomTokenObtainPairView.as_view()),
path('token/refresh/', TokenRefreshView.as_view()), path('token/refresh/', TokenRefreshView.as_view()),
path('reset-password/', RequestResetPasswordView.as_view()),
path('confirm-reset-password/', ConfirmResetPasswordView.as_view()),
path('me/', MeView.as_view()), path('me/', MeView.as_view()),
path('users/', UserManageViewSet.as_view()), path('users/', UserManageViewSet.as_view()),
path('users/<int:pk>/', UserDetailView.as_view()), path('users/<int:pk>/', UserDetailView.as_view()),

View File

@ -8,14 +8,14 @@ from django.contrib.auth import get_user_model
from django.core.mail import send_mail from django.core.mail import send_mail
from django.conf import settings from django.conf import settings
from .models import VerificationCode from .models import VerificationCode
from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer, SendCodeSerializer, LoginSerializer from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer, SendCodeSerializer, CodeLoginSerializer, PasswordLoginSerializer, ResetPasswordSerializer, ConfirmResetPasswordSerializer
from .permissions import IsSuperAdmin from .permissions import IsSuperAdmin
User = get_user_model() User = get_user_model()
class SendCodeView(APIView): class SendCodeView(APIView):
"""发送邮箱验证码""" """发送邮箱验证码(用于登入或密码重置)"""
permission_classes = [AllowAny] permission_classes = [AllowAny]
def post(self, request): def post(self, request):
@ -33,7 +33,7 @@ class SendCodeView(APIView):
# 发送邮件 # 发送邮件
try: try:
send_mail( send_mail(
subject='【集团招聘平台】登入验证码', subject='【集团招聘平台】验证码',
message=f'您的验证码是:{code}\n\n验证码有效期为10分钟请勿泄露给他人。', message=f'您的验证码是:{code}\n\n验证码有效期为10分钟请勿泄露给他人。',
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@offer.com'), from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@offer.com'),
recipient_list=[email], recipient_list=[email],
@ -49,16 +49,75 @@ class SendCodeView(APIView):
return Response({'message': '验证码已发送到您的邮箱'}) return Response({'message': '验证码已发送到您的邮箱'})
class RequestResetPasswordView(APIView):
"""请求密码重置(发送验证码)"""
permission_classes = [AllowAny]
def post(self, request):
serializer = ResetPasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data['email']
# 使用 SendCodeView 的逻辑发送验证码
VerificationCode.objects.filter(email=email).delete()
code = VerificationCode.generate_code()
vc = VerificationCode.objects.create(email=email, code=code)
try:
send_mail(
subject='【集团招聘平台】密码重置验证码',
message=f'您的验证码是:{code}\n\n验证码有效期为10分钟请勿泄露给他人。',
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@offer.com'),
recipient_list=[email],
fail_silently=False,
)
except Exception as e:
vc.delete()
return Response(
{'error': '邮件发送失败,请稍后重试'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({'message': '验证码已发送到您的邮箱'})
class ConfirmResetPasswordView(APIView):
"""确认密码重置"""
permission_classes = [AllowAny]
def post(self, request):
serializer = ConfirmResetPasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
vc = serializer.validated_data['vc']
new_password = serializer.validated_data['new_password']
# 设置新密码
user.set_password(new_password)
user.save()
# 标记验证码为已使用
vc.mark_as_verified()
return Response({'message': '密码重置成功,请使用新密码登入'})
class CustomTokenObtainPairView(TokenObtainPairView): class CustomTokenObtainPairView(TokenObtainPairView):
"""自定义登入视图,支持邮箱验证码和用户名密码两种方式""" """自定义登入视图,支持三种方式:邮箱验证码、邮箱密码、用户名密码"""
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
# 判断是否是邮箱验证码登入(有 email 和 code还是用户名密码登入有 username 和 password # 判断登入方式
if 'email' in request.data and 'code' in request.data: has_code = 'code' in request.data
has_email = 'email' in request.data
has_username = 'username' in request.data
has_password = 'password' in request.data
if has_email and has_code:
# 邮箱验证码登入 # 邮箱验证码登入
return self._login_with_code(request) return self._login_with_code(request)
elif 'username' in request.data and 'password' in request.data: elif (has_email or has_username) and has_password:
# 用户名密码登入 # 邮箱或用户名 + 密码登入
return self._login_with_password(request) return self._login_with_password(request)
else: else:
return Response( return Response(
@ -68,7 +127,7 @@ class CustomTokenObtainPairView(TokenObtainPairView):
def _login_with_code(self, request): def _login_with_code(self, request):
"""邮箱验证码登入""" """邮箱验证码登入"""
serializer = LoginSerializer(data=request.data) serializer = CodeLoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user'] user = serializer.validated_data['user']
@ -86,15 +145,23 @@ class CustomTokenObtainPairView(TokenObtainPairView):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
def _login_with_password(self, request): def _login_with_password(self, request):
"""用户名密码登入""" """邮箱或用户名 + 密码登入"""
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer serializer = PasswordLoginSerializer(data=request.data)
serializer = TokenObtainPairSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return Response(serializer.validated_data, status=status.HTTP_200_OK)
user = serializer.validated_data['user']
# 生成 JWT token
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(user)
return Response({
'refresh': str(refresh),
'access': str(refresh.access_token),
}, status=status.HTTP_200_OK)
class RegisterView(APIView): class RegisterView(APIView):
"""邮箱验证码注册""" """码注册"""
permission_classes = [AllowAny] permission_classes = [AllowAny]
def post(self, request): def post(self, request):