feat: 实现邮箱验证码登入功能

后端改动:
- 新增 VerificationCode 模型,支持验证码有效期和重试限制
- 新增 SendCodeView 生成并发送邮箱验证码
- 自定义 TokenObtainPairView 支持邮箱+验证码登入
- 添加 SendCodeSerializer 和 LoginSerializer

前端改动:
- 改写 LoginView.vue 为单页面邮箱+验证码登入流程
- 修改 auth API,新增 sendCode() 和修改 loginApi()
- 更新 auth store 的 login 方法支持邮箱和验证码

功能特性:
- 验证码有效期 10 分钟
- 同一邮箱 5 次错误尝试后锁定 10 分钟
- 支持重新发送验证码
- 完全替换原有用户名密码登入方式

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
TianyangZhang 2026-03-25 15:30:23 +08:00
parent 1029bf812d
commit 2edc9beef3
9 changed files with 374 additions and 24 deletions

View File

@ -0,0 +1,30 @@
# Generated by Django 4.2.20 on 2026-03-25 07:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='VerificationCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254)),
('code', models.CharField(max_length=6)),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_verified', models.BooleanField(default=False)),
('attempts', models.IntegerField(default=0)),
('locked_until', models.DateTimeField(blank=True, null=True)),
],
options={
'verbose_name': '验证码',
'verbose_name_plural': '验证码',
'unique_together': {('email', 'code')},
},
),
]

View File

@ -1,5 +1,51 @@
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils import timezone
import random
import string
class VerificationCode(models.Model):
"""邮箱验证码模型"""
email = models.EmailField()
code = models.CharField(max_length=6)
created_at = models.DateTimeField(auto_now_add=True)
is_verified = models.BooleanField(default=False)
attempts = models.IntegerField(default=0)
locked_until = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = '验证码'
verbose_name_plural = '验证码'
unique_together = [['email', 'code']]
def is_valid(self):
"""检查验证码是否有效"""
if self.is_verified:
return False
if self.locked_until and timezone.now() < self.locked_until:
return False
# 检查10分钟内有效
if (timezone.now() - self.created_at).total_seconds() > 600:
return False
return True
def increment_attempts(self):
"""增加失败尝试次数5次后锁定"""
self.attempts += 1
if self.attempts >= 5:
self.locked_until = timezone.now() + timezone.timedelta(minutes=10)
self.save()
def mark_as_verified(self):
"""标记为已使用"""
self.is_verified = True
self.save()
@staticmethod
def generate_code():
"""生成6位数字验证码"""
return ''.join(random.choices(string.digits, k=6))
class User(AbstractUser): class User(AbstractUser):

View File

@ -1,5 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import VerificationCode
User = get_user_model() User = get_user_model()
@ -36,3 +37,56 @@ class AdminUserSerializer(serializers.ModelSerializer):
user.set_password(password) user.set_password(password)
user.save() user.save()
return user return user
class SendCodeSerializer(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 LoginSerializer(serializers.Serializer):
"""邮箱验证码登入 serializer"""
email = serializers.EmailField()
code = serializers.CharField(max_length=6, 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('请先获取验证码')
# 检查是否被锁定
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('验证码已过期或已使用')
# 验证码是否正确
if vc.code != code:
vc.increment_attempts()
raise serializers.ValidationError('验证码错误')
attrs['user'] = user
attrs['vc'] = vc
return attrs

View File

@ -1,10 +1,11 @@
from django.urls import path from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from rest_framework_simplejwt.views import TokenRefreshView
from .views import RegisterView, MeView, UserManageViewSet, UserDetailView from .views import RegisterView, MeView, UserManageViewSet, UserDetailView, SendCodeView, CustomTokenObtainPairView
urlpatterns = [ urlpatterns = [
path('register/', RegisterView.as_view()), path('register/', RegisterView.as_view()),
path('login/', TokenObtainPairView.as_view()), path('send-code/', SendCodeView.as_view()),
path('login/', CustomTokenObtainPairView.as_view()),
path('token/refresh/', TokenRefreshView.as_view()), path('token/refresh/', TokenRefreshView.as_view()),
path('me/', MeView.as_view()), path('me/', MeView.as_view()),
path('users/', UserManageViewSet.as_view()), path('users/', UserManageViewSet.as_view()),

View File

@ -1,14 +1,82 @@
from rest_framework import generics from rest_framework import generics, status
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer from django.core.mail import send_mail
from django.conf import settings
from .models import VerificationCode
from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer, SendCodeSerializer, LoginSerializer
from .permissions import IsSuperAdmin from .permissions import IsSuperAdmin
User = get_user_model() User = get_user_model()
class SendCodeView(APIView):
"""发送邮箱验证码"""
permission_classes = [AllowAny]
def post(self, request):
serializer = SendCodeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data['email']
# 清除旧验证码
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 CustomTokenObtainPairView(TokenObtainPairView):
"""自定义邮箱验证码登入视图"""
serializer_class = LoginSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
vc = serializer.validated_data['vc']
# 标记验证码为已使用
vc.mark_as_verified()
# 生成 JWT token
refresh = self.get_token(user)
return Response({
'refresh': str(refresh),
'access': str(refresh.access_token),
}, status=status.HTTP_200_OK)
@classmethod
def get_token(cls, user):
"""使用 TokenObtainPairSerializer 生成 token"""
token = super().get_token(user)
return token
class RegisterView(generics.CreateAPIView): class RegisterView(generics.CreateAPIView):
serializer_class = RegisterSerializer serializer_class = RegisterSerializer
permission_classes = [AllowAny] permission_classes = [AllowAny]

View File

@ -90,3 +90,11 @@ LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai' TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Email Configuration
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@offer.com')
EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com')
EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')

View File

@ -1,7 +1,8 @@
import client from './client' import client from './client'
import axios from 'axios' import axios from 'axios'
export const login = (data) => axios.post('/api/auth/login/', data) export const sendCode = (email) => axios.post('/api/auth/send-code/', { email })
export const loginApi = (data) => axios.post('/api/auth/login/', data)
export const register = (data) => client.post('/auth/register/', data) export const register = (data) => client.post('/auth/register/', data)
export const getMe = () => client.get('/auth/me/') export const getMe = () => client.get('/auth/me/')
export const updateMe = (data) => client.patch('/auth/me/', data) export const updateMe = (data) => client.patch('/auth/me/', data)

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { login as loginApi, getMe } from '@/api/auth' import { loginApi, getMe } from '@/api/auth'
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
@ -13,8 +13,8 @@ export const useAuthStore = defineStore('auth', {
isSeeker: s => s.user?.role === 'seeker', isSeeker: s => s.user?.role === 'seeker',
}, },
actions: { actions: {
async login(username, password) { async login(email, code) {
const { data } = await loginApi({ username, password }) const { data } = await loginApi({ email, code })
localStorage.setItem('access_token', data.access) localStorage.setItem('access_token', data.access)
localStorage.setItem('refresh_token', data.refresh) localStorage.setItem('refresh_token', data.refresh)
await this.fetchMe() await this.fetchMe()

View File

@ -1,46 +1,188 @@
<template> <template>
<div class="auth-page"> <div class="auth-page">
<el-card class="auth-card"> <el-card class="auth-card">
<h2>登录</h2> <h2>邮箱登录</h2>
<el-form :model="form" @submit.prevent="handleLogin"> <el-form :model="form" @submit.prevent="handleLogin">
<el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item> <!-- 邮箱输入 -->
<el-form-item><el-input v-model="form.password" type="password" placeholder="密码" show-password /></el-form-item> <el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width:100%">登录</el-button> <el-input
v-model="form.email"
placeholder="请输入邮箱"
type="email"
:disabled="codeSent"
/>
</el-form-item>
<!-- 获取验证码按钮 -->
<el-form-item v-if="!codeSent">
<el-button
type="info"
@click="handleSendCode"
:loading="sendingCode"
style="width: 100%"
>
获取验证码
</el-button>
</el-form-item>
<!-- 验证码输入 -->
<el-form-item v-if="codeSent">
<div style="display: flex; gap: 8px;">
<el-input
v-model="form.code"
placeholder="请输入验证码6位"
maxlength="6"
style="flex: 1"
/>
<el-button
type="info"
@click="handleResendCode"
:disabled="codeCountdown > 0"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : '重新获取' }}
</el-button>
</div>
</el-form-item>
<!-- 错误提示和重试次数 -->
<div v-if="codeSent && attemptsLeft < 5" style="margin-bottom: 12px; text-align: center; color: #f56c6c; font-size: 12px;">
还有 {{ attemptsLeft }} 次重试机会
</div>
<div v-if="isLocked" style="margin-bottom: 12px; text-align: center; color: #f56c6c; font-size: 12px;">
验证次数过多请10分钟后重试
</div>
<!-- 登录按钮 -->
<el-button
v-if="codeSent"
type="primary"
native-type="submit"
:loading="logging"
style="width: 100%"
:disabled="isLocked"
>
登录
</el-button>
</el-form> </el-form>
<div style="margin-top:12px;text-align:center">
没有账号<router-link to="/register">立即注册</router-link> <div style="margin-top: 12px; text-align: center; font-size: 12px; color: #999;">
需要帮助<a href="#" style="color: #409eff; text-decoration: none;">联系支持</a>
</div> </div>
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup> <script setup>
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { sendCode, loginApi } from '@/api/auth'
const form = reactive({ username: '', password: '' }) const form = reactive({ email: '', code: '' })
const loading = ref(false) const loading = ref(false)
const codeSent = ref(false)
const sendingCode = ref(false)
const logging = ref(false)
const codeCountdown = ref(0)
const attemptsLeft = ref(5)
const isLocked = ref(false)
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
async function handleLogin() { //
loading.value = true const startCountdown = () => {
codeCountdown.value = 60
const interval = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) clearInterval(interval)
}, 1000)
}
//
async function handleSendCode() {
if (!form.email) {
ElMessage.warning('请输入邮箱')
return
}
sendingCode.value = true
try { try {
await auth.login(form.username, form.password) await sendCode(form.email)
ElMessage.success('验证码已发送到您的邮箱')
codeSent.value = true
attemptsLeft.value = 5
isLocked.value = false
startCountdown()
} catch (err) {
ElMessage.error(err.response?.data?.error || err.response?.data?.email?.[0] || '发送失败,请检查邮箱是否正确')
} finally {
sendingCode.value = false
}
}
//
async function handleResendCode() {
form.code = ''
await handleSendCode()
}
//
async function handleLogin() {
if (!form.code || form.code.length !== 6) {
ElMessage.warning('请输入正确的6位验证码')
return
}
logging.value = true
try {
await auth.login(form.email, form.code)
ElMessage.success('登录成功')
const redirect = route.query.redirect const redirect = route.query.redirect
if (redirect) return router.push(redirect) if (redirect) return router.push(redirect)
if (auth.isSeeker) router.push('/seeker/resume') if (auth.isSeeker) router.push('/seeker/resume')
else router.push('/admin/jobs') else router.push('/admin/jobs')
} catch { } catch (err) {
ElMessage.error('用户名或密码错误') const errorMsg = err.response?.data?.non_field_errors?.[0] ||
err.response?.data?.code?.[0] ||
err.response?.data?.email?.[0] ||
err.response?.data?.error ||
'登录失败'
ElMessage.error(errorMsg)
//
if (errorMsg.includes('错误')) {
attemptsLeft.value = Math.max(0, attemptsLeft.value - 1)
if (attemptsLeft.value === 0) {
isLocked.value = true
ElMessage.error('验证次数过多请10分钟后重试')
}
}
} finally { } finally {
loading.value = false logging.value = false
} }
} }
</script> </script>
<style scoped> <style scoped>
.auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; } .auth-page {
.auth-card { width: 380px; } display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.auth-card {
width: 380px;
}
h2 {
text-align: center;
margin-bottom: 24px;
color: #333;
font-weight: 600;
}
</style> </style>