From c4aef881bcc5bc579d0ae30fc73fa49518219919 Mon Sep 17 00:00:00 2001 From: Yaosanqi137 Date: Sun, 21 Sep 2025 00:47:53 +0800 Subject: [PATCH] feat: add JWT auth --- README.md | 4 +- src/backend/email_notice/services.py | 2 +- .../templates/email_notification.html | 2 +- src/backend/email_notice/views.py | 53 +++++---- src/backend/finance/views.py | 5 + src/backend/item_manager/settings.py | 16 ++- src/backend/item_manager/urls.py | 3 + src/backend/items/views.py | 5 + src/backend/personnel/views.py | 3 + src/backend/requirements.txt | Bin 382 -> 490 bytes src/fronted/src/components/AppHeader.vue | 2 +- src/fronted/src/components/LogoutBar.vue | 97 ++++++++++++++++ src/fronted/src/router/index.js | 20 ++++ src/fronted/src/services/api.js | 105 ++++++++++++++---- src/fronted/src/views/Login.vue | 47 ++++---- src/fronted/src/views/Settings.vue | 39 +++---- src/fronted/vue.config.js | 2 +- 17 files changed, 312 insertions(+), 93 deletions(-) create mode 100644 src/fronted/src/components/LogoutBar.vue diff --git a/README.md b/README.md index 24aa3a0..a218022 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ItemManagerWebsite -爱特工作室物品管理及财务管理系统 +爱特工作室管理系统 -爱特工作室物品管理及财务管理系统是中国海洋大学爱特工作室开发的一个用于管理物品和财务的系统。该系统旨在帮助工作室更高效地管理物品库存和财务记录。遵循**MIT协议**开源,允许自由、合法地使用。 +爱特工作室管理系统是中国海洋大学爱特工作室开发的一个用于管理物品和财务的系统。该系统旨在帮助工作室更高效地管理物品库存和财务记录。遵循**MIT协议**开源,允许自由、合法地使用。 ![ITSTUDIO](it-org.svg) diff --git a/src/backend/email_notice/services.py b/src/backend/email_notice/services.py index 49c969d..e1832a4 100644 --- a/src/backend/email_notice/services.py +++ b/src/backend/email_notice/services.py @@ -196,7 +196,7 @@ class EmailNotificationService: ]) for it in data_items: plain_lines.append(f"- {it['label']}: {it['value']}") - plain_lines.append("\n此邮件由爱特工作室物品管理及财务管理系统自动发送") + plain_lines.append("\n此邮件由爱特工作室管理系统自动发送") plain_message = "\n".join(plain_lines) # 发送邮件到所有启用的通知邮箱 diff --git a/src/backend/email_notice/templates/email_notification.html b/src/backend/email_notice/templates/email_notification.html index 30f359c..76ba094 100644 --- a/src/backend/email_notice/templates/email_notification.html +++ b/src/backend/email_notice/templates/email_notification.html @@ -53,7 +53,7 @@ {% endif %} diff --git a/src/backend/email_notice/views.py b/src/backend/email_notice/views.py index d4661d5..0640266 100644 --- a/src/backend/email_notice/views.py +++ b/src/backend/email_notice/views.py @@ -1,23 +1,24 @@ +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication import json import re -from django.http import JsonResponse -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_http_methods - from .services import EmailNotificationService -@csrf_exempt -@require_http_methods(["POST", "GET"]) +@api_view(["GET", "POST"]) +@authentication_classes([JWTAuthentication]) +@permission_classes([IsAuthenticated]) def notification_settings(request): - """通知设置API""" + """通知设置API(需要JWT)""" if request.method == 'GET': try: settings_data = EmailNotificationService.get_notification_settings() all_emails = EmailNotificationService.get_all_notification_emails() - return JsonResponse({ + return Response({ 'success': True, 'data': { 'email_enabled': settings_data.get('email_enabled', False), @@ -26,11 +27,11 @@ def notification_settings(request): } }) except Exception as e: - return JsonResponse({'success': False, 'error': str(e)}, status=500) + return Response({'success': False, 'error': str(e)}, status=500) elif request.method == 'POST': try: - data = json.loads(request.body) + data = request.data if hasattr(request, 'data') else json.loads(request.body or '{}') emails_to_update = data.get('notification_emails') email_enabled_status = data.get('email_enabled') @@ -38,7 +39,7 @@ def notification_settings(request): was_updated = False # 1. 如果请求中包含 'notification_emails' 键,则处理邮箱列表 - if emails_to_update is not None: # 允许 emails_to_update 为空列表 [] + if emails_to_update is not None: # 允许空列表 email_regex = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$') valid_emails_data = [] @@ -70,7 +71,7 @@ def notification_settings(request): # 执行更新成功,返回成功 if was_updated: - return JsonResponse({ + return Response({ 'success': True, 'message': '通知设置已更新', # 返回最新的数据状态 @@ -81,31 +82,31 @@ def notification_settings(request): }) else: # 如果请求体为空或不包含任何有效键,则返回错误 - return JsonResponse({'success': False, 'error': '未提供任何有效的更新数据'}, status=400) + return Response({'success': False, 'error': '未提供任何有效的更新数据'}, status=400) - except json.JSONDecodeError: - return JsonResponse({'success': False, 'error': '无效的JSON数据'}, status=400) except Exception as e: - return JsonResponse({'success': False, 'error': str(e)}, status=500) + return Response({'success': False, 'error': str(e)}, status=500) - return JsonResponse({'success': False, 'error': '不支持的请求方法'}, status=405) + return Response({'success': False, 'error': '不支持的请求方法'}, status=405) -@csrf_exempt -@require_http_methods(["POST"]) + +@api_view(["POST"]) +@authentication_classes([JWTAuthentication]) +@permission_classes([IsAuthenticated]) def toggle_email_status(request): - """切换邮箱启用状态API""" + """切换邮箱启用状态API(需要JWT)""" try: - data = json.loads(request.body) + data = request.data if hasattr(request, 'data') else json.loads(request.body or '{}') email_id = data.get('email_id') is_enabled = data.get('is_enabled', True) if not email_id: - return JsonResponse({'success': False, 'error': '缺少邮箱ID'}, status=400) + return Response({'success': False, 'error': '缺少邮箱ID'}, status=400) success = EmailNotificationService.toggle_email_status(email_id, is_enabled) if success: - return JsonResponse({ + return Response({ 'success': True, 'message': f'邮箱状态已更新为{"启用" if is_enabled else "禁用"}', 'data': { @@ -113,9 +114,7 @@ def toggle_email_status(request): } }) else: - return JsonResponse({'success': False, 'error': '更新邮箱状态失败'}, status=500) + return Response({'success': False, 'error': '更新邮箱状态失败'}, status=500) - except json.JSONDecodeError: - return JsonResponse({'success': False, 'error': '无效的JSON数据'}, status=400) except Exception as e: - return JsonResponse({'success': False, 'error': str(e)}, status=500) \ No newline at end of file + return Response({'success': False, 'error': str(e)}, status=500) diff --git a/src/backend/finance/views.py b/src/backend/finance/views.py index 96a0cb7..78cdbe8 100644 --- a/src/backend/finance/views.py +++ b/src/backend/finance/views.py @@ -5,6 +5,7 @@ from django.utils import timezone from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import FinancialRecord, Department, Category, ProofImage from .serializers import ( @@ -20,6 +21,7 @@ class FinancialRecordViewSet(viewsets.ModelViewSet): """ 获取财务记录 """ + authentication_classes = [JWTAuthentication] queryset = FinancialRecord.objects.all() def get_serializer_class(self): @@ -185,6 +187,7 @@ class ProofImageViewSet(viewsets.ModelViewSet): """ 凭证API """ + authentication_classes = [JWTAuthentication] queryset = ProofImage.objects.all() serializer_class = ProofImageSerializer @@ -283,6 +286,7 @@ class DepartmentViewSet(viewsets.ModelViewSet): """ 获取部门 """ + authentication_classes = [JWTAuthentication] queryset = Department.objects.all() serializer_class = DepartmentSerializer @@ -346,5 +350,6 @@ class CategoryViewSet(viewsets.ModelViewSet): """ 获取分类 """ + authentication_classes = [JWTAuthentication] queryset = Category.objects.all() serializer_class = CategorySerializer diff --git a/src/backend/item_manager/settings.py b/src/backend/item_manager/settings.py index ec80983..9e5c771 100644 --- a/src/backend/item_manager/settings.py +++ b/src/backend/item_manager/settings.py @@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ import json import os +from datetime import timedelta from pathlib import Path # SECURE 文件用来存储敏感信息,如 SECRET_KEY,SMTP信息 等 @@ -134,13 +135,24 @@ AUTH_PASSWORD_VALIDATORS = [ # REST Framework configuration REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": [ - "rest_framework.permissions.AllowAny", + "rest_framework.permissions.IsAuthenticated", ], "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework.authentication.SessionAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", ], } +# JWT 配置(可根据需要调整过期时间) +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "AUTH_HEADER_TYPES": ("Bearer",), +} + # CORS settings CORS_ALLOWED_ORIGINS = [ diff --git a/src/backend/item_manager/urls.py b/src/backend/item_manager/urls.py index b40d14a..8f9429a 100644 --- a/src/backend/item_manager/urls.py +++ b/src/backend/item_manager/urls.py @@ -19,6 +19,7 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import path, include +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns = [ path("admin/", admin.site.urls), @@ -27,6 +28,8 @@ urlpatterns = [ path("", include("email_notice.urls")), path("", include("personnel.urls")), path("api-auth/", include("rest_framework.urls")), + path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), ] if settings.DEBUG: diff --git a/src/backend/items/views.py b/src/backend/items/views.py index 10c02c4..08285a8 100644 --- a/src/backend/items/views.py +++ b/src/backend/items/views.py @@ -3,6 +3,7 @@ from django.utils import timezone from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import Item, ItemUsage, Category from .serializers import ( @@ -13,6 +14,7 @@ from .serializers import ( class ItemViewSet(viewsets.ModelViewSet): """物品管理API""" + authentication_classes = [JWTAuthentication] queryset = Item.objects.all() serializer_class = ItemSerializer @@ -107,6 +109,7 @@ class ItemViewSet(viewsets.ModelViewSet): class ItemUsageViewSet(viewsets.ModelViewSet): """使用记录管理API""" + authentication_classes = [JWTAuthentication] queryset = ItemUsage.objects.all() serializer_class = ItemUsageSerializer @@ -130,11 +133,13 @@ class ItemUsageViewSet(viewsets.ModelViewSet): class CategoryViewSet(viewsets.ModelViewSet): """物品类别管理API""" + authentication_classes = [JWTAuthentication] queryset = Category.objects.all() serializer_class = CategorySerializer class UserViewSet(viewsets.ReadOnlyModelViewSet): """用户管理API(只读)""" + authentication_classes = [JWTAuthentication] queryset = User.objects.all() serializer_class = UserSerializer diff --git a/src/backend/personnel/views.py b/src/backend/personnel/views.py index 2c5dad3..3e40784 100644 --- a/src/backend/personnel/views.py +++ b/src/backend/personnel/views.py @@ -6,6 +6,7 @@ from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.filters import SearchFilter, OrderingFilter from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication from .filters import PersonnelFilter from .models import Personnel, ProjectGroup @@ -20,6 +21,7 @@ logger = logging.getLogger(__name__) class PersonnelViewSet(viewsets.ModelViewSet): """人员信息视图集""" + authentication_classes = [JWTAuthentication] queryset = Personnel.objects.all() filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = PersonnelFilter @@ -191,6 +193,7 @@ class PersonnelViewSet(viewsets.ModelViewSet): class ProjectGroupViewSet(viewsets.ModelViewSet): """项目组视图集""" + authentication_classes = [JWTAuthentication] queryset = ProjectGroup.objects.all() serializer_class = ProjectGroupSerializer filter_backends = [DjangoFilterBackend, SearchFilter] diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index df029f115667e63bb051bee1462160ea350a3c82..7a784c9eab87dd3dcc5f386b1aeca8e81aa67b79 100644 GIT binary patch delta 77 zcmeyz^on^y9iuca0~bR8LnVV3LpVbSgDnsmGUzdw1F_*mX>B!KhGK?HhFpdMh8%`e VhAf71h7zzMQ=kG6He>)90030|4GjPQ delta 7 OcmaFG{Eulv9U}k^iUS=0 diff --git a/src/fronted/src/components/AppHeader.vue b/src/fronted/src/components/AppHeader.vue index 33ff9fb..690c8a0 100644 --- a/src/fronted/src/components/AppHeader.vue +++ b/src/fronted/src/components/AppHeader.vue @@ -5,7 +5,7 @@ -

爱特工作室物品管理及财务管理系统

+

爱特工作室管理系统

+ +
+
+ +
+
账户
+
当前为已登录状态,如需切换账号请先退出登录
+
+
+
+ + 退出登录 + +
+
+
+ + + + + + diff --git a/src/fronted/src/router/index.js b/src/fronted/src/router/index.js index a357e4d..13d8a7f 100644 --- a/src/fronted/src/router/index.js +++ b/src/fronted/src/router/index.js @@ -10,6 +10,7 @@ import FinanceRecordDetail from '../views/FinanceRecordDetail.vue' import PersonnelList from '../views/PersonnelList.vue' import DepartmentProjectGroupManagement from '../views/DepartmentProjectGroupManagement.vue' import Settings from '../views/Settings.vue' +import { authService } from '@/services/api' const routes = [ { @@ -76,4 +77,23 @@ const router = createRouter({ routes }) +router.beforeEach((to, from, next) => { + // 允许直接访问登录页 + if (to.path === '/login') { + if (authService.isAuthenticated()) { + // 已登录,跳转到首页或原定路径 + const redirect = to.query.redirect || '/' + return next(redirect) + } + return next() + } + + // 其他页面需要登录 + if (!authService.isAuthenticated()) { + return next({ path: '/login', query: { redirect: to.fullPath } }) + } + + next() +}) + export default router diff --git a/src/fronted/src/services/api.js b/src/fronted/src/services/api.js index f4447ef..ecdf45f 100644 --- a/src/fronted/src/services/api.js +++ b/src/fronted/src/services/api.js @@ -1,38 +1,106 @@ import axios from 'axios' export const API_BASE_URL = 'http://localhost:8000/api' - export const API_BASE_URL_WITHOUT_API = 'http://localhost:8000' +const ACCESS_TOKEN_KEY = 'access_token' +const REFRESH_TOKEN_KEY = 'refresh_token' + +export const authService = { + getAccessToken() { + return localStorage.getItem(ACCESS_TOKEN_KEY) + }, + getRefreshToken() { + return localStorage.getItem(REFRESH_TOKEN_KEY) + }, + setTokens({ access, refresh }) { + if (access) localStorage.setItem(ACCESS_TOKEN_KEY, access) + if (refresh) localStorage.setItem(REFRESH_TOKEN_KEY, refresh) + }, + clearTokens() { + localStorage.removeItem(ACCESS_TOKEN_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) + }, + isAuthenticated() { + return !!this.getAccessToken() + } +} + const apiClient = axios.create({ baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json' - } + headers: { 'Content-Type': 'application/json' } }) -// 请求拦截器 +// 请求拦截器:自动附加JWT apiClient.interceptors.request.use( config => { - console.log('API请求:', config.method?.toUpperCase(), config.url) + const token = authService.getAccessToken() + if (token) { + config.headers = config.headers || {} + config.headers['Authorization'] = `Bearer ${token}` + } return config }, - error => { + error => Promise.reject(error) +) + +// 响应拦截器:401时尝试刷新token并重试 +let isRefreshing = false +let pendingQueue = [] + +function processQueue(error, token = null) { + pendingQueue.forEach(({ resolve, reject, config }) => { + if (token) { + config.headers['Authorization'] = `Bearer ${token}` + resolve(apiClient(config)) + } else { + reject(error) + } + }) + pendingQueue = [] +} + +apiClient.interceptors.response.use( + response => response, + async error => { + const originalRequest = error.config || {} + const status = error.response?.status + + if (status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + pendingQueue.push({ resolve, reject, config: originalRequest }) + }) + } + + originalRequest._retry = true + isRefreshing = true + + try { + const refresh = authService.getRefreshToken() + if (!refresh) throw new Error('No refresh token') + const resp = await axios.post(`${API_BASE_URL_WITHOUT_API}/api/token/refresh/`, { refresh }) + const newAccess = resp.data?.access + if (!newAccess) throw new Error('No access token in refresh response') + authService.setTokens({ access: newAccess }) + processQueue(null, newAccess) + return apiClient(originalRequest) + } catch (refreshErr) { + processQueue(refreshErr, null) + authService.clearTokens() + // 跳转到登录页 + try { window?.location && (window.location.href = '/login') } catch (_) {} + return Promise.reject(refreshErr) + } finally { + isRefreshing = false + } + } + return Promise.reject(error) } ) -// 响应拦截器 -apiClient.interceptors.response.use( - response => { - console.log('API响应:', response.status, response.config.url) - return response - }, - error => { - console.error('API错误:', error.response?.status, error.response?.data) - return Promise.reject(error) - } -) +export { apiClient } export const itemService = { // 获取所有物品 @@ -314,4 +382,3 @@ export const projectGroupService = { }) } } - diff --git a/src/fronted/src/views/Login.vue b/src/fronted/src/views/Login.vue index f22d5cc..d541b21 100644 --- a/src/fronted/src/views/Login.vue +++ b/src/fronted/src/views/Login.vue @@ -17,7 +17,7 @@