feat: enhance security by implementing HttpOnly refresh tokens and in-memory access token management

- Added isomorphic-dompurify for improved XSS protection
- Refactored token storage to use in-memory management for access tokens
- Implemented server-side route handlers for OAuth token management
- Introduced security headers in next.config.js
- Removed client-side exposure of client_secret and sensitive tokens
- Updated API interceptors to handle token refresh logic
- Cleaned up cookie management for refresh tokens
This commit is contained in:
hamed
2026-06-20 13:10:17 +03:30
parent a19058d9a2
commit 194ffd889c
29 changed files with 1007 additions and 190 deletions
@@ -0,0 +1,179 @@
# سخت‌سازی امنیتی سایت عمومی: ذخیره‌ی توکن، XSS، هدرها، client_secret
## پروژه
`nobat724_front` (سایت عمومی). این پرامپت **cross-repo** است — پرامپت همتای backend: `clinicpro/.claude/prompt/fix-auth-token-hardening.md` که **اول** باید اجرا شود (قرارداد توکن را عوض می‌کند: `oauth/token` به‌جای `uuid` فیلد `grant` می‌گیرد و `verify-code` فیلد `grant` برمی‌گرداند).
مرجع: گزارش امنیتی این session (OWASP Top 10). یافته‌های frontend: **C-2 (Critical)**، **C-3 (Critical)**، **H-2 (High)**، **H-3 (High)**.
## زمینه
ممیزی امنیتی این مشکلات را در سایت عمومی پیدا کرد:
- **C-2:** `access_token` و `refresh_token` با `js-cookie` ست می‌شوند (`Cookies.set(...)`) — یعنی **غیر HttpOnly، بدون Secure، بدون SameSite**؛ هر اسکریپتی می‌تواند بخواندشان. با XSS → سرقت کامل توکن.
- **C-3:** بدنه‌ی بلاگ/کلینیک/پزشک با `dangerouslySetInnerHTML={{ __html: sanitizeHtml(...) }}` رندر می‌شود، اما `lib/sanitize.js` یک sanitizer **regex دستی و قابل دور زدن** است (مثلاً `<img src=x onerror=...>` چون `<img>` در لیست خطرناک نیست عبور می‌کند). → Stored XSS.
- **H-2:** `next.config.js` هیچ تابع `headers()` ندارد → بدون CSP/HSTS/X-Frame-Options/nosniff/Referrer-Policy/Permissions-Policy.
- **H-3:** توکن با `NEXT_PUBLIC_CLIENT_SECRET` گرفته می‌شود؛ هر مقدار `NEXT_PUBLIC_*` داخل bundle مرورگر inline و عمومی می‌شود.
## مشکل / هدف
refresh token را به کوکی **HttpOnly سمت سرور** ببر و توکن‌گیری/OAuth را در یک Route Handler سرور-ساید انجام بده (تا `client_secret` و `refresh_token` هرگز به مرورگر نروند)؛ sanitizer را با **DOMPurify** عوض کن؛ security headers را در `next.config.js` اضافه کن.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `services/api.js` | interceptor — خواندن `access_token` از کوکی |
| `services/response.js` | `getToken` که `client_secret` می‌فرستد |
| `components/register/verificationPage/SendReq.js` | ست‌کردن کوکی‌ها بعد از لاگین (`Cookies.set`) |
| `components/appointment/detail/SubmitData.js` | `Cookies.set("access_token"/"refresh_token")` |
| `components/dashboard/userAccount/.../ButtonSendData.js` | `Cookies.set("access_token")` |
| `lib/sanitize.js` | sanitizer regex فعلی |
| `lib/req.js` / `services/clinicApi.js` | فراخوانی‌های مصرف‌کننده‌ی توکن جدید |
| `app/api/auth/token/route.js` (جدید) | Route Handler سرور-ساید برای OAuth + ست کوکی HttpOnly |
| `next.config.js` | افزودن `headers()` |
## وضعیت فعلی (کد واقعی)
`services/response.js``client_secret` در درخواست:
```js
getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => {
// ...
formData.append("client_secret", client_secret);
// ...
}
```
`components/register/verificationPage/SendReq.js` (و مشابه در SubmitData.js):
```js
Cookies.set("access_token", response.access_token, cookieOptions);
Cookies.set("refresh_token", response.refresh_token, { expires: 7 });
```
`services/api.js` interceptor:
```js
if (config.requireAuth) {
const token = Cookies.get("access_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
}
```
`lib/sanitize.js` — regex قابل دور زدن:
```js
const DANGEROUS_TAGS = ['script','iframe','object','embed','link','meta','base','form'];
export function sanitizeHtml(html) {
let sanitized = html;
DANGEROUS_TAGS.forEach((tag) => { /* regex replace */ });
sanitized = sanitized.replace(/\s+on\w+\s*=\s*.../gi, '');
return sanitized;
}
```
`next.config.js` — بدون `headers()`.
## وظایف
### ۱. OAuth + ست کوکی در یک Route Handler سرور-ساید (C-2, H-3)
یک Route Handler بساز: `app/api/auth/token/route.js`. این هندلر:
- ورودی `{ grant }` (مطابق قرارداد جدید backend) را از body می‌گیرد.
- توکن را با `process.env.CLIENT_SECRET` (**بدون** `NEXT_PUBLIC`) و `process.env.CLIENT_ID` از backend (`oauth/token`) می‌گیرد.
- `refresh_token` را در کوکی **HttpOnly; Secure; SameSite=Lax; path=/** ست می‌کند.
- `access_token` را در پاسخ JSON برمی‌گرداند تا کلاینت در **memory** نگهش دارد (نه کوکی، نه localStorage).
```js
import { cookies } from 'next/headers';
import axios from 'axios';
export async function POST(req) {
const { grant } = await req.json();
const res = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/oauth/token`, {
grant_type: 'mobile',
grant,
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
});
const { access_token, refresh_token, expires_in } = res.data;
const jar = cookies();
jar.set('refresh_token', refresh_token, {
httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30,
});
return Response.json({ access_token, expires_in });
}
```
یک Route Handler refresh هم بساز (`app/api/auth/refresh/route.js`) که `refresh_token` را از کوکی HttpOnly می‌خواند، `oauth/token/refresh` را صدا می‌زند، کوکی جدید ست می‌کند و access token تازه برمی‌گرداند. و `app/api/auth/logout/route.js` که کوکی را پاک و `oauth/logout` را صدا می‌زند.
> `services/response.js::getToken` که `client_secret` سمت کلاینت می‌فرستد را **حذف/جایگزین** کن با فراخوانی این Route Handler. هیچ `NEXT_PUBLIC_CLIENT_SECRET` در کد کلاینت نماند.
### ۲. مدیریت access token در memory به‌جای کوکی JS (C-2)
- همه‌ی `Cookies.set("access_token", ...)` و `Cookies.set("refresh_token", ...)` را حذف کن (SendReq.js, SubmitData.js, ButtonSendData.js و هرجای دیگر).
- access token را در یک ماژول in-memory نگه‌دار (مثلاً `lib/tokenStore.js` با یک متغیر و getter/setter، یا context). در `services/api.js` interceptor به‌جای `Cookies.get("access_token")` از این store بخوان.
- روی 401، interceptor اول `app/api/auth/refresh` را امتحان کند؛ اگر شکست خورد، logout و redirect.
- چون access token در memory با refresh صفحه پاک می‌شود، در bootstrap اپ (مثلاً یک Provider بالای درخت) یک‌بار `app/api/auth/refresh` صدا بزن تا از روی کوکی HttpOnly، access token تازه بگیری.
> کوکی‌های `uuid`/`userInfo` که حساس نیستند می‌توانند بمانند ولی `userInfo` را به فیلدهای غیرحساس محدود کن.
### ۳. جایگزینی sanitizer با DOMPurify (C-3)
`isomorphic-dompurify` را نصب و `lib/sanitize.js` را بازنویسی کن (امضای `sanitizeHtml` حفظ شود تا `Caption.js`/`TextDetail.js`/`blog`/`clinic`/`doctor` تغییر نکنند):
```js
import DOMPurify from 'isomorphic-dompurify';
export function sanitizeHtml(html) {
if (!html || typeof html !== 'string') return '';
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p','br','strong','em','b','i','u','ul','ol','li','a','h2','h3','h4','blockquote','img','span','table','thead','tbody','tr','td','th'],
ALLOWED_ATTR: ['href','target','rel','src','alt','title'],
ALLOW_DATA_ATTR: false,
});
}
// safeJsonParse را همان‌طور که هست نگه دار
```
> `isomorphic-dompurify` در SSR (Server Component) و کلاینت هر دو کار می‌کند — مهم، چون این صفحات SSR هستند.
### ۴. Security headers در next.config.js (H-2)
تابع `headers()` به `nextConfig` اضافه کن:
```js
async headers() {
return [{
source: '/(.*)',
headers: [
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'Content-Security-Policy', value: [
"default-src 'self'",
"img-src 'self' https: data:",
"script-src 'self' 'unsafe-inline'", // اگر JSON-LD/Next نیاز داشت؛ در صورت امکان nonce
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"connect-src 'self' https://api.clinic-pro.ir",
"frame-ancestors 'none'",
"object-src 'none'",
"base-uri 'self'",
].join('; ') },
],
}];
},
```
> CSP را با build و مرور صفحات اصلی (home/doctor/clinic/blog/panel) تست کن؛ اگر چیزی بلاک شد (MUI inline style, JSON-LD)، با `'unsafe-inline'` فقط برای style یا nonce برای script حلش کن — نه باز کردن کامل.
## نکات مهم
- **ترتیب:** اول پرامپت backend اجرا شود (قرارداد `grant`)، بعد این. تا قبل از آن، `oauth/token` همچنان `uuid` می‌خواهد؛ این پرامپت بر اساس قرارداد جدید (`grant`) نوشته شده.
- App Router؛ Route Handlerها سرور-ساید هستند و به `process.env.CLIENT_SECRET` دسترسی دارند بدون افشا به مرورگر.
- multi-domain را نشکن: `domain` کوکی را مثل کد فعلی بر اساس hostname ست کن (برای کوکی HttpOnly در Route Handler هم همان منطق domain را اعمال کن تا روی ساب‌دامین‌های شهرها کار کند).
- بعد از تغییر env، مطمئن شو `CLIENT_SECRET` و `CLIENT_ID` (بدون `NEXT_PUBLIC`) در محیط deploy ست شده‌اند (در `docker-compose.yml` از قبل `CLIENT_SECRET`/`CLIENT_ID` تعریف شده).
- تست:
- `npm run build` بدون خطا.
- جریان لاگین کامل: OTP → access token در memory، refresh در کوکی HttpOnly (در DevTools → Application → Cookies باید `HttpOnly` ✓ و `Secure` ✓ باشد).
- refresh صفحه → اپ از کوکی HttpOnly دوباره access token می‌گیرد و کاربر لاگین می‌ماند.
- در DevTools Console: `document.cookie` نباید `access_token`/`refresh_token` نشان دهد.
- بدنه‌ی بلاگ با payload تست `<img src=x onerror=alert(1)>` رندر شود ولی اجرا نشود (DOMPurify پاکش کند).
- هدرهای امنیتی در Network tab روی پاسخ صفحات دیده شوند.
- در bundle مرورگر (`.next/static`) رشته‌ی `client_secret` یا مقدار آن نباشد.
+13
View File
@@ -2,10 +2,23 @@
import { ThemeProvider } from "next-themes";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import Cookies from "js-cookie";
import { setAccessToken } from "@/lib/tokenStore";
export function Providers({ children }) {
const router = usePathname();
useEffect(() => {
if (!Cookies.get("userInfo")) return;
fetch("/api/auth/refresh", { method: "POST" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.access_token) setAccessToken(data.access_token);
})
.catch(() => {});
}, []);
return (
<ThemeProvider
attribute={router.includes("/panel") ? "class" : "data-"}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { axiosInstance } from "@/lib/req";
import { clearRefreshCookie, COOKIE_NAME } from "@/lib/refreshCookie";
export async function POST(request) {
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const host = request.headers.get("host");
const refreshToken = request.cookies.get(COOKIE_NAME)?.value;
if (refreshToken) {
try {
await axiosInstance.post(
`${API_URL}/oauth/logout`,
{ refresh_token: refreshToken },
{ headers: { "Content-Type": "application/json", Authorization: "" } }
);
} catch {
// revoke best-effort; clearing the cookie below is what matters
}
}
const response = NextResponse.json({ success: true }, { status: 200 });
clearRefreshCookie(response, host);
return response;
}
+19 -13
View File
@@ -1,28 +1,34 @@
import { NextResponse } from "next/server";
import { axiosInstance } from "@/lib/req";
import { setRefreshCookie, clearRefreshCookie, COOKIE_NAME } from "@/lib/refreshCookie";
export async function POST(request) {
const { refresh_token } = await request.json();
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const host = request.headers.get("host");
const refreshToken = request.cookies.get(COOKIE_NAME)?.value;
if (!refresh_token) {
return NextResponse.json({ error: "refresh_token is required" }, { status: 400 });
if (!refreshToken) {
return NextResponse.json({ error: "no refresh token" }, { status: 401 });
}
try {
const res = await axiosInstance.post(
`${API_URL}/oauth/token/refresh`,
{ refresh_token },
{
headers: { "Content-Type": "application/json", Authorization: "" },
}
{ refresh_token: refreshToken },
{ headers: { "Content-Type": "application/json", Authorization: "" } }
);
return NextResponse.json(res.data, { status: res.status });
const { access_token, refresh_token, expires_in } = res.data;
const response = NextResponse.json({ access_token, expires_in }, { status: 200 });
setRefreshCookie(response, refresh_token, host);
return response;
} catch (error) {
if (error.response) {
return NextResponse.json(error.response.data, { status: error.response.status });
}
return NextResponse.json({ error: "Refresh request failed" }, { status: 500 });
const response = NextResponse.json(
error.response?.data ?? { error: "Refresh request failed" },
{ status: 401 }
);
clearRefreshCookie(response, host);
return response;
}
}
+15 -7
View File
@@ -1,9 +1,9 @@
import { NextResponse } from "next/server";
import { axiosInstance } from "@/lib/req";
import { setRefreshCookie } from "@/lib/refreshCookie";
export async function POST(request) {
const { uuid, code } = await request.json();
const API_URL = process.env.NEXT_PUBLIC_API_URL;
if (!uuid || !code) {
@@ -13,20 +13,28 @@ export async function POST(request) {
const jsonHeaders = { "Content-Type": "application/json", Authorization: "" };
try {
// مرحله ۱: تأیید OTP — بدون این، oauth/token کد را verified نمی‌بیند
await axiosInstance.post(
const verify = await axiosInstance.post(
`${API_URL}/api/v1/user/verify-code`,
{ uuid, code },
{ headers: jsonHeaders }
);
// مرحله ۲: تبدیل uuid تأییدشده به توکن
const res = await axiosInstance.post(
const grant = verify.data?.data?.grant;
if (!grant) {
return NextResponse.json({ error: "grant not issued" }, { status: 400 });
}
const token = await axiosInstance.post(
`${API_URL}/oauth/token`,
{ grant_type: "mobile", uuid },
{ grant_type: "mobile", grant },
{ headers: jsonHeaders }
);
return NextResponse.json(res.data, { status: res.status });
const { access_token, refresh_token, expires_in } = token.data;
const response = NextResponse.json({ access_token, expires_in }, { status: 200 });
setRefreshCookie(response, refresh_token, request.headers.get("host"));
return response;
} catch (error) {
if (error.response) {
return NextResponse.json(error.response.data, { status: error.response.status });
+6 -1
View File
@@ -8,8 +8,13 @@ import { useState } from "react";
function ModalLogout({ open, handleClose }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const logout = () => {
const logout = async () => {
setLoading(true);
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch {
// clearing client state below is what matters
}
removeToken();
router.replace("/login");
};
+1 -1
View File
@@ -4,7 +4,7 @@ import Cookies from "js-cookie";
function SendAppo({ hour, setStep, setSelectedSlot, date, setSelectedDate }) {
const sendReq = () => {
const isLogged = Cookies.get("access_token");
const isLogged = Cookies.get("userInfo");
// ذخیره کردن اطلاعات اسلات انتخاب شده
if (hour && date) {
+5 -4
View File
@@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
import { fetchReq } from "@/lib/req";
import { safeJsonParse } from "@/lib/sanitize";
import { buildPatientUser } from "@/lib/representationAdapters";
import { getServerAccessToken } from "@/lib/serverToken";
export default async function Dashboard({ searchParams }) {
const awaitedSearchParams = await searchParams;
@@ -16,9 +17,9 @@ export default async function Dashboard({ searchParams }) {
const ability = defineAbilitiesFor(user);
const cookieStore = await cookies();
const token = cookieStore.get("access_token");
const token = await getServerAccessToken();
if (!ability.can("access", "Dashboard")) {
if (!ability.can("access", "Dashboard") || !token) {
removeToken();
return redirect("/login");
}
@@ -27,7 +28,7 @@ export default async function Dashboard({ searchParams }) {
// may still carry the OTP uuid, which 404s against user-profile.
const userInfo = safeJsonParse(cookieStore.get("userInfo")?.value);
const userUuid = userInfo?.uuid || cookieStore.get("uuid")?.value;
const authHeader = { headers: { Authorization: `Bearer ${token.value}` } };
const authHeader = { headers: { Authorization: `Bearer ${token}` } };
const API = process.env.NEXT_PUBLIC_API_URL;
const [profile, appointmentsRes, paymentsRes] = await Promise.all([
@@ -54,7 +55,7 @@ export default async function Dashboard({ searchParams }) {
return (
<Content
logged={token.value}
logged={true}
params={awaitedSearchParams}
matchedCity={matchedCity}
user={buildPatientUser(profile, extras)}
+12 -9
View File
@@ -3,6 +3,7 @@ import { cookies } from "next/headers";
import { fetchReq } from "@/lib/req";
import { safeJsonParse } from "@/lib/sanitize";
import { adaptRepresentationDashboard } from "@/lib/representationAdapters";
import { getServerAccessToken } from "@/lib/serverToken";
export const dynamic = "force-dynamic";
@@ -13,21 +14,23 @@ async function Dashboard() {
try {
const cookieStore = await cookies();
const userInfo = cookieStore.get("userInfo");
const accessToken = cookieStore.get("access_token");
if (userInfo && accessToken) {
if (userInfo) {
const parsedUserInfo = safeJsonParse(userInfo.value);
const representationUuid = parsedUserInfo?.representation_uuid;
if (representationUuid) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const accessToken = await getServerAccessToken();
if (accessToken) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
dashboard = await fetchReq(
`${API_URL}/api/v1/representation/${representationUuid}/dashboard/monthly?year=${year}&month=${month}`,
{ headers: { Authorization: `Bearer ${accessToken.value}` } }
);
dashboard = await fetchReq(
`${API_URL}/api/v1/representation/${representationUuid}/dashboard/monthly?year=${year}&month=${month}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
}
}
}
} catch (error) {
+13 -10
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { fetchReq } from "@/lib/req";
import { safeJsonParse } from "@/lib/sanitize";
import { getServerAccessToken } from "@/lib/serverToken";
async function LayoutPanel({ children }) {
const user = await getUser();
@@ -20,22 +21,24 @@ async function LayoutPanel({ children }) {
try {
const cookieStore = await cookies();
const userInfo = cookieStore.get("userInfo");
const accessToken = cookieStore.get("access_token");
if (userInfo && accessToken) {
if (userInfo) {
const parsedUserInfo = safeJsonParse(userInfo.value);
if (!parsedUserInfo) return;
const representationUuid = parsedUserInfo?.representation_uuid;
if (representationUuid) {
representationInfo = await fetchReq(
`${API_URL}/api/v1/representation/${representationUuid}`,
{
headers: {
Authorization: `Bearer ${accessToken.value}`,
},
}
);
const accessToken = await getServerAccessToken();
if (accessToken) {
representationInfo = await fetchReq(
`${API_URL}/api/v1/representation/${representationUuid}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
}
}
}
} catch (error) {
+12 -9
View File
@@ -3,6 +3,7 @@ import { cookies } from "next/headers";
import { fetchReq } from "@/lib/req";
import { safeJsonParse } from "@/lib/sanitize";
import { adaptRepresentationDashboard } from "@/lib/representationAdapters";
import { getServerAccessToken } from "@/lib/serverToken";
export const dynamic = "force-dynamic";
@@ -13,21 +14,23 @@ async function page() {
try {
const cookieStore = await cookies();
const userInfo = cookieStore.get("userInfo");
const accessToken = cookieStore.get("access_token");
if (userInfo && accessToken) {
if (userInfo) {
const parsedUserInfo = safeJsonParse(userInfo.value);
const representationUuid = parsedUserInfo?.representation_uuid;
if (representationUuid) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const accessToken = await getServerAccessToken();
if (accessToken) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
dashboard = await fetchReq(
`${API_URL}/api/v1/representation/${representationUuid}/dashboard/monthly?year=${year}&month=${month}`,
{ headers: { Authorization: `Bearer ${accessToken.value}` } }
);
dashboard = await fetchReq(
`${API_URL}/api/v1/representation/${representationUuid}/dashboard/monthly?year=${year}&month=${month}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
}
}
}
} catch (error) {
+9 -6
View File
@@ -3,6 +3,7 @@ import { cookies } from "next/headers";
import { fetchReq } from "@/lib/req";
import { safeJsonParse } from "@/lib/sanitize";
import { adaptBankAccount } from "@/lib/representationAdapters";
import { getServerAccessToken } from "@/lib/serverToken";
export const dynamic = "force-dynamic";
@@ -13,17 +14,19 @@ async function UserAccount() {
try {
const cookieStore = await cookies();
const userInfo = cookieStore.get("userInfo");
const accessToken = cookieStore.get("access_token");
if (userInfo && accessToken) {
if (userInfo) {
const parsedUserInfo = safeJsonParse(userInfo.value);
const representationUuid = parsedUserInfo?.representation_uuid;
if (representationUuid) {
representation = await fetchReq(
`${API_URL}/api/v1/representation/${representationUuid}`,
{ headers: { Authorization: `Bearer ${accessToken.value}` } }
);
const accessToken = await getServerAccessToken();
if (accessToken) {
representation = await fetchReq(
`${API_URL}/api/v1/representation/${representationUuid}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
}
}
}
} catch (error) {
+4 -12
View File
@@ -4,6 +4,7 @@ import { Button } from "@mui/material";
import ArrowLeftB from "@/components/icons/ArrowLeftB";
import { request } from "@/services/response";
import Cookies from "js-cookie";
import { setAccessToken } from "@/lib/tokenStore";
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt }) {
const [loading, setLoading] = useState(false);
@@ -11,21 +12,12 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother,
const refreshAccessToken = async () => {
try {
const refreshToken = Cookies.get("refresh_token");
if (!refreshToken) return false;
const res = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
const res = await fetch("/api/auth/refresh", { method: "POST" });
if (!res.ok) return false;
const response = await res.json();
if (response?.access_token) {
Cookies.set("access_token", response.access_token, { expires: 7 });
if (response.refresh_token) {
Cookies.set("refresh_token", response.refresh_token, { expires: 7 });
}
setAccessToken(response.access_token);
return true;
}
return false;
@@ -12,7 +12,6 @@ import Surgeries from "./surgeries";
import FamilyHistory from "./familyHistory";
import Relatives from "./relatives";
import { removeAdditionalKeysDashboard } from "@/helper";
import Cookies from "js-cookie";
import { toast } from "react-toastify";
import LoadingComponent from "@/app/component/LoadingComponent";
@@ -37,11 +36,7 @@ function DetailUser({ user, isTurnsDetails, setIsTurnsDetails }) {
const usedKays = removeAdditionalKeysDashboard(information);
setLoading(true);
request
.patchUserProfile(
usedKays,
information?.uuid,
Cookies.get("access_token")
)
.patchUserProfile(usedKays, information?.uuid)
.then(() => {
setLoading(false);
toast.success("اطلاعات با موفقیت ثبت شد");
@@ -1,14 +1,13 @@
import ArrowLeftWhiteD from "@/components/icons/ArrowLeftWhiteD";
import {
changeDateType,
handleTimeExpiresToken,
isValidIranNationalCode,
removeAdditionalKeysDashboard,
} from "@/helper";
import { request } from "@/services/response";
import { Button, CircularProgress } from "@mui/material";
import Cookies from "js-cookie";
import { toast } from "react-toastify";
import { setAccessToken } from "@/lib/tokenStore";
function ButtonSendData({
loading,
@@ -31,21 +30,11 @@ function ButtonSendData({
.postUserProfile(changeDateType(information, true))
.then((response) => {
if (response.uuid) {
fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: Cookies.get("refresh_token") }),
})
.then((r) => r.json())
fetch("/api/auth/refresh", { method: "POST" })
.then((r) => (r.ok ? r.json() : null))
.then((res) => {
if (res?.access_token) {
const expiresTime = handleTimeExpiresToken(res.expires_in);
Cookies.set("access_token", res.access_token, {
expires: expiresTime.accessTokenExpires,
path: "/",
secure: true,
sameSite: "strict",
});
setAccessToken(res.access_token);
}
setInformation({
...information,
@@ -69,11 +58,7 @@ function ButtonSendData({
const usedKays = removeAdditionalKeysDashboard(information);
request
.patchUserProfile(
changeDateType(usedKays, true),
information?.uuid,
Cookies.get("access_token")
)
.patchUserProfile(changeDateType(usedKays, true), information?.uuid)
.then(() => {
setLoading(false);
toast.success("اطلاعات با موفقیت ثبت شد");
+2 -2
View File
@@ -6,14 +6,14 @@ import { cookies } from "next/headers";
async function StLayout({ children, name }) {
const { matchedCity } = await getStateInfo();
const cookieStore = await cookies();
const token = cookieStore.get("access_token");
const isLogged = Boolean(cookieStore.get("userInfo"));
return (
<div>
<main className="w-full min-h-screen">
<header className="w-full sticky bg-[#FFF] !z-[70] shadow-[0px_1px_24.8px_0px_rgba(204,_204,_204,_0.18)] mt-[12px] sm:mt-[21px] md:mt-[30px] lg:mt-[40px] top-0 right-1/2">
<div className="w-full padding-responsive">
<Header matchedCity={matchedCity} name={name} logged={token} />
<Header matchedCity={matchedCity} name={name} logged={isLogged} />
</div>
</header>
<section className="opacity-page -mt-[calc(48px_+_12px)] sm:-mt-[calc(56px_+_21px)] md:-mt-[calc(64px_+_30px)] lg:-mt-[calc(80px_+_40px)]">
+1 -1
View File
@@ -18,7 +18,7 @@ function Content({ matchedCity, name, logged }) {
const pathname = usePathname();
useEffect(() => {
setIsLogged(Boolean(Cookies.get("access_token")));
setIsLogged(Boolean(Cookies.get("userInfo")));
}, [pathname]);
return (
@@ -3,6 +3,7 @@ import { request } from "@/services/response";
import Cookies from "js-cookie";
import { toast } from "react-toastify";
import { handleTimeExpiresToken } from "@/helper";
import { setAccessToken } from "@/lib/tokenStore";
function SendReq({
loading,
@@ -44,14 +45,13 @@ function SendReq({
const isHttps = window.location.protocol === "https:";
const isLocalhost = hostname.includes("localhost");
// تنظیمات پایه
// فقط داده‌های غیرحساس در کوکی JS؛ access_token در memory، refresh_token در کوکی HttpOnly سرور
let cookieOptions = {
path: "/",
sameSite: "lax",
expires: expiresTime.accessTokenExpires,
expires: expiresTime.refreshTokenExpires,
};
// اگر روی دامنه اصلی و https بود
if (!isLocalhost && isHttps) {
cookieOptions = {
...cookieOptions,
@@ -60,17 +60,8 @@ function SendReq({
};
}
Cookies.set("access_token", response.access_token, cookieOptions);
Cookies.set("refresh_token", response.refresh_token, {
...cookieOptions,
expires: expiresTime.refreshTokenExpires,
});
Cookies.set("uuid", uuid, {
...cookieOptions,
expires: expiresTime.refreshTokenExpires,
});
setAccessToken(response.access_token);
Cookies.set("uuid", uuid, cookieOptions);
getInfo(response.access_token, cookieOptions);
};
+4 -7
View File
@@ -2,11 +2,8 @@ import { cookies } from "next/headers";
export async function getUser() {
const cookieStore = await cookies();
const raw =
cookieStore.get("access_token") &&
cookieStore.get("refresh_token") &&
cookieStore.get("uuid") &&
cookieStore.get("userInfo");
if (!raw) return null;
return raw;
const isAuthenticated =
cookieStore.get("refresh_token") && cookieStore.get("userInfo");
if (!isAuthenticated) return null;
return cookieStore.get("userInfo");
}
+34
View File
@@ -0,0 +1,34 @@
const COOKIE_NAME = "refresh_token";
function cookieDomain(host) {
if (!host) return undefined;
const hostname = host.split(":")[0];
if (hostname.includes("localhost") || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return undefined;
}
return "." + hostname.split(".").slice(-2).join(".");
}
export function setRefreshCookie(response, refreshToken, host, maxAge = 60 * 60 * 24 * 30) {
response.cookies.set(COOKIE_NAME, refreshToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge,
domain: cookieDomain(host),
});
}
export function clearRefreshCookie(response, host) {
response.cookies.set(COOKIE_NAME, "", {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 0,
domain: cookieDomain(host),
});
}
export { COOKIE_NAME };
+10 -12
View File
@@ -1,19 +1,17 @@
const DANGEROUS_TAGS = ['script', 'iframe', 'object', 'embed', 'link', 'meta', 'base', 'form'];
import DOMPurify from "isomorphic-dompurify";
export function sanitizeHtml(html) {
if (!html || typeof html !== 'string') return '';
if (!html || typeof html !== "string") return "";
let sanitized = html;
DANGEROUS_TAGS.forEach((tag) => {
const openClose = new RegExp(`<${tag}[\\s\\S]*?(?:<\\/${tag}>|/?>)`, 'gi');
sanitized = sanitized.replace(openClose, '');
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
"p", "br", "strong", "em", "b", "i", "u", "ul", "ol", "li", "a",
"h2", "h3", "h4", "h5", "blockquote", "img", "span", "div",
"table", "thead", "tbody", "tr", "td", "th",
],
ALLOWED_ATTR: ["href", "target", "rel", "src", "alt", "title"],
ALLOW_DATA_ATTR: false,
});
sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
sanitized = sanitized.replace(/(?:javascript|vbscript):/gi, '');
return sanitized;
}
export function safeJsonParse(str, fallback = null) {
+19
View File
@@ -0,0 +1,19 @@
import { cookies } from "next/headers";
import { axiosInstance } from "@/lib/req";
export async function getServerAccessToken() {
const cookieStore = await cookies();
const refreshToken = cookieStore.get("refresh_token")?.value;
if (!refreshToken) return null;
try {
const res = await axiosInstance.post(
`${process.env.NEXT_PUBLIC_API_URL}/oauth/token/refresh`,
{ refresh_token: refreshToken },
{ headers: { "Content-Type": "application/json", Authorization: "" } }
);
return res.data?.access_token ?? null;
} catch {
return null;
}
}
+13
View File
@@ -0,0 +1,13 @@
let accessToken = null;
export function getAccessToken() {
return accessToken;
}
export function setAccessToken(token) {
accessToken = token || null;
}
export function clearAccessToken() {
accessToken = null;
}
+28
View File
@@ -40,6 +40,34 @@ const nextConfig = {
poweredByHeader: false,
// Enable standalone output for Docker
output: 'standalone',
async headers() {
const csp = [
"default-src 'self'",
"img-src 'self' https: data: blob:",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"connect-src 'self' https://api.clinic-pro.ir https://back-dev.clinic-pro.ir",
"frame-ancestors 'none'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ');
return [
{
source: '/(.*)',
headers: [
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'Content-Security-Policy', value: csp },
],
},
];
},
};
module.exports = nextConfig;
+511 -4
View File
@@ -26,6 +26,7 @@
"dayjs": "^1.11.19",
"html2canvas": "^1.4.1",
"i": "^0.3.7",
"isomorphic-dompurify": "^3.18.0",
"jalaali-js": "^1.2.8",
"jalali-moment": "^3.3.11",
"js-cookie": "^3.0.5",
@@ -68,6 +69,53 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -207,6 +255,18 @@
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@casl/ability": {
"version": "6.7.3",
"resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.3.tgz",
@@ -229,6 +289,140 @@
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz",
"integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
"integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
@@ -404,6 +598,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
@@ -1978,6 +2189,15 @@
],
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2368,6 +2588,19 @@
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2510,6 +2743,19 @@
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
@@ -2552,6 +2798,12 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@@ -2620,11 +2872,10 @@
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -2649,6 +2900,18 @@
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -3066,6 +3329,18 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
@@ -3283,6 +3558,12 @@
"node": ">=0.12.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
@@ -3302,6 +3583,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/isomorphic-dompurify": {
"version": "3.18.0",
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.18.0.tgz",
"integrity": "sha512-ajp0D8laIHeoYlhBTevpE2HUhqWaqLXFk6K/wV3Ok8kDraBZpZsifwVWaY8IfJntMRIo1VSksgKV+lXyet9Q7A==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.4.11",
"jsdom": "^29.1.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
}
},
"node_modules/jalaali-js": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
@@ -3352,6 +3646,46 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -3479,6 +3813,15 @@
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/maplibre-gl": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.6.2.tgz",
@@ -3525,6 +3868,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5886,6 +6235,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6203,6 +6564,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -6428,6 +6798,15 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -6584,6 +6963,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -6988,6 +7379,12 @@
"node": ">= 4.7.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -7113,6 +7510,24 @@
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tldts": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz",
"integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.3"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz",
"integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -7125,6 +7540,30 @@
"node": ">=8.0"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -7149,6 +7588,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/uninstall": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/uninstall/-/uninstall-0.0.0.tgz",
@@ -7183,6 +7631,18 @@
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -7192,6 +7652,38 @@
"defaults": "^1.0.3"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7228,6 +7720,21 @@
"node": ">=8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+2 -1
View File
@@ -27,6 +27,7 @@
"dayjs": "^1.11.19",
"html2canvas": "^1.4.1",
"i": "^0.3.7",
"isomorphic-dompurify": "^3.18.0",
"jalaali-js": "^1.2.8",
"jalali-moment": "^3.3.11",
"js-cookie": "^3.0.5",
@@ -35,6 +36,7 @@
"next": "^15.5.7",
"next-themes": "^0.4.6",
"npm": "^11.7.0",
"postcss": "^8.5.6",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
@@ -49,7 +51,6 @@
"stylis-plugin-rtl": "^2.1.1",
"swiper": "^12.0.3",
"tailwindcss": "^3.4.19",
"postcss": "^8.5.6",
"uninstall": "^0.0.0"
},
"devDependencies": {
+54 -24
View File
@@ -1,6 +1,7 @@
import axios from "axios";
import Cookies from "js-cookie";
import { toast } from "react-toastify";
import { getAccessToken, setAccessToken, clearAccessToken } from "@/lib/tokenStore";
function extractErrorMessage(error) {
const errors = error?.response?.data?.errors;
@@ -17,48 +18,77 @@ const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
const api = axios.create({
baseURL: BASE_URL,
"X-CSRF-Token": "",
headers: {
"Content-Type": "application/json",
},
});
// اضافه کردن interceptor برای افزودن token به ریکوست‌هایی که نیاز به احراز هویت دارند
let refreshPromise = null;
async function refreshAccessToken() {
if (!refreshPromise) {
refreshPromise = fetch("/api/auth/refresh", { method: "POST" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const token = data?.access_token ?? null;
setAccessToken(token);
return token;
})
.catch(() => null)
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
function handleSessionExpired() {
clearAccessToken();
const hostname = window.location.hostname;
const domain = hostname.includes("localhost")
? undefined
: `.${hostname.split(".").slice(-2).join(".")}`;
const opts = { path: "/", ...(domain && { domain }) };
["uuid", "userInfo"].forEach((key) => Cookies.remove(key, opts));
window.location.href = "/login";
}
api.interceptors.request.use(
(config) => {
// فقط اگر requireAuth در config تنظیم شده باشد، token اضافه می‌شود
if (config.requireAuth) {
const token = Cookies.get("access_token");
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
(error) => Promise.reject(error)
);
api.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
if (typeof window !== "undefined") {
if (error?.response?.status === 401) {
const hostname = window.location.hostname;
const domain = hostname.includes("localhost")
? undefined
: `.${hostname.split(".").slice(-2).join(".")}`;
const opts = { path: "/", ...(domain && { domain }) };
["access_token", "refresh_token", "uuid", "userInfo"].forEach((key) => {
Cookies.remove(key, opts);
});
window.location.href = "/login";
} else if (!error?.config?.skipErrorToast) {
toast.error(extractErrorMessage(error));
(response) => response.data,
async (error) => {
const original = error?.config;
if (
error?.response?.status === 401 &&
original?.requireAuth &&
!original._retried &&
typeof window !== "undefined"
) {
original._retried = true;
const token = await refreshAccessToken();
if (token) {
original.headers = { ...original.headers, Authorization: `Bearer ${token}` };
return api(original);
}
handleSessionExpired();
return Promise.reject(error);
}
if (typeof window !== "undefined" && !error?.config?.skipErrorToast) {
toast.error(extractErrorMessage(error));
}
return Promise.reject(error);
}
-24
View File
@@ -11,30 +11,6 @@ export const request = {
},
removeTokenHead
),
getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => {
const formData = new URLSearchParams();
formData.append("grant_type", grant_type);
formData.append("client_id", client_id);
formData.append("client_secret", client_secret);
formData.append("uuid", uuid);
formData.append("code", code);
formData.append("scope", scope);
return api.post("oauth/token", formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "",
},
});
},
postRefreshToken: (formData) => {
return api.post("oauth/token", formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "",
},
});
},
getUserProfile: (uuid) => api.get(`/api/v1/user-profile/${uuid}`, { requireAuth: true }),
postUserProfile: (data) => api.post("api/v1/user-profile", data, { requireAuth: true }),
patchUserProfile: (data, uuid) =>
+4 -2
View File
@@ -1,10 +1,12 @@
import Cookies from "js-cookie";
import { clearAccessToken } from "@/lib/tokenStore";
const removeToken = () => {
Cookies.remove("access_token", { path: "/" });
Cookies.remove("refresh_token", { path: "/" });
Cookies.remove("uuid", { path: "/" });
Cookies.remove("userInfo", { path: "/" });
if (typeof window !== "undefined") {
clearAccessToken();
}
};
export { removeToken };