Browse Source

feat(api)!: add account exchange-token endpoint

Introduce ExchangeTokenAPIView at /api/account/exchange-token/ to
exchange temporary tokens for a DRF auth token and minimal user
profile (id, fullname, email, avatar). Tokens are one-time use and
validated via OnlineClassTokenManager. Added serializer, view, URL,
and Swagger examples.

Update CourseOnlineClassTokenAPIView to return a fixed join URL:
https://imamjavad.newhorizonco.uk/join-class?token={TOKEN}&slug={SLUG}.
Store course_slug in token payload and remove redirect_path and the
_base_components helper. Examples and docs updated.

Docs: add CHANGELOG_EXCHANGE_TOKEN.md and exchange_token_api.md;
update online_class_entry_flow.md.

BREAKING CHANGE: exchange-token endpoint moved from
/api/courses/auth/exchange-token/ to /api/account/exchange-token/.
Response shape changed: adds token, user.id is numeric, user.name
renamed to user.fullname, user.role and user.is_admin removed.
Online token response now returns a fixed URL and includes course
slug. redirect_path support removed; clients must use the returned
token for subsequent requests.
master
mortezaei 7 months ago
parent
commit
97489d9d70
  1. 1
      apps/account/serializers/__init__.py
  2. 11
      apps/account/serializers/auth.py
  3. 1
      apps/account/urls.py
  4. 1
      apps/account/views/__init__.py
  5. 126
      apps/account/views/auth.py
  6. 23
      apps/course/views/course.py
  7. 140
      docs/CHANGELOG_EXCHANGE_TOKEN.md
  8. 379
      docs/exchange_token_api.md
  9. 4
      docs/online_class_entry_flow.md

1
apps/account/serializers/__init__.py

@ -1,3 +1,4 @@
from .user import *
from .notification import *
from .location_history import *
from .auth import *

11
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

1
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'),

1
apps/account/views/__init__.py

@ -1,5 +1,6 @@
from .user import *
from .notification import *
from .location_history import *
from .auth import *

126
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)

23
apps/course/views/course.py

@ -305,8 +305,8 @@ class CourseOnlineClassTokenAPIView(GenericAPIView):
description="Token generated successfully.",
examples={
"application/json": {
"token": "<temporary-token>",
"url": "https://frontend.example.com?token=<temporary-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:
@ -571,4 +559,3 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return timezone.localtime(value).isoformat()

140
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` (آپدیت)

379
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 <USER_BACKEND_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 <USER_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<Map<String, dynamic>> 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<void> 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<string | null>(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 (
<div className="flex items-center justify-center min-h-screen">
<div className="text-red-500">{error}</div>
</div>
);
}
return (
<div className="flex items-center justify-center min-h-screen">
<div>در حال ورود...</div>
</div>
);
}
```

4
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. اعتبارسنجی توکن و دریافت داده‌های کلاس
- **هدف**: فرانت با توکن دریافتی اطلاعات کلاس، پروفایل کاربر و متادیتا را بگیرد.

Loading…
Cancel
Save