add first release for oauth application syncing

This commit is contained in:
Hesabix 2025-08-16 01:56:06 +00:00
parent 51d68b9874
commit da644e3260
29 changed files with 7133 additions and 1 deletions

View file

@ -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

View file

@ -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
<!DOCTYPE html>
<html>
<head>
<title>OAuth Example</title>
</head>
<body>
<button onclick="login()">ورود با Hesabix</button>
<script>
const CLIENT_ID = 'YOUR_CLIENT_ID';
const REDIRECT_URI = 'https://myapp.com/callback';
function login() {
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'read_profile',
state: Math.random().toString(36)
});
window.location.href = `https://hesabix.com/oauth/authorize?${params}`;
}
// بررسی callback
if (window.location.search.includes('code=')) {
handleCallback();
}
async function handleCallback() {
const code = new URLSearchParams(window.location.search).get('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: CLIENT_ID,
client_secret: 'YOUR_CLIENT_SECRET',
code: code,
redirect_uri: REDIRECT_URI
})
});
const data = await response.json();
console.log('Token received:', data);
}
</script>
</body>
</html>
```
### مثال 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

View file

@ -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

View file

@ -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

331
docs/OAuth_README.md Normal file
View file

@ -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 تماس بگیرید.

View file

@ -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

View file

@ -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 }

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250815230230 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250816003509 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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);
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Command;
use App\Service\OAuthService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:cleanup-oauth',
description: 'پاکسازی توکن‌ها و کدهای منقضی شده OAuth',
)]
class CleanupOAuthCommand extends Command
{
private OAuthService $oauthService;
public function __construct(OAuthService $oauthService)
{
parent::__construct();
$this->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;
}
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Command;
use App\Service\OAuthService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:create-oauth-scopes',
description: 'ایجاد محدوده‌های پیش‌فرض OAuth',
)]
class CreateOAuthScopesCommand extends Command
{
private OAuthService $oauthService;
public function __construct(OAuthService $oauthService)
{
parent::__construct();
$this->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;
}
}
}

View file

@ -0,0 +1,453 @@
<?php
namespace App\Controller;
use App\Entity\OAuthApplication;
use App\Entity\OAuthScope;
use App\Service\OAuthService;
use App\Service\Extractor;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use App\Service\Provider;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/api/admin/oauth')]
class OAuthApplicationController extends AbstractController
{
private OAuthService $oauthService;
private Extractor $extractor;
private LoggerInterface $logger;
private Provider $provider;
private EntityManagerInterface $entityManager;
private ValidatorInterface $validator;
public function __construct(
OAuthService $oauthService,
Extractor $extractor,
#[Autowire('@monolog.logger.oauth')] LoggerInterface $logger,
Provider $provider,
EntityManagerInterface $entityManager,
ValidatorInterface $validator
) {
$this->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 ? 'برنامه فعال شد' : 'برنامه غیرفعال شد'
]));
}
}

View file

@ -0,0 +1,346 @@
<?php
namespace App\Controller;
use App\Entity\OAuthApplication;
use App\Entity\OAuthAuthorizationCode;
use App\Entity\OAuthAccessToken;
use App\Service\OAuthService;
use App\Service\Extractor;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
#[Route('/oauth')]
class OAuthController extends AbstractController
{
private OAuthService $oauthService;
private Extractor $extractor;
public function __construct(OAuthService $oauthService, Extractor $extractor)
{
$this->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'
]
]);
}
}

View file

@ -0,0 +1,216 @@
<?php
namespace App\Entity;
use App\Repository\OAuthAccessTokenRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: OAuthAccessTokenRepository::class)]
class OAuthAccessToken
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true)]
private ?string $token = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $refreshToken = null;
#[ORM\Column(type: 'json')]
private array $scopes = [];
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $expiresAt = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $lastUsedAt = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?OAuthApplication $application = null;
#[ORM\ManyToOne]
private ?OAuthScope $scope = null;
#[ORM\Column(type: 'boolean')]
private bool $isRevoked = false;
#[ORM\Column(length: 255, nullable: true)]
private ?string $ipAddress = null;
#[ORM\Column(length: 500, nullable: true)]
private ?string $userAgent = null;
public function __construct()
{
$this->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();
}
}

View file

@ -0,0 +1,332 @@
<?php
namespace App\Entity;
use App\Repository\OAuthApplicationRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation\SerializedName;
#[ORM\Entity(repositoryClass: OAuthApplicationRepository::class)]
class OAuthApplication
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 255)]
private ?string $name = null;
#[ORM\Column(length: 500, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Url]
private ?string $website = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Url]
private ?string $redirectUri = null;
#[ORM\Column(length: 64, unique: true)]
private ?string $clientId = null;
#[ORM\Column(length: 128)]
private ?string $clientSecret = null;
#[ORM\Column(type: 'boolean')]
#[SerializedName('isActive')]
private bool $isActive = true;
#[ORM\Column(length: 255, nullable: true)]
private ?string $logoUrl = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $updatedAt = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?User $owner = null;
#[ORM\OneToMany(mappedBy: 'application', targetEntity: OAuthAccessToken::class, orphanRemoval: true)]
private Collection $accessTokens;
#[ORM\OneToMany(mappedBy: 'application', targetEntity: OAuthAuthorizationCode::class, orphanRemoval: true)]
private Collection $authorizationCodes;
#[ORM\ManyToMany(targetEntity: OAuthScope::class, inversedBy: 'applications')]
private Collection $scopes;
#[ORM\Column(type: 'json', nullable: true)]
private array $allowedScopes = [];
#[ORM\Column(type: 'integer', options: ['default' => 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<int, OAuthAccessToken>
*/
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<int, OAuthAuthorizationCode>
*/
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<int, OAuthScope>
*/
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();
}
}

View file

@ -0,0 +1,192 @@
<?php
namespace App\Entity;
use App\Repository\OAuthAuthorizationCodeRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: OAuthAuthorizationCodeRepository::class)]
class OAuthAuthorizationCode
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true)]
private ?string $code = null;
#[ORM\Column(length: 255)]
private ?string $redirectUri = null;
#[ORM\Column(type: 'json')]
private array $scopes = [];
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $expiresAt = null;
#[ORM\Column(type: 'boolean')]
private bool $isUsed = false;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?OAuthApplication $application = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $state = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $codeChallenge = null;
#[ORM\Column(length: 10, nullable: true)]
private ?string $codeChallengeMethod = null;
public function __construct()
{
$this->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();
}
}

View file

@ -0,0 +1,162 @@
<?php
namespace App\Entity;
use App\Repository\OAuthScopeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: OAuthScopeRepository::class)]
class OAuthScope
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 100, unique: true)]
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 100)]
#[Assert\Regex('/^[a-z_]+$/')]
private ?string $name = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
private ?string $description = null;
#[ORM\Column(type: 'boolean')]
private bool $isDefault = false;
#[ORM\Column(type: 'boolean')]
private bool $isSystem = false;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
#[ORM\ManyToMany(targetEntity: OAuthApplication::class, mappedBy: 'scopes')]
private Collection $applications;
#[ORM\OneToMany(mappedBy: 'scope', targetEntity: OAuthAccessToken::class)]
private Collection $accessTokens;
public function __construct()
{
$this->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<int, OAuthApplication>
*/
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<int, OAuthAccessToken>
*/
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;
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace App\Repository;
use App\Entity\OAuthAccessToken;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<OAuthAccessToken>
*
* @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();
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Repository;
use App\Entity\OAuthApplication;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<OAuthApplication>
*
* @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();
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Repository;
use App\Entity\OAuthAuthorizationCode;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<OAuthAuthorizationCode>
*
* @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();
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Repository;
use App\Entity\OAuthScope;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<OAuthScope>
*
* @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();
}
}

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Service\OAuthService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class OAuthAuthenticator extends AbstractAuthenticator
{
private OAuthService $oauthService;
public function __construct(OAuthService $oauthService)
{
$this->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);
}
}

View file

@ -0,0 +1,303 @@
<?php
namespace App\Service;
use App\Entity\OAuthApplication;
use App\Entity\OAuthAuthorizationCode;
use App\Entity\OAuthAccessToken;
use App\Entity\OAuthScope;
use App\Entity\User;
use App\Repository\OAuthApplicationRepository;
use App\Repository\OAuthAuthorizationCodeRepository;
use App\Repository\OAuthAccessTokenRepository;
use App\Repository\OAuthScopeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class OAuthService
{
private EntityManagerInterface $entityManager;
private OAuthApplicationRepository $applicationRepository;
private OAuthAuthorizationCodeRepository $authorizationCodeRepository;
private OAuthAccessTokenRepository $accessTokenRepository;
private OAuthScopeRepository $scopeRepository;
public function __construct(
EntityManagerInterface $entityManager,
OAuthApplicationRepository $applicationRepository,
OAuthAuthorizationCodeRepository $authorizationCodeRepository,
OAuthAccessTokenRepository $accessTokenRepository,
OAuthScopeRepository $scopeRepository
) {
$this->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
];
}
}

View file

@ -0,0 +1,577 @@
<template>
<div>
<!-- هدر اصلی -->
<div class="d-flex align-center mb-8">
<div class="d-flex align-center bg-primary-lighten-5 pa-4 rounded-lg">
<v-icon size="32" color="primary" class="mr-3">mdi-oauth</v-icon>
<div>
<h3 class="text-h5 font-weight-medium text-primary mb-1">برنامههای OAuth</h3>
<p class="text-caption text-medium-emphasis mb-0">مدیریت برنامههای احراز هویت خارجی</p>
</div>
</div>
</div>
<!-- توضیحات OAuth -->
<v-alert
type="info"
variant="tonal"
class="mb-6"
>
<template v-slot:prepend>
<v-icon>mdi-information</v-icon>
</template>
<div class="text-right">
<strong>OAuth چیست؟</strong><br>
OAuth یک پروتکل استاندارد برای احراز هویت است که به برنامههای خارجی اجازه میدهد
بدون نیاز به رمز عبور، به حساب کاربران دسترسی داشته باشند. این روش امنتر و راحتتر از
روشهای سنتی است.
</div>
</v-alert>
<!-- کارت مدیریت برنامهها -->
<v-card variant="outlined" class="pa-6" elevation="0">
<v-card-title class="text-subtitle-1 font-weight-medium pb-4 d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon start class="mr-2" color="primary">mdi-oauth</v-icon>
مدیریت برنامهها
</div>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showCreateDialog = true"
>
برنامه جدید
</v-btn>
</v-card-title>
<v-card-text class="pa-0">
<v-alert
v-if="applications.length === 0"
type="info"
variant="tonal"
class="mb-4"
>
هنوز هیچ برنامه OAuth ایجاد نکردهاید. برای شروع، یک برنامه جدید ایجاد کنید.
</v-alert>
<v-row v-else>
<v-col
v-for="app in applications"
:key="app.id"
cols="12"
md="6"
lg="4"
>
<v-card
variant="outlined"
class="h-100 oauth-card"
:class="{ 'oauth-card-active': app.isActive, 'oauth-card-inactive': !app.isActive }"
>
<v-card-title class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-avatar
:color="app.isActive ? 'success' : 'grey'"
size="32"
class="mr-2"
>
<v-icon size="16" color="white">
{{ app.isActive ? 'mdi-check' : 'mdi-close' }}
</v-icon>
</v-avatar>
{{ app.name }}
</div>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
icon="mdi-dots-vertical"
variant="text"
v-bind="props"
></v-btn>
</template>
<v-list>
<v-list-item @click="editApplication(app)" class="oauth-menu-item">
<template v-slot:prepend>
<v-icon>mdi-pencil</v-icon>
</template>
<v-list-item-title>ویرایش</v-list-item-title>
</v-list-item>
<v-list-item @click="showStats(app)" class="oauth-menu-item">
<template v-slot:prepend>
<v-icon>mdi-chart-line</v-icon>
</template>
<v-list-item-title>آمار</v-list-item-title>
</v-list-item>
<v-list-item @click="regenerateSecret(app)" class="oauth-menu-item">
<template v-slot:prepend>
<v-icon>mdi-refresh</v-icon>
</template>
<v-list-item-title>بازسازی کلید</v-list-item-title>
</v-list-item>
<v-list-item @click="revokeTokens(app)" class="oauth-menu-item">
<template v-slot:prepend>
<v-icon>mdi-logout</v-icon>
</template>
<v-list-item-title>لغو توکنها</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item @click="deleteApplication(app)" color="error" class="oauth-menu-item">
<template v-slot:prepend>
<v-icon color="error">mdi-delete</v-icon>
</template>
<v-list-item-title class="text-error">حذف</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card-title>
<v-card-text>
<div v-if="app.description" class="mb-3 oauth-info-text">
{{ app.description }}
</div>
<div class="text-caption text-grey">
<div class="mb-1">
<strong>Client ID:</strong>
<span class="oauth-code">{{ app.clientId }}</span>
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
@click="copyToClipboard(app.clientId)"
></v-btn>
</div>
<div class="mb-1">
<strong>Redirect URI:</strong> {{ app.redirectUri }}
</div>
<div class="mb-1">
<strong>وضعیت:</strong>
<v-chip
:color="app.isActive ? 'success' : 'error'"
size="x-small"
class="ml-1 oauth-status-chip"
>
{{ app.isActive ? 'فعال' : 'غیرفعال' }}
</v-chip>
</div>
<div class="mb-1">
<strong>تایید:</strong>
<v-chip
:color="app.isVerified ? 'success' : 'warning'"
size="x-small"
class="ml-1 oauth-status-chip"
>
{{ app.isVerified ? 'تایید شده' : 'در انتظار تایید' }}
</v-chip>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Dialog ایجاد/ویرایش برنامه -->
<v-dialog v-model="showCreateDialog" max-width="700px" persistent class="oauth-dialog">
<v-card>
<v-card-title class="text-h6 pa-6 pb-4">
<v-icon start class="mr-2" color="primary">mdi-oauth</v-icon>
{{ editingApp ? 'ویرایش برنامه OAuth' : 'ایجاد برنامه OAuth جدید' }}
</v-card-title>
<v-card-text class="pa-6 pt-0">
<v-form ref="form" @submit.prevent="saveApplication">
<v-row>
<v-col cols="12" class="oauth-form-field">
<v-text-field
v-model="form.name"
label="نام برنامه *"
:rules="formRules.name"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-application"
required
></v-text-field>
</v-col>
<v-col cols="12" class="oauth-form-field">
<v-textarea
v-model="form.description"
label="توضیحات"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-text"
rows="3"
auto-grow
></v-textarea>
</v-col>
<v-col cols="12" md="6" class="oauth-form-field">
<v-text-field
v-model="form.website"
label="آدرس وب‌سایت"
:rules="formRules.website"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-web"
type="url"
placeholder="https://example.com"
></v-text-field>
</v-col>
<v-col cols="12" md="6" class="oauth-form-field">
<v-text-field
v-model="form.redirectUri"
label="آدرس بازگشت (Redirect URI) *"
:rules="formRules.redirectUri"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-link"
type="url"
placeholder="https://example.com/callback"
required
></v-text-field>
</v-col>
<v-col cols="12" md="6" class="oauth-form-field">
<v-text-field
v-model.number="form.rateLimit"
label="محدودیت درخواست (در ساعت)"
:rules="formRules.rateLimit"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-speedometer"
type="number"
min="1"
max="10000"
hint="تعداد درخواست مجاز در هر ساعت"
persistent-hint
></v-text-field>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-6">
<v-spacer></v-spacer>
<v-btn
variant="outlined"
@click="cancelEdit"
class="mr-2"
>
انصراف
</v-btn>
<v-btn
color="primary"
@click="saveApplication"
:loading="saving"
prepend-icon="mdi-content-save"
>
{{ editingApp ? 'ویرایش' : 'ایجاد' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar برای نمایش پیامها -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
location="top"
>
{{ snackbar.text }}
<template v-slot:actions>
<v-btn
color="white"
variant="text"
@click="snackbar.show = false"
>
بستن
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue'
import axios from 'axios'
import Swal from 'sweetalert2'
export default defineComponent({
name: 'OAuthManager',
setup() {
const applications = ref([])
const loading = ref(false)
const saving = ref(false)
const showCreateDialog = ref(false)
const editingApp = ref(null)
const form = ref({
name: '',
description: '',
website: '',
redirectUri: '',
allowedScopes: [],
rateLimit: 1000
})
const formRules = {
name: [
(v: any) => !!v || 'نام برنامه الزامی است',
(v: any) => v.length >= 3 || 'نام برنامه باید حداقل 3 کاراکتر باشد',
(v: any) => v.length <= 255 || 'نام برنامه نمی‌تواند بیشتر از 255 کاراکتر باشد'
],
redirectUri: [
(v: any) => !!v || 'آدرس بازگشت الزامی است',
(v: any) => /^https?:\/\/.+/.test(v) || 'آدرس بازگشت باید یک URL معتبر باشد'
],
website: [
(v: any) => !v || /^https?:\/\/.+/.test(v) || 'آدرس وب‌سایت باید یک URL معتبر باشد'
],
rateLimit: [
(v: any) => v >= 1 || 'محدودیت درخواست باید حداقل 1 باشد',
(v: any) => v <= 10000 || 'محدودیت درخواست نمی‌تواند بیشتر از 10000 باشد'
]
}
const snackbar = ref({
show: false,
text: '',
color: 'success',
timeout: 3000
})
const loadApplications = async () => {
loading.value = true
try {
const response = await axios.get('/api/oauth/applications')
applications.value = response.data.data || []
} catch (error) {
console.error('خطا در بارگذاری برنامه‌ها:', error)
showSnackbar('خطا در بارگذاری برنامه‌ها', 'error')
} finally {
loading.value = false
}
}
const saveApplication = async () => {
const { valid } = await form.value.$refs?.validate()
if (!valid) {
showSnackbar('لطفاً خطاهای فرم را برطرف کنید', 'error')
return
}
saving.value = true
try {
if (editingApp.value) {
await axios.put(`/api/oauth/applications/${editingApp.value.id}`, form.value)
showSnackbar('برنامه با موفقیت ویرایش شد', 'success')
} else {
const response = await axios.post('/api/oauth/applications', form.value)
showSnackbar('برنامه با موفقیت ایجاد شد', 'success')
// نمایش client_id و client_secret
if (response.data.data?.client_id) {
Swal.fire({
title: 'اطلاعات برنامه',
html: `
<div class="text-right">
<p><strong>Client ID:</strong> <code>${response.data.data.client_id}</code></p>
<p><strong>Client Secret:</strong> <code>${response.data.data.client_secret}</code></p>
<p class="text-warning"> این اطلاعات را در جای امنی ذخیره کنید!</p>
</div>
`,
icon: 'info'
})
}
}
await loadApplications()
cancelEdit()
} catch (error) {
console.error('خطا در ذخیره برنامه:', error)
const errorMessage = error.response?.data?.message || 'خطا در ذخیره برنامه'
showSnackbar(errorMessage, 'error')
} finally {
saving.value = false
}
}
const editApplication = (app: any) => {
editingApp.value = app
form.value = {
name: app.name,
description: app.description || '',
website: app.website || '',
redirectUri: app.redirectUri,
allowedScopes: app.allowedScopes || [],
rateLimit: app.rateLimit || 1000
}
showCreateDialog.value = true
}
const cancelEdit = () => {
editingApp.value = null
showCreateDialog.value = false
form.value = {
name: '',
description: '',
website: '',
redirectUri: '',
allowedScopes: [],
rateLimit: 1000
}
}
const deleteApplication = async (app: any) => {
const result = await Swal.fire({
title: 'حذف برنامه',
text: `آیا از حذف برنامه "${app.name}" اطمینان دارید؟`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف'
})
if (result.isConfirmed) {
try {
await axios.delete(`/api/oauth/applications/${app.id}`)
showSnackbar('برنامه با موفقیت حذف شد', 'success')
await loadApplications()
} catch (error) {
console.error('خطا در حذف برنامه:', error)
const errorMessage = error.response?.data?.message || 'خطا در حذف برنامه'
showSnackbar(errorMessage, 'error')
}
}
}
const regenerateSecret = async (app: any) => {
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/oauth/applications/${app.id}/regenerate-secret`)
Swal.fire({
title: 'کلید جدید',
html: `<p><strong>Client Secret جدید:</strong> <code>${response.data.data.client_secret}</code></p>`,
icon: 'success'
})
await loadApplications()
showSnackbar('کلید جدید با موفقیت ایجاد شد', 'success')
} catch (error) {
console.error('خطا در بازسازی کلید:', error)
const errorMessage = error.response?.data?.message || 'خطا در بازسازی کلید'
showSnackbar(errorMessage, 'error')
}
}
}
const revokeTokens = async (app: any) => {
const result = await Swal.fire({
title: 'لغو توکن‌ها',
text: 'آیا از لغو تمام توکن‌های این برنامه اطمینان دارید؟',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'لغو توکن‌ها',
cancelButtonText: 'انصراف'
})
if (result.isConfirmed) {
try {
const response = await axios.post(`/api/oauth/applications/${app.id}/revoke-tokens`)
showSnackbar(`${response.data.data.revokedCount} توکن لغو شد`, 'success')
} catch (error) {
console.error('خطا در لغو توکن‌ها:', error)
const errorMessage = error.response?.data?.message || 'خطا در لغو توکن‌ها'
showSnackbar(errorMessage, 'error')
}
}
}
const showStats = async (app: any) => {
try {
const response = await axios.get(`/api/oauth/applications/${app.id}/stats`)
const stats = response.data.data
Swal.fire({
title: `آمار استفاده - ${app.name}`,
html: `
<div class="text-right">
<p><strong>کل توکنها:</strong> ${stats.totalTokens}</p>
<p><strong>توکنهای فعال:</strong> ${stats.activeTokens}</p>
<p><strong>توکنهای منقضی شده:</strong> ${stats.expiredTokens}</p>
${stats.lastUsed ? `<p><strong>آخرین استفاده:</strong> ${new Date(stats.lastUsed).toLocaleDateString('fa-IR')}</p>` : ''}
</div>
`,
icon: 'info'
})
} catch (error) {
console.error('خطا در بارگذاری آمار:', error)
showSnackbar('خطا در بارگذاری آمار', 'error')
}
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
showSnackbar('متن در کلیپ‌بورد کپی شد', 'success')
} catch (error) {
console.error('خطا در کپی:', error)
showSnackbar('خطا در کپی کردن متن', 'error')
}
}
const showSnackbar = (text: string, color = 'success') => {
snackbar.value = {
show: true,
text: text,
color: color,
timeout: 3000
}
}
onMounted(() => {
loadApplications()
})
return {
applications,
loading,
saving,
showCreateDialog,
editingApp,
form,
formRules,
snackbar,
saveApplication,
editApplication,
cancelEdit,
deleteApplication,
regenerateSecret,
revokeTokens,
showStats,
copyToClipboard
}
}
})
</script>
<style scoped>
@import './oauth-styles.css';
</style>

View file

@ -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",

View file

@ -0,0 +1,291 @@
<template>
<div class="oauth-authorize-container">
<v-container class="fill-height">
<v-row justify="center" align="center">
<v-col cols="12" sm="8" md="6" lg="4">
<v-card class="oauth-card" elevation="8">
<v-card-title class="text-center pa-6">
<v-icon size="48" color="primary" class="mb-4">mdi-shield-check</v-icon>
<h2 class="text-h4 font-weight-bold">مجوزدهی OAuth</h2>
</v-card-title>
<v-card-text class="pa-6">
<!-- Loading State -->
<div v-if="loading" class="text-center">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
<p class="mt-4 text-body-1">در حال بارگذاری...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center">
<v-icon size="64" color="error" class="mb-4">mdi-alert-circle</v-icon>
<h3 class="text-h6 text-error mb-2">خطا در مجوزدهی</h3>
<p class="text-body-1 mb-4">{{ error }}</p>
<v-btn color="primary" @click="goBack">بازگشت</v-btn>
</div>
<!-- Authorization Form -->
<div v-else-if="application" class="authorization-form">
<!-- Application Info -->
<div class="application-info mb-6">
<div class="d-flex align-center mb-4">
<v-avatar size="64" color="primary" class="mr-4">
<v-icon size="32" color="white">{{ getApplicationIcon() }}</v-icon>
</v-avatar>
<div>
<h3 class="text-h5 font-weight-bold">{{ application.name }}</h3>
<p class="text-body-2 text-medium-emphasis">{{ application.description }}</p>
<v-chip size="small" color="info" variant="outlined" class="mt-1">
{{ application.website }}
</v-chip>
</div>
</div>
</div>
<!-- Scopes -->
<div class="scopes-section mb-6">
<h4 class="text-h6 font-weight-bold mb-3">این برنامه درخواست دسترسی به:</h4>
<v-list class="scopes-list">
<v-list-item v-for="scope in scopes" :key="scope" class="scope-item">
<template v-slot:prepend>
<v-icon color="success" size="20">mdi-check-circle</v-icon>
</template>
<v-list-item-title class="text-body-1">
{{ getScopeDescription(scope) }}
</v-list-item-title>
</v-list-item>
<v-list-item v-if="scopes.length === 0" class="scope-item">
<template v-slot:prepend>
<v-icon color="info" size="20">mdi-information</v-icon>
</template>
<v-list-item-title class="text-body-1">
هیچ مجوز خاصی درخواست نشده است
</v-list-item-title>
</v-list-item>
</v-list>
</div>
<!-- Security Notice -->
<v-alert type="info" variant="tonal" class="mb-6">
<template v-slot:prepend>
<v-icon>mdi-shield-lock</v-icon>
</template>
<div>
<strong>امنیت:</strong> این برنامه فقط به اطلاعات مشخص شده دسترسی خواهد داشت و نمیتواند رمز عبور شما را ببیند.
</div>
</v-alert>
<!-- Action Buttons -->
<div class="action-buttons d-flex gap-3">
<v-btn
block
variant="outlined"
color="error"
size="large"
@click="denyAccess"
:loading="processing"
>
<v-icon start>mdi-close</v-icon>
رد کردن
</v-btn>
<v-btn
block
color="primary"
size="large"
@click="approveAccess"
:loading="processing"
>
<v-icon start>mdi-check</v-icon>
تایید و ادامه
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'OAuthAuthorize',
data() {
return {
loading: true,
processing: false,
error: null,
application: null,
scopes: [],
state: null,
clientId: null,
redirectUri: null,
scope: null
};
},
async mounted() {
await this.initializeAuthorization();
},
methods: {
async initializeAuthorization() {
try {
// دریافت پارامترها از URL
const urlParams = new URLSearchParams(window.location.search);
this.clientId = urlParams.get('client_id');
this.redirectUri = urlParams.get('redirect_uri');
this.scope = urlParams.get('scope');
this.state = urlParams.get('state');
// بررسی پارامترهای اجباری
if (!this.clientId || !this.redirectUri) {
throw new Error('پارامترهای اجباری موجود نیست');
}
// بررسی وضعیت لاگین کاربر
const loginCheck = await axios.post('/api/user/check/login');
if (!loginCheck.data.Success) {
// اگر کاربر لاگین نیست، به صفحه لاگین هدایت شود
this.redirectToLogin();
return;
}
// دریافت اطلاعات برنامه OAuth
await this.loadApplicationInfo();
// پردازش scope ها
this.scopes = this.scope ? this.scope.split(' ') : [];
this.loading = false;
} catch (error) {
console.error('Error initializing authorization:', error);
this.error = error.response?.data?.message || error.message || 'خطا در بارگذاری اطلاعات';
this.loading = false;
}
},
async loadApplicationInfo() {
try {
const response = await axios.get(`/api/admin/oauth/applications/client/${this.clientId}`);
if (response.data.Success) {
this.application = response.data.data;
} else {
throw new Error('برنامه یافت نشد');
}
} catch (error) {
throw new Error('خطا در دریافت اطلاعات برنامه');
}
},
redirectToLogin() {
const loginUrl = `/user/login?redirect=${encodeURIComponent(window.location.href)}`;
this.$router.push(loginUrl);
},
async approveAccess() {
await this.processAuthorization(true);
},
async denyAccess() {
await this.processAuthorization(false);
},
async processAuthorization(approved) {
this.processing = true;
try {
const response = await axios.post('/api/oauth/authorize', {
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope: this.scope,
state: this.state,
approved: approved
});
if (response.data.Success) {
// هدایت به redirect_uri
const redirectUrl = response.data.redirect_url;
window.location.href = redirectUrl;
} else {
throw new Error(response.data.message || 'خطا در پردازش مجوز');
}
} catch (error) {
console.error('Authorization error:', error);
this.error = error.response?.data?.message || error.message || 'خطا در پردازش مجوز';
this.processing = false;
}
},
getApplicationIcon() {
// آیکون پیشفرض برای برنامهها
return 'mdi-application';
},
getScopeDescription(scope) {
const scopeDescriptions = {
'read_profile': 'خواندن اطلاعات پروفایل',
'write_profile': 'تغییر اطلاعات پروفایل',
'read_business': 'خواندن اطلاعات کسب و کار',
'write_business': 'تغییر اطلاعات کسب و کار',
'read_financial': 'خواندن اطلاعات مالی',
'write_financial': 'تغییر اطلاعات مالی',
'read_contacts': 'خواندن لیست مخاطبین',
'write_contacts': 'تغییر لیست مخاطبین',
'read_documents': 'خواندن اسناد',
'write_documents': 'تغییر اسناد',
'admin_access': 'دسترسی مدیریتی'
};
return scopeDescriptions[scope] || scope;
},
goBack() {
window.history.back();
}
}
};
</script>
<style scoped>
.oauth-authorize-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.oauth-card {
border-radius: 16px;
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.95);
}
.scopes-list {
background: transparent;
}
.scope-item {
border-radius: 8px;
margin-bottom: 8px;
background: rgba(0, 0, 0, 0.02);
}
.application-info {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding-bottom: 16px;
}
.action-buttons {
gap: 12px;
}
/* Responsive Design */
@media (max-width: 600px) {
.action-buttons {
flex-direction: column;
}
.oauth-card {
margin: 16px;
}
}
</style>

View file

@ -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);
}

View file

@ -0,0 +1,528 @@
<template>
<v-container>
<v-row>
<v-col cols="12">
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<div>
<v-icon class="mr-2">mdi-oauth</v-icon>
مدیریت برنامههای OAuth
</div>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showCreateDialog = true"
:loading="loading"
>
برنامه جدید
</v-btn>
</v-card-title>
<v-card-text>
<v-alert
v-if="applications.length === 0"
type="info"
variant="tonal"
class="mb-4"
>
هنوز هیچ برنامه OAuth ایجاد نکردهاید. برای شروع، یک برنامه جدید ایجاد کنید.
</v-alert>
<v-row v-else>
<v-col
v-for="app in applications"
:key="app.id"
cols="12"
md="6"
lg="4"
>
<v-card variant="outlined" class="h-100">
<v-card-title class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-avatar
:color="app.isActive ? 'success' : 'grey'"
size="32"
class="mr-2"
>
<v-icon size="16" color="white">
{{ app.isActive ? 'mdi-check' : 'mdi-close' }}
</v-icon>
</v-avatar>
{{ app.name }}
</div>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
icon="mdi-dots-vertical"
variant="text"
v-bind="props"
></v-btn>
</template>
<v-list>
<v-list-item @click="editApplication(app)">
<template v-slot:prepend>
<v-icon>mdi-pencil</v-icon>
</template>
<v-list-item-title>ویرایش</v-list-item-title>
</v-list-item>
<v-list-item @click="showStats(app)">
<template v-slot:prepend>
<v-icon>mdi-chart-line</v-icon>
</template>
<v-list-item-title>آمار</v-list-item-title>
</v-list-item>
<v-list-item @click="regenerateSecret(app)">
<template v-slot:prepend>
<v-icon>mdi-refresh</v-icon>
</template>
<v-list-item-title>بازسازی کلید</v-list-item-title>
</v-list-item>
<v-list-item @click="revokeTokens(app)">
<template v-slot:prepend>
<v-icon>mdi-logout</v-icon>
</template>
<v-list-item-title>لغو توکنها</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item @click="deleteApplication(app)" color="error">
<template v-slot:prepend>
<v-icon color="error">mdi-delete</v-icon>
</template>
<v-list-item-title class="text-error">حذف</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card-title>
<v-card-text>
<div v-if="app.description" class="mb-3">
{{ app.description }}
</div>
<div class="text-caption text-grey">
<div class="mb-1">
<strong>Client ID:</strong>
<code class="text-primary">{{ app.clientId }}</code>
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
@click="copyToClipboard(app.clientId)"
></v-btn>
</div>
<div class="mb-1">
<strong>Redirect URI:</strong> {{ app.redirectUri }}
</div>
<div class="mb-1">
<strong>وضعیت:</strong>
<v-chip
:color="app.isActive ? 'success' : 'error'"
size="x-small"
class="ml-1"
>
{{ app.isActive ? 'فعال' : 'غیرفعال' }}
</v-chip>
</div>
<div class="mb-1">
<strong>تایید:</strong>
<v-chip
:color="app.isVerified ? 'success' : 'warning'"
size="x-small"
class="ml-1"
>
{{ app.isVerified ? 'تایید شده' : 'در انتظار تایید' }}
</v-chip>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Dialog ایجاد/ویرایش برنامه -->
<v-dialog v-model="showCreateDialog" max-width="600px" persistent>
<v-card>
<v-card-title>
{{ editingApp ? 'ویرایش برنامه' : 'ایجاد برنامه جدید' }}
</v-card-title>
<v-card-text>
<v-form ref="form" @submit.prevent="saveApplication">
<v-text-field
v-model="form.name"
label="نام برنامه"
:rules="[v => !!v || 'نام برنامه الزامی است']"
required
></v-text-field>
<v-textarea
v-model="form.description"
label="توضیحات"
rows="3"
></v-textarea>
<v-text-field
v-model="form.website"
label="آدرس وب‌سایت"
type="url"
></v-text-field>
<v-text-field
v-model="form.redirectUri"
label="آدرس بازگشت (Redirect URI)"
:rules="[v => !!v || 'آدرس بازگشت الزامی است']"
required
></v-text-field>
<v-select
v-model="form.allowedScopes"
:items="availableScopes"
item-title="description"
item-value="name"
label="محدوده‌های دسترسی"
multiple
chips
closable-chips
></v-select>
<v-text-field
v-model.number="form.rateLimit"
label="محدودیت درخواست (در ساعت)"
type="number"
min="1"
max="10000"
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="cancelEdit">انصراف</v-btn>
<v-btn
color="primary"
@click="saveApplication"
:loading="saving"
>
{{ editingApp ? 'ویرایش' : 'ایجاد' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog آمار -->
<v-dialog v-model="showStatsDialog" max-width="500px">
<v-card>
<v-card-title>آمار استفاده - {{ selectedApp?.name }}</v-card-title>
<v-card-text v-if="appStats">
<v-list>
<v-list-item>
<template v-slot:prepend>
<v-icon>mdi-key</v-icon>
</template>
<v-list-item-title>کل توکنها</v-list-item-title>
<template v-slot:append>
<v-chip>{{ appStats.totalTokens }}</v-chip>
</template>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon color="success">mdi-check-circle</v-icon>
</template>
<v-list-item-title>توکنهای فعال</v-list-item-title>
<template v-slot:append>
<v-chip color="success">{{ appStats.activeTokens }}</v-chip>
</template>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon color="warning">mdi-clock</v-icon>
</template>
<v-list-item-title>توکنهای منقضی شده</v-list-item-title>
<template v-slot:append>
<v-chip color="warning">{{ appStats.expiredTokens }}</v-chip>
</template>
</v-list-item>
<v-list-item v-if="appStats.lastUsed">
<template v-slot:prepend>
<v-icon>mdi-calendar</v-icon>
</template>
<v-list-item-title>آخرین استفاده</v-list-item-title>
<template v-slot:append>
<span class="text-caption">{{ formatDate(appStats.lastUsed) }}</span>
</template>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="showStatsDialog = false">بستن</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue'
import axios from 'axios'
import Swal from 'sweetalert2'
export default defineComponent({
name: 'OAuthManagement',
setup() {
const applications = ref([])
const availableScopes = ref([])
const loading = ref(false)
const saving = ref(false)
const showCreateDialog = ref(false)
const showStatsDialog = ref(false)
const editingApp = ref(null)
const selectedApp = ref(null)
const appStats = ref(null)
const form = ref({
name: '',
description: '',
website: '',
redirectUri: '',
allowedScopes: [],
rateLimit: 1000
})
const loadApplications = async () => {
loading.value = true
try {
const response = await axios.get('/api/oauth/applications')
applications.value = response.data.data || []
} catch (error) {
console.error('خطا در بارگذاری برنامه‌ها:', error)
Swal.fire('خطا', 'خطا در بارگذاری برنامه‌ها', 'error')
} finally {
loading.value = false
}
}
const loadScopes = async () => {
try {
const response = await axios.get('/api/oauth/scopes')
availableScopes.value = response.data.data || []
} catch (error) {
console.error('خطا در بارگذاری محدوده‌ها:', error)
}
}
const saveApplication = async () => {
const { valid } = await form.value.$refs?.validate()
if (!valid) return
saving.value = true
try {
if (editingApp.value) {
await axios.put(`/api/oauth/applications/${editingApp.value.id}`, form.value)
Swal.fire('موفق', 'برنامه با موفقیت ویرایش شد', 'success')
} else {
const response = await axios.post('/api/oauth/applications', form.value)
Swal.fire('موفق', 'برنامه با موفقیت ایجاد شد', 'success')
// نمایش client_id و client_secret
if (response.data.data?.client_id) {
Swal.fire({
title: 'اطلاعات برنامه',
html: `
<div class="text-right">
<p><strong>Client ID:</strong> <code>${response.data.data.client_id}</code></p>
<p><strong>Client Secret:</strong> <code>${response.data.data.client_secret}</code></p>
<p class="text-warning"> این اطلاعات را در جای امنی ذخیره کنید!</p>
</div>
`,
icon: 'info'
})
}
}
await loadApplications()
cancelEdit()
} catch (error) {
console.error('خطا در ذخیره برنامه:', error)
Swal.fire('خطا', 'خطا در ذخیره برنامه', 'error')
} finally {
saving.value = false
}
}
const editApplication = (app) => {
editingApp.value = app
form.value = {
name: app.name,
description: app.description || '',
website: app.website || '',
redirectUri: app.redirectUri,
allowedScopes: app.allowedScopes || [],
rateLimit: app.rateLimit || 1000
}
showCreateDialog.value = true
}
const cancelEdit = () => {
editingApp.value = null
showCreateDialog.value = false
form.value = {
name: '',
description: '',
website: '',
redirectUri: '',
allowedScopes: [],
rateLimit: 1000
}
}
const deleteApplication = async (app) => {
const result = await Swal.fire({
title: 'حذف برنامه',
text: `آیا از حذف برنامه "${app.name}" اطمینان دارید؟`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف'
})
if (result.isConfirmed) {
try {
await axios.delete(`/api/oauth/applications/${app.id}`)
Swal.fire('موفق', 'برنامه با موفقیت حذف شد', 'success')
await loadApplications()
} catch (error) {
console.error('خطا در حذف برنامه:', error)
Swal.fire('خطا', 'خطا در حذف برنامه', 'error')
}
}
}
const regenerateSecret = async (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/oauth/applications/${app.id}/regenerate-secret`)
Swal.fire({
title: 'کلید جدید',
html: `<p><strong>Client Secret جدید:</strong> <code>${response.data.data.client_secret}</code></p>`,
icon: 'success'
})
await loadApplications()
} catch (error) {
console.error('خطا در بازسازی کلید:', error)
Swal.fire('خطا', 'خطا در بازسازی کلید', 'error')
}
}
}
const revokeTokens = async (app) => {
const result = await Swal.fire({
title: 'لغو توکن‌ها',
text: 'آیا از لغو تمام توکن‌های این برنامه اطمینان دارید؟',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'لغو توکن‌ها',
cancelButtonText: 'انصراف'
})
if (result.isConfirmed) {
try {
const response = await axios.post(`/api/oauth/applications/${app.id}/revoke-tokens`)
Swal.fire('موفق', `${response.data.data.revokedCount} توکن لغو شد`, 'success')
} catch (error) {
console.error('خطا در لغو توکن‌ها:', error)
Swal.fire('خطا', 'خطا در لغو توکن‌ها', 'error')
}
}
}
const showStats = async (app) => {
selectedApp.value = app
try {
const response = await axios.get(`/api/oauth/applications/${app.id}/stats`)
appStats.value = response.data.data
showStatsDialog.value = true
} catch (error) {
console.error('خطا در بارگذاری آمار:', error)
Swal.fire('خطا', 'خطا در بارگذاری آمار', 'error')
}
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
Swal.fire({
title: 'کپی شد',
text: 'متن در کلیپ‌بورد کپی شد',
icon: 'success',
timer: 1500,
showConfirmButton: false
})
} catch (error) {
console.error('خطا در کپی:', error)
}
}
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('fa-IR')
}
onMounted(() => {
loadApplications()
loadScopes()
})
return {
applications,
availableScopes,
loading,
saving,
showCreateDialog,
showStatsDialog,
editingApp,
selectedApp,
appStats,
form,
saveApplication,
editApplication,
cancelEdit,
deleteApplication,
regenerateSecret,
revokeTokens,
showStats,
copyToClipboard,
formatDate
}
}
})
</script>
<style scoped>
code {
background: #f5f5f5;
padding: 2px 4px;
border-radius: 4px;
font-family: monospace;
}
</style>

View file

@ -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: `
<div class="text-right">
<p><strong>Client ID:</strong> <code>${response.data.data.client_id}</code></p>
<p><strong>Client Secret:</strong> <code>${response.data.data.client_secret}</code></p>
<p class="text-warning"> این اطلاعات را در جای امنی ذخیره کنید!</p>
</div>
`,
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: `<p><strong>Client Secret جدید:</strong> <code>${response.data.data.client_secret}</code></p>`,
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: `
<div class="text-right">
<p><strong>کل توکنها:</strong> ${stats.total_tokens || 0}</p>
<p><strong>توکنهای فعال:</strong> ${stats.active_tokens || 0}</p>
<p><strong>توکنهای منقضی شده:</strong> ${stats.expired_tokens || 0}</p>
${stats.last_used ? `<p><strong>آخرین استفاده:</strong> ${new Date(stats.last_used).toLocaleDateString('fa-IR')}</p>` : ''}
</div>
`,
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();
}
})
</script>
@ -985,9 +1342,552 @@ export default defineComponent({
</v-row>
</v-card-text>
</v-window-item>
<!-- تب پنجم: برنامههای OAuth -->
<v-window-item :value="4">
<v-card-text class="pa-8">
<div class="d-flex align-center mb-8">
<div class="d-flex align-center bg-primary-lighten-5 pa-4 rounded-lg">
<v-icon size="32" color="primary" class="mr-3">mdi-oauth</v-icon>
<div>
<h3 class="text-h5 font-weight-medium text-primary mb-1">برنامههای OAuth</h3>
<p class="text-caption text-medium-emphasis mb-0">مدیریت برنامههای احراز هویت خارجی</p>
</div>
</div>
</div>
<!-- Header Section -->
<div class="mb-8">
<div class="d-flex align-center justify-space-between mb-4">
<div>
<h2 class="text-h4 font-weight-bold text-primary mb-2">
<v-icon start size="32" color="primary">mdi-oauth</v-icon>
مدیریت برنامههای OAuth
</h2>
<p class="text-body-1 text-medium-emphasis">
برنامههای خارجی که میتوانند از حساب کاربران شما استفاده کنند
</p>
</div>
<v-btn
color="primary"
size="large"
prepend-icon="mdi-plus"
@click="showOAuthDialog = true"
elevation="2"
class="text-none"
>
ایجاد برنامه جدید
</v-btn>
</div>
<!-- Stats Cards -->
<v-row class="mb-6">
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="text-center pa-4" elevation="0">
<v-icon size="32" color="primary" class="mb-2">mdi-application</v-icon>
<div class="text-h5 font-weight-bold text-primary">{{ oauthApplications.length }}</div>
<div class="text-body-2 text-medium-emphasis">کل برنامهها</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="text-center pa-4" elevation="0">
<v-icon size="32" color="success" class="mb-2">mdi-check-circle</v-icon>
<div class="text-h5 font-weight-bold text-success">{{ oauthApplications.filter(app => app.isActive).length }}</div>
<div class="text-body-2 text-medium-emphasis">فعال</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="text-center pa-4" elevation="0">
<v-icon size="32" color="warning" class="mb-2">mdi-pause-circle</v-icon>
<div class="text-h5 font-weight-bold text-warning">{{ oauthApplications.filter(app => !app.isActive).length }}</div>
<div class="text-body-2 text-medium-emphasis">غیرفعال</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="text-center pa-4" elevation="0">
<v-icon size="32" color="info" class="mb-2">mdi-clock-outline</v-icon>
<div class="text-h5 font-weight-bold text-info">{{ oauthApplications.filter(app => new Date(app.createdAt.timestamp * 1000) > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)).length }}</div>
<div class="text-body-2 text-medium-emphasis">جدید (7 روز)</div>
</v-card>
</v-col>
</v-row>
<!-- Info Alert -->
<v-alert
type="info"
variant="tonal"
class="mb-6"
border="start"
border-color="primary"
>
<template v-slot:prepend>
<v-icon size="24" color="primary">mdi-information</v-icon>
</template>
<div class="text-right">
<div class="text-subtitle-1 font-weight-medium mb-2">OAuth چیست؟</div>
<p class="text-body-2 mb-0">
OAuth یک پروتکل استاندارد برای احراز هویت است که به برنامههای خارجی اجازه میدهد
بدون نیاز به رمز عبور، به حساب کاربران دسترسی داشته باشند. این روش امنتر و راحتتر از
روشهای سنتی است.
</p>
</div>
</v-alert>
</div>
<!-- Applications Section -->
<div>
<div class="d-flex align-center justify-space-between mb-4">
<h3 class="text-h6 font-weight-medium">برنامههای شما</h3>
<v-chip
:color="oauthApplications.length > 0 ? 'success' : 'warning'"
variant="tonal"
size="small"
>
{{ oauthApplications.length }} برنامه
</v-chip>
</div>
<v-card
v-if="oauthApplications.length === 0"
variant="outlined"
class="text-center pa-8"
elevation="0"
>
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-application-outline</v-icon>
<h3 class="text-h6 font-weight-medium text-grey-darken-1 mb-2">
هنوز برنامهای ایجاد نکردهاید
</h3>
<p class="text-body-2 text-grey mb-6">
برای شروع استفاده از OAuth، اولین برنامه خود را ایجاد کنید
</p>
<v-btn
color="primary"
size="large"
prepend-icon="mdi-plus"
@click="showOAuthDialog = true"
elevation="2"
>
ایجاد اولین برنامه
</v-btn>
</v-card>
<v-row v-else>
<v-col
v-for="app in oauthApplications"
:key="`${app.id}-${app.isActive}`"
cols="12"
md="6"
lg="4"
>
<v-card
variant="outlined"
class="h-100 application-card"
:class="{ 'inactive-app': !app.isActive }"
elevation="0"
hover
>
<!-- Header -->
<v-card-title class="d-flex justify-space-between align-center pa-4 pb-2">
<div class="d-flex align-center">
<v-avatar
:color="app.isActive ? 'success' : 'grey'"
size="40"
class="mr-3"
>
<v-icon size="20" color="white">
{{ app.isActive ? 'mdi-check' : 'mdi-pause' }}
</v-icon>
</v-avatar>
<div>
<div class="text-subtitle-1 font-weight-medium text-truncate" style="max-width: 200px;">
{{ app.name }}
</div>
<div class="text-caption text-medium-emphasis">
{{ new Date(app.createdAt.timestamp * 1000).toLocaleDateString('fa-IR') }}
</div>
</div>
</div>
<!-- Status Badge -->
<v-chip
:color="app.isActive ? 'success' : 'error'"
variant="tonal"
size="small"
class="ml-2"
>
{{ app.isActive ? 'فعال' : 'غیرفعال' }}
</v-chip>
</v-card-title>
<!-- Content -->
<v-card-text class="pa-4 pt-0">
<!-- Description -->
<div v-if="app.description" class="mb-4">
<p class="text-body-2 text-medium-emphasis mb-0">
{{ app.description }}
</p>
</div>
<!-- Info Grid -->
<div class="info-grid mb-4">
<div class="info-item">
<div class="info-label">
<v-icon size="16" class="mr-1">mdi-key</v-icon>
Client ID
</div>
<div class="info-value">
<code class="text-primary">{{ app.clientId }}</code>
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
@click="copyToClipboard(app.clientId)"
class="ml-1"
></v-btn>
</div>
</div>
<div class="info-item">
<div class="info-label">
<v-icon size="16" class="mr-1">mdi-link</v-icon>
Redirect URI
</div>
<div class="info-value text-truncate">
{{ app.redirectUri }}
</div>
</div>
<div class="info-item">
<div class="info-label">
<v-icon size="16" class="mr-1">mdi-shield</v-icon>
محدوده دسترسی
</div>
<div class="info-value">
<v-chip
v-for="scope in app.allowedScopes"
:key="scope"
size="x-small"
variant="tonal"
color="primary"
class="mr-1 mb-1"
>
{{ scope }}
</v-chip>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex flex-wrap gap-2">
<v-btn
size="small"
variant="outlined"
color="primary"
@click="editOAuthApp(app)"
prepend-icon="mdi-pencil"
>
ویرایش
</v-btn>
<v-btn
size="small"
variant="outlined"
color="info"
@click="showOAuthStats(app)"
prepend-icon="mdi-chart-line"
>
آمار
</v-btn>
<v-btn
size="small"
variant="outlined"
:color="app.isActive ? 'error' : 'success'"
@click="toggleOAuthStatus(app)"
:prepend-icon="app.isActive ? 'mdi-pause' : 'mdi-play'"
>
{{ app.isActive ? 'غیرفعال' : 'فعال' }}
</v-btn>
</div>
</v-card-text>
<!-- Menu -->
<v-card-actions class="pa-4 pt-0">
<v-spacer></v-spacer>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
icon="mdi-dots-vertical"
variant="text"
size="small"
v-bind="props"
></v-btn>
</template>
<v-list>
<v-list-item @click="regenerateOAuthSecret(app)">
<template v-slot:prepend>
<v-icon>mdi-refresh</v-icon>
</template>
<v-list-item-title>بازسازی کلید</v-list-item-title>
</v-list-item>
<v-list-item @click="revokeOAuthTokens(app)">
<template v-slot:prepend>
<v-icon>mdi-logout</v-icon>
</template>
<v-list-item-title>لغو توکنها</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item @click="deleteOAuthApp(app)" color="error">
<template v-slot:prepend>
<v-icon color="error">mdi-delete</v-icon>
</template>
<v-list-item-title class="text-error">حذف</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</div>
</v-card-text>
</v-window-item>
</v-window>
</v-card>
</v-container>
<!-- Dialog ایجاد/ویرایش برنامه OAuth -->
<v-dialog v-model="showOAuthDialog" max-width="700px" persistent>
<v-card>
<v-card-title class="text-h6 pa-6 pb-4">
<v-icon start class="mr-2" color="primary">mdi-oauth</v-icon>
{{ editingOAuthApp ? 'ویرایش برنامه OAuth' : 'ایجاد برنامه OAuth جدید' }}
</v-card-title>
<v-card-text class="pa-6 pt-0">
<v-form ref="oauthForm" @submit.prevent="saveOAuthApplication">
<v-row>
<v-col cols="12">
<v-text-field
v-model="oauthForm.name"
label="نام برنامه *"
:rules="oauthFormRules.name"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-application"
required
></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea
v-model="oauthForm.description"
label="توضیحات"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-text"
rows="3"
auto-grow
></v-textarea>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="oauthForm.website"
label="آدرس وب‌سایت"
:rules="oauthFormRules.website"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-web"
type="url"
placeholder="https://example.com"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="oauthForm.redirectUri"
label="آدرس بازگشت (Redirect URI) *"
:rules="oauthFormRules.redirectUri"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-link"
type="url"
placeholder="https://example.com/callback"
required
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="oauthForm.rateLimit"
label="محدودیت درخواست (در ساعت)"
:rules="oauthFormRules.rateLimit"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-speedometer"
type="number"
min="1"
max="10000"
hint="تعداد درخواست مجاز در هر ساعت"
persistent-hint
></v-text-field>
</v-col>
<!-- IP Whitelist -->
<v-col cols="12">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1 font-weight-medium pb-2">
<v-icon start class="mr-2" color="primary">mdi-ip-network</v-icon>
آدرسهای IP مجاز
</v-card-title>
<v-card-text class="pa-0">
<p class="text-body-2 text-medium-emphasis mb-4">
در صورت خالی بودن، از هر آدرس IP میتوان به برنامه متصل شد.
برای محدود کردن دسترسی، آدرسهای IP مجاز را وارد کنید.
</p>
<div class="d-flex align-center mb-3">
<v-text-field
v-model="newIpAddress"
label="آدرس IP"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-ip"
placeholder="192.168.1.1 یا 192.168.1.0/24"
class="mr-2"
:rules="[ipValidationRule]"
></v-text-field>
<v-btn
color="primary"
@click="addIpAddress"
:disabled="!newIpAddress || !isValidIp(newIpAddress)"
prepend-icon="mdi-plus"
>
افزودن
</v-btn>
</div>
<v-chip-group v-if="oauthForm.ipWhitelist.length > 0">
<v-chip
v-for="(ip, index) in oauthForm.ipWhitelist"
:key="index"
closable
@click:close="removeIpAddress(index)"
color="primary"
variant="tonal"
>
{{ ip }}
</v-chip>
</v-chip-group>
<v-alert
v-else
type="info"
variant="tonal"
class="mt-2"
>
<template v-slot:prepend>
<v-icon>mdi-information</v-icon>
</template>
هیچ آدرس IP محدودیتی تعریف نشده است. از هر آدرس IP میتوان به برنامه متصل شد.
</v-alert>
</v-card-text>
</v-card>
</v-col>
<!-- Scope Management -->
<v-col cols="12">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1 font-weight-medium pb-2">
<v-icon start class="mr-2" color="primary">mdi-shield</v-icon>
محدودههای دسترسی (Scopes)
</v-card-title>
<v-card-text class="pa-0">
<p class="text-body-2 text-medium-emphasis mb-4">
محدودههای دسترسی که این برنامه میتواند از کاربران درخواست کند.
</p>
<v-row>
<v-col cols="12" md="6" v-for="scope in availableScopes" :key="scope.name">
<v-checkbox
v-model="oauthForm.allowedScopes"
:value="scope.name"
:label="scope.name"
:hint="scope.description"
persistent-hint
color="primary"
>
<template v-slot:label>
<div>
<div class="font-weight-medium">{{ scope.name }}</div>
<div class="text-caption text-medium-emphasis">{{ scope.description }}</div>
</div>
</template>
</v-checkbox>
</v-col>
</v-row>
<v-alert
v-if="oauthForm.allowedScopes.length === 0"
type="warning"
variant="tonal"
class="mt-2"
>
<template v-slot:prepend>
<v-icon>mdi-alert</v-icon>
</template>
هیچ محدوده دسترسی انتخاب نشده است. این برنامه دسترسی محدودی خواهد داشت.
</v-alert>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-6">
<v-spacer></v-spacer>
<v-btn
variant="outlined"
@click="cancelOAuthEdit"
class="mr-2"
>
انصراف
</v-btn>
<v-btn
color="primary"
@click="saveOAuthApplication"
:loading="loading"
prepend-icon="mdi-content-save"
>
{{ editingOAuthApp ? 'ویرایش' : 'ایجاد' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar برای نمایش پیامها -->
<v-snackbar
v-model="oauthSnackbar.show"
:color="oauthSnackbar.color"
:timeout="oauthSnackbar.timeout"
location="top"
>
{{ oauthSnackbar.text }}
<template v-slot:actions>
<v-btn
color="white"
variant="text"
@click="oauthSnackbar.show = false"
>
بستن
</v-btn>
</template>
</v-snackbar>
</template>
<style scoped>
@ -1144,4 +2044,59 @@ export default defineComponent({
.opacity-50 .v-select--disabled {
opacity: 0.4;
}
/* استایل‌های OAuth */
.application-card {
transition: all 0.3s ease;
border-radius: 12px;
}
.application-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1) !important;
}
.inactive-app {
opacity: 0.7;
}
.inactive-app:hover {
opacity: 1;
}
.info-grid {
display: grid;
gap: 12px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
display: flex;
align-items: center;
font-size: 0.75rem;
font-weight: 500;
color: rgba(var(--v-theme-on-surface), 0.7);
}
.info-value {
display: flex;
align-items: center;
font-size: 0.875rem;
word-break: break-all;
}
.gap-2 {
gap: 8px;
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>