diff --git a/offer_backend/apps/accounts/migrations/0002_verificationcode.py b/offer_backend/apps/accounts/migrations/0002_verificationcode.py new file mode 100644 index 0000000..4e5c134 --- /dev/null +++ b/offer_backend/apps/accounts/migrations/0002_verificationcode.py @@ -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')}, + }, + ), + ] diff --git a/offer_backend/apps/accounts/models.py b/offer_backend/apps/accounts/models.py index ae0bdb9..9ec3b81 100644 --- a/offer_backend/apps/accounts/models.py +++ b/offer_backend/apps/accounts/models.py @@ -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): diff --git a/offer_backend/apps/accounts/serializers.py b/offer_backend/apps/accounts/serializers.py index 96620f0..deadfca 100644 --- a/offer_backend/apps/accounts/serializers.py +++ b/offer_backend/apps/accounts/serializers.py @@ -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 diff --git a/offer_backend/apps/accounts/urls.py b/offer_backend/apps/accounts/urls.py index 28c4ef6..d5202c3 100644 --- a/offer_backend/apps/accounts/urls.py +++ b/offer_backend/apps/accounts/urls.py @@ -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()), diff --git a/offer_backend/apps/accounts/views.py b/offer_backend/apps/accounts/views.py index 86da59b..00e5adb 100644 --- a/offer_backend/apps/accounts/views.py +++ b/offer_backend/apps/accounts/views.py @@ -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] diff --git a/offer_backend/config/settings/base.py b/offer_backend/config/settings/base.py index ec8f389..2bdc0ac 100644 --- a/offer_backend/config/settings/base.py +++ b/offer_backend/config/settings/base.py @@ -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='') diff --git a/offer_frontend/src/api/auth.js b/offer_frontend/src/api/auth.js index b30ac94..a32a438 100644 --- a/offer_frontend/src/api/auth.js +++ b/offer_frontend/src/api/auth.js @@ -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) diff --git a/offer_frontend/src/stores/auth.js b/offer_frontend/src/stores/auth.js index 54ecf10..055dc74 100644 --- a/offer_frontend/src/stores/auth.js +++ b/offer_frontend/src/stores/auth.js @@ -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() diff --git a/offer_frontend/src/views/auth/LoginView.vue b/offer_frontend/src/views/auth/LoginView.vue index 66e0058..a7a4bac 100644 --- a/offer_frontend/src/views/auth/LoginView.vue +++ b/offer_frontend/src/views/auth/LoginView.vue @@ -1,46 +1,188 @@ + +