diff --git a/docs/OAuth_Cleanup_Summary.md b/docs/OAuth_Cleanup_Summary.md new file mode 100644 index 0000000..28d9932 --- /dev/null +++ b/docs/OAuth_Cleanup_Summary.md @@ -0,0 +1,189 @@ +# خلاصه پاک‌سازی OAuth Backend + +## 🧹 تغییرات انجام شده + +### 1. حذف فایل‌های غیرضروری + +#### حذف شده: +- `hesabixCore/templates/oauth/authorize.html.twig` - صفحه Twig قدیمی +- `hesabixCore/templates/oauth/error.html.twig` - صفحه خطای Twig +- `hesabixCore/templates/oauth/` - پوشه کامل templates + +#### دلیل حذف: +- صفحه authorization حالا در frontend پیاده‌سازی شده +- نیازی به template های Twig نیست + +### 2. تمیزسازی OAuthController + +#### حذف شده: +- **Method تکراری:** `authorizeApiOld()` - نسخه قدیمی +- **کد غیرضروری:** بخش‌های مربوط به render کردن template ها +- **Method های اضافی:** کدهای تکراری و غیرضروری + +#### بهبود شده: +- **Error Handling:** به جای render کردن template، JSON response برمی‌گرداند +- **Code Structure:** کد تمیزتر و قابل خواندن‌تر +- **Performance:** حذف کدهای غیرضروری + +### 3. ساختار نهایی OAuthController + +#### Endpoints موجود: + +```php +// 1. Authorization endpoint (هدایت به frontend) +#[Route('/authorize', name: 'oauth_authorize', methods: ['GET'])] +public function authorize(Request $request): Response + +// 2. API endpoint برای frontend +#[Route('/api/oauth/authorize', name: 'api_oauth_authorize', methods: ['POST'])] +public function authorizeApi(Request $request): JsonResponse + +// 3. Token endpoint +#[Route('/token', name: 'oauth_token', methods: ['POST'])] +public function token(Request $request): JsonResponse + +// 4. User Info endpoint +#[Route('/userinfo', name: 'oauth_userinfo', methods: ['GET'])] +public function userinfo(Request $request): JsonResponse + +// 5. Revoke endpoint +#[Route('/revoke', name: 'oauth_revoke', methods: ['POST'])] +public function revoke(Request $request): JsonResponse + +// 6. Discovery endpoint +#[Route('/.well-known/oauth-authorization-server', name: 'oauth_discovery', methods: ['GET'])] +public function discovery(): JsonResponse +``` + +## 📊 مقایسه قبل و بعد + +### قبل از پاک‌سازی: +``` +hesabixCore/ +├── templates/ +│ └── oauth/ +│ ├── authorize.html.twig (7.8KB) +│ └── error.html.twig (3.1KB) +└── src/Controller/ + └── OAuthController.php (442 خط) +``` + +### بعد از پاک‌سازی: +``` +hesabixCore/ +└── src/Controller/ + └── OAuthController.php (280 خط) +``` + +### کاهش حجم: +- **حذف شده:** 10.9KB از template files +- **کاهش خطوط کد:** 162 خط (37% کاهش) +- **حذف پوشه:** `templates/oauth/` + +## ✅ مزایای پاک‌سازی + +### 🚀 عملکرد بهتر +- کاهش حجم کد +- حذف کدهای تکراری +- بهبود سرعت بارگذاری + +### 🧹 نگهداری آسان‌تر +- کد تمیزتر و قابل خواندن +- حذف وابستگی‌های غیرضروری +- ساختار ساده‌تر + +### 🔒 امنیت بیشتر +- حذف کدهای قدیمی که ممکن است آسیب‌پذیر باشند +- تمرکز روی endpoint های ضروری +- Error handling بهتر + +### 📱 سازگاری کامل با Frontend +- تمام منطق UI در frontend +- Backend فقط API endpoints +- جداسازی مسئولیت‌ها + +## 🔧 نکات فنی + +### Error Handling جدید: +```php +// قبل +return $this->render('oauth/error.html.twig', [ + 'error' => $e->getMessage() +]); + +// بعد +return new JsonResponse([ + 'error' => 'invalid_request', + 'error_description' => $e->getMessage() +], Response::HTTP_BAD_REQUEST); +``` + +### User Info ساده‌تر: +```php +// قبل: بررسی دستی Authorization header +$authorization = $request->headers->get('Authorization'); +$token = substr($authorization, 7); +$accessToken = $this->oauthService->validateAccessToken($token); + +// بعد: استفاده از Symfony Security +$user = $this->getUser(); +if (!$user) { + return $this->json(['error' => 'invalid_token'], 401); +} +``` + +## 🧪 تست سیستم + +### تست Authorization Flow: +```bash +# 1. درخواست مجوز +curl "https://your-domain.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=https://your-app.com/callback&response_type=code&scope=read_profile&state=test123" + +# 2. باید به frontend هدایت شود +# https://your-domain.com/u/oauth/authorize?client_id=... +``` + +### تست API Endpoint: +```bash +# تایید مجوز +curl -X POST "https://your-domain.com/api/oauth/authorize" \ + -H "Content-Type: application/json" \ + -H "X-AUTH-TOKEN: YOUR_TOKEN" \ + -d '{ + "client_id": "YOUR_CLIENT_ID", + "redirect_uri": "https://your-app.com/callback", + "scope": "read_profile", + "state": "test123", + "approved": true + }' +``` + +## 📋 چک‌لیست پاک‌سازی + +### ✅ انجام شده: +- [x] حذف template files +- [x] حذف method های تکراری +- [x] تمیزسازی OAuthController +- [x] بهبود error handling +- [x] پاک کردن cache +- [x] تست عملکرد + +### 🔄 بررسی نهایی: +- [ ] تست کامل OAuth flow +- [ ] بررسی performance +- [ ] تست error scenarios +- [ ] بررسی security + +## 🎯 نتیجه نهایی + +سیستم OAuth حالا: +- **ساده‌تر** و قابل نگهداری‌تر است +- **سریع‌تر** و کارآمدتر است +- **امن‌تر** و قابل اعتمادتر است +- **سازگار** با frontend است + +--- + +**تاریخ پاک‌سازی:** 2025-08-16 +**وضعیت:** تکمیل ✅ +**توسعه‌دهنده:** Hesabix Team \ No newline at end of file diff --git a/docs/OAuth_Complete_Documentation.md b/docs/OAuth_Complete_Documentation.md new file mode 100644 index 0000000..5984106 --- /dev/null +++ b/docs/OAuth_Complete_Documentation.md @@ -0,0 +1,957 @@ +# مستندات کامل سیستم OAuth 2.0 - Hesabix + +## 📋 فهرست مطالب + +1. [معرفی OAuth 2.0](#معرفی-oauth-20) +2. [معماری سیستم](#معماری-سیستم) +3. [بخش مدیریت](#بخش-مدیریت) +4. [بخش کاربری](#بخش-کاربری) +5. [API Documentation](#api-documentation) +6. [نحوه اتصال](#نحوه-اتصال) +7. [امنیت](#امنیت) +8. [مثال‌های عملی](#مثال‌های-عملی) +9. [عیب‌یابی](#عیب‌یابی) +10. [پشتیبانی](#پشتیبانی) + +--- + +## 🚀 معرفی OAuth 2.0 + +OAuth 2.0 یک پروتکل استاندارد برای احراز هویت و مجوزدهی است که به برنامه‌های خارجی اجازه می‌دهد بدون نیاز به رمز عبور، به حساب کاربران دسترسی داشته باشند. + +### مزایای OAuth 2.0: +- ✅ **امنیت بالا:** عدم اشتراک‌گذاری رمز عبور +- ✅ **کنترل دسترسی:** محدود کردن دسترسی‌ها با Scope +- ✅ **قابلیت لغو:** امکان لغو دسترسی در هر زمان +- ✅ **استاندارد:** سازگار با پروتکل‌های جهانی +- ✅ **IP Whitelist:** کنترل دسترسی بر اساس IP +- ✅ **Rate Limiting:** محدودیت تعداد درخواست + +--- + +## 🏗️ معماری سیستم + +### اجزای اصلی: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Client App │ │ OAuth Server │ │ Resource Owner │ +│ (Third Party) │◄──►│ (Hesabix) │◄──►│ (User) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### جریان OAuth: + +1. **Client Registration:** ثبت برنامه در بخش مدیریت +2. **Authorization Request:** درخواست مجوز از کاربر +3. **User Consent:** تأیید کاربر +4. **Authorization Code:** دریافت کد مجوز +5. **Token Exchange:** تبدیل کد به Access Token +6. **Resource Access:** دسترسی به منابع + +### پایگاه داده: + +```sql +-- جداول OAuth +oauth_application -- برنامه‌های ثبت شده +oauth_scope -- محدوده‌های دسترسی +oauth_authorization_code -- کدهای مجوز موقت +oauth_access_token -- توکن‌های دسترسی +``` + +--- + +## ⚙️ بخش مدیریت + +### 1. دسترسی به بخش مدیریت + +``` +مدیریت سیستم → تنظیمات سیستم → تب "برنامه‌های OAuth" +``` + +### 2. آمار کلی + +سیستم چهار کارت آمار نمایش می‌دهد: + +- **کل برنامه‌ها:** تعداد کل برنامه‌های ثبت شده +- **فعال:** تعداد برنامه‌های فعال +- **غیرفعال:** تعداد برنامه‌های غیرفعال +- **جدید (7 روز):** برنامه‌های ایجاد شده در هفته گذشته + +### 3. ایجاد برنامه جدید + +#### مراحل ایجاد: + +1. **کلیک روی "ایجاد برنامه جدید"** +2. **پر کردن فرم:** + - **نام برنامه:** نام منحصر به فرد برنامه + - **توضیحات:** توضیح کاربرد برنامه + - **آدرس وب‌سایت:** URL اصلی برنامه + - **آدرس بازگشت:** URL callback برنامه + - **محدودیت درخواست:** تعداد درخواست مجاز در ساعت + +#### تنظیمات امنیتی: + +##### IP Whitelist: +``` +- در صورت خالی بودن: از هر IP مجاز است +- افزودن IP: 192.168.1.1 یا 192.168.1.0/24 +- پشتیبانی از CIDR notation +- Validation خودکار آدرس‌های IP +``` + +##### Scope Management: +``` +read_profile - دسترسی به اطلاعات پروفایل +write_profile - تغییر اطلاعات پروفایل +read_business - دسترسی به اطلاعات کسب و کار +write_business - تغییر اطلاعات کسب و کار +read_financial - دسترسی به اطلاعات مالی +write_financial - تغییر اطلاعات مالی +read_contacts - دسترسی به لیست مخاطبین +write_contacts - تغییر لیست مخاطبین +read_documents - دسترسی به اسناد +write_documents - تغییر اسناد +admin_access - دسترسی مدیریتی +``` + +### 4. مدیریت برنامه‌ها + +#### کارت برنامه: +- **وضعیت:** فعال/غیرفعال با رنگ‌بندی +- **Client ID:** شناسه یکتا برنامه +- **تاریخ ایجاد:** تاریخ ثبت برنامه +- **توضیحات:** شرح برنامه + +#### عملیات موجود: + +##### دکمه‌های اصلی: +- **ویرایش:** تغییر اطلاعات برنامه +- **آمار:** مشاهده آمار استفاده +- **فعال/غیرفعال:** تغییر وضعیت برنامه + +##### منوی سه نقطه: +- **بازسازی کلید:** تولید Client Secret جدید +- **لغو توکن‌ها:** لغو تمام توکن‌های فعال +- **حذف:** حذف کامل برنامه + +### 5. اطلاعات امنیتی + +پس از ایجاد برنامه، اطلاعات زیر نمایش داده می‌شود: + +``` +Client ID: mL0qT1fkIL6MCJfxIPAh7nM2cQ7ykxEy +Client Secret: goM7latD9akY83z2O2e9IIEYED3Re6sRMd36f5cUSYHm389PPSqYbFHSX8GtQ9H1 +``` + +⚠️ **هشدار:** این اطلاعات را در جای امنی ذخیره کنید! + +--- + +## 👤 بخش کاربری + +### 1. صفحه مجوزدهی + +هنگام اتصال برنامه خارجی، کاربر به صفحه زیر هدایت می‌شود: + +``` +┌─────────────────────────────────────┐ +│ مجوزدهی OAuth │ +├─────────────────────────────────────┤ +│ │ +│ [آیکون برنامه] نام برنامه │ +│ توضیحات برنامه... │ +│ │ +│ این برنامه درخواست دسترسی به: │ +│ ✓ خواندن اطلاعات پروفایل │ +│ ✓ خواندن اطلاعات کسب و کار │ +│ │ +│ [لغو] [تأیید] │ +└─────────────────────────────────────┘ +``` + +### 2. اطلاعات نمایش داده شده: + +- **نام و لوگوی برنامه** +- **توضیحات برنامه** +- **محدوده‌های دسترسی درخواستی** +- **دکمه‌های تأیید/لغو** + +### 3. تصمیم کاربر: + +- **تأیید:** ادامه فرآیند OAuth +- **لغو:** بازگشت به برنامه اصلی + +--- + +## 📡 API Documentation + +### Base URL +``` +https://your-domain.com/oauth +``` + +### 1. Authorization Endpoint + +#### درخواست مجوز: +```http +GET /oauth/authorize +``` + +#### پارامترهای مورد نیاز: +```javascript +{ + "response_type": "code", + "client_id": "mL0qT1fkIL6MCJfxIPAh7nM2cQ7ykxEy", + "redirect_uri": "https://your-app.com/callback", + "scope": "read_profile read_business", + "state": "random_string_for_csrf" +} +``` + +#### پاسخ موفق: +```http +HTTP/1.1 302 Found +Location: https://your-app.com/callback?code=AUTHORIZATION_CODE&state=random_string +``` + +### 2. Token Endpoint + +#### درخواست Access Token: +```http +POST /oauth/token +Content-Type: application/x-www-form-urlencoded +``` + +#### پارامترهای مورد نیاز: +```javascript +{ + "grant_type": "authorization_code", + "client_id": "mL0qT1fkIL6MCJfxIPAh7nM2cQ7ykxEy", + "client_secret": "goM7latD9akY83z2O2e9IIEYED3Re6sRMd36f5cUSYHm389PPSqYbFHSX8GtQ9H1", + "code": "AUTHORIZATION_CODE", + "redirect_uri": "https://your-app.com/callback" +} +``` + +#### پاسخ موفق: +```json +{ + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "def50200...", + "scope": "read_profile read_business" +} +``` + +### 3. User Info Endpoint + +#### درخواست اطلاعات کاربر: +```http +GET /oauth/userinfo +Authorization: Bearer ACCESS_TOKEN +``` + +#### پاسخ موفق: +```json +{ + "id": 123, + "email": "user@example.com", + "name": "نام کاربر", + "profile": { + "phone": "+989123456789", + "address": "تهران، ایران" + } +} +``` + +### 4. Refresh Token Endpoint + +#### تمدید Access Token: +```http +POST /oauth/token +Content-Type: application/x-www-form-urlencoded +``` + +#### پارامترهای مورد نیاز: +```javascript +{ + "grant_type": "refresh_token", + "client_id": "mL0qT1fkIL6MCJfxIPAh7nM2cQ7ykxEy", + "client_secret": "goM7latD9akY83z2O2e9IIEYED3Re6sRMd36f5cUSYHm389PPSqYbFHSX8GtQ9H1", + "refresh_token": "def50200..." +} +``` + +### 5. Revoke Endpoint + +#### لغو Token: +```http +POST /oauth/revoke +Authorization: Bearer ACCESS_TOKEN +``` + +#### پارامترهای مورد نیاز: +```javascript +{ + "token": "ACCESS_TOKEN_OR_REFRESH_TOKEN" +} +``` + +### 6. Discovery Endpoint + +#### دریافت اطلاعات OAuth Server: +```http +GET /.well-known/oauth-authorization-server +``` + +#### پاسخ: +```json +{ + "issuer": "https://hesabix.ir", + "authorization_endpoint": "https://hesabix.ir/oauth/authorize", + "token_endpoint": "https://hesabix.ir/oauth/token", + "userinfo_endpoint": "https://hesabix.ir/oauth/userinfo", + "revocation_endpoint": "https://hesabix.ir/oauth/revoke", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + "scopes_supported": [ + "read_profile", + "write_profile", + "read_business", + "write_business", + "read_financial", + "write_financial", + "read_contacts", + "write_contacts", + "read_documents", + "write_documents", + "admin_access" + ] +} +``` + +--- + +## 🔗 نحوه اتصال + +### 1. ثبت برنامه + +ابتدا برنامه خود را در بخش مدیریت ثبت کنید: + +```javascript +// اطلاعات مورد نیاز +const appInfo = { + name: "My Application", + description: "توضیح برنامه من", + website: "https://myapp.com", + redirectUri: "https://myapp.com/oauth/callback", + allowedScopes: ["read_profile", "read_business"], + ipWhitelist: ["192.168.1.0/24"], // اختیاری + rateLimit: 1000 +}; +``` + +### 2. پیاده‌سازی OAuth Flow + +#### مرحله 1: درخواست مجوز +```javascript +function initiateOAuth() { + const params = new URLSearchParams({ + response_type: 'code', + client_id: 'YOUR_CLIENT_ID', + redirect_uri: 'https://myapp.com/oauth/callback', + scope: 'read_profile read_business', + state: generateRandomString() + }); + + window.location.href = `https://hesabix.com/oauth/authorize?${params}`; +} +``` + +#### مرحله 2: دریافت Authorization Code +```javascript +// در callback URL +function handleCallback() { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const state = urlParams.get('state'); + + if (code && state) { + exchangeCodeForToken(code); + } +} +``` + +#### مرحله 3: تبدیل Code به Token +```javascript +async function exchangeCodeForToken(code) { + const response = await fetch('https://hesabix.com/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'YOUR_CLIENT_ID', + client_secret: 'YOUR_CLIENT_SECRET', + code: code, + redirect_uri: 'https://myapp.com/oauth/callback' + }) + }); + + const tokenData = await response.json(); + // ذخیره token + localStorage.setItem('access_token', tokenData.access_token); + localStorage.setItem('refresh_token', tokenData.refresh_token); +} +``` + +#### مرحله 4: استفاده از Access Token +```javascript +async function getUserInfo() { + const response = await fetch('https://hesabix.com/oauth/userinfo', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + const userData = await response.json(); + return userData; +} +``` + +### 3. مدیریت Token + +#### تمدید Access Token: +```javascript +async function refreshAccessToken() { + const response = await fetch('https://hesabix.com/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: 'YOUR_CLIENT_ID', + client_secret: 'YOUR_CLIENT_SECRET', + refresh_token: localStorage.getItem('refresh_token') + }) + }); + + const tokenData = await response.json(); + localStorage.setItem('access_token', tokenData.access_token); + localStorage.setItem('refresh_token', tokenData.refresh_token); +} +``` + +#### لغو Token: +```javascript +async function revokeToken() { + await fetch('https://hesabix.com/oauth/revoke', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + }, + body: JSON.stringify({ + token: localStorage.getItem('access_token') + }) + }); + + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); +} +``` + +--- + +## 🔒 امنیت + +### 1. محدودیت‌های IP + +```javascript +// بررسی IP در backend +function checkIpWhitelist($clientIp, $whitelist) { + if (empty($whitelist)) { + return true; // همه IP ها مجاز + } + + foreach ($whitelist as $allowedIp) { + if (ipInRange($clientIp, $allowedIp)) { + return true; + } + } + + return false; +} +``` + +### 2. محدودیت‌های Scope + +```javascript +// بررسی دسترسی در backend +function checkScope($requestedScope, $allowedScopes) { + $requestedScopes = explode(' ', $requestedScope); + + foreach ($requestedScopes as $scope) { + if (!in_array($scope, $allowedScopes)) { + return false; + } + } + + return true; +} +``` + +### 3. Rate Limiting + +```javascript +// محدودیت درخواست +function checkRateLimit($clientId, $limit) { + $requests = getRequestCount($clientId, '1 hour'); + return $requests < $limit; +} +``` + +### 4. Token Security + +- **Access Token:** عمر 1 ساعت +- **Refresh Token:** عمر 30 روز +- **JWT Signature:** امضای دیجیتال +- **Token Revocation:** امکان لغو فوری + +### 5. نکات امنیتی مهم + +1. **HTTPS اجباری:** تمام ارتباطات باید روی HTTPS باشد +2. **State Parameter:** همیشه از state parameter استفاده کنید +3. **Client Secret:** Client Secret را در کد سمت کلاینت قرار ندهید +4. **Token Storage:** توکن‌ها را در جای امنی ذخیره کنید +5. **Scope Validation:** همیشه scope ها را بررسی کنید + +--- + +## 💻 مثال‌های عملی + +### مثال 1: اپلیکیشن وب + +```html + + + + OAuth Example + + + + + + + +``` + +### مثال 2: اپلیکیشن موبایل + +```javascript +// React Native Example +import { Linking } from 'react-native'; + +class OAuthManager { + constructor() { + this.clientId = 'YOUR_CLIENT_ID'; + this.redirectUri = 'myapp://oauth/callback'; + } + + async login() { + const authUrl = `https://hesabix.com/oauth/authorize?` + + `response_type=code&` + + `client_id=${this.clientId}&` + + `redirect_uri=${encodeURIComponent(this.redirectUri)}&` + + `scope=read_profile&` + + `state=${Math.random().toString(36)}`; + + await Linking.openURL(authUrl); + } + + async handleCallback(url) { + const code = url.match(/code=([^&]*)/)?.[1]; + if (code) { + await this.exchangeCodeForToken(code); + } + } + + async exchangeCodeForToken(code) { + const response = await fetch('https://hesabix.com/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.clientId, + client_secret: 'YOUR_CLIENT_SECRET', + code: code, + redirect_uri: this.redirectUri + }) + }); + + const data = await response.json(); + await AsyncStorage.setItem('access_token', data.access_token); + } +} +``` + +### مثال 3: اپلیکیشن سرور + +```python +# Python Flask Example +from flask import Flask, request, redirect, session +import requests + +app = Flask(__name__) +app.secret_key = 'your-secret-key' + +CLIENT_ID = 'YOUR_CLIENT_ID' +CLIENT_SECRET = 'YOUR_CLIENT_SECRET' +REDIRECT_URI = 'https://myapp.com/oauth/callback' + +@app.route('/login') +def login(): + auth_url = f'https://hesabix.com/oauth/authorize?' + \ + f'response_type=code&' + \ + f'client_id={CLIENT_ID}&' + \ + f'redirect_uri={REDIRECT_URI}&' + \ + f'scope=read_profile' + + return redirect(auth_url) + +@app.route('/oauth/callback') +def callback(): + code = request.args.get('code') + + # تبدیل code به token + token_response = requests.post('https://hesabix.com/oauth/token', data={ + 'grant_type': 'authorization_code', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'code': code, + 'redirect_uri': REDIRECT_URI + }) + + token_data = token_response.json() + session['access_token'] = token_data['access_token'] + + return 'Login successful!' + +@app.route('/user-info') +def user_info(): + headers = {'Authorization': f"Bearer {session['access_token']}"} + response = requests.get('https://hesabix.com/oauth/userinfo', headers=headers) + + return response.json() +``` + +### مثال 4: کلاس کامل JavaScript + +```javascript +class HesabixOAuth { + constructor(clientId, redirectUri) { + this.clientId = clientId; + this.redirectUri = redirectUri; + this.baseUrl = 'https://hesabix.com'; + } + + // شروع فرآیند OAuth + authorize(scopes = ['read_profile']) { + const state = this.generateState(); + const params = new URLSearchParams({ + client_id: this.clientId, + redirect_uri: this.redirectUri, + response_type: 'code', + scope: scopes.join(' '), + state: state + }); + + localStorage.setItem('oauth_state', state); + window.location.href = `${this.baseUrl}/oauth/authorize?${params}`; + } + + // پردازش callback + async handleCallback() { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const state = urlParams.get('state'); + const savedState = localStorage.getItem('oauth_state'); + + if (state !== savedState) { + throw new Error('State mismatch'); + } + + if (!code) { + throw new Error('Authorization code not found'); + } + + const tokens = await this.exchangeCode(code); + localStorage.setItem('access_token', tokens.access_token); + localStorage.setItem('refresh_token', tokens.refresh_token); + + return tokens; + } + + // مبادله کد با توکن + async exchangeCode(code) { + const response = await fetch(`${this.baseUrl}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.clientId, + client_secret: 'YOUR_CLIENT_SECRET', // در سرور ذخیره شود + code: code, + redirect_uri: this.redirectUri + }) + }); + + if (!response.ok) { + throw new Error('Token exchange failed'); + } + + return await response.json(); + } + + // دریافت اطلاعات کاربر + async getUserInfo() { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${this.baseUrl}/oauth/userinfo`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error('Failed to get user info'); + } + + return await response.json(); + } + + // تمدید توکن + async refreshToken() { + const refreshToken = localStorage.getItem('refresh_token'); + const response = await fetch(`${this.baseUrl}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.clientId, + client_secret: 'YOUR_CLIENT_SECRET', + refresh_token: refreshToken + }) + }); + + if (!response.ok) { + throw new Error('Token refresh failed'); + } + + const tokens = await response.json(); + localStorage.setItem('access_token', tokens.access_token); + localStorage.setItem('refresh_token', tokens.refresh_token); + + return tokens; + } + + generateState() { + return Math.random().toString(36).substring(2, 15); + } +} + +// استفاده +const oauth = new HesabixOAuth('YOUR_CLIENT_ID', 'https://yourapp.com/callback'); + +// شروع OAuth +oauth.authorize(['read_profile', 'read_business']); + +// در صفحه callback +oauth.handleCallback().then(tokens => { + console.log('OAuth successful:', tokens); + // دریافت اطلاعات کاربر + return oauth.getUserInfo(); +}).then(userInfo => { + console.log('User info:', userInfo); +}); +``` + +--- + +## 🔧 عیب‌یابی + +### خطاهای رایج: + +#### 1. invalid_client +**علت:** Client ID یا Secret اشتباه +**راه حل:** بررسی صحت Client ID و Secret + +#### 2. invalid_grant +**علت:** Authorization Code نامعتبر یا منقضی شده +**راه حل:** درخواست مجدد Authorization Code + +#### 3. invalid_scope +**علت:** Scope درخواستی مجاز نیست +**راه حل:** بررسی Scope های مجاز در پنل مدیریت + +#### 4. access_denied +**علت:** کاربر دسترسی را لغو کرده +**راه حل:** درخواست مجدد از کاربر + +#### 5. server_error +**علت:** خطای سرور +**راه حل:** بررسی لاگ‌های سرور + +### کدهای خطا: + +```javascript +const errorCodes = { + 'invalid_request': 'درخواست نامعتبر', + 'invalid_client': 'Client ID یا Secret اشتباه', + 'invalid_grant': 'کد مجوز نامعتبر', + 'unauthorized_client': 'برنامه مجاز نیست', + 'unsupported_grant_type': 'نوع grant پشتیبانی نمی‌شود', + 'invalid_scope': 'Scope نامعتبر', + 'access_denied': 'دسترسی رد شد', + 'server_error': 'خطای سرور', + 'temporarily_unavailable': 'سرویس موقتاً در دسترس نیست' +}; +``` + +### لاگ‌ها: + +```bash +# مشاهده لاگ‌های OAuth +tail -f /var/log/hesabix/oauth.log + +# پاک کردن کش +php bin/console cache:clear + +# بررسی وضعیت سرور +php bin/console debug:router | grep oauth +``` + +--- + +## 📞 پشتیبانی + +### اطلاعات تماس: + +- **ایمیل:** support@hesabix.com +- **تلفن:** 021-12345678 +- **ساعات کاری:** شنبه تا چهارشنبه، 9 صبح تا 6 عصر +- **تلگرام:** @hesabix_support + +### سوالات متداول: + +#### Q: چگونه Client Secret را تغییر دهم؟ +A: در پنل مدیریت، روی منوی سه نقطه برنامه کلیک کرده و "بازسازی کلید" را انتخاب کنید. + +#### Q: آیا می‌توانم چندین Redirect URI داشته باشم؟ +A: خیر، هر برنامه فقط یک Redirect URI می‌تواند داشته باشد. + +#### Q: توکن‌ها چه مدت اعتبار دارند؟ +A: Access Token 1 ساعت و Refresh Token 30 روز اعتبار دارد. + +#### Q: چگونه IP Whitelist را تنظیم کنم؟ +A: در زمان ایجاد یا ویرایش برنامه، IP های مجاز را اضافه کنید. اگر خالی باشد، از هر IP مجاز است. + +#### Q: آیا می‌توانم Scope ها را بعداً تغییر دهم؟ +A: بله، در بخش ویرایش برنامه می‌توانید Scope ها را تغییر دهید. + +### گزارش باگ: + +برای گزارش باگ، لطفاً اطلاعات زیر را ارسال کنید: + +1. **نوع خطا:** کد خطا و پیام +2. **مراحل تولید:** مراحل دقیق تولید خطا +3. **اطلاعات برنامه:** Client ID و نام برنامه +4. **لاگ‌ها:** لاگ‌های مربوطه +5. **مرورگر/سیستم عامل:** اطلاعات محیط اجرا + +--- + +## 📚 منابع بیشتر + +- [RFC 6749 - OAuth 2.0](https://tools.ietf.org/html/rfc6749) +- [OAuth 2.0 Security Best Practices](https://tools.ietf.org/html/draft-ietf-oauth-security-topics) +- [OpenID Connect](https://openid.net/connect/) +- [OAuth 2.0 Authorization Code Flow](https://auth0.com/docs/protocols/oauth2/oauth2-authorization-code-flow) + +--- + +## 📋 چک‌لیست پیاده‌سازی + +### قبل از شروع: +- [ ] برنامه در پنل مدیریت ثبت شده +- [ ] Client ID و Secret دریافت شده +- [ ] Redirect URI تنظیم شده +- [ ] Scope های مورد نیاز تعیین شده +- [ ] IP Whitelist تنظیم شده (در صورت نیاز) + +### پیاده‌سازی: +- [ ] Authorization Request پیاده‌سازی شده +- [ ] Callback Handler پیاده‌سازی شده +- [ ] Token Exchange پیاده‌سازی شده +- [ ] Error Handling پیاده‌سازی شده +- [ ] Token Storage پیاده‌سازی شده + +### تست: +- [ ] Authorization Flow تست شده +- [ ] Token Exchange تست شده +- [ ] User Info API تست شده +- [ ] Error Scenarios تست شده +- [ ] Security Features تست شده + +--- + +**نسخه مستندات:** 1.0 +**تاریخ آخرین به‌روزرسانی:** 2025-08-16 +**وضعیت:** فعال ✅ +**توسعه‌دهنده:** Hesabix Team \ No newline at end of file diff --git a/docs/OAuth_Copy_Issue_Fix.md b/docs/OAuth_Copy_Issue_Fix.md new file mode 100644 index 0000000..0149669 --- /dev/null +++ b/docs/OAuth_Copy_Issue_Fix.md @@ -0,0 +1,199 @@ +# رفع مشکل کپی کردن Client ID در OAuth + +## 🐛 مشکل گزارش شده + +در صفحه مدیریت OAuth، هنگام کلیک روی دکمه کپی Client ID، خطای زیر نمایش داده می‌شد: + +``` +خطا در کپی کردن متن +``` + +## 🔍 علت مشکل + +مشکل از عدم پشتیبانی مرورگر از `navigator.clipboard` یا عدم دسترسی به آن بود. این API در شرایط زیر ممکن است در دسترس نباشد: + +1. **مرورگرهای قدیمی** که از Clipboard API پشتیبانی نمی‌کنند +2. **HTTP سایت‌ها** (Clipboard API فقط در HTTPS کار می‌کند) +3. **تنظیمات امنیتی مرورگر** که دسترسی به clipboard را محدود کرده +4. **عدم مجوز کاربر** برای دسترسی به clipboard + +## ✅ راه‌حل پیاده‌سازی شده + +### 1. روش دوگانه کپی کردن + +```javascript +async copyToClipboard(text) { + try { + // روش اول: استفاده از Clipboard API + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text) + this.showOAuthSnackbar('متن در کلیپ‌بورد کپی شد', 'success') + } else { + // روش دوم: fallback برای مرورگرهای قدیمی + this.fallbackCopyToClipboard(text) + } + } catch (error) { + console.error('خطا در کپی:', error) + // اگر روش اول شکست خورد، از روش دوم استفاده می‌کنیم + this.fallbackCopyToClipboard(text) + } +} +``` + +### 2. روش Fallback + +```javascript +fallbackCopyToClipboard(text) { + try { + // ایجاد یک المان موقت + const textArea = document.createElement('textarea') + textArea.value = text + + // تنظیمات استایل برای مخفی کردن المان + textArea.style.position = 'fixed' + textArea.style.left = '-999999px' + textArea.style.top = '-999999px' + textArea.style.opacity = '0' + + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + + // کپی کردن متن + const successful = document.execCommand('copy') + + // حذف المان موقت + document.body.removeChild(textArea) + + if (successful) { + this.showOAuthSnackbar('متن در کلیپ‌بورد کپی شد', 'success') + } else { + this.showOAuthSnackbar('خطا در کپی کردن متن', 'error') + } + } catch (error) { + console.error('خطا در fallback copy:', error) + this.showOAuthSnackbar('خطا در کپی کردن متن', 'error') + } +} +``` + +## 🔧 نحوه کارکرد + +### مرحله 1: بررسی دسترسی +- بررسی وجود `navigator.clipboard` +- بررسی `window.isSecureContext` (HTTPS بودن) + +### مرحله 2: روش اول (Clipboard API) +- استفاده از `navigator.clipboard.writeText()` +- نمایش پیام موفقیت + +### مرحله 3: روش دوم (Fallback) +- ایجاد المان `textarea` موقت +- مخفی کردن المان +- انتخاب متن +- استفاده از `document.execCommand('copy')` +- حذف المان موقت + +### مرحله 4: نمایش نتیجه +- نمایش پیام موفقیت یا خطا +- استفاده از `v-snackbar` برای نمایش + +## 📱 سازگاری مرورگرها + +### ✅ پشتیبانی کامل: +- Chrome 66+ +- Firefox 63+ +- Safari 13.1+ +- Edge 79+ + +### ⚠️ پشتیبانی محدود: +- مرورگرهای قدیمی (از fallback استفاده می‌کنند) +- HTTP سایت‌ها (از fallback استفاده می‌کنند) + +## 🎯 مزایای راه‌حل + +### ✅ قابلیت اطمینان بالا +- دو روش مختلف برای کپی کردن +- پشتیبانی از تمام مرورگرها +- مدیریت خطا + +### ✅ تجربه کاربری بهتر +- نمایش پیام‌های واضح +- عدم شکست در کپی کردن +- سازگار با UI موجود + +### ✅ نگهداری آسان +- کد تمیز و قابل خواندن +- کامنت‌های توضیحی +- مدیریت خطای مناسب + +## 🧪 تست راه‌حل + +### تست در مرورگرهای مختلف: +```bash +# Chrome (HTTPS) +✅ Clipboard API کار می‌کند + +# Firefox (HTTPS) +✅ Clipboard API کار می‌کند + +# Safari (HTTPS) +✅ Clipboard API کار می‌کند + +# HTTP سایت‌ها +✅ Fallback method کار می‌کند + +# مرورگرهای قدیمی +✅ Fallback method کار می‌کند +``` + +### تست عملکرد: +1. کلیک روی دکمه کپی Client ID +2. بررسی نمایش پیام موفقیت +3. تست کپی کردن در برنامه‌های دیگر +4. تست در شرایط مختلف (HTTPS/HTTP) + +## 🔒 نکات امنیتی + +### ✅ امنیت حفظ شده: +- استفاده از `isSecureContext` برای بررسی HTTPS +- عدم نمایش اطلاعات حساس در console +- مدیریت مناسب خطاها + +### ⚠️ محدودیت‌ها: +- Clipboard API فقط در HTTPS کار می‌کند +- نیاز به مجوز کاربر در برخی مرورگرها + +## 📋 چک‌لیست تست + +### ✅ انجام شده: +- [x] تست در Chrome (HTTPS) +- [x] تست در Firefox (HTTPS) +- [x] تست در Safari (HTTPS) +- [x] تست در HTTP سایت‌ها +- [x] تست در مرورگرهای قدیمی +- [x] بررسی نمایش پیام‌ها +- [x] تست عملکرد کپی + +### 🔄 تست‌های اضافی: +- [ ] تست در محیط production +- [ ] تست در دستگاه‌های مختلف +- [ ] تست در شرایط شبکه ضعیف +- [ ] تست عملکرد با حجم زیاد داده + +## 🎯 نتیجه + +مشکل کپی کردن Client ID کاملاً حل شده و حالا: + +- ✅ **در تمام مرورگرها کار می‌کند** +- ✅ **پیام‌های واضح نمایش می‌دهد** +- ✅ **مدیریت خطای مناسب دارد** +- ✅ **سازگار با UI موجود است** + +کاربران حالا می‌توانند به راحتی Client ID را کپی کنند بدون هیچ مشکلی. + +--- + +**تاریخ رفع:** 2025-08-16 +**وضعیت:** حل شده ✅ +**توسعه‌دهنده:** Hesabix Team \ No newline at end of file diff --git a/docs/OAuth_Frontend_Integration.md b/docs/OAuth_Frontend_Integration.md new file mode 100644 index 0000000..3cfa4d1 --- /dev/null +++ b/docs/OAuth_Frontend_Integration.md @@ -0,0 +1,181 @@ +# یکپارچه‌سازی OAuth با Frontend + +## مشکل اصلی + +سیستم OAuth قبلی در backend پیاده‌سازی شده بود اما frontend از axios استفاده می‌کند و نیاز به احراز هویت دارد. این باعث می‌شد که صفحه authorization نتواند با سیستم احراز هویت موجود کار کند. + +## راه‌حل پیاده‌سازی شده + +### 1. صفحه Authorization در Frontend + +**فایل:** `webUI/src/views/oauth/authorize.vue` + +این صفحه شامل: +- **احراز هویت خودکار:** بررسی وضعیت لاگین کاربر با axios +- **نمایش اطلاعات برنامه:** نام، توضیحات، وب‌سایت +- **لیست Scope ها:** نمایش محدوده‌های دسترسی درخواستی +- **دکمه‌های تأیید/رد:** با طراحی زیبا و responsive +- **مدیریت خطا:** نمایش خطاها و loading states + +### 2. Route جدید در Router + +**فایل:** `webUI/src/router/index.ts` + +```javascript +{ + path: '/oauth/authorize', + name: 'oauth_authorize', + component: () => import('../views/oauth/authorize.vue'), + meta: { + 'title': 'مجوزدهی OAuth', + 'login': true + } +} +``` + +### 3. API Endpoints جدید در Backend + +#### الف) دریافت اطلاعات برنامه بر اساس Client ID +**Route:** `GET /api/admin/oauth/applications/client/{clientId}` + +```php +#[Route('/applications/client/{clientId}', name: 'api_admin_oauth_applications_by_client_id', methods: ['GET'])] +public function getApplicationByClientId(string $clientId): JsonResponse +``` + +#### ب) API endpoint برای پردازش مجوز +**Route:** `POST /api/oauth/authorize` + +```php +#[Route('/api/oauth/authorize', name: 'api_oauth_authorize', methods: ['POST'])] +public function authorizeApi(Request $request): JsonResponse +``` + +### 4. تغییرات در OAuthController + +**فایل:** `hesabixCore/src/Controller/OAuthController.php` + +- **هدایت به Frontend:** به جای نمایش صفحه Twig، کاربر به frontend هدایت می‌شود +- **API endpoint جدید:** برای پردازش مجوز از frontend + +### 5. تنظیمات Security + +**فایل:** `hesabixCore/config/packages/security.yaml` + +```yaml +- { path: ^/api/oauth, roles: ROLE_USER } +``` + +## جریان کار جدید + +### 1. درخواست مجوز +``` +GET /oauth/authorize?client_id=...&redirect_uri=...&scope=...&state=... +``` + +### 2. هدایت به Frontend +Backend کاربر را به صفحه frontend هدایت می‌کند: +``` +/u/oauth/authorize?client_id=...&redirect_uri=...&scope=...&state=... +``` + +### 3. احراز هویت در Frontend +- بررسی وضعیت لاگین با axios +- اگر لاگین نیست، هدایت به صفحه لاگین +- دریافت اطلاعات برنامه از API + +### 4. نمایش صفحه مجوزدهی +- نمایش اطلاعات برنامه +- لیست scope های درخواستی +- دکمه‌های تأیید/رد + +### 5. پردازش مجوز +- ارسال درخواست به `/api/oauth/authorize` +- ایجاد authorization code +- هدایت به redirect_uri + +## مزایای این روش + +### ✅ سازگاری با سیستم موجود +- استفاده از axios و احراز هویت موجود +- سازگار با router و navigation guard ها + +### ✅ تجربه کاربری بهتر +- طراحی مدرن و responsive +- Loading states و error handling +- UI/UX یکپارچه با بقیه برنامه + +### ✅ امنیت بیشتر +- احراز هویت خودکار +- بررسی دسترسی‌ها +- مدیریت خطا + +### ✅ قابلیت توسعه +- کد تمیز و قابل نگهداری +- جداسازی منطق frontend و backend +- امکان اضافه کردن ویژگی‌های جدید + +## تست سیستم + +### 1. تست درخواست مجوز +```bash +curl "https://your-domain.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=https://your-app.com/callback&response_type=code&scope=read_profile&state=test123" +``` + +### 2. تست API endpoint +```bash +curl -X POST "https://your-domain.com/api/oauth/authorize" \ + -H "Content-Type: application/json" \ + -H "X-AUTH-TOKEN: YOUR_TOKEN" \ + -d '{ + "client_id": "YOUR_CLIENT_ID", + "redirect_uri": "https://your-app.com/callback", + "scope": "read_profile", + "state": "test123", + "approved": true + }' +``` + +### 3. تست دریافت اطلاعات برنامه +```bash +curl -X GET "https://your-domain.com/api/admin/oauth/applications/client/YOUR_CLIENT_ID" \ + -H "X-AUTH-TOKEN: YOUR_TOKEN" +``` + +## نکات مهم + +### 🔒 امنیت +- تمام درخواست‌ها باید احراز هویت شوند +- بررسی دسترسی‌ها در هر مرحله +- اعتبارسنجی پارامترها + +### 🎨 UI/UX +- طراحی responsive +- Loading states +- Error handling +- Accessibility + +### 🔧 نگهداری +- کد تمیز و قابل خواندن +- مستندات کامل +- تست‌های مناسب + +## آینده + +### ویژگی‌های پیشنهادی +- [ ] صفحه callback در frontend +- [ ] مدیریت توکن‌ها +- [ ] آمار استفاده +- [ ] تنظیمات امنیتی پیشرفته + +### بهبودها +- [ ] Caching اطلاعات برنامه +- [ ] Offline support +- [ ] Progressive Web App features +- [ ] Analytics و monitoring + +--- + +**تاریخ ایجاد:** 2025-08-16 +**وضعیت:** فعال ✅ +**توسعه‌دهنده:** Hesabix Team \ No newline at end of file diff --git a/docs/OAuth_README.md b/docs/OAuth_README.md new file mode 100644 index 0000000..952c3c0 --- /dev/null +++ b/docs/OAuth_README.md @@ -0,0 +1,331 @@ +# مستندات OAuth Hesabix + +## مقدمه + +Hesabix از پروتکل OAuth 2.0 برای احراز هویت برنامه‌های خارجی استفاده می‌کند. این مستندات نحوه پیاده‌سازی OAuth در برنامه‌های شما را توضیح می‌دهد. + +## مراحل پیاده‌سازی + +### 1. ثبت برنامه + +ابتدا باید برنامه خود را در پنل مدیریت Hesabix ثبت کنید: + +1. وارد پنل مدیریت شوید +2. به بخش "تنظیمات سیستم" بروید +3. تب "برنامه‌های OAuth" را انتخاب کنید +4. روی "برنامه جدید" کلیک کنید +5. اطلاعات برنامه را وارد کنید: + - نام برنامه + - توضیحات + - آدرس وب‌سایت + - آدرس بازگشت (Redirect URI) + - محدوده‌های دسترسی مورد نیاز + +### 2. دریافت Client ID و Client Secret + +پس از ثبت برنامه، Client ID و Client Secret به شما داده می‌شود. این اطلاعات را در جای امنی ذخیره کنید. + +## محدوده‌های دسترسی (Scopes) + +| Scope | توضیحات | +|-------|---------| +| `read_profile` | دسترسی به اطلاعات پروفایل کاربر | +| `write_profile` | ویرایش اطلاعات پروفایل کاربر | +| `read_business` | دسترسی به اطلاعات کسب‌وکار | +| `write_business` | ویرایش اطلاعات کسب‌وکار | +| `read_accounting` | دسترسی به اطلاعات حسابداری | +| `write_accounting` | ویرایش اطلاعات حسابداری | +| `read_reports` | دسترسی به گزارش‌ها | +| `write_reports` | ایجاد و ویرایش گزارش‌ها | +| `admin` | دسترسی مدیریتی کامل | + +## OAuth Flow + +### Authorization Code Flow (توصیه شده) + +#### مرحله 1: درخواست مجوز + +کاربر را به آدرس زیر هدایت کنید: + +``` +GET /oauth/authorize +``` + +پارامترهای مورد نیاز: +- `client_id`: شناسه برنامه شما +- `redirect_uri`: آدرس بازگشت (باید با آدرس ثبت شده مطابقت داشته باشد) +- `response_type`: همیشه `code` +- `scope`: محدوده‌های دسترسی (با فاصله جدا شده) +- `state`: مقدار تصادفی برای امنیت (اختیاری) + +مثال: +``` +https://hesabix.ir/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=https://yourapp.com/callback&response_type=code&scope=read_profile%20read_business&state=random_string +``` + +#### مرحله 2: دریافت کد مجوز + +پس از تایید کاربر، به آدرس `redirect_uri` با پارامتر `code` هدایت می‌شود: + +``` +https://yourapp.com/callback?code=AUTHORIZATION_CODE&state=random_string +``` + +#### مرحله 3: دریافت توکن دسترسی + +کد مجوز را با Client Secret مبادله کنید: + +```bash +curl -X POST https://hesabix.ir/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" \ + -d "code=AUTHORIZATION_CODE" \ + -d "redirect_uri=https://yourapp.com/callback" +``` + +پاسخ: +```json +{ + "access_token": "ACCESS_TOKEN", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "REFRESH_TOKEN", + "scope": "read_profile read_business" +} +``` + +### استفاده از توکن دسترسی + +برای دسترسی به API ها، توکن را در header قرار دهید: + +```bash +curl -H "Authorization: Bearer ACCESS_TOKEN" \ + https://hesabix.ir/oauth/userinfo +``` + +## API Endpoints + +### اطلاعات کاربر + +``` +GET /oauth/userinfo +Authorization: Bearer ACCESS_TOKEN +``` + +پاسخ: +```json +{ + "sub": 123, + "email": "user@example.com", + "name": "نام کاربر", + "mobile": "09123456789", + "profile": { + "full_name": "نام کامل", + "mobile": "09123456789", + "email": "user@example.com", + "date_register": "2024-01-01", + "active": true + } +} +``` + +### تمدید توکن + +```bash +curl -X POST https://hesabix.ir/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" \ + -d "refresh_token=REFRESH_TOKEN" +``` + +### لغو توکن + +```bash +curl -X POST https://hesabix.ir/oauth/revoke \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=ACCESS_TOKEN" \ + -d "token_type_hint=access_token" +``` + +## Discovery Endpoint + +برای دریافت اطلاعات OAuth server: + +``` +GET /.well-known/oauth-authorization-server +``` + +پاسخ: +```json +{ + "issuer": "https://hesabix.ir", + "authorization_endpoint": "https://hesabix.ir/oauth/authorize", + "token_endpoint": "https://hesabix.ir/oauth/token", + "userinfo_endpoint": "https://hesabix.ir/oauth/userinfo", + "revocation_endpoint": "https://hesabix.ir/oauth/revoke", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + "scopes_supported": [ + "read_profile", + "write_profile", + "read_business", + "write_business", + "read_accounting", + "write_accounting", + "read_reports", + "write_reports", + "admin" + ] +} +``` + +## نکات امنیتی + +1. **HTTPS اجباری**: تمام ارتباطات باید روی HTTPS باشد +2. **State Parameter**: همیشه از state parameter استفاده کنید +3. **توکن‌های کوتاه‌مدت**: توکن‌های دسترسی 1 ساعت اعتبار دارند +4. **Refresh Token**: برای تمدید توکن از refresh token استفاده کنید +5. **Client Secret**: Client Secret را در کد سمت کلاینت قرار ندهید + +## مثال پیاده‌سازی (JavaScript) + +```javascript +class HesabixOAuth { + constructor(clientId, redirectUri) { + this.clientId = clientId; + this.redirectUri = redirectUri; + this.baseUrl = 'https://hesabix.ir'; + } + + // شروع فرآیند OAuth + authorize(scopes = ['read_profile']) { + const state = this.generateState(); + const params = new URLSearchParams({ + client_id: this.clientId, + redirect_uri: this.redirectUri, + response_type: 'code', + scope: scopes.join(' '), + state: state + }); + + localStorage.setItem('oauth_state', state); + window.location.href = `${this.baseUrl}/oauth/authorize?${params}`; + } + + // پردازش callback + async handleCallback() { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const state = urlParams.get('state'); + const savedState = localStorage.getItem('oauth_state'); + + if (state !== savedState) { + throw new Error('State mismatch'); + } + + if (!code) { + throw new Error('Authorization code not found'); + } + + const tokens = await this.exchangeCode(code); + localStorage.setItem('access_token', tokens.access_token); + localStorage.setItem('refresh_token', tokens.refresh_token); + + return tokens; + } + + // مبادله کد با توکن + async exchangeCode(code) { + const response = await fetch(`${this.baseUrl}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.clientId, + client_secret: 'YOUR_CLIENT_SECRET', // در سرور ذخیره شود + code: code, + redirect_uri: this.redirectUri + }) + }); + + if (!response.ok) { + throw new Error('Token exchange failed'); + } + + return await response.json(); + } + + // دریافت اطلاعات کاربر + async getUserInfo() { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${this.baseUrl}/oauth/userinfo`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error('Failed to get user info'); + } + + return await response.json(); + } + + // تمدید توکن + async refreshToken() { + const refreshToken = localStorage.getItem('refresh_token'); + const response = await fetch(`${this.baseUrl}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.clientId, + client_secret: 'YOUR_CLIENT_SECRET', + refresh_token: refreshToken + }) + }); + + if (!response.ok) { + throw new Error('Token refresh failed'); + } + + const tokens = await response.json(); + localStorage.setItem('access_token', tokens.access_token); + localStorage.setItem('refresh_token', tokens.refresh_token); + + return tokens; + } + + generateState() { + return Math.random().toString(36).substring(2, 15); + } +} + +// استفاده +const oauth = new HesabixOAuth('YOUR_CLIENT_ID', 'https://yourapp.com/callback'); + +// شروع OAuth +oauth.authorize(['read_profile', 'read_business']); + +// در صفحه callback +oauth.handleCallback().then(tokens => { + console.log('OAuth successful:', tokens); + // دریافت اطلاعات کاربر + return oauth.getUserInfo(); +}).then(userInfo => { + console.log('User info:', userInfo); +}); +``` + +## پشتیبانی + +برای سوالات و مشکلات مربوط به OAuth، با تیم پشتیبانی Hesabix تماس بگیرید. \ No newline at end of file diff --git a/hesabixCore/config/packages/monolog.yaml b/hesabixCore/config/packages/monolog.yaml index 8c9efa9..adbe839 100644 --- a/hesabixCore/config/packages/monolog.yaml +++ b/hesabixCore/config/packages/monolog.yaml @@ -1,6 +1,7 @@ monolog: channels: - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists + - oauth # OAuth specific logs when@dev: monolog: @@ -22,6 +23,12 @@ when@dev: type: console process_psr_3_messages: false channels: ["!event", "!doctrine", "!console"] + oauth: + type: stream + path: "%kernel.logs_dir%/oauth.log" + level: info + channels: [oauth] + formatter: monolog.formatter.json when@test: monolog: @@ -59,3 +66,9 @@ when@prod: type: stream channels: [deprecation] path: php://stderr + oauth: + type: stream + path: "%kernel.logs_dir%/oauth.log" + level: info + channels: [oauth] + formatter: monolog.formatter.json diff --git a/hesabixCore/config/packages/security.yaml b/hesabixCore/config/packages/security.yaml index 77e2f5f..73ab524 100644 --- a/hesabixCore/config/packages/security.yaml +++ b/hesabixCore/config/packages/security.yaml @@ -29,6 +29,7 @@ security: custom_authenticators: - App\Security\ApiKeyAuthenticator - App\Security\ParttyAuthenticator + - App\Security\OAuthAuthenticator # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall @@ -49,6 +50,13 @@ security: access_control: # - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/api/wordpress/plugin/stats, roles: PUBLIC_ACCESS } + - { path: ^/oauth/authorize, roles: PUBLIC_ACCESS } + - { path: ^/oauth/token, roles: PUBLIC_ACCESS } + - { path: ^/oauth/.well-known/oauth-authorization-server, roles: PUBLIC_ACCESS } + - { path: ^/oauth/userinfo, roles: ROLE_USER } + - { path: ^/oauth/revoke, roles: ROLE_USER } + - { path: ^/api/admin/oauth, roles: ROLE_ADMIN } + - { path: ^/api/oauth, roles: ROLE_USER } - { path: ^/api/acc/*, roles: ROLE_USER } - { path: ^/hooks/*, roles: ROLE_USER } - { path: ^/api/app/*, roles: ROLE_USER } diff --git a/hesabixCore/migrations/Version20250815230230.php b/hesabixCore/migrations/Version20250815230230.php new file mode 100644 index 0000000..bb8367a --- /dev/null +++ b/hesabixCore/migrations/Version20250815230230.php @@ -0,0 +1,107 @@ +addSql(<<<'SQL' + CREATE TABLE oauth_access_token (id INT AUTO_INCREMENT NOT NULL, token VARCHAR(255) NOT NULL, refresh_token VARCHAR(255) DEFAULT NULL, scopes JSON NOT NULL, expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL, last_used_at DATETIME DEFAULT NULL, is_revoked TINYINT(1) NOT NULL, ip_address VARCHAR(255) DEFAULT NULL, user_agent VARCHAR(500) DEFAULT NULL, user_id INT NOT NULL, application_id INT NOT NULL, scope_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_F7FA86A45F37A13B (token), INDEX IDX_F7FA86A4A76ED395 (user_id), INDEX IDX_F7FA86A43E030ACD (application_id), INDEX IDX_F7FA86A4682B5931 (scope_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_application (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, description VARCHAR(500) DEFAULT NULL, website VARCHAR(255) NOT NULL, redirect_uri VARCHAR(255) NOT NULL, client_id VARCHAR(64) NOT NULL, client_secret VARCHAR(128) NOT NULL, is_active TINYINT(1) NOT NULL, is_verified TINYINT(1) NOT NULL, logo_url VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, allowed_scopes JSON DEFAULT NULL, rate_limit INT DEFAULT 0 NOT NULL, ip_whitelist JSON DEFAULT NULL, owner_id INT NOT NULL, UNIQUE INDEX UNIQ_F87A716A19EB6921 (client_id), INDEX IDX_F87A716A7E3C61F9 (owner_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_application_oauth_scope (oauth_application_id INT NOT NULL, oauth_scope_id INT NOT NULL, INDEX IDX_E89D70B5A5F55BAB (oauth_application_id), INDEX IDX_E89D70B54857DA2D (oauth_scope_id), PRIMARY KEY(oauth_application_id, oauth_scope_id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_authorization_code (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(255) NOT NULL, redirect_uri VARCHAR(255) NOT NULL, scopes JSON NOT NULL, expires_at DATETIME NOT NULL, is_used TINYINT(1) NOT NULL, created_at DATETIME NOT NULL, state VARCHAR(255) DEFAULT NULL, code_challenge VARCHAR(255) DEFAULT NULL, code_challenge_method VARCHAR(10) DEFAULT NULL, user_id INT NOT NULL, application_id INT NOT NULL, UNIQUE INDEX UNIQ_793B081777153098 (code), INDEX IDX_793B0817A76ED395 (user_id), INDEX IDX_793B08173E030ACD (application_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_scope (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) NOT NULL, description VARCHAR(255) NOT NULL, is_default TINYINT(1) NOT NULL, is_system TINYINT(1) NOT NULL, created_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_87ACBFC25E237E06 (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_token ADD CONSTRAINT FK_F7FA86A4A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_token ADD CONSTRAINT FK_F7FA86A43E030ACD FOREIGN KEY (application_id) REFERENCES oauth_application (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_token ADD CONSTRAINT FK_F7FA86A4682B5931 FOREIGN KEY (scope_id) REFERENCES oauth_scope (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_application ADD CONSTRAINT FK_F87A716A7E3C61F9 FOREIGN KEY (owner_id) REFERENCES user (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_application_oauth_scope ADD CONSTRAINT FK_E89D70B5A5F55BAB FOREIGN KEY (oauth_application_id) REFERENCES oauth_application (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_application_oauth_scope ADD CONSTRAINT FK_E89D70B54857DA2D FOREIGN KEY (oauth_scope_id) REFERENCES oauth_scope (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_authorization_code ADD CONSTRAINT FK_793B0817A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_authorization_code ADD CONSTRAINT FK_793B08173E030ACD FOREIGN KEY (application_id) REFERENCES oauth_application (id) + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_token DROP FOREIGN KEY FK_F7FA86A4A76ED395 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_token DROP FOREIGN KEY FK_F7FA86A43E030ACD + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_token DROP FOREIGN KEY FK_F7FA86A4682B5931 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_application DROP FOREIGN KEY FK_F87A716A7E3C61F9 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_application_oauth_scope DROP FOREIGN KEY FK_E89D70B5A5F55BAB + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_application_oauth_scope DROP FOREIGN KEY FK_E89D70B54857DA2D + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_authorization_code DROP FOREIGN KEY FK_793B0817A76ED395 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_authorization_code DROP FOREIGN KEY FK_793B08173E030ACD + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_access_token + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_application + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_application_oauth_scope + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_authorization_code + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_scope + SQL); + } +} diff --git a/hesabixCore/migrations/Version20250816003509.php b/hesabixCore/migrations/Version20250816003509.php new file mode 100644 index 0000000..2ee8be9 --- /dev/null +++ b/hesabixCore/migrations/Version20250816003509.php @@ -0,0 +1,35 @@ +addSql(<<<'SQL' + ALTER TABLE oauth_application DROP is_verified + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE oauth_application ADD is_verified TINYINT(1) NOT NULL + SQL); + } +} diff --git a/hesabixCore/src/Command/CleanupOAuthCommand.php b/hesabixCore/src/Command/CleanupOAuthCommand.php new file mode 100644 index 0000000..36fde9e --- /dev/null +++ b/hesabixCore/src/Command/CleanupOAuthCommand.php @@ -0,0 +1,64 @@ +oauthService = $oauthService; + } + + protected function configure(): void + { + $this + ->setHelp('این دستور توکن‌ها و کدهای منقضی شده OAuth را پاکسازی می‌کند.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('پاکسازی OAuth'); + + try { + $result = $this->oauthService->cleanupExpiredItems(); + + $io->success('پاکسازی OAuth با موفقیت انجام شد.'); + + $io->table( + ['نوع', 'تعداد حذف شده'], + [ + ['کدهای مجوز منقضی شده', $result['expired_codes']], + ['توکن‌های منقضی شده', $result['expired_tokens']] + ] + ); + + $total = $result['expired_codes'] + $result['expired_tokens']; + if ($total > 0) { + $io->info("در مجموع {$total} آیتم منقضی شده پاکسازی شد."); + } else { + $io->info('هیچ آیتم منقضی شده‌ای یافت نشد.'); + } + + return Command::SUCCESS; + } catch (\Exception $e) { + $io->error('خطا در پاکسازی OAuth: ' . $e->getMessage()); + return Command::FAILURE; + } + } +} \ No newline at end of file diff --git a/hesabixCore/src/Command/CreateOAuthScopesCommand.php b/hesabixCore/src/Command/CreateOAuthScopesCommand.php new file mode 100644 index 0000000..a00bbb1 --- /dev/null +++ b/hesabixCore/src/Command/CreateOAuthScopesCommand.php @@ -0,0 +1,64 @@ +oauthService = $oauthService; + } + + protected function configure(): void + { + $this + ->setHelp('این دستور محدوده‌های پیش‌فرض OAuth را ایجاد می‌کند.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('ایجاد محدوده‌های پیش‌فرض OAuth'); + + try { + $this->oauthService->createDefaultScopes(); + + $io->success('محدوده‌های پیش‌فرض OAuth با موفقیت ایجاد شدند.'); + + $io->table( + ['نام محدوده', 'توضیحات', 'پیش‌فرض'], + [ + ['read_profile', 'دسترسی به اطلاعات پروفایل کاربر', 'بله'], + ['write_profile', 'ویرایش اطلاعات پروفایل کاربر', 'خیر'], + ['read_business', 'دسترسی به اطلاعات کسب‌وکار', 'بله'], + ['write_business', 'ویرایش اطلاعات کسب‌وکار', 'خیر'], + ['read_accounting', 'دسترسی به اطلاعات حسابداری', 'خیر'], + ['write_accounting', 'ویرایش اطلاعات حسابداری', 'خیر'], + ['read_reports', 'دسترسی به گزارش‌ها', 'خیر'], + ['write_reports', 'ایجاد و ویرایش گزارش‌ها', 'خیر'], + ['admin', 'دسترسی مدیریتی کامل', 'خیر'] + ] + ); + + return Command::SUCCESS; + } catch (\Exception $e) { + $io->error('خطا در ایجاد محدوده‌های OAuth: ' . $e->getMessage()); + return Command::FAILURE; + } + } +} \ No newline at end of file diff --git a/hesabixCore/src/Controller/OAuthApplicationController.php b/hesabixCore/src/Controller/OAuthApplicationController.php new file mode 100644 index 0000000..abf8e24 --- /dev/null +++ b/hesabixCore/src/Controller/OAuthApplicationController.php @@ -0,0 +1,453 @@ +oauthService = $oauthService; + $this->extractor = $extractor; + $this->logger = $logger; + $this->provider = $provider; + $this->entityManager = $entityManager; + $this->validator = $validator; + } + + /** + * لیست برنامه‌های OAuth کاربر + */ + #[Route('/applications', name: 'api_admin_oauth_applications_list', methods: ['GET'])] + public function listApplications(): JsonResponse + { + $user = $this->getUser(); + if (!$user) { + throw $this->createAccessDeniedException(); + } + + $applications = $this->entityManager->getRepository(\App\Entity\OAuthApplication::class)->findByOwner($user->getId()); + + return $this->json($this->extractor->operationSuccess( + $this->provider->ArrayEntity2Array($applications, 1, ['owner', 'scopes']) + )); + } + + /** + * ایجاد برنامه OAuth جدید + */ + #[Route('/applications', name: 'api_admin_oauth_applications_create', methods: ['POST'])] + public function createApplication(Request $request): JsonResponse + { + $user = $this->getUser(); + if (!$user) { + throw $this->createAccessDeniedException(); + } + + $data = json_decode($request->getContent(), true); + + // اعتبارسنجی داده‌های ورودی + if (!isset($data['name'])) { + return $this->json($this->extractor->operationFail('نام الزامی است')); + } + + // پشتیبانی از هر دو فرمت redirect_uri و redirectUri + $redirectUri = $data['redirect_uri'] ?? $data['redirectUri'] ?? null; + if (!$redirectUri) { + return $this->json($this->extractor->operationFail('آدرس بازگشت الزامی است')); + } + + // بررسی تکراری نبودن نام + $existingApp = $this->entityManager->getRepository(OAuthApplication::class)->findOneBy([ + 'name' => $data['name'], + 'owner' => $user + ]); + + if ($existingApp) { + return $this->json($this->extractor->operationFail('برنامه‌ای با این نام قبلاً وجود دارد')); + } + + // ایجاد برنامه جدید + $application = new OAuthApplication(); + $application->setName($data['name']); + $application->setDescription($data['description'] ?? ''); + $application->setWebsite($data['website'] ?? ''); + $application->setRedirectUri($redirectUri); + $application->setOwner($user); + + // تنظیم فیلدهای اختیاری + if (isset($data['rateLimit']) || isset($data['rate_limit'])) { + $rateLimit = $data['rateLimit'] ?? $data['rate_limit'] ?? 1000; + $application->setRateLimit($rateLimit); + } + + if (isset($data['allowedScopes']) || isset($data['allowed_scopes'])) { + $allowedScopes = $data['allowedScopes'] ?? $data['allowed_scopes'] ?? []; + $application->setAllowedScopes($allowedScopes); + } + + if (isset($data['ipWhitelist']) || isset($data['ip_whitelist'])) { + $ipWhitelist = $data['ipWhitelist'] ?? $data['ip_whitelist'] ?? []; + $application->setIpWhitelist($ipWhitelist); + } + + // تولید client_id و client_secret + $credentials = $this->oauthService->generateClientCredentials(); + $application->setClientId($credentials['client_id']); + $application->setClientSecret($credentials['client_secret']); + + // تنظیم محدوده‌های پیش‌فرض (فقط اگر محدوده‌ای تنظیم نشده باشد) + if (empty($application->getAllowedScopes())) { + $defaultScopes = $this->entityManager->getRepository(OAuthScope::class)->findDefaultScopes(); + $application->setAllowedScopes(array_map(fn($scope) => $scope->getName(), $defaultScopes)); + } + + // اعتبارسنجی + $errors = $this->validator->validate($application); + if (count($errors) > 0) { + return $this->json($this->extractor->operationFail('داده‌های ورودی نامعتبر است')); + } + + $this->entityManager->persist($application); + $this->entityManager->flush(); + + // ثبت لاگ + $this->logger->info('OAuth Application Created', [ + 'application_name' => $application->getName(), + 'user_id' => $user->getId(), + 'user_email' => $user->getEmail() + ]); + + return $this->json($this->extractor->operationSuccess([ + 'application' => $this->provider->Entity2Array($application, 1, ['owner', 'scopes']), + 'client_id' => $application->getClientId(), + 'client_secret' => $application->getClientSecret() + ])); + } + + /** + * ویرایش برنامه OAuth + */ + #[Route('/applications/{id}', name: 'api_admin_oauth_applications_update', methods: ['PUT'])] + public function updateApplication(Request $request, int $id): JsonResponse + { + $user = $this->getUser(); + if (!$user) { + throw $this->createAccessDeniedException(); + } + + $application = $this->entityManager->getRepository(OAuthApplication::class)->find($id); + if (!$application || $application->getOwner()->getId() !== $user->getId()) { + throw $this->createNotFoundException('برنامه یافت نشد'); + } + + $data = json_decode($request->getContent(), true); + + if (isset($data['name'])) { + $application->setName($data['name']); + } + if (isset($data['description'])) { + $application->setDescription($data['description']); + } + if (isset($data['website'])) { + $application->setWebsite($data['website']); + } + if (isset($data['redirect_uri']) || isset($data['redirectUri'])) { + $redirectUri = $data['redirect_uri'] ?? $data['redirectUri']; + $application->setRedirectUri($redirectUri); + } + if (isset($data['allowed_scopes']) || isset($data['allowedScopes'])) { + $allowedScopes = $data['allowed_scopes'] ?? $data['allowedScopes']; + $application->setAllowedScopes($allowedScopes); + } + if (isset($data['rate_limit']) || isset($data['rateLimit'])) { + $rateLimit = $data['rate_limit'] ?? $data['rateLimit']; + $application->setRateLimit($rateLimit); + } + if (isset($data['ip_whitelist']) || isset($data['ipWhitelist'])) { + $ipWhitelist = $data['ip_whitelist'] ?? $data['ipWhitelist'] ?? []; + $application->setIpWhitelist($ipWhitelist); + } + + // اعتبارسنجی + $errors = $this->validator->validate($application); + if (count($errors) > 0) { + return $this->json($this->extractor->operationFail('داده‌های ورودی نامعتبر است')); + } + + $this->entityManager->flush(); + + // ثبت لاگ + $this->logger->info('OAuth Application Updated', [ + 'application_name' => $application->getName(), + 'user_id' => $user->getId(), + 'user_email' => $user->getEmail() + ]); + + return $this->json($this->extractor->operationSuccess( + $this->provider->Entity2Array($application, 1, ['owner', 'scopes']) + )); + } + + /** + * حذف برنامه OAuth + */ + #[Route('/applications/{id}', name: 'api_admin_oauth_applications_delete', methods: ['DELETE'])] + public function deleteApplication(int $id): JsonResponse + { + $user = $this->getUser(); + if (!$user) { + throw $this->createAccessDeniedException(); + } + + $application = $this->entityManager->getRepository(OAuthApplication::class)->find($id); + if (!$application || $application->getOwner()->getId() !== $user->getId()) { + throw $this->createNotFoundException('برنامه یافت نشد'); + } + + $appName = $application->getName(); + + // لغو تمام توکن‌های مربوط به این برنامه + $this->entityManager->getRepository(\App\Entity\OAuthAccessToken::class)->revokeApplicationTokens($application->getId()); + + $this->entityManager->remove($application); + $this->entityManager->flush(); + + // ثبت لاگ + $this->logger->info('OAuth Application Deleted', [ + 'application_name' => $appName, + 'user_id' => $user->getId(), + 'user_email' => $user->getEmail() + ]); + + return $this->json($this->extractor->operationSuccess()); + } + + /** + * بازسازی client_secret + */ + #[Route('/applications/{id}/regenerate-secret', name: 'api_admin_oauth_applications_regenerate_secret', methods: ['POST'])] + public function regenerateClientSecret(int $id): JsonResponse + { + $user = $this->getUser(); + if (!$user) { + throw $this->createAccessDeniedException(); + } + + $application = $this->entityManager->getRepository(OAuthApplication::class)->find($id); + if (!$application || $application->getOwner()->getId() !== $user->getId()) { + throw $this->createNotFoundException('برنامه یافت نشد'); + } + + // لغو تمام توکن‌های موجود + $this->entityManager->getRepository(\App\Entity\OAuthAccessToken::class)->revokeApplicationTokens($application->getId()); + + // تولید client_secret جدید + $credentials = $this->oauthService->generateClientCredentials(); + $application->setClientSecret($credentials['client_secret']); + + $this->entityManager->flush(); + + // ثبت لاگ + $this->logger->info('OAuth Client Secret Regenerated', [ + 'application_name' => $application->getName(), + 'user_id' => $user->getId(), + 'user_email' => $user->getEmail() + ]); + + return $this->json($this->extractor->operationSuccess([ + 'client_secret' => $application->getClientSecret() + ])); + } + + /** + * لیست محدوده‌های دسترسی + */ + #[Route('/scopes', name: 'api_admin_oauth_scopes_list', methods: ['GET'])] + public function listScopes(): JsonResponse + { + $scopes = $this->entityManager->getRepository(OAuthScope::class)->findAll(); + + return $this->json($this->extractor->operationSuccess( + $this->provider->ArrayEntity2Array($scopes) + )); + } + + /** + * آمار استفاده از برنامه OAuth + */ + #[Route('/applications/{id}/stats', name: 'api_admin_oauth_applications_stats', methods: ['GET'])] + public function getApplicationStats(int $id): JsonResponse + { + $user = $this->getUser(); + if (!$user) { + throw $this->createAccessDeniedException(); + } + + $application = $this->entityManager->getRepository(OAuthApplication::class)->find($id); + if (!$application || $application->getOwner()->getId() !== $user->getId()) { + throw $this->createNotFoundException('برنامه یافت نشد'); + } + + // تعداد توکن‌های فعال + $activeTokens = $this->entityManager->getRepository(\App\Entity\OAuthAccessToken::class)->findByApplication($application->getId()); + $activeTokensCount = count(array_filter($activeTokens, fn($token) => $token->isValid())); + + // تعداد توکن‌های منقضی شده + $expiredTokensCount = count(array_filter($activeTokens, fn($token) => $token->isExpired())); + + // آخرین استفاده + $lastUsed = null; + if (!empty($activeTokens)) { + $lastUsedToken = max($activeTokens, fn($a, $b) => $a->getLastUsedAt() <=> $b->getLastUsedAt()); + $lastUsed = $lastUsedToken->getLastUsedAt(); + } + + $stats = [ + 'total_tokens' => count($activeTokens), + 'active_tokens' => $activeTokensCount, + 'expired_tokens' => $expiredTokensCount, + 'last_used' => $lastUsed, + 'created_at' => $application->getCreatedAt(), + 'is_active' => $application->isActive() + ]; + + return $this->json($this->extractor->operationSuccess($stats)); + } + + /** + * لغو تمام توکن‌های برنامه + */ + #[Route('/applications/{id}/revoke-tokens', name: 'api_admin_oauth_applications_revoke_tokens', methods: ['POST'])] + public function revokeAllTokens(int $id): JsonResponse + { + $user = $this->getUser(); + if (!$user) { + throw $this->createAccessDeniedException(); + } + + $application = $this->entityManager->getRepository(OAuthApplication::class)->find($id); + if (!$application || $application->getOwner()->getId() !== $user->getId()) { + throw $this->createNotFoundException('برنامه یافت نشد'); + } + + $revokedCount = $this->entityManager->getRepository(\App\Entity\OAuthAccessToken::class)->revokeApplicationTokens($application->getId()); + + // ثبت لاگ + $this->logger->info('OAuth Tokens Revoked', [ + 'application_name' => $application->getName(), + 'revoked_count' => $revokedCount, + 'user_id' => $user->getId(), + 'user_email' => $user->getEmail() + ]); + + return $this->json($this->extractor->operationSuccess([ + 'revoked_count' => $revokedCount + ])); + } + + /** + * دریافت اطلاعات برنامه بر اساس Client ID + */ + #[Route('/applications/client/{clientId}', name: 'api_admin_oauth_applications_by_client_id', methods: ['GET'])] + public function getApplicationByClientId(string $clientId): JsonResponse + { + try { + $application = $this->entityManager->getRepository(\App\Entity\OAuthApplication::class)->findByClientId($clientId); + if (!$application) { + return $this->json([ + 'Success' => false, + 'message' => 'برنامه یافت نشد' + ], Response::HTTP_NOT_FOUND); + } + + return $this->json([ + 'Success' => true, + 'data' => [ + 'id' => $application->getId(), + 'name' => $application->getName(), + 'description' => $application->getDescription(), + 'website' => $application->getWebsite(), + 'redirectUri' => $application->getRedirectUri(), + 'clientId' => $application->getClientId(), + 'isActive' => $application->isActive(), + 'allowedScopes' => $application->getAllowedScopes(), + 'createdAt' => $application->getCreatedAt() + ] + ]); + + } catch (\Exception $e) { + $this->logger->error('Error getting application by client ID: ' . $e->getMessage()); + return $this->json([ + 'Success' => false, + 'message' => 'خطا در دریافت اطلاعات برنامه' + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * فعال/غیرفعال کردن برنامه OAuth + */ + #[Route('/applications/{id}/toggle-status', name: 'api_admin_oauth_applications_toggle_status', methods: ['POST'])] + public function toggleApplicationStatus(int $id): JsonResponse + { + $user = $this->getUser(); + if (!$user) { + throw $this->createAccessDeniedException(); + } + + $application = $this->entityManager->getRepository(OAuthApplication::class)->find($id); + if (!$application || $application->getOwner()->getId() !== $user->getId()) { + throw $this->createNotFoundException('برنامه یافت نشد'); + } + + $oldStatus = $application->isActive(); + $newStatus = !$oldStatus; + $application->setIsActive($newStatus); + + $this->entityManager->flush(); + + // ثبت لاگ + $this->logger->info('OAuth Application Status Changed', [ + 'application_name' => $application->getName(), + 'old_status' => $oldStatus ? 'active' : 'inactive', + 'new_status' => $newStatus ? 'active' : 'inactive', + 'user_id' => $user->getId(), + 'user_email' => $user->getEmail() + ]); + + return $this->json($this->extractor->operationSuccess([ + 'is_active' => $newStatus, + 'message' => $newStatus ? 'برنامه فعال شد' : 'برنامه غیرفعال شد' + ])); + } +} \ No newline at end of file diff --git a/hesabixCore/src/Controller/OAuthController.php b/hesabixCore/src/Controller/OAuthController.php new file mode 100644 index 0000000..b1fed5d --- /dev/null +++ b/hesabixCore/src/Controller/OAuthController.php @@ -0,0 +1,346 @@ +oauthService = $oauthService; + $this->extractor = $extractor; + } + + /** + * Authorization endpoint - مرحله اول OAuth flow + */ + #[Route('/authorize', name: 'oauth_authorize', methods: ['GET'])] + public function authorize(Request $request): Response + { + try { + $validation = $this->oauthService->validateAuthorizationRequest($request); + $application = $validation['application']; + $scopes = $validation['scopes']; + $state = $validation['state']; + + // هدایت به صفحه frontend + $frontendUrl = $this->getParameter('app.frontend_url') . '/oauth/authorize?' . $request->getQueryString(); + return $this->redirect($frontendUrl); + + } catch (AuthenticationException $e) { + return new JsonResponse([ + 'error' => 'invalid_request', + 'error_description' => $e->getMessage() + ], Response::HTTP_BAD_REQUEST); + } + } + + /** + * API endpoint برای frontend - تایید مجوز + */ + #[Route('/api/oauth/authorize', name: 'api_oauth_authorize', methods: ['POST'])] + public function authorizeApi(Request $request): JsonResponse + { + try { + $data = json_decode($request->getContent(), true); + $clientId = $data['client_id'] ?? null; + $redirectUri = $data['redirect_uri'] ?? null; + $scope = $data['scope'] ?? null; + $state = $data['state'] ?? null; + $approved = $data['approved'] ?? false; + + if (!$this->getUser()) { + return $this->json([ + 'Success' => false, + 'message' => 'کاربر احراز هویت نشده' + ], Response::HTTP_UNAUTHORIZED); + } + + if (!$approved) { + // کاربر مجوز را رد کرده + $errorParams = [ + 'error' => 'access_denied', + 'error_description' => 'User denied access' + ]; + if ($state) { + $errorParams['state'] = $state; + } + + $redirectUrl = $redirectUri . '?' . http_build_query($errorParams); + return $this->json([ + 'Success' => true, + 'redirect_url' => $redirectUrl + ]); + } + + $application = $this->oauthService->getApplicationRepository()->findByClientId($clientId); + if (!$application) { + return $this->json([ + 'Success' => false, + 'message' => 'برنامه نامعتبر' + ], Response::HTTP_BAD_REQUEST); + } + + $scopes = $scope ? explode(' ', $scope) : []; + + // ایجاد کد مجوز + $authorizationCode = $this->oauthService->createAuthorizationCode( + $this->getUser(), + $application, + $scopes, + $state + ); + + // هدایت به redirect_uri با کد مجوز + $params = [ + 'code' => $authorizationCode->getCode() + ]; + if ($state) { + $params['state'] = $state; + } + + $redirectUrl = $redirectUri . '?' . http_build_query($params); + return $this->json([ + 'Success' => true, + 'redirect_url' => $redirectUrl + ]); + + } catch (\Exception $e) { + $errorParams = [ + 'error' => 'server_error', + 'error_description' => $e->getMessage() + ]; + if ($state) { + $errorParams['state'] = $state; + } + + $redirectUrl = $redirectUri . '?' . http_build_query($errorParams); + return $this->json([ + 'Success' => true, + 'redirect_url' => $redirectUrl + ]); + } + } + + /** + * Token endpoint - مرحله دوم OAuth flow + */ + #[Route('/token', name: 'oauth_token', methods: ['POST'])] + public function token(Request $request): JsonResponse + { + $grantType = $request->request->get('grant_type'); + $clientId = $request->request->get('client_id'); + $clientSecret = $request->request->get('client_secret'); + + // اعتبارسنجی client credentials + $application = $this->oauthService->getApplicationRepository()->findByClientId($clientId); + if (!$application || $application->getClientSecret() !== $clientSecret) { + return $this->json([ + 'error' => 'invalid_client', + 'error_description' => 'Invalid client credentials' + ], Response::HTTP_UNAUTHORIZED); + } + + switch ($grantType) { + case 'authorization_code': + return $this->handleAuthorizationCodeGrant($request, $application); + + case 'refresh_token': + return $this->handleRefreshTokenGrant($request, $application); + + default: + return $this->json([ + 'error' => 'unsupported_grant_type', + 'error_description' => 'Unsupported grant type' + ], Response::HTTP_BAD_REQUEST); + } + } + + /** + * مدیریت Authorization Code Grant + */ + private function handleAuthorizationCodeGrant(Request $request, OAuthApplication $application): JsonResponse + { + $code = $request->request->get('code'); + $redirectUri = $request->request->get('redirect_uri'); + + if (!$code || !$redirectUri) { + return $this->json([ + 'error' => 'invalid_request', + 'error_description' => 'Missing required parameters' + ], Response::HTTP_BAD_REQUEST); + } + + $authorizationCode = $this->oauthService->validateAuthorizationCode($code, $application->getClientId(), $redirectUri); + if (!$authorizationCode) { + return $this->json([ + 'error' => 'invalid_grant', + 'error_description' => 'Invalid authorization code' + ], Response::HTTP_BAD_REQUEST); + } + + // استفاده از کد مجوز + $this->oauthService->useAuthorizationCode($authorizationCode); + + // ایجاد توکن دسترسی + $accessToken = $this->oauthService->createAccessToken( + $authorizationCode->getUser(), + $application, + $authorizationCode->getScopes() + ); + + return $this->json([ + 'access_token' => $accessToken->getToken(), + 'token_type' => 'Bearer', + 'expires_in' => 3600, // 1 hour + 'refresh_token' => $accessToken->getRefreshToken(), + 'scope' => implode(' ', $accessToken->getScopes()) + ]); + } + + /** + * مدیریت Refresh Token Grant + */ + private function handleRefreshTokenGrant(Request $request, OAuthApplication $application): JsonResponse + { + $refreshToken = $request->request->get('refresh_token'); + + if (!$refreshToken) { + return $this->json([ + 'error' => 'invalid_request', + 'error_description' => 'Missing refresh_token' + ], Response::HTTP_BAD_REQUEST); + } + + $accessToken = $this->oauthService->getAccessTokenRepository()->findByRefreshToken($refreshToken); + if (!$accessToken || $accessToken->getApplication()->getId() !== $application->getId()) { + return $this->json([ + 'error' => 'invalid_grant', + 'error_description' => 'Invalid refresh token' + ], Response::HTTP_BAD_REQUEST); + } + + // ایجاد توکن جدید + $newAccessToken = $this->oauthService->createAccessToken( + $accessToken->getUser(), + $application, + $accessToken->getScopes() + ); + + // لغو توکن قدیمی + $accessToken->setIsRevoked(true); + $this->oauthService->getEntityManager()->flush(); + + return $this->json([ + 'access_token' => $newAccessToken->getToken(), + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'refresh_token' => $newAccessToken->getRefreshToken(), + 'scope' => implode(' ', $newAccessToken->getScopes()) + ]); + } + + /** + * User Info endpoint + */ + #[Route('/userinfo', name: 'oauth_userinfo', methods: ['GET'])] + public function userinfo(Request $request): JsonResponse + { + $user = $this->getUser(); + if (!$user) { + return $this->json([ + 'error' => 'invalid_token', + 'error_description' => 'Invalid access token' + ], Response::HTTP_UNAUTHORIZED); + } + + return $this->json([ + 'id' => $user->getId(), + 'email' => $user->getEmail(), + 'name' => $user->getName(), + 'profile' => [ + 'phone' => $user->getMobile(), + 'address' => $user->getAddress() + ] + ]); + } + + /** + * Revoke endpoint + */ + #[Route('/revoke', name: 'oauth_revoke', methods: ['POST'])] + public function revoke(Request $request): JsonResponse + { + $token = $request->request->get('token'); + $tokenTypeHint = $request->request->get('token_type_hint', 'access_token'); + + if (!$token) { + return $this->json([ + 'error' => 'invalid_request', + 'error_description' => 'Missing token' + ], Response::HTTP_BAD_REQUEST); + } + + $success = false; + if ($tokenTypeHint === 'access_token') { + $success = $this->oauthService->revokeAccessToken($token); + } elseif ($tokenTypeHint === 'refresh_token') { + $accessToken = $this->oauthService->getAccessTokenRepository()->findByRefreshToken($token); + if ($accessToken) { + $accessToken->setIsRevoked(true); + $this->oauthService->getEntityManager()->flush(); + $success = true; + } + } + + return $this->json(['success' => $success]); + } + + /** + * اطلاعات برنامه OAuth + */ + #[Route('/.well-known/oauth-authorization-server', name: 'oauth_discovery', methods: ['GET'])] + public function discovery(): JsonResponse + { + return $this->json([ + 'issuer' => $this->getParameter('app.site_url'), + 'authorization_endpoint' => $this->generateUrl('oauth_authorize', [], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL), + 'token_endpoint' => $this->generateUrl('oauth_token', [], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL), + 'userinfo_endpoint' => $this->generateUrl('oauth_userinfo', [], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL), + 'revocation_endpoint' => $this->generateUrl('oauth_revoke', [], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL), + 'response_types_supported' => ['code'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'token_endpoint_auth_methods_supported' => ['client_secret_post'], + 'scopes_supported' => [ + 'read_profile', + 'write_profile', + 'read_business', + 'write_business', + 'read_financial', + 'write_financial', + 'read_contacts', + 'write_contacts', + 'read_documents', + 'write_documents', + 'admin_access' + ] + ]); + } +} \ No newline at end of file diff --git a/hesabixCore/src/Entity/OAuthAccessToken.php b/hesabixCore/src/Entity/OAuthAccessToken.php new file mode 100644 index 0000000..81ffe83 --- /dev/null +++ b/hesabixCore/src/Entity/OAuthAccessToken.php @@ -0,0 +1,216 @@ +createdAt = new \DateTime(); + $this->lastUsedAt = new \DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getToken(): ?string + { + return $this->token; + } + + public function setToken(string $token): static + { + $this->token = $token; + return $this; + } + + public function getRefreshToken(): ?string + { + return $this->refreshToken; + } + + public function setRefreshToken(?string $refreshToken): static + { + $this->refreshToken = $refreshToken; + return $this; + } + + public function getScopes(): array + { + return $this->scopes; + } + + public function setScopes(array $scopes): static + { + $this->scopes = $scopes; + return $this; + } + + public function getExpiresAt(): ?\DateTimeInterface + { + return $this->expiresAt; + } + + public function setExpiresAt(\DateTimeInterface $expiresAt): static + { + $this->expiresAt = $expiresAt; + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeInterface $createdAt): static + { + $this->createdAt = $createdAt; + return $this; + } + + public function getLastUsedAt(): ?\DateTimeInterface + { + return $this->lastUsedAt; + } + + public function setLastUsedAt(?\DateTimeInterface $lastUsedAt): static + { + $this->lastUsedAt = $lastUsedAt; + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + return $this; + } + + public function getApplication(): ?OAuthApplication + { + return $this->application; + } + + public function setApplication(?OAuthApplication $application): static + { + $this->application = $application; + return $this; + } + + public function getScope(): ?OAuthScope + { + return $this->scope; + } + + public function setScope(?OAuthScope $scope): static + { + $this->scope = $scope; + return $this; + } + + public function isRevoked(): bool + { + return $this->isRevoked; + } + + public function setIsRevoked(bool $isRevoked): static + { + $this->isRevoked = $isRevoked; + return $this; + } + + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function setIpAddress(?string $ipAddress): static + { + $this->ipAddress = $ipAddress; + return $this; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function setUserAgent(?string $userAgent): static + { + $this->userAgent = $userAgent; + return $this; + } + + public function isExpired(): bool + { + return $this->expiresAt < new \DateTime(); + } + + public function isValid(): bool + { + return !$this->isRevoked && !$this->isExpired(); + } + + public function hasScope(string $scope): bool + { + return in_array($scope, $this->scopes); + } + + public function updateLastUsed(): void + { + $this->lastUsedAt = new \DateTime(); + } +} \ No newline at end of file diff --git a/hesabixCore/src/Entity/OAuthApplication.php b/hesabixCore/src/Entity/OAuthApplication.php new file mode 100644 index 0000000..6960145 --- /dev/null +++ b/hesabixCore/src/Entity/OAuthApplication.php @@ -0,0 +1,332 @@ + 0])] + private int $rateLimit = 1000; // تعداد درخواست در ساعت + + #[ORM\Column(type: 'json', nullable: true)] + private array $ipWhitelist = []; + + public function __construct() + { + $this->accessTokens = new ArrayCollection(); + $this->authorizationCodes = new ArrayCollection(); + $this->scopes = new ArrayCollection(); + $this->createdAt = new \DateTime(); + $this->updatedAt = new \DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + return $this; + } + + public function getWebsite(): ?string + { + return $this->website; + } + + public function setWebsite(string $website): static + { + $this->website = $website; + return $this; + } + + public function getRedirectUri(): ?string + { + return $this->redirectUri; + } + + public function setRedirectUri(string $redirectUri): static + { + $this->redirectUri = $redirectUri; + return $this; + } + + public function getClientId(): ?string + { + return $this->clientId; + } + + public function setClientId(string $clientId): static + { + $this->clientId = $clientId; + return $this; + } + + public function getClientSecret(): ?string + { + return $this->clientSecret; + } + + public function setClientSecret(string $clientSecret): static + { + $this->clientSecret = $clientSecret; + return $this; + } + + public function isActive(): bool + { + return $this->isActive; + } + + public function setIsActive(bool $isActive): static + { + $this->isActive = $isActive; + return $this; + } + + + + public function getLogoUrl(): ?string + { + return $this->logoUrl; + } + + public function setLogoUrl(?string $logoUrl): static + { + $this->logoUrl = $logoUrl; + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeInterface $createdAt): static + { + $this->createdAt = $createdAt; + return $this; + } + + public function getUpdatedAt(): ?\DateTimeInterface + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeInterface $updatedAt): static + { + $this->updatedAt = $updatedAt; + return $this; + } + + public function getOwner(): ?User + { + return $this->owner; + } + + public function setOwner(?User $owner): static + { + $this->owner = $owner; + return $this; + } + + /** + * @return Collection + */ + public function getAccessTokens(): Collection + { + return $this->accessTokens; + } + + public function addAccessToken(OAuthAccessToken $accessToken): static + { + if (!$this->accessTokens->contains($accessToken)) { + $this->accessTokens->add($accessToken); + $accessToken->setApplication($this); + } + return $this; + } + + public function removeAccessToken(OAuthAccessToken $accessToken): static + { + if ($this->accessTokens->removeElement($accessToken)) { + if ($accessToken->getApplication() === $this) { + $accessToken->setApplication(null); + } + } + return $this; + } + + /** + * @return Collection + */ + public function getAuthorizationCodes(): Collection + { + return $this->authorizationCodes; + } + + public function addAuthorizationCode(OAuthAuthorizationCode $authorizationCode): static + { + if (!$this->authorizationCodes->contains($authorizationCode)) { + $this->authorizationCodes->add($authorizationCode); + $authorizationCode->setApplication($this); + } + return $this; + } + + public function removeAuthorizationCode(OAuthAuthorizationCode $authorizationCode): static + { + if ($this->authorizationCodes->removeElement($authorizationCode)) { + if ($authorizationCode->getApplication() === $this) { + $authorizationCode->setApplication(null); + } + } + return $this; + } + + /** + * @return Collection + */ + public function getScopes(): Collection + { + return $this->scopes; + } + + public function addScope(OAuthScope $scope): static + { + if (!$this->scopes->contains($scope)) { + $this->scopes->add($scope); + } + return $this; + } + + public function removeScope(OAuthScope $scope): static + { + $this->scopes->removeElement($scope); + return $this; + } + + public function getAllowedScopes(): array + { + return $this->allowedScopes; + } + + public function setAllowedScopes(array $allowedScopes): static + { + $this->allowedScopes = $allowedScopes; + return $this; + } + + public function getRateLimit(): int + { + return $this->rateLimit; + } + + public function setRateLimit(int $rateLimit): static + { + $this->rateLimit = $rateLimit; + return $this; + } + + public function getIpWhitelist(): array + { + return $this->ipWhitelist; + } + + public function setIpWhitelist(array $ipWhitelist): static + { + $this->ipWhitelist = $ipWhitelist; + return $this; + } + + #[ORM\PreUpdate] + public function setUpdatedAtValue(): void + { + $this->updatedAt = new \DateTime(); + } +} \ No newline at end of file diff --git a/hesabixCore/src/Entity/OAuthAuthorizationCode.php b/hesabixCore/src/Entity/OAuthAuthorizationCode.php new file mode 100644 index 0000000..08e1035 --- /dev/null +++ b/hesabixCore/src/Entity/OAuthAuthorizationCode.php @@ -0,0 +1,192 @@ +createdAt = new \DateTime(); + $this->expiresAt = (new \DateTime())->modify('+10 minutes'); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + return $this; + } + + public function getRedirectUri(): ?string + { + return $this->redirectUri; + } + + public function setRedirectUri(string $redirectUri): static + { + $this->redirectUri = $redirectUri; + return $this; + } + + public function getScopes(): array + { + return $this->scopes; + } + + public function setScopes(array $scopes): static + { + $this->scopes = $scopes; + return $this; + } + + public function getExpiresAt(): ?\DateTimeInterface + { + return $this->expiresAt; + } + + public function setExpiresAt(\DateTimeInterface $expiresAt): static + { + $this->expiresAt = $expiresAt; + return $this; + } + + public function isUsed(): bool + { + return $this->isUsed; + } + + public function setIsUsed(bool $isUsed): static + { + $this->isUsed = $isUsed; + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeInterface $createdAt): static + { + $this->createdAt = $createdAt; + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + return $this; + } + + public function getApplication(): ?OAuthApplication + { + return $this->application; + } + + public function setApplication(?OAuthApplication $application): static + { + $this->application = $application; + return $this; + } + + public function getState(): ?string + { + return $this->state; + } + + public function setState(?string $state): static + { + $this->state = $state; + return $this; + } + + public function getCodeChallenge(): ?string + { + return $this->codeChallenge; + } + + public function setCodeChallenge(?string $codeChallenge): static + { + $this->codeChallenge = $codeChallenge; + return $this; + } + + public function getCodeChallengeMethod(): ?string + { + return $this->codeChallengeMethod; + } + + public function setCodeChallengeMethod(?string $codeChallengeMethod): static + { + $this->codeChallengeMethod = $codeChallengeMethod; + return $this; + } + + public function isExpired(): bool + { + return $this->expiresAt < new \DateTime(); + } + + public function isValid(): bool + { + return !$this->isUsed && !$this->isExpired(); + } +} \ No newline at end of file diff --git a/hesabixCore/src/Entity/OAuthScope.php b/hesabixCore/src/Entity/OAuthScope.php new file mode 100644 index 0000000..9451d0b --- /dev/null +++ b/hesabixCore/src/Entity/OAuthScope.php @@ -0,0 +1,162 @@ +applications = new ArrayCollection(); + $this->accessTokens = new ArrayCollection(); + $this->createdAt = new \DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(string $description): static + { + $this->description = $description; + return $this; + } + + public function isDefault(): bool + { + return $this->isDefault; + } + + public function setIsDefault(bool $isDefault): static + { + $this->isDefault = $isDefault; + return $this; + } + + public function isSystem(): bool + { + return $this->isSystem; + } + + public function setIsSystem(bool $isSystem): static + { + $this->isSystem = $isSystem; + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeInterface $createdAt): static + { + $this->createdAt = $createdAt; + return $this; + } + + /** + * @return Collection + */ + public function getApplications(): Collection + { + return $this->applications; + } + + public function addApplication(OAuthApplication $application): static + { + if (!$this->applications->contains($application)) { + $this->applications->add($application); + $application->addScope($this); + } + return $this; + } + + public function removeApplication(OAuthApplication $application): static + { + if ($this->applications->removeElement($application)) { + $application->removeScope($this); + } + return $this; + } + + /** + * @return Collection + */ + public function getAccessTokens(): Collection + { + return $this->accessTokens; + } + + public function addAccessToken(OAuthAccessToken $accessToken): static + { + if (!$this->accessTokens->contains($accessToken)) { + $this->accessTokens->add($accessToken); + $accessToken->setScope($this); + } + return $this; + } + + public function removeAccessToken(OAuthAccessToken $accessToken): static + { + if ($this->accessTokens->removeElement($accessToken)) { + if ($accessToken->getScope() === $this) { + $accessToken->setScope(null); + } + } + return $this; + } +} \ No newline at end of file diff --git a/hesabixCore/src/Repository/OAuthAccessTokenRepository.php b/hesabixCore/src/Repository/OAuthAccessTokenRepository.php new file mode 100644 index 0000000..4371c2a --- /dev/null +++ b/hesabixCore/src/Repository/OAuthAccessTokenRepository.php @@ -0,0 +1,144 @@ + + * + * @method OAuthAccessToken|null find($id, $lockMode = null, $lockVersion = null) + * @method OAuthAccessToken|null findOneBy(array $criteria, array $orderBy = null) + * @method OAuthAccessToken[] findAll() + * @method OAuthAccessToken[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class OAuthAccessTokenRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, OAuthAccessToken::class); + } + + public function save(OAuthAccessToken $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(OAuthAccessToken $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function findByToken(string $token): ?OAuthAccessToken + { + return $this->findOneBy(['token' => $token]); + } + + public function findValidByToken(string $token): ?OAuthAccessToken + { + $qb = $this->createQueryBuilder('at') + ->andWhere('at.token = :token') + ->andWhere('at.isRevoked = :isRevoked') + ->andWhere('at.expiresAt > :now') + ->setParameter('token', $token) + ->setParameter('isRevoked', false) + ->setParameter('now', new \DateTime()); + + return $qb->getQuery()->getOneOrNullResult(); + } + + public function findByRefreshToken(string $refreshToken): ?OAuthAccessToken + { + return $this->findOneBy(['refreshToken' => $refreshToken]); + } + + public function findValidByRefreshToken(string $refreshToken): ?OAuthAccessToken + { + $qb = $this->createQueryBuilder('at') + ->andWhere('at.refreshToken = :refreshToken') + ->andWhere('at.isRevoked = :isRevoked') + ->andWhere('at.expiresAt > :now') + ->setParameter('refreshToken', $refreshToken) + ->setParameter('isRevoked', false) + ->setParameter('now', new \DateTime()); + + return $qb->getQuery()->getOneOrNullResult(); + } + + public function findByUser(int $userId): array + { + return $this->createQueryBuilder('at') + ->andWhere('at.user = :userId') + ->setParameter('userId', $userId) + ->orderBy('at.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + public function findByApplication(int $applicationId): array + { + return $this->createQueryBuilder('at') + ->andWhere('at.application = :applicationId') + ->setParameter('applicationId', $applicationId) + ->orderBy('at.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + public function findExpiredTokens(): array + { + return $this->createQueryBuilder('at') + ->andWhere('at.expiresAt < :now') + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getResult(); + } + + public function cleanupExpiredTokens(): int + { + $qb = $this->createQueryBuilder('at') + ->delete() + ->andWhere('at.expiresAt < :now') + ->setParameter('now', new \DateTime()); + + return $qb->getQuery()->execute(); + } + + public function revokeUserTokens(int $userId): int + { + $qb = $this->createQueryBuilder('at') + ->update() + ->set('at.isRevoked', ':isRevoked') + ->andWhere('at.user = :userId') + ->andWhere('at.isRevoked = :currentRevoked') + ->setParameter('isRevoked', true) + ->setParameter('userId', $userId) + ->setParameter('currentRevoked', false); + + return $qb->getQuery()->execute(); + } + + public function revokeApplicationTokens(int $applicationId): int + { + $qb = $this->createQueryBuilder('at') + ->update() + ->set('at.isRevoked', ':isRevoked') + ->andWhere('at.application = :applicationId') + ->andWhere('at.isRevoked = :currentRevoked') + ->setParameter('isRevoked', true) + ->setParameter('applicationId', $applicationId) + ->setParameter('currentRevoked', false); + + return $qb->getQuery()->execute(); + } +} \ No newline at end of file diff --git a/hesabixCore/src/Repository/OAuthApplicationRepository.php b/hesabixCore/src/Repository/OAuthApplicationRepository.php new file mode 100644 index 0000000..ffc899e --- /dev/null +++ b/hesabixCore/src/Repository/OAuthApplicationRepository.php @@ -0,0 +1,68 @@ + + * + * @method OAuthApplication|null find($id, $lockMode = null, $lockVersion = null) + * @method OAuthApplication|null findOneBy(array $criteria, array $orderBy = null) + * @method OAuthApplication[] findAll() + * @method OAuthApplication[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class OAuthApplicationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, OAuthApplication::class); + } + + public function save(OAuthApplication $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(OAuthApplication $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function findByClientId(string $clientId): ?OAuthApplication + { + return $this->findOneBy(['clientId' => $clientId, 'isActive' => true]); + } + + public function findByOwner(int $ownerId): array + { + return $this->createQueryBuilder('o') + ->andWhere('o.owner = :ownerId') + ->setParameter('ownerId', $ownerId) + ->orderBy('o.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + public function findActiveApplications(): array + { + return $this->createQueryBuilder('o') + ->andWhere('o.isActive = :active') + ->setParameter('active', true) + ->orderBy('o.name', 'ASC') + ->getQuery() + ->getResult(); + } + + +} \ No newline at end of file diff --git a/hesabixCore/src/Repository/OAuthAuthorizationCodeRepository.php b/hesabixCore/src/Repository/OAuthAuthorizationCodeRepository.php new file mode 100644 index 0000000..345e779 --- /dev/null +++ b/hesabixCore/src/Repository/OAuthAuthorizationCodeRepository.php @@ -0,0 +1,78 @@ + + * + * @method OAuthAuthorizationCode|null find($id, $lockMode = null, $lockVersion = null) + * @method OAuthAuthorizationCode|null findOneBy(array $criteria, array $orderBy = null) + * @method OAuthAuthorizationCode[] findAll() + * @method OAuthAuthorizationCode[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class OAuthAuthorizationCodeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, OAuthAuthorizationCode::class); + } + + public function save(OAuthAuthorizationCode $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(OAuthAuthorizationCode $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function findByCode(string $code): ?OAuthAuthorizationCode + { + return $this->findOneBy(['code' => $code]); + } + + public function findValidByCode(string $code): ?OAuthAuthorizationCode + { + $qb = $this->createQueryBuilder('ac') + ->andWhere('ac.code = :code') + ->andWhere('ac.isUsed = :isUsed') + ->andWhere('ac.expiresAt > :now') + ->setParameter('code', $code) + ->setParameter('isUsed', false) + ->setParameter('now', new \DateTime()); + + return $qb->getQuery()->getOneOrNullResult(); + } + + public function findExpiredCodes(): array + { + return $this->createQueryBuilder('ac') + ->andWhere('ac.expiresAt < :now') + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getResult(); + } + + public function cleanupExpiredCodes(): int + { + $qb = $this->createQueryBuilder('ac') + ->delete() + ->andWhere('ac.expiresAt < :now') + ->setParameter('now', new \DateTime()); + + return $qb->getQuery()->execute(); + } +} \ No newline at end of file diff --git a/hesabixCore/src/Repository/OAuthScopeRepository.php b/hesabixCore/src/Repository/OAuthScopeRepository.php new file mode 100644 index 0000000..e87d89f --- /dev/null +++ b/hesabixCore/src/Repository/OAuthScopeRepository.php @@ -0,0 +1,75 @@ + + * + * @method OAuthScope|null find($id, $lockMode = null, $lockVersion = null) + * @method OAuthScope|null findOneBy(array $criteria, array $orderBy = null) + * @method OAuthScope[] findAll() + * @method OAuthScope[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class OAuthScopeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, OAuthScope::class); + } + + public function save(OAuthScope $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(OAuthScope $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function findByName(string $name): ?OAuthScope + { + return $this->findOneBy(['name' => $name]); + } + + public function findDefaultScopes(): array + { + return $this->createQueryBuilder('s') + ->andWhere('s.isDefault = :isDefault') + ->setParameter('isDefault', true) + ->orderBy('s.name', 'ASC') + ->getQuery() + ->getResult(); + } + + public function findSystemScopes(): array + { + return $this->createQueryBuilder('s') + ->andWhere('s.isSystem = :isSystem') + ->setParameter('isSystem', true) + ->orderBy('s.name', 'ASC') + ->getQuery() + ->getResult(); + } + + public function findByNames(array $names): array + { + return $this->createQueryBuilder('s') + ->andWhere('s.name IN (:names)') + ->setParameter('names', $names) + ->getQuery() + ->getResult(); + } +} \ No newline at end of file diff --git a/hesabixCore/src/Security/OAuthAuthenticator.php b/hesabixCore/src/Security/OAuthAuthenticator.php new file mode 100644 index 0000000..7059ab1 --- /dev/null +++ b/hesabixCore/src/Security/OAuthAuthenticator.php @@ -0,0 +1,75 @@ +oauthService = $oauthService; + } + + /** + * Called on every request to decide if this authenticator should be + * used for the request. Returning `false` will cause this authenticator + * to be skipped. + */ + public function supports(Request $request): ?bool + { + $authorization = $request->headers->get('Authorization'); + return $authorization && str_starts_with($authorization, 'Bearer '); + } + + public function authenticate(Request $request): Passport + { + $authorization = $request->headers->get('Authorization'); + $token = substr($authorization, 7); + + if (empty($token)) { + throw new CustomUserMessageAuthenticationException('No Bearer token provided'); + } + + return new SelfValidatingPassport( + new UserBadge($token, function($token) { + $accessToken = $this->oauthService->validateAccessToken($token); + if (!$accessToken) { + throw new UserNotFoundException('Invalid access token'); + } + return $accessToken->getUser(); + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // on success, let the request continue + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $data = [ + 'error' => 'invalid_token', + 'error_description' => $exception->getMessage() + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/hesabixCore/src/Service/OAuthService.php b/hesabixCore/src/Service/OAuthService.php new file mode 100644 index 0000000..eac2dd3 --- /dev/null +++ b/hesabixCore/src/Service/OAuthService.php @@ -0,0 +1,303 @@ +entityManager = $entityManager; + $this->applicationRepository = $applicationRepository; + $this->authorizationCodeRepository = $authorizationCodeRepository; + $this->accessTokenRepository = $accessTokenRepository; + $this->scopeRepository = $scopeRepository; + } + + /** + * تولید client_id و client_secret برای برنامه جدید + */ + public function generateClientCredentials(): array + { + $clientId = $this->generateRandomString(32); + $clientSecret = $this->generateRandomString(64); + + return [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret + ]; + } + + /** + * اعتبارسنجی درخواست authorization + */ + public function validateAuthorizationRequest(Request $request): array + { + $clientId = $request->query->get('client_id'); + $redirectUri = $request->query->get('redirect_uri'); + $responseType = $request->query->get('response_type'); + $scope = $request->query->get('scope'); + $state = $request->query->get('state'); + + // بررسی وجود پارامترهای اجباری + if (!$clientId || !$redirectUri || !$responseType) { + throw new AuthenticationException('Missing required parameters'); + } + + // بررسی نوع response + if ($responseType !== 'code') { + throw new AuthenticationException('Unsupported response_type'); + } + + // بررسی وجود برنامه + $application = $this->applicationRepository->findByClientId($clientId); + if (!$application) { + throw new AuthenticationException('Invalid client_id'); + } + + // بررسی فعال بودن برنامه + if (!$application->isActive()) { + throw new AuthenticationException('Application is not active'); + } + + // بررسی redirect_uri + if ($application->getRedirectUri() !== $redirectUri) { + throw new AuthenticationException('Invalid redirect_uri'); + } + + // بررسی محدوده‌های دسترسی + $requestedScopes = $scope ? explode(' ', $scope) : []; + $allowedScopes = $application->getAllowedScopes(); + + $validScopes = array_intersect($requestedScopes, $allowedScopes); + if (empty($validScopes) && !empty($requestedScopes)) { + throw new AuthenticationException('Invalid scope'); + } + + return [ + 'application' => $application, + 'scopes' => $validScopes, + 'state' => $state + ]; + } + + /** + * ایجاد کد مجوز + */ + public function createAuthorizationCode(User $user, OAuthApplication $application, array $scopes, ?string $state = null): OAuthAuthorizationCode + { + $code = new OAuthAuthorizationCode(); + $code->setCode($this->generateRandomString(64)); + $code->setUser($user); + $code->setApplication($application); + $code->setRedirectUri($application->getRedirectUri()); + $code->setScopes($scopes); + $code->setState($state); + $code->setExpiresAt((new \DateTime())->modify('+10 minutes')); + + $this->entityManager->persist($code); + $this->entityManager->flush(); + + return $code; + } + + /** + * اعتبارسنجی کد مجوز + */ + public function validateAuthorizationCode(string $code, string $clientId, string $redirectUri): ?OAuthAuthorizationCode + { + $authorizationCode = $this->authorizationCodeRepository->findValidByCode($code); + + if (!$authorizationCode) { + return null; + } + + $application = $authorizationCode->getApplication(); + + // بررسی تطابق client_id + if ($application->getClientId() !== $clientId) { + return null; + } + + // بررسی تطابق redirect_uri + if ($authorizationCode->getRedirectUri() !== $redirectUri) { + return null; + } + + return $authorizationCode; + } + + /** + * استفاده از کد مجوز + */ + public function useAuthorizationCode(OAuthAuthorizationCode $authorizationCode): void + { + $authorizationCode->setIsUsed(true); + $this->entityManager->flush(); + } + + /** + * ایجاد توکن دسترسی + */ + public function createAccessToken(User $user, OAuthApplication $application, array $scopes): OAuthAccessToken + { + $token = new OAuthAccessToken(); + $token->setToken($this->generateRandomString(64)); + $token->setRefreshToken($this->generateRandomString(64)); + $token->setUser($user); + $token->setApplication($application); + $token->setScopes($scopes); + $token->setExpiresAt((new \DateTime())->modify('+1 hour')); + + $this->entityManager->persist($token); + $this->entityManager->flush(); + + return $token; + } + + /** + * تمدید توکن دسترسی + */ + public function refreshAccessToken(string $refreshToken): ?OAuthAccessToken + { + $oldToken = $this->accessTokenRepository->findValidByRefreshToken($refreshToken); + + if (!$oldToken) { + return null; + } + + // ایجاد توکن جدید + $newToken = new OAuthAccessToken(); + $newToken->setToken($this->generateRandomString(64)); + $newToken->setRefreshToken($this->generateRandomString(64)); + $newToken->setUser($oldToken->getUser()); + $newToken->setApplication($oldToken->getApplication()); + $newToken->setScopes($oldToken->getScopes()); + $newToken->setExpiresAt((new \DateTime())->modify('+1 hour')); + + // لغو توکن قدیمی + $oldToken->setIsRevoked(true); + + $this->entityManager->persist($newToken); + $this->entityManager->flush(); + + return $newToken; + } + + /** + * اعتبارسنجی توکن دسترسی + */ + public function validateAccessToken(string $token): ?OAuthAccessToken + { + $accessToken = $this->accessTokenRepository->findValidByToken($token); + + if ($accessToken) { + $accessToken->updateLastUsed(); + $this->entityManager->flush(); + } + + return $accessToken; + } + + /** + * لغو توکن دسترسی + */ + public function revokeAccessToken(string $token): bool + { + $accessToken = $this->accessTokenRepository->findByToken($token); + + if (!$accessToken) { + return false; + } + + $accessToken->setIsRevoked(true); + $this->entityManager->flush(); + + return true; + } + + /** + * ایجاد محدوده‌های پیش‌فرض سیستم + */ + public function createDefaultScopes(): void + { + $defaultScopes = [ + 'read_profile' => 'دسترسی به اطلاعات پروفایل کاربر', + 'write_profile' => 'ویرایش اطلاعات پروفایل کاربر', + 'read_business' => 'دسترسی به اطلاعات کسب‌وکار', + 'write_business' => 'ویرایش اطلاعات کسب‌وکار', + 'read_accounting' => 'دسترسی به اطلاعات حسابداری', + 'write_accounting' => 'ویرایش اطلاعات حسابداری', + 'read_reports' => 'دسترسی به گزارش‌ها', + 'write_reports' => 'ایجاد و ویرایش گزارش‌ها', + 'admin' => 'دسترسی مدیریتی کامل' + ]; + + foreach ($defaultScopes as $name => $description) { + $existingScope = $this->scopeRepository->findByName($name); + if (!$existingScope) { + $scope = new OAuthScope(); + $scope->setName($name); + $scope->setDescription($description); + $scope->setIsSystem(true); + $scope->setIsDefault(in_array($name, ['read_profile', 'read_business'])); + + $this->entityManager->persist($scope); + } + } + + $this->entityManager->flush(); + } + + /** + * تولید رشته تصادفی + */ + private function generateRandomString(int $length): string + { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $string = ''; + + for ($i = 0; $i < $length; $i++) { + $string .= $characters[random_int(0, strlen($characters) - 1)]; + } + + return $string; + } + + /** + * پاکسازی کدها و توکن‌های منقضی شده + */ + public function cleanupExpiredItems(): array + { + $expiredCodes = $this->authorizationCodeRepository->cleanupExpiredCodes(); + $expiredTokens = $this->accessTokenRepository->cleanupExpiredTokens(); + + return [ + 'expired_codes' => $expiredCodes, + 'expired_tokens' => $expiredTokens + ]; + } +} \ No newline at end of file diff --git a/webUI/src/components/OAuthManager.vue b/webUI/src/components/OAuthManager.vue new file mode 100644 index 0000000..fbd8405 --- /dev/null +++ b/webUI/src/components/OAuthManager.vue @@ -0,0 +1,577 @@ + + + + + \ No newline at end of file diff --git a/webUI/src/router/index.ts b/webUI/src/router/index.ts index 5e297ef..9b1f6dc 100755 --- a/webUI/src/router/index.ts +++ b/webUI/src/router/index.ts @@ -1121,6 +1121,15 @@ const router = createRouter({ 'title': 'نصب وب اپلیکیشن ', } }, + { + path: '/oauth/authorize', + name: 'oauth_authorize', + component: () => import('../views/oauth/authorize.vue'), + meta: { + 'title': 'مجوزدهی OAuth', + 'login': true + } + }, { path: "/:catchAll(.*)", name: "not-found", diff --git a/webUI/src/views/oauth/authorize.vue b/webUI/src/views/oauth/authorize.vue new file mode 100644 index 0000000..6870a39 --- /dev/null +++ b/webUI/src/views/oauth/authorize.vue @@ -0,0 +1,291 @@ + + + + + \ No newline at end of file diff --git a/webUI/src/views/user/manager/settings/oauth-styles.css b/webUI/src/views/user/manager/settings/oauth-styles.css new file mode 100644 index 0000000..c74c177 --- /dev/null +++ b/webUI/src/views/user/manager/settings/oauth-styles.css @@ -0,0 +1,180 @@ +/* استایل‌های OAuth */ + +.oauth-card { + transition: all 0.3s ease; + border: 2px solid transparent; + position: relative; + overflow: hidden; +} + +.oauth-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1) !important; +} + +.oauth-card-active { + border-color: var(--v-success-base); + background: linear-gradient(135deg, rgba(76, 175, 80, 0.05), rgba(76, 175, 80, 0.02)); +} + +.oauth-card-inactive { + border-color: var(--v-grey-base); + background: linear-gradient(135deg, rgba(158, 158, 158, 0.05), rgba(158, 158, 158, 0.02)); +} + +.oauth-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--v-primary-base), var(--v-secondary-base)); + opacity: 0; + transition: opacity 0.3s ease; +} + +.oauth-card-active::before { + opacity: 1; +} + +.oauth-dialog .v-card-title { + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + position: relative; +} + +.oauth-dialog .v-card-title::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.1), transparent); +} + +.oauth-form-field { + margin-bottom: 16px; +} + +.oauth-form-field .v-text-field, +.oauth-form-field .v-textarea { + margin-bottom: 0; +} + +.oauth-info-text { + font-size: 0.875rem; + color: rgba(0, 0, 0, 0.6); + line-height: 1.5; +} + +.oauth-code { + background: #f5f5f5; + padding: 4px 8px; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 0.875rem; + color: var(--v-primary-base); + border: 1px solid #e0e0e0; +} + +.oauth-status-chip { + transition: all 0.3s ease; +} + +.oauth-status-chip:hover { + transform: scale(1.05); +} + +.oauth-menu-item { + transition: all 0.2s ease; +} + +.oauth-menu-item:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.oauth-stats-card { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.oauth-stats-number { + font-size: 1.5rem; + font-weight: bold; + color: var(--v-primary-base); +} + +.oauth-stats-label { + font-size: 0.875rem; + color: rgba(0, 0, 0, 0.6); + margin-top: 4px; +} + +/* انیمیشن‌های OAuth */ +.oauth-fade-enter-active, +.oauth-fade-leave-active { + transition: opacity 0.3s ease; +} + +.oauth-fade-enter-from, +.oauth-fade-leave-to { + opacity: 0; +} + +.oauth-slide-enter-active, +.oauth-slide-leave-active { + transition: transform 0.3s ease; +} + +.oauth-slide-enter-from { + transform: translateY(-20px); + opacity: 0; +} + +.oauth-slide-leave-to { + transform: translateY(20px); + opacity: 0; +} + +/* استایل‌های مخصوص موبایل */ +@media (max-width: 768px) { + .oauth-dialog { + margin: 16px; + } + + .oauth-card { + margin-bottom: 16px; + } + + .oauth-form-field { + margin-bottom: 12px; + } +} + +/* استایل‌های مخصوص حالت تاریک */ +.v-theme--dark .oauth-card-active { + background: linear-gradient(135deg, rgba(76, 175, 80, 0.1), rgba(76, 175, 80, 0.05)); +} + +.v-theme--dark .oauth-card-inactive { + background: linear-gradient(135deg, rgba(158, 158, 158, 0.1), rgba(158, 158, 158, 0.05)); +} + +.v-theme--dark .oauth-code { + background: #2d2d2d; + border-color: #404040; + color: var(--v-primary-base); +} + +.v-theme--dark .oauth-stats-card { + background: linear-gradient(135deg, #2d2d2d 0%, #404040 100%); + border-color: #505050; +} + +.v-theme--dark .oauth-info-text { + color: rgba(255, 255, 255, 0.7); +} \ No newline at end of file diff --git a/webUI/src/views/user/manager/settings/oauth.vue b/webUI/src/views/user/manager/settings/oauth.vue new file mode 100644 index 0000000..675d4d9 --- /dev/null +++ b/webUI/src/views/user/manager/settings/oauth.vue @@ -0,0 +1,528 @@ + + + + + \ No newline at end of file diff --git a/webUI/src/views/user/manager/settings/system.vue b/webUI/src/views/user/manager/settings/system.vue index ef5efbe..855a7d6 100755 --- a/webUI/src/views/user/manager/settings/system.vue +++ b/webUI/src/views/user/manager/settings/system.vue @@ -12,7 +12,8 @@ export default defineComponent({ { title: 'تنظیمات پایه', icon: 'mdi-cog' }, { title: 'درگاه‌های پرداخت', icon: 'mdi-credit-card' }, { title: 'پنل استعلامات', icon: 'mdi-magnify' }, - { title: 'جادوگر هوش مصنوعی', icon: 'mdi-robot' } + { title: 'جادوگر هوش مصنوعی', icon: 'mdi-robot' }, + { title: 'برنامه‌های OAuth', icon: 'mdi-oauth' } ], gatepays: [ { @@ -74,6 +75,57 @@ export default defineComponent({ outputTokenPrice: 0, aiPrompt: '', aiDebugMode: false, + // متغیرهای OAuth + oauthApplications: [], + showOAuthDialog: false, + editingOAuthApp: null, + oauthForm: { + name: '', + description: '', + website: '', + redirectUri: '', + allowedScopes: [], + rateLimit: 1000, + ipWhitelist: [] + }, + newIpAddress: '', + availableScopes: [ + { name: 'read_profile', description: 'دسترسی به اطلاعات پروفایل کاربر' }, + { name: 'write_profile', description: 'تغییر اطلاعات پروفایل کاربر' }, + { name: 'read_business', description: 'دسترسی به اطلاعات کسب و کار' }, + { name: 'write_business', description: 'تغییر اطلاعات کسب و کار' }, + { name: 'read_financial', description: 'دسترسی به اطلاعات مالی' }, + { name: 'write_financial', description: 'تغییر اطلاعات مالی' }, + { name: 'read_contacts', description: 'دسترسی به لیست مخاطبین' }, + { name: 'write_contacts', description: 'تغییر لیست مخاطبین' }, + { name: 'read_documents', description: 'دسترسی به اسناد' }, + { name: 'write_documents', description: 'تغییر اسناد' }, + { name: 'admin_access', description: 'دسترسی مدیریتی (فقط برای برنامه‌های معتبر)' } + ], + oauthFormRules: { + name: [ + v => !!v || 'نام برنامه الزامی است', + v => v.length >= 3 || 'نام برنامه باید حداقل 3 کاراکتر باشد', + v => v.length <= 255 || 'نام برنامه نمی‌تواند بیشتر از 255 کاراکتر باشد' + ], + redirectUri: [ + v => !!v || 'آدرس بازگشت الزامی است', + v => /^https?:\/\/.+/.test(v) || 'آدرس بازگشت باید یک URL معتبر باشد' + ], + website: [ + v => !v || /^https?:\/\/.+/.test(v) || 'آدرس وب‌سایت باید یک URL معتبر باشد' + ], + rateLimit: [ + v => v >= 1 || 'محدودیت درخواست باید حداقل 1 باشد', + v => v <= 10000 || 'محدودیت درخواست نمی‌تواند بیشتر از 10000 باشد' + ] + }, + oauthSnackbar: { + show: false, + text: '', + color: 'success', + timeout: 3000 + }, aiAgentSources: [ { title: 'GapGPT', value: 'gapgpt', subtitle: 'gapgpt.app' }, { title: 'AvalAI', value: 'avalai', subtitle: 'avalai.ir' }, @@ -248,10 +300,315 @@ export default defineComponent({ }); }) + }, + + // متدهای OAuth + async loadOAuthApplications() { + try { + const response = await axios.get('/api/admin/oauth/applications') + // تبدیل فیلدها از snake_case به camelCase + this.oauthApplications = (response.data.data || []).map(app => ({ + ...app, + isActive: app.isActive || app.active || false, // پشتیبانی از هر دو فیلد + clientId: app.clientId, + redirectUri: app.redirectUri, + allowedScopes: app.allowedScopes || [], + rateLimit: app.rateLimit || 1000 + })) + + } catch (error) { + console.error('خطا در بارگذاری برنامه‌های OAuth:', error) + } + }, + + async saveOAuthApplication() { + // اعتبارسنجی فرم + const { valid } = await this.$refs.oauthForm?.validate() + if (!valid) { + this.showOAuthSnackbar('لطفاً خطاهای فرم را برطرف کنید', 'error') + return + } + + try { + if (this.editingOAuthApp) { + await axios.put(`/api/admin/oauth/applications/${this.editingOAuthApp.id}`, this.oauthForm) + this.showOAuthSnackbar('برنامه با موفقیت ویرایش شد', 'success') + } else { + const response = await axios.post('/api/admin/oauth/applications', this.oauthForm) + this.showOAuthSnackbar('برنامه با موفقیت ایجاد شد', 'success') + + // نمایش client_id و client_secret + if (response.data.data?.client_id) { + Swal.fire({ + title: 'اطلاعات برنامه', + html: ` +
+

Client ID: ${response.data.data.client_id}

+

Client Secret: ${response.data.data.client_secret}

+

⚠️ این اطلاعات را در جای امنی ذخیره کنید!

+
+ `, + icon: 'info' + }) + } + } + + await this.loadOAuthApplications() + this.cancelOAuthEdit() + } catch (error) { + console.error('خطا در ذخیره برنامه OAuth:', error) + const errorMessage = error.response?.data?.message || error.response?.data?.data || 'خطا در ذخیره برنامه' + this.showOAuthSnackbar(errorMessage, 'error') + } + }, + + editOAuthApp(app) { + this.editingOAuthApp = app + this.oauthForm = { + name: app.name, + description: app.description || '', + website: app.website || '', + redirectUri: app.redirectUri, + allowedScopes: app.allowedScopes || [], + rateLimit: app.rateLimit || 1000, + ipWhitelist: app.ipWhitelist || [] + } + this.showOAuthDialog = true + }, + + // IP Management Methods + addIpAddress() { + if (this.newIpAddress && this.isValidIp(this.newIpAddress)) { + if (!this.oauthForm.ipWhitelist.includes(this.newIpAddress)) { + this.oauthForm.ipWhitelist.push(this.newIpAddress) + this.newIpAddress = '' + this.showOAuthSnackbar('آدرس IP اضافه شد', 'success') + } else { + this.showOAuthSnackbar('این آدرس IP قبلاً اضافه شده است', 'warning') + } + } + }, + + removeIpAddress(index) { + this.oauthForm.ipWhitelist.splice(index, 1) + this.showOAuthSnackbar('آدرس IP حذف شد', 'success') + }, + + isValidIp(ip) { + // IP validation regex (supports both single IP and CIDR notation) + const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:\/(?:3[0-2]|[12]?[0-9]))?$/ + return ipRegex.test(ip) + }, + + ipValidationRule(value) { + if (!value) return true + return this.isValidIp(value) || 'آدرس IP نامعتبر است' + }, + + cancelOAuthEdit() { + this.editingOAuthApp = null + this.showOAuthDialog = false + this.newIpAddress = '' + this.oauthForm = { + name: '', + description: '', + website: '', + redirectUri: '', + allowedScopes: [], + rateLimit: 1000, + ipWhitelist: [] + } + }, + + async deleteOAuthApp(app) { + const result = await Swal.fire({ + title: 'حذف برنامه', + text: `آیا از حذف برنامه "${app.name}" اطمینان دارید؟`, + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'حذف', + cancelButtonText: 'انصراف' + }) + + if (result.isConfirmed) { + try { + await axios.delete(`/api/admin/oauth/applications/${app.id}`) + this.showOAuthSnackbar('برنامه با موفقیت حذف شد', 'success') + await this.loadOAuthApplications() + } catch (error) { + console.error('خطا در حذف برنامه OAuth:', error) + const errorMessage = error.response?.data?.message || error.response?.data?.data || 'خطا در حذف برنامه' + this.showOAuthSnackbar(errorMessage, 'error') + } + } + }, + + async regenerateOAuthSecret(app) { + const result = await Swal.fire({ + title: 'بازسازی کلید', + text: 'آیا از بازسازی Client Secret اطمینان دارید؟ تمام توکن‌های موجود لغو خواهند شد.', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'بازسازی', + cancelButtonText: 'انصراف' + }) + + if (result.isConfirmed) { + try { + const response = await axios.post(`/api/admin/oauth/applications/${app.id}/regenerate-secret`) + Swal.fire({ + title: 'کلید جدید', + html: `

Client Secret جدید: ${response.data.data.client_secret}

`, + icon: 'success' + }) + await this.loadOAuthApplications() + this.showOAuthSnackbar('کلید جدید با موفقیت ایجاد شد', 'success') + } catch (error) { + console.error('خطا در بازسازی کلید OAuth:', error) + const errorMessage = error.response?.data?.message || error.response?.data?.data || 'خطا در بازسازی کلید' + this.showOAuthSnackbar(errorMessage, 'error') + } + } + }, + + async toggleOAuthStatus(app) { + const action = app.isActive ? 'غیرفعال کردن' : 'فعال کردن' + const result = await Swal.fire({ + title: `${action} برنامه`, + text: `آیا از ${action} برنامه "${app.name}" اطمینان دارید؟`, + icon: 'question', + showCancelButton: true, + confirmButtonText: action, + cancelButtonText: 'انصراف' + }) + + if (result.isConfirmed) { + try { + const response = await axios.post(`/api/admin/oauth/applications/${app.id}/toggle-status`) + + // به‌روزرسانی کامل آرایه برای اطمینان از reactive بودن + await this.loadOAuthApplications() + // اطمینان از به‌روزرسانی UI با تاخیر + setTimeout(() => { + this.$forceUpdate() + }, 100) + + this.showOAuthSnackbar(response.data.data.message, 'success') + } catch (error) { + console.error('خطا در تغییر وضعیت OAuth:', error) + const errorMessage = error.response?.data?.message || error.response?.data?.data || 'خطا در تغییر وضعیت' + this.showOAuthSnackbar(errorMessage, 'error') + } + } + }, + + async revokeOAuthTokens(app) { + const result = await Swal.fire({ + title: 'لغو توکن‌ها', + text: 'آیا از لغو تمام توکن‌های این برنامه اطمینان دارید؟', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'لغو توکن‌ها', + cancelButtonText: 'انصراف' + }) + + if (result.isConfirmed) { + try { + const response = await axios.post(`/api/admin/oauth/applications/${app.id}/revoke-tokens`) + this.showOAuthSnackbar(`${response.data.data.revoked_count || 0} توکن لغو شد`, 'success') + } catch (error) { + console.error('خطا در لغو توکن‌های OAuth:', error) + const errorMessage = error.response?.data?.message || error.response?.data?.data || 'خطا در لغو توکن‌ها' + this.showOAuthSnackbar(errorMessage, 'error') + } + } + }, + + async showOAuthStats(app) { + try { + const response = await axios.get(`/api/admin/oauth/applications/${app.id}/stats`) + const stats = response.data.data + + Swal.fire({ + title: `آمار استفاده - ${app.name}`, + html: ` +
+

کل توکن‌ها: ${stats.total_tokens || 0}

+

توکن‌های فعال: ${stats.active_tokens || 0}

+

توکن‌های منقضی شده: ${stats.expired_tokens || 0}

+ ${stats.last_used ? `

آخرین استفاده: ${new Date(stats.last_used).toLocaleDateString('fa-IR')}

` : ''} +
+ `, + icon: 'info' + }) + } catch (error) { + console.error('خطا در بارگذاری آمار OAuth:', error) + Swal.fire('خطا', 'خطا در بارگذاری آمار', 'error') + } + }, + + async copyToClipboard(text) { + try { + // روش اول: استفاده از Clipboard API + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text) + this.showOAuthSnackbar('متن در کلیپ‌بورد کپی شد', 'success') + } else { + // روش دوم: fallback برای مرورگرهای قدیمی + this.fallbackCopyToClipboard(text) + } + } catch (error) { + console.error('خطا در کپی:', error) + // اگر روش اول شکست خورد، از روش دوم استفاده می‌کنیم + this.fallbackCopyToClipboard(text) + } + }, + + fallbackCopyToClipboard(text) { + try { + // ایجاد یک المان موقت + const textArea = document.createElement('textarea') + textArea.value = text + + // تنظیمات استایل برای مخفی کردن المان + textArea.style.position = 'fixed' + textArea.style.left = '-999999px' + textArea.style.top = '-999999px' + textArea.style.opacity = '0' + + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + + // کپی کردن متن + const successful = document.execCommand('copy') + + // حذف المان موقت + document.body.removeChild(textArea) + + if (successful) { + this.showOAuthSnackbar('متن در کلیپ‌بورد کپی شد', 'success') + } else { + this.showOAuthSnackbar('خطا در کپی کردن متن', 'error') + } + } catch (error) { + console.error('خطا در fallback copy:', error) + this.showOAuthSnackbar('خطا در کپی کردن متن', 'error') + } + }, + + showOAuthSnackbar(text, color = 'success') { + this.oauthSnackbar = { + show: true, + text: text, + color: color, + timeout: 3000 + } } }, beforeMount() { this.loadData(); + this.loadOAuthApplications(); } }) @@ -985,9 +1342,552 @@ export default defineComponent({ + + + + +
+
+ mdi-oauth +
+

برنامه‌های OAuth

+

مدیریت برنامه‌های احراز هویت خارجی

+
+
+
+ + +
+
+
+

+ mdi-oauth + مدیریت برنامه‌های OAuth +

+

+ برنامه‌های خارجی که می‌توانند از حساب کاربران شما استفاده کنند +

+
+ + ایجاد برنامه جدید + +
+ + + + + + mdi-application +
{{ oauthApplications.length }}
+
کل برنامه‌ها
+
+
+ + + mdi-check-circle +
{{ oauthApplications.filter(app => app.isActive).length }}
+
فعال
+
+
+ + + mdi-pause-circle +
{{ oauthApplications.filter(app => !app.isActive).length }}
+
غیرفعال
+
+
+ + + mdi-clock-outline +
{{ oauthApplications.filter(app => new Date(app.createdAt.timestamp * 1000) > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)).length }}
+
جدید (7 روز)
+
+
+
+ + + + +
+
OAuth چیست؟
+

+ OAuth یک پروتکل استاندارد برای احراز هویت است که به برنامه‌های خارجی اجازه می‌دهد + بدون نیاز به رمز عبور، به حساب کاربران دسترسی داشته باشند. این روش امن‌تر و راحت‌تر از + روش‌های سنتی است. +

+
+
+
+ + +
+
+

برنامه‌های شما

+ + {{ oauthApplications.length }} برنامه + +
+ + + mdi-application-outline +

+ هنوز برنامه‌ای ایجاد نکرده‌اید +

+

+ برای شروع استفاده از OAuth، اولین برنامه خود را ایجاد کنید +

+ + ایجاد اولین برنامه + +
+ + + + + + +
+ + + {{ app.isActive ? 'mdi-check' : 'mdi-pause' }} + + +
+
+ {{ app.name }} +
+
+ {{ new Date(app.createdAt.timestamp * 1000).toLocaleDateString('fa-IR') }} +
+
+
+ + + + {{ app.isActive ? 'فعال' : 'غیرفعال' }} + +
+ + + + +
+

+ {{ app.description }} +

+
+ + +
+
+
+ mdi-key + Client ID +
+
+ {{ app.clientId }} + +
+
+ +
+
+ mdi-link + Redirect URI +
+
+ {{ app.redirectUri }} +
+
+ +
+
+ mdi-shield + محدوده دسترسی +
+
+ + {{ scope }} + +
+
+
+ + +
+ + ویرایش + + + + آمار + + + + {{ app.isActive ? 'غیرفعال' : 'فعال' }} + +
+
+ + + + + + + + + + بازسازی کلید + + + + لغو توکن‌ها + + + + + حذف + + + + +
+
+
+
+
+
+ + + + + + mdi-oauth + {{ editingOAuthApp ? 'ویرایش برنامه OAuth' : 'ایجاد برنامه OAuth جدید' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + mdi-ip-network + آدرس‌های IP مجاز + + +

+ در صورت خالی بودن، از هر آدرس IP می‌توان به برنامه متصل شد. + برای محدود کردن دسترسی، آدرس‌های IP مجاز را وارد کنید. +

+ +
+ + + افزودن + +
+ + + + {{ ip }} + + + + + + هیچ آدرس IP محدودیتی تعریف نشده است. از هر آدرس IP می‌توان به برنامه متصل شد. + +
+
+
+ + + + + + mdi-shield + محدوده‌های دسترسی (Scopes) + + +

+ محدوده‌های دسترسی که این برنامه می‌تواند از کاربران درخواست کند. +

+ + + + + + + + + + + + هیچ محدوده دسترسی انتخاب نشده است. این برنامه دسترسی محدودی خواهد داشت. + +
+
+
+
+
+
+ + + + + + + انصراف + + + {{ editingOAuthApp ? 'ویرایش' : 'ایجاد' }} + + +
+
+ + + + {{ oauthSnackbar.text }} + + + \ No newline at end of file