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:
parent
1029bf812d
commit
2edc9beef3
|
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,5 +1,51 @@
|
|||
from django.contrib.auth.models import AbstractUser
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from .models import VerificationCode
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
|
@ -36,3 +37,56 @@ class AdminUserSerializer(serializers.ModelSerializer):
|
|||
user.set_password(password)
|
||||
user.save()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
from .views import RegisterView, MeView, UserManageViewSet, UserDetailView
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
from .views import RegisterView, MeView, UserManageViewSet, UserDetailView, SendCodeView, CustomTokenObtainPairView
|
||||
|
||||
urlpatterns = [
|
||||
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('me/', MeView.as_view()),
|
||||
path('users/', UserManageViewSet.as_view()),
|
||||
|
|
|
|||
|
|
@ -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.response import Response
|
||||
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 .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
|
||||
|
||||
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):
|
||||
serializer_class = RegisterSerializer
|
||||
permission_classes = [AllowAny]
|
||||
|
|
|
|||
|
|
@ -90,3 +90,11 @@ LANGUAGE_CODE = 'zh-hans'
|
|||
TIME_ZONE = 'Asia/Shanghai'
|
||||
USE_I18N = 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='')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import client from './client'
|
||||
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 getMe = () => client.get('/auth/me/')
|
||||
export const updateMe = (data) => client.patch('/auth/me/', data)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { login as loginApi, getMe } from '@/api/auth'
|
||||
import { loginApi, getMe } from '@/api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
|
|
@ -13,8 +13,8 @@ export const useAuthStore = defineStore('auth', {
|
|||
isSeeker: s => s.user?.role === 'seeker',
|
||||
},
|
||||
actions: {
|
||||
async login(username, password) {
|
||||
const { data } = await loginApi({ username, password })
|
||||
async login(email, code) {
|
||||
const { data } = await loginApi({ email, code })
|
||||
localStorage.setItem('access_token', data.access)
|
||||
localStorage.setItem('refresh_token', data.refresh)
|
||||
await this.fetchMe()
|
||||
|
|
|
|||
|
|
@ -1,46 +1,188 @@
|
|||
<template>
|
||||
<div class="auth-page">
|
||||
<el-card class="auth-card">
|
||||
<h2>登录</h2>
|
||||
<h2>邮箱登录</h2>
|
||||
<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-button type="primary" native-type="submit" :loading="loading" style="width:100%">登录</el-button>
|
||||
<!-- 邮箱输入 -->
|
||||
<el-form-item>
|
||||
<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>
|
||||
<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>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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 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 route = useRoute()
|
||||
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 {
|
||||
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
|
||||
if (redirect) return router.push(redirect)
|
||||
if (auth.isSeeker) router.push('/seeker/resume')
|
||||
else router.push('/admin/jobs')
|
||||
} catch {
|
||||
ElMessage.error('用户名或密码错误')
|
||||
} catch (err) {
|
||||
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 {
|
||||
loading.value = false
|
||||
logging.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; }
|
||||
.auth-card { width: 380px; }
|
||||
.auth-page {
|
||||
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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue