diff --git a/apps/account/serializers/__init__.py b/apps/account/serializers/__init__.py index 178e8c6..75a3847 100644 --- a/apps/account/serializers/__init__.py +++ b/apps/account/serializers/__init__.py @@ -1,3 +1,4 @@ from .user import * from .notification import * -from .location_history import * \ No newline at end of file +from .location_history import * +from .auth import * \ No newline at end of file diff --git a/apps/account/serializers/auth.py b/apps/account/serializers/auth.py new file mode 100644 index 0000000..43223da --- /dev/null +++ b/apps/account/serializers/auth.py @@ -0,0 +1,11 @@ +from rest_framework import serializers + + +class ExchangeTokenSerializer(serializers.Serializer): + temp_token = serializers.CharField(max_length=128) + + def validate_temp_token(self, value: str) -> str: + value = value.strip() + if not value: + raise serializers.ValidationError("temp_token is required.") + return value diff --git a/apps/account/urls.py b/apps/account/urls.py index d378526..ec6f757 100644 --- a/apps/account/urls.py +++ b/apps/account/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path('verify/', views.UserVerifyView.as_view(), name='user-verify'), path('login/', views.UserLoginView.as_view(), name='user-login'), path('guest/', views.UserGuestView.as_view(), name='user-guest'), + path('exchange-token/', views.ExchangeTokenAPIView.as_view(), name='exchange-token'), path('location-update/', views.LocationHistoryView.as_view(), name='user-location-history'), diff --git a/apps/account/views/__init__.py b/apps/account/views/__init__.py index 136fe46..8751a5c 100644 --- a/apps/account/views/__init__.py +++ b/apps/account/views/__init__.py @@ -1,5 +1,6 @@ from .user import * from .notification import * from .location_history import * +from .auth import * diff --git a/apps/account/views/auth.py b/apps/account/views/auth.py new file mode 100644 index 0000000..a4bf4e3 --- /dev/null +++ b/apps/account/views/auth.py @@ -0,0 +1,126 @@ +import logging + +from django.contrib.auth import get_user_model + +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status +from rest_framework.authtoken.models import Token +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from apps.account.serializers import ExchangeTokenSerializer +from utils import absolute_url +from utils.redis import OnlineClassTokenManager + + +logger = logging.getLogger(__name__) +UserModel = get_user_model() + + +class ExchangeTokenAPIView(GenericAPIView): + """ + تبدیل temporary token به اطلاعات کاربر برای ورود از اپ موبایل + """ + permission_classes = [AllowAny] + serializer_class = ExchangeTokenSerializer + + @swagger_auto_schema( + operation_description="Exchange temporary token for user information and authentication token.", + request_body=ExchangeTokenSerializer, + responses={ + status.HTTP_200_OK: openapi.Response( + description="Token exchanged successfully.", + examples={ + "application/json": { + "success": True, + "message": "ورود موفق", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 123, + "fullname": "علی احمدی", + "email": "user@example.com", + "avatar": "https://cdn.example.com/avatar.jpg" + } + } + } + ), + status.HTTP_400_BAD_REQUEST: openapi.Response( + description="Invalid request.", + examples={ + "application/json": { + "success": False, + "message": "توکن ارسال نشده است" + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="Token not found or expired.", + examples={ + "application/json": { + "success": False, + "message": "توکن نامعتبر یا منقضی شده است" + } + } + ), + } + ) + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + temp_token = serializer.validated_data['temp_token'] + + # دریافت اطلاعات از Redis/Cache + manager = OnlineClassTokenManager() + try: + token_data = manager.get_payload(temp_token) + except Exception: + return Response({ + 'success': False, + 'message': 'توکن نامعتبر یا منقضی شده است' + }, status=status.HTTP_404_NOT_FOUND) + + user_id = token_data.get('user_id') + if not user_id: + return Response({ + 'success': False, + 'message': 'توکن نامعتبر است' + }, status=status.HTTP_400_BAD_REQUEST) + + # دریافت کاربر + try: + user = UserModel.objects.get(id=user_id) + except UserModel.DoesNotExist: + return Response({ + 'success': False, + 'message': 'کاربر یافت نشد' + }, status=status.HTTP_404_NOT_FOUND) + + # حذف توکن موقت (one-time use) + manager.delete_token(temp_token) + + # دریافت یا تولید Token واقعی کاربر + auth_token, _ = Token.objects.get_or_create(user=user) + + # دریافت avatar URL + avatar_url = None + if hasattr(user, 'avatar') and user.avatar: + try: + avatar_url = absolute_url(user.avatar.url) + except Exception: + avatar_url = None + + # برگرداندن اطلاعات کاربر با token واقعی + return Response({ + 'success': True, + 'message': 'ورود موفق', + 'token': auth_token.key, + 'user': { + 'id': user.id, + 'fullname': user.get_full_name() or user.username or '', + 'email': user.email or '', + 'avatar': avatar_url + } + }, status=status.HTTP_200_OK) diff --git a/apps/course/views/course.py b/apps/course/views/course.py index fbcd85c..35dd16d 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -305,8 +305,8 @@ class CourseOnlineClassTokenAPIView(GenericAPIView): description="Token generated successfully.", examples={ "application/json": { - "token": "", - "url": "https://frontend.example.com?token=", + "token": "abc123xyz789...", + "url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123xyz789...&slug=python-basics", "expires_in": 300, } } @@ -329,20 +329,18 @@ class CourseOnlineClassTokenAPIView(GenericAPIView): identifier = f"{request.user.id}:{user_token.key[:8]}" token = manager.generate_token(course_id=course.id, user_identifier=identifier) - redirect_path = serializer.validated_data.get('redirect_path') - base_url, path_value = self._build_base_components(request, redirect_path) - manager.store_token(token, { 'course_id': course.id, 'user_id': request.user.id, 'user_token': user_token.key, - 'redirect_path': path_value, + 'course_slug': course.slug, 'extra': { 'professor_in_class': False, }, }) - entry_url = manager.build_entry_url(token, base_url=base_url) + # ساخت URL ثابت با token و course slug + entry_url = f"https://imamjavad.newhorizonco.uk/join-class?token={token}&slug={course.slug}" return Response({ 'token': token, @@ -350,16 +348,6 @@ class CourseOnlineClassTokenAPIView(GenericAPIView): 'expires_in': getattr(settings, 'ONLINE_CLASS_TOKEN_TTL', 300), }, status=status.HTTP_201_CREATED) - def _build_base_components(self, request, redirect_path=None): - base_uri = request.build_absolute_uri('/') - domain = base_uri.rstrip('/') - default_path = getattr(settings, 'ONLINE_CLASS_DEFAULT_PATH', 'join-class') - target_path = redirect_path or default_path - sanitized = str(target_path).strip('/') - path_value = f"/{sanitized}" if sanitized else '/' - base_url = f"{domain}/{sanitized}" if sanitized else domain - return base_url, path_value - @staticmethod def _user_has_access(user, course: Course) -> bool: if user.is_staff or course.professor_id == user.id: @@ -570,5 +558,4 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): return value if timezone.is_naive(value): value = timezone.make_aware(value, timezone.get_current_timezone()) - return timezone.localtime(value).isoformat() - \ No newline at end of file + return timezone.localtime(value).isoformat() \ No newline at end of file diff --git a/docs/CHANGELOG_EXCHANGE_TOKEN.md b/docs/CHANGELOG_EXCHANGE_TOKEN.md new file mode 100644 index 0000000..0406cab --- /dev/null +++ b/docs/CHANGELOG_EXCHANGE_TOKEN.md @@ -0,0 +1,140 @@ +# تغییرات API Exchange Token + +## نسخه جدید (اکتبر 2024) + +### تغییرات اساسی + +#### 1. انتقال API از `courses` به `account` +- **قدیمی**: `/api/courses/auth/exchange-token/` +- **جدید**: `/api/account/exchange-token/` + +#### 2. تغییر ساختار Response +**قدیمی:** +```json +{ + "success": true, + "message": "ورود موفق", + "user": { + "id": "123", + "email": "user@example.com", + "name": "علی احمدی", + "role": "admin", + "is_admin": true + } +} +``` + +**جدید:** +```json +{ + "success": true, + "message": "ورود موفق", + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", + "user": { + "id": 123, + "fullname": "علی احمدی", + "email": "user@example.com", + "avatar": "https://cdn.example.com/avatar.jpg" + } +} +``` + +#### 3. تغییر URL تولید Token موقت +**Response قبلی:** +```json +{ + "token": "abc123", + "url": "https://frontend.example.com/join-class?token=abc123", + "expires_in": 300 +} +``` + +**Response جدید:** +```json +{ + "token": "abc123xyz789", + "url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123xyz789&slug=python-basics", + "expires_in": 300 +} +``` + +### تغییرات در فیلدها + +| فیلد قدیمی | فیلد جدید | توضیحات | +|-----------|----------|---------| +| `user.name` | `user.fullname` | تغییر نام فیلد | +| `user.id` (string) | `user.id` (number) | تغییر نوع داده | +| `user.role` | حذف شد | دیگر ارسال نمی‌شود | +| `user.is_admin` | حذف شد | دیگر ارسال نمی‌شود | +| - | `token` | **اضافه شد**: توکن احراز هویت DRF | +| - | `user.avatar` | **اضافه شد**: URL تصویر پروفایل | + +### URL ثابت + +دیگر از تنظیمات یا environment variable استفاده نمی‌شود. URL ثابت است: + +``` +https://imamjavad.newhorizonco.uk/join-class?token={TEMP_TOKEN}&slug={COURSE_SLUG} +``` + +### مزایای تغییرات + +✅ **Token واقعی**: دیگر نیازی به login مجدد نیست +✅ **Avatar**: نمایش تصویر پروفایل کاربر +✅ **Slug دوره**: دسترسی مستقیم به اطلاعات دوره +✅ **URL ثابت**: عدم وابستگی به تنظیمات +✅ **ساده‌تر**: حذف فیلدهای اضافی (role, is_admin) + +### Migration Guide + +#### Frontend (Next.js) + +**قدیمی:** +```typescript +const user = await api.exchangeToken(tempToken); +localStorage.setItem('user', JSON.stringify(user)); +``` + +**جدید:** +```typescript +const data = await exchangeToken(tempToken); +localStorage.setItem('authToken', data.token); // ⭐ جدید +localStorage.setItem('user', JSON.stringify(data.user)); +``` + +#### Flutter + +**قدیمی:** +```dart +final user = response['user']; +// نیاز به login مجدد داشت +``` + +**جدید:** +```dart +final authToken = response['token']; // ⭐ جدید +final user = response['user']; +// ذخیره token برای درخواست‌های بعدی +await storage.write(key: 'authToken', value: authToken); +``` + +### Breaking Changes ⚠️ + +1. **URL تغییر کرد**: باید از `/api/account/exchange-token/` استفاده شود +2. **فیلد `token` اجباری است**: باید در frontend ذخیره و استفاده شود +3. **فیلد `name` به `fullname` تغییر کرد** +4. **فیلدهای `role` و `is_admin` حذف شدند** +5. **URL موقت شامل `slug` می‌شود** + +### تاریخ اعمال تغییرات + +تاریخ: **14 اکتبر 2024** + +### فایل‌های تغییر یافته + +1. `apps/account/serializers/auth.py` (جدید) +2. `apps/account/views/auth.py` (جدید) +3. `apps/account/urls.py` (آپدیت) +4. `apps/course/views/course.py` (آپدیت: CourseOnlineClassTokenAPIView) +5. `docs/exchange_token_api.md` (آپدیت) +6. `docs/online_class_entry_flow.md` (آپدیت) diff --git a/docs/exchange_token_api.md b/docs/exchange_token_api.md new file mode 100644 index 0000000..120c108 --- /dev/null +++ b/docs/exchange_token_api.md @@ -0,0 +1,379 @@ +# API تبدیل توکن موقت (Exchange Token) + +## 📝 توضیحات + +این API برای تبدیل توکن موقت (temporary token) به اطلاعات کاربر واقعی استفاده می‌شود. + +--- + +## 🔗 Endpoint + +``` +POST /api/account/exchange-token/ +``` + +**دسترسی:** عمومی (AllowAny) + +--- + +## 📥 Request Body + +```json +{ + "temp_token": "abc123xyz" +} +``` + +--- + +## 📤 Response + +### ✅ موفق (200 OK) + +```json +{ + "success": true, + "message": "ورود موفق", + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", + "user": { + "id": 123, + "fullname": "علی احمدی", + "email": "user@example.com", + "avatar": "https://cdn.example.com/avatar.jpg" + } +} +``` + +**نکته:** +- `token`: توکن احراز هویت واقعی کاربر (DRF Token) که برای درخواست‌های بعدی استفاده می‌شود +- `avatar`: در صورت عدم وجود، مقدار `null` برگردانده می‌شود + +### ❌ خطا - توکن نامعتبر یا منقضی (404 NOT FOUND) + +```json +{ + "success": false, + "message": "توکن نامعتبر یا منقضی شده است" +} +``` + +### ❌ خطا - کاربر یافت نشد (404 NOT FOUND) + +```json +{ + "success": false, + "message": "کاربر یافت نشد" +} +``` + +### ❌ خطا - توکن ارسال نشده (400 BAD REQUEST) + +```json +{ + "success": false, + "message": "توکن نامعتبر است" +} +``` + +--- + +## 🧪 تست با curl + +### مرحله 1: تولید توکن موقت + +```bash +curl -X POST http://localhost:8000/api/courses/1/online/token/ \ + -H "Authorization: Token " \ + -H "Content-Type: application/json" +``` + +**Response:** +```json +{ + "token": "abc123xyz789...", + "url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123xyz789...&slug=python-basics", + "expires_in": 300 +} +``` + +**نکته:** URL ثابت است و شامل `token` و `slug` دوره می‌باشد. + +### مرحله 2: تبدیل توکن موقت + +```bash +curl -X POST http://localhost:8000/api/account/exchange-token/ \ + -H "Content-Type: application/json" \ + -d '{"temp_token": "abc123xyz"}' +``` + +**Response:** +```json +{ + "success": true, + "message": "ورود موفق", + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", + "user": { + "id": 123, + "fullname": "علی احمدی", + "email": "user@example.com", + "avatar": "https://cdn.example.com/avatar.jpg" + } +} +``` + +--- + +## 🔐 امنیت + +1. **One-Time Use**: توکن بعد از استفاده حذف می‌شود +2. **TTL**: توکن پس از 300 ثانیه (5 دقیقه) منقضی می‌شود +3. **Role Detection**: نقش کاربر (admin/user) بر اساس course و permissions تشخیص داده می‌شود + +--- + +## 📋 فایل‌های پیاده‌سازی شده + +1. **Serializer**: `/apps/account/serializers/auth.py` + - اضافه شد: `ExchangeTokenSerializer` + +2. **View**: `/apps/account/views/auth.py` + - اضافه شد: `ExchangeTokenAPIView` + +3. **URL**: `/apps/account/urls.py` + - اضافه شد: `path('exchange-token/', ...)` + +--- + +## 🔄 فلوی کامل + +``` +📱 Mobile App + ↓ +POST /api/courses/1/online/token/ +Headers: Authorization: Token + ↓ +Response: { + "token": "abc123", + "url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123&slug=python-basics" +} + ↓ +📱 Open URL in Browser + ↓ +🌐 Frontend: /join-class?token=abc123&slug=python-basics + ↓ +POST /api/account/exchange-token/ +Body: {"temp_token": "abc123"} + ↓ +Response: { + "token": "a1b2c3...", + "user": {...} +} + ↓ +Save token + user in localStorage + ↓ +Redirect to /online-classroom (with slug if needed) +``` + +--- + +## ✨ ویژگی‌ها + +✅ **Token Management**: استفاده از Redis برای ذخیره‌سازی موقت +✅ **Auth Token**: برگرداندن DRF Token واقعی برای احراز هویت +✅ **Security**: One-time use و TTL محدود +✅ **Error Handling**: مدیریت کامل خطاها +✅ **Avatar Support**: برگرداندن URL avatar کاربر +✅ **Documentation**: Swagger/OpenAPI documentation + +--- + +## 🛠️ نکات پیاده‌سازی + +1. از `OnlineClassTokenManager` استفاده می‌شود که در `utils/redis.py` تعریف شده +2. توکن موقت پس از استفاده با `manager.delete_token()` حذف می‌شود +3. نقش کاربر با بررسی `user.can_manage_course(course)` تشخیص داده می‌شود +4. در صورت عدم وجود course_id، فقط `user.is_staff` بررسی می‌شود + +--- + +## 📱 مثال Flutter + +```dart +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:url_launcher/url_launcher.dart'; + +class OnlineClassService { + final String baseUrl = 'https://your-backend.com'; + + /// مرحله 1: دریافت توکن موقت از بکند + Future> getTemporaryToken({ + required int courseId, + required String userToken, + }) async { + final response = await http.post( + Uri.parse('$baseUrl/courses/$courseId/online/token/'), + headers: { + 'Authorization': 'Token $userToken', + 'Content-Type': 'application/json', + }, + ); + + if (response.statusCode == 201) { + return jsonDecode(response.body); + } else { + throw Exception('Failed to get temporary token'); + } + } + + /// مرحله 2: باز کردن لینک در مرورگر + Future openOnlineClass({ + required int courseId, + required String userToken, + }) async { + try { + // دریافت توکن موقت + final tokenData = await getTemporaryToken( + courseId: courseId, + userToken: userToken, + ); + + final joinUrl = tokenData['url'] as String; + + // باز کردن در مرورگر + final uri = Uri.parse(joinUrl); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + } else { + throw Exception('Could not launch $joinUrl'); + } + } catch (e) { + print('Error opening online class: $e'); + rethrow; + } + } +} + +// استفاده در UI +class CourseDetailScreen extends StatelessWidget { + final int courseId; + final String userToken; + final OnlineClassService _service = OnlineClassService(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + try { + await _service.openOnlineClass( + courseId: courseId, + userToken: userToken, + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در ورود به کلاس: $e')), + ); + } + }, + child: Text('ورود به کلاس آنلاین'), + ), + ), + ); + } +} +``` + +### نصب dependencies برای Flutter + +در `pubspec.yaml`: + +```yaml +dependencies: + http: ^1.1.0 + url_launcher: ^6.2.1 +``` + +--- + +## 🌐 مثال Frontend (Next.js) + +صفحه `/app/join-class/page.tsx`: + +```typescript +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { api } from '@/lib/api'; + +export default function JoinClassPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [error, setError] = useState(null); + + useEffect(() => { + const token = searchParams.get('token'); + + if (!token) { + setError('توکن یافت نشد'); + return; + } + + // تبدیل توکن به اطلاعات کاربر + exchangeToken(token) + .then((data) => { + // ذخیره token و user در localStorage + localStorage.setItem('authToken', data.token); + localStorage.setItem('user', JSON.stringify(data.user)); + + // هدایت به صفحه کلاس + router.push('/online-classroom'); + }) + .catch((err) => { + setError(err.message || 'خطا در ورود'); + }); + }, [searchParams, router]); + + async function exchangeToken(tempToken: string) { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/account/exchange-token/`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ temp_token: tempToken }), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to exchange token'); + } + + const data = await response.json(); + return data; // Returns { success, message, token, user } + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+
در حال ورود...
+
+ ); +} +``` + + diff --git a/docs/online_class_entry_flow.md b/docs/online_class_entry_flow.md index 34c091a..c77d25f 100644 --- a/docs/online_class_entry_flow.md +++ b/docs/online_class_entry_flow.md @@ -22,11 +22,13 @@ curl --request POST \ ```json { "token": "5f7b8c...e1", - "url": "https://front.example.com/online-classroom?token=5f7b8c...e1", + "url": "https://imamjavad.newhorizonco.uk/join-class?token=5f7b8c...e1&slug=dars-akhlagh", "expires_in": 300 } ``` +**نکته:** URL ثابت است: `https://imamjavad.newhorizonco.uk/join-class?token={TOKEN}&slug={COURSE_SLUG}` + ## 2. اعتبارسنجی توکن و دریافت داده‌های کلاس - **هدف**: فرانت با توکن دریافتی اطلاعات کلاس، پروفایل کاربر و متادیتا را بگیرد.