- Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup.
318 lines
11 KiB
Markdown
318 lines
11 KiB
Markdown
# نکات پیادهسازی — تسک ۰۱: راهاندازی پروژه
|
||
|
||
## تفاوتهای اصلی Drupal vs Symfony
|
||
|
||
### CSRF Token
|
||
در Drupal: endpoint مخصوص `GET /session/token` برای دریافت CSRF توکن وجود دارد.
|
||
در Symfony: چون API کاملاً stateless است و JWT استفاده میشود، CSRF Token
|
||
سنتی **نیاز نیست**. به جای آن، JWT در هر request ارسال میشود.
|
||
→ در TASK-02 endpoint ساختگی `/session/token` پیادهسازی میشود که یک مقدار تصادفی
|
||
برمیگرداند تا کلاینت موجود بدون تغییر کار کند.
|
||
|
||
### UUID
|
||
در Drupal: UUID داخلی Drupal مدیریت میشود.
|
||
در Symfony: از `symfony/uid` (built-in) استفاده کن — نه `ramsey/uuid`.
|
||
→ تمام ID های عمومی در API باید UUID باشند، نه auto-increment.
|
||
|
||
---
|
||
|
||
## پیکربندی Security (security.yaml)
|
||
|
||
```yaml
|
||
# config/packages/security.yaml
|
||
security:
|
||
password_hashers:
|
||
App\Auth\Entity\User:
|
||
algorithm: bcrypt
|
||
cost: 12
|
||
|
||
providers:
|
||
# تنها provider — username همیشه شماره موبایل است (برای همه نقشها)
|
||
app_user_provider:
|
||
entity:
|
||
class: App\Auth\Entity\User
|
||
property: mobileNumber
|
||
|
||
firewalls:
|
||
dev:
|
||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||
security: false
|
||
|
||
health:
|
||
pattern: ^/health$
|
||
security: false
|
||
|
||
# Endpoints کاملاً عمومی (بدون هیچ بررسی)
|
||
public:
|
||
pattern: ^/(api/v1/user/send-code|api/v1/user/verify-code|api/v1/user/register|oauth/token|session/token)$
|
||
stateless: true
|
||
security: false
|
||
|
||
api:
|
||
pattern: ^/(api|oauth)/
|
||
stateless: true
|
||
# ۱) Password login برای doctor/clinic/secretary
|
||
custom_authenticators:
|
||
- App\Auth\Security\PasswordAuthenticator
|
||
# ۲) JWT middleware — Authorization: Bearer را میخواند
|
||
jwt: ~
|
||
|
||
access_control:
|
||
# Public endpoints
|
||
- { path: ^/health$, roles: PUBLIC_ACCESS }
|
||
- { path: ^/api/v1/user/send-code, roles: PUBLIC_ACCESS }
|
||
- { path: ^/api/v1/user/verify-code, roles: PUBLIC_ACCESS }
|
||
- { path: ^/api/v1/user/register, roles: PUBLIC_ACCESS }
|
||
- { path: ^/api/v1/user/login, roles: PUBLIC_ACCESS }
|
||
- { path: ^/oauth/token$, roles: PUBLIC_ACCESS }
|
||
- { path: ^/oauth/token/refresh$, roles: PUBLIC_ACCESS }
|
||
- { path: ^/session/token, roles: PUBLIC_ACCESS }
|
||
|
||
# Swagger — فقط در dev
|
||
- { path: ^/api/doc, roles: PUBLIC_ACCESS, env: dev }
|
||
|
||
# Admin-only
|
||
- { path: ^/api/v1/user/\d+$, methods: [DELETE], roles: ROLE_ADMIN }
|
||
|
||
# Authenticated
|
||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||
- { path: ^/oauth/userinfo, roles: IS_AUTHENTICATED_FULLY }
|
||
- { path: ^/oauth/logout, roles: IS_AUTHENTICATED_FULLY }
|
||
```
|
||
|
||
---
|
||
|
||
## ⚠ استاندارد JWT — Access Token در هدر، Refresh Token در Body
|
||
|
||
این مهمترین نکته امنیتی در مدیریت توکنهاست. دو توکن دو جای کاملاً متفاوت دارند:
|
||
|
||
### Access Token → فقط در `Authorization` header
|
||
|
||
```
|
||
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
|
||
```
|
||
|
||
**LexikJWTAuthenticationBundle** این هدر را به صورت خودکار در تمام endpoint های محافظتشده بررسی میکند.
|
||
در Controller کد اضافهای لازم نیست.
|
||
|
||
**هرگز access_token را اینجا نفرست:**
|
||
```
|
||
❌ GET /api/endpoint?token=eyJ... ← URL — در لاگهای سرور ذخیره میشود
|
||
❌ POST body: {"token": "eyJ..."} ← Body — افشا در لاگهای request
|
||
❌ Cookie: access_token=eyJ... ← Cookie — باید HttpOnly باشد و CSRF لازم دارد
|
||
```
|
||
|
||
### Refresh Token → فقط در body برای یک endpoint خاص
|
||
|
||
```json
|
||
POST /oauth/token/refresh
|
||
Content-Type: application/json
|
||
|
||
{ "refresh_token": "a8f3b2c1d0e4..." }
|
||
```
|
||
|
||
Refresh Token هرگز در `Authorization` header نمیرود. این endpoint در `access_control` با `PUBLIC_ACCESS` است — تأیید هویت با خود Refresh Token انجام میشود.
|
||
|
||
### گردش کامل توکنها
|
||
|
||
```
|
||
[ورود — یکبار]
|
||
POST /api/v1/user/login (password)
|
||
یا
|
||
POST /oauth/token (OTP)
|
||
|
||
← Response:
|
||
{
|
||
"access_token": "eyJ..." ← TTL=1h — در RAM/memory ذخیره کن
|
||
"refresh_token": "a8f3b2..." ← TTL=30d — در HttpOnly Cookie یا secure storage
|
||
}
|
||
|
||
[هر request محافظتشده]
|
||
Authorization: Bearer eyJ... ← فقط access_token
|
||
|
||
[وقتی access_token منقضی — 401 دریافت شد]
|
||
POST /oauth/token/refresh
|
||
{ "refresh_token": "a8f3b2..." }
|
||
← Response: access_token جدید + refresh_token جدید (Rotation)
|
||
|
||
[خروج]
|
||
POST /oauth/logout
|
||
Authorization: Bearer eyJ...
|
||
{ "refresh_token": "a8f3b2..." }
|
||
← هر دو توکن باطل میشوند
|
||
```
|
||
|
||
---
|
||
|
||
## پیکربندی CORS — محدود، نه باز
|
||
|
||
```yaml
|
||
# config/packages/nelmio_cors.yaml
|
||
nelmio_cors:
|
||
defaults:
|
||
origin_regex: true
|
||
allow_origin:
|
||
- '%env(CORS_ALLOW_ORIGIN)%'
|
||
allow_methods: ['GET', 'OPTIONS', 'POST', 'PATCH', 'DELETE']
|
||
allow_headers: ['Content-Type', 'Authorization', 'X-CSRF-Token', 'Content-Disposition']
|
||
expose_headers: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset']
|
||
max_age: 3600
|
||
allow_credentials: false
|
||
paths:
|
||
'^/api/':
|
||
# ⚠️ هرگز '*' نگذار — فقط دامنههای مشخص
|
||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||
'^/oauth/':
|
||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||
'^/health':
|
||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||
```
|
||
|
||
```dotenv
|
||
# .env.local (production)
|
||
CORS_ALLOW_ORIGIN=^https://(app\.clinicpro\.ir|admin\.clinicpro\.ir)$
|
||
|
||
# .env (development)
|
||
CORS_ALLOW_ORIGIN=^https?://(localhost|.*\.ddev\.site)(:\d+)?$
|
||
```
|
||
|
||
> **⚠ هشدار:** هرگز `allow_origin: ['*']` در production استفاده نکن.
|
||
> این اجازه میدهد هر وبسایت مخرب درخواستهای authenticated ارسال کند.
|
||
|
||
---
|
||
|
||
## Security Headers (EventSubscriber)
|
||
|
||
```php
|
||
// src/Shared/EventSubscriber/SecurityHeadersSubscriber.php
|
||
class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||
{
|
||
public function onKernelResponse(ResponseEvent $event): void
|
||
{
|
||
if (!$event->isMainRequest()) return;
|
||
|
||
$response = $event->getResponse();
|
||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||
$response->headers->set('X-Frame-Options', 'DENY');
|
||
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||
|
||
if ($event->getRequest()->isSecure()) {
|
||
$response->headers->set(
|
||
'Strict-Transport-Security',
|
||
'max-age=31536000; includeSubDomains'
|
||
);
|
||
}
|
||
|
||
// برای API responses، Content-Security-Policy محدود
|
||
if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
|
||
$response->headers->set('Content-Security-Policy', "default-src 'none'");
|
||
}
|
||
}
|
||
|
||
public static function getSubscribedEvents(): array
|
||
{
|
||
return [KernelEvents::RESPONSE => 'onKernelResponse'];
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Swagger UI — فقط در محیط Dev
|
||
|
||
```yaml
|
||
# config/packages/nelmio_api_doc.yaml
|
||
when@prod:
|
||
nelmio_api_doc:
|
||
# در production کاملاً غیرفعال میشود
|
||
# route ها به /api/doc باید در routing فقط برای dev تعریف شوند
|
||
```
|
||
|
||
```yaml
|
||
# config/routes/nelmio_api_doc.yaml
|
||
when@dev:
|
||
app.swagger_ui:
|
||
path: /api/doc
|
||
methods: GET
|
||
defaults:
|
||
_controller: nelmio_api_doc.controller.swagger_ui
|
||
|
||
app.swagger_json:
|
||
path: /api/doc.json
|
||
methods: GET
|
||
defaults:
|
||
_controller: nelmio_api_doc.controller.swagger
|
||
```
|
||
|
||
> **⚠** با این روش، در production هیچ route ای برای `/api/doc` وجود ندارد → 404
|
||
|
||
---
|
||
|
||
## Audit Log — جدول security_logs
|
||
|
||
برای رویدادهای امنیتی حساس، یک جدول جداگانه وجود دارد:
|
||
|
||
```sql
|
||
CREATE TABLE security_logs (
|
||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
event_type VARCHAR(50) NOT NULL, -- 'otp_failed', 'login_success', 'role_changed', 'payment_verified', ...
|
||
user_id INT NULL,
|
||
ip_address VARCHAR(45) NOT NULL,
|
||
user_agent VARCHAR(255) NULL,
|
||
details JSON NULL, -- اطلاعات اضافه (بدون data حساس!)
|
||
created_at INT NOT NULL
|
||
);
|
||
|
||
CREATE INDEX idx_sec_logs_event ON security_logs(event_type);
|
||
CREATE INDEX idx_sec_logs_user ON security_logs(user_id);
|
||
CREATE INDEX idx_sec_logs_created ON security_logs(created_at);
|
||
```
|
||
|
||
**رویدادهایی که باید log شوند:**
|
||
```
|
||
otp_sent — ارسال OTP (mobile ماسکشده: 0912***1713)
|
||
otp_failed — کد اشتباه
|
||
otp_expired — کد منقضی
|
||
login_success — ورود موفق
|
||
login_failed — ورود ناموفق
|
||
logout — خروج
|
||
token_refreshed — Refresh Token استفاده شد
|
||
role_changed — تغییر نقش کاربر
|
||
user_deleted — حذف کاربر
|
||
payment_initiated — شروع پرداخت
|
||
payment_verified — تأیید پرداخت
|
||
payment_failed — پرداخت ناموفق
|
||
file_uploaded — آپلود فایل
|
||
```
|
||
|
||
> **⚠ دادههای حساس را log نکن:** شماره کامل موبایل، کد OTP، شماره کارت، JWT.
|
||
|
||
---
|
||
|
||
## نکات DDEV
|
||
|
||
```bash
|
||
ddev exec php bin/console ...
|
||
ddev composer ...
|
||
ddev describe # مشاهده آدرسها و پورتها
|
||
ddev ssh # ورود به container
|
||
```
|
||
|
||
URL پروژه: `https://clinic-pro.ddev.site`
|
||
|
||
---
|
||
|
||
## نکات امنیتی Production
|
||
|
||
```
|
||
✅ هرگز config/jwt/private.pem را در git commit نکن (.gitignore)
|
||
✅ JWT_PASSPHRASE را قوی انتخاب کن (حداقل 32 کاراکتر تصادفی)
|
||
✅ APP_SECRET را با openssl rand -hex 32 تولید کن
|
||
✅ .env.local برای production (نه .env)
|
||
✅ CORS_ALLOW_ORIGIN فقط دامنههای مشخص (نه *)
|
||
✅ Swagger UI فقط در dev فعال است
|
||
✅ APP_DEBUG=false در production
|
||
```
|