فراموش کردن رمز عبور یکی از رایجترین اتفاقات در هر سیستم کاربری است. تقریباً هر وبسایتی که امکان ثبتنام و ورود کاربران را دارد، به یک سیستم Password Reset یا «فراموشی رمز عبور» نیاز دارد.
اما پیادهسازی Password Reset فقط به این معنی نیست که یک لینک برای کاربر ارسال کنیم و اجازه دهیم رمز عبورش را تغییر دهد. اگر این سیستم به شکل نادرست طراحی شود، مهاجم میتواند با سرقت یا حدس Token، سوءاستفاده از لینک بازیابی، User Enumeration یا حملات مختلف، کنترل حساب کاربر را به دست بگیرد.
در این مقاله با PHP و PDO یک سیستم ساده اما اصولی برای فراموشی و بازیابی رمز عبور طراحی میکنیم و با مفاهیمی مثل Token تصادفی، Hash کردن Token، تاریخ انقضا، Token یکبارمصرف، password_hash()، Session و Rate Limiting آشنا میشویم.
Password Reset چیست؟
Password Reset فرآیندی است که به کاربر اجازه میدهد بدون دانستن رمز عبور فعلی، در صورت اثبات دسترسی به ایمیل یا روش بازیابی معتبر، یک رمز عبور جدید برای حساب خود تعیین کند.
فرآیند معمول به این شکل است:
کاربر رمز عبور را فراموش کرده است
↓
وارد کردن ایمیل
↓
تولید Token تصادفی
↓
ذخیره Hash توکن در دیتابیس
↓
ارسال لینک به ایمیل
↓
کاربر روی لینک کلیک میکند
↓
بررسی Token و انقضا
↓
دریافت رمز عبور جدید
↓
Hash کردن رمز جدید
↓
تغییر رمز عبور
↓
باطل کردن Token
↓
ابطال Sessionهای قبلی
نکته مهم این است که در این فرآیند، ما رمز عبور قبلی را بازیابی نمیکنیم.
# چرا نباید رمز عبور قبلی را بازیابی کنیم؟
رمزهای عبور نباید به شکل قابل بازیابی ذخیره شوند.
برای مثال، این طراحی کاملاً اشتباه است:
users
-----------------------------
id
email
password
اگر مقدار password شامل رمز واقعی کاربر باشد، با لو رفتن دیتابیس، رمز کاربران نیز لو میرود.
در طراحی امن، رمز عبور با تابعی مانند password_hash() ذخیره میشود:
$passwordHash = password_hash(
$password,
PASSWORD_DEFAULT
);
بنابراین سیستم باید به جای بازیابی رمز قبلی، به کاربر اجازه دهد یک رمز جدید تعیین کند.
# Password Reset امن چه مراحلی دارد؟
یک سیستم مناسب میتواند این مراحل را داشته باشد:
- کاربر ایمیل خود را وارد میکند.
- سیستم بررسی میکند آیا حسابی با این ایمیل وجود دارد یا خیر.
- در هر دو حالت، پیام عمومی یکسانی نمایش داده میشود.
- در صورت وجود حساب، یک Token امن تولید میشود.
- Hash توکن در دیتابیس ذخیره میشود.
- Token خام داخل لینک ایمیل قرار میگیرد.
- Token دارای تاریخ انقضا است.
- کاربر لینک را باز میکند.
- سیستم Token را بررسی میکند.
- کاربر رمز جدید وارد میکند.
- رمز جدید با
password_hash()ذخیره میشود. - Token بلافاصله باطل میشود.
- Sessionهای قبلی کاربر نیز بهتر است باطل شوند.
# چرا نباید وجود یا عدم وجود ایمیل را اعلام کنیم؟
فرض کنید کاربر ایمیل زیر را وارد کند:
test@example.com
اگر سیستم بگوید:
> این ایمیل در سیستم وجود ندارد.
مهاجم میتواند تعداد زیادی ایمیل را امتحان کند و متوجه شود کدام ایمیلها در سایت حساب کاربری دارند.
این حمله را User Enumeration مینامیم.
بهتر است در هر دو حالت یک پیام مشابه نمایش داده شود:
اگر حسابی با این ایمیل وجود داشته باشد،
لینک بازیابی رمز عبور برای شما ارسال خواهد شد.
بنابراین مهاجم نمیتواند از پاسخ سیستم متوجه شود که یک حساب وجود دارد یا خیر.
# ساخت جدول Password Reset
یکی از روشهای مناسب این است که Tokenهای بازیابی را در یک جدول جداگانه نگهداری کنیم.
برای مثال:
CREATE TABLE password_reset_tokens (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL,
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (user_id),
INDEX (token_hash)
);
ساختار این جدول تقریباً به این شکل است:
| ستون | کاربرد |
|---|---|
id | شناسه رکورد |
user_id | کاربری که Token متعلق به او است |
token_hash | Hash توکن |
expires_at | زمان انقضا |
used_at | زمان مصرف شدن Token |
created_at | زمان ایجاد Token |
# چرا Token خام را در دیتابیس ذخیره نکنیم؟
فرض کنید Token این باشد:
8d4e7c9f...
بهتر است همین مقدار خام را در دیتابیس قرار ندهیم.
ابتدا Token را تولید میکنیم:
$token = bin2hex(random_bytes(32));
سپس Hash آن را میسازیم:
$tokenHash = hash('sha256', $token);
و فقط Hash را ذخیره میکنیم:
INSERT INTO password_reset_tokens (...)
VALUES (..., $tokenHash, ...)
اما Token خام را داخل لینک ایمیل قرار میدهیم:
https://example.com/reset-password?token=...
در نتیجه:
Token خام
↓
ایمیل / لینک
↓
Hash Token
↓
دیتابیس
اگر دیتابیس لو رفت، مهاجم مستقیماً Token قابل استفاده را در اختیار ندارد.
# چرا از random_bytes() استفاده کنیم؟
برای تولید Token امنیتی نباید از توابعی مثل اینها استفاده کنیم:
rand();
یا:
mt_rand();
یا:
time();
این توابع برای تولید Token امنیتی مناسب نیستند.
روش مناسب:
$token = bin2hex(random_bytes(32));
تابع random_bytes() برای تولید داده تصادفی مناسب کاربردهای امنیتی در PHP طراحی شده است.
32 بایت داده تصادفی پس از تبدیل با bin2hex() به یک رشته 64 کاراکتری تبدیل میشود.
# ساخت فرم «فراموشی رمز عبور»
یک فرم ساده میتواند به این شکل باشد:
<form method="POST" action="/forgot-password">
<label for="email">
ایمیل
</label>
<input
type="email"
id="email"
name="email"
required
>
<button type="submit">
ارسال لینک بازیابی
</button>
</form>
در پروژه واقعی باید برای این فرم نیز CSRF Protection داشته باشیم.
# دریافت ایمیل در PHP
برای مثال:
$email = trim($_POST['email'] ?? '');
سپس اعتبارسنجی:
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
exit('ایمیل نامعتبر است.');
}
در سیستم واقعی بهتر است پیام خطا نیز متناسب با UX طراحی شود.
# پیدا کردن کاربر
با PDO میتوانیم کاربر را پیدا کنیم:
$stmt = $pdo->prepare(
'SELECT id, email
FROM users
WHERE email = :email
LIMIT 1'
);
$stmt->execute([
'email' => $email
]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
در اینجا از Prepared Statement استفاده کردهایم تا Query در برابر SQL Injection ایمنتر باشد.
# پیام عمومی برای جلوگیری از User Enumeration
حتی اگر کاربر پیدا نشد، بهتر است پاسخ عمومی بدهیم:
$message = 'اگر حسابی با این ایمیل وجود داشته باشد، لینک بازیابی رمز عبور برای شما ارسال خواهد شد.';
اگر کاربر وجود نداشت، میتوانیم بدون انجام عملیات حساس همین پیام را نمایش دهیم.
# حذف Tokenهای قبلی
قبل از تولید Token جدید میتوانیم Tokenهای فعال قبلی همان کاربر را باطل کنیم.
برای مثال:
if ($user) {
$stmt = $pdo->prepare(
'UPDATE password_reset_tokens
SET used_at = NOW()
WHERE user_id = :user_id
AND used_at IS NULL'
);
$stmt->execute([
'user_id' => $user['id']
]);
}
این کار باعث میشود Tokenهای قبلی قابل استفاده نباشند.
# تولید Token جدید
حالا Token جدید را تولید میکنیم:
$token = bin2hex(random_bytes(32));
سپس Hash:
$tokenHash = hash('sha256', $token);
# تعیین زمان انقضا
برای مثال میتوانیم Token را 30 دقیقه معتبر کنیم:
$expiresAt = date(
'Y-m-d H:i:s',
time() + 1800
);
عدد 1800 برابر با 30 دقیقه است.
در سیستمهای واقعی میتوانید مدت اعتبار را بر اساس نیاز پروژه تعیین کنید.
مثلاً:
15 دقیقه
30 دقیقه
60 دقیقه
Tokenهای Password Reset بهتر است عمر محدودی داشته باشند.
# ذخیره Token در دیتابیس
اکنون:
$stmt = $pdo->prepare(
'INSERT INTO password_reset_tokens
(user_id, token_hash, expires_at)
VALUES
(:user_id, :token_hash, :expires_at)'
);
$stmt->execute([
'user_id' => $user['id'],
'token_hash' => $tokenHash,
'expires_at' => $expiresAt
]);
در دیتابیس فقط Hash ذخیره میشود.
# ساخت لینک بازیابی
لینک را میتوانیم به شکل زیر بسازیم:
$resetUrl =
'https://example.com/reset-password?token='
. urlencode($token);
نکته امنیتی مهم:
دامنه لینک را بهتر است از Configuration ثابت برنامه دریافت کنیم.
مثلاً:
$baseUrl = 'https://example.com';
$resetUrl =
$baseUrl
. '/reset-password?token='
. urlencode($token);
نباید بدون بررسی از مقدارهایی مانند:
$_SERVER['HTTP_HOST']
برای ساخت لینک حساس استفاده کنیم؛ زیرا در بعضی معماریها Host Header میتواند دستکاری شود.
# ارسال لینک از طریق ایمیل
برای نمونه:
$subject = 'بازیابی رمز عبور';
$message = "
برای تغییر رمز عبور خود روی لینک زیر کلیک کنید:
$resetUrl
این لینک تا 30 دقیقه معتبر است.
";
در پروژه واقعی بهتر است ارسال ایمیل را به سرویس یا Mailer مناسب بسپاریم.
همچنین بهتر است ارسال ایمیل در سیستمهای پرترافیک به صورت Queue/Job انجام شود تا درخواست HTTP کاربر منتظر ارسال ایمیل نماند.
# صفحه Reset Password
کاربر بعد از کلیک روی لینک وارد صفحهای مانند زیر میشود:
https://example.com/reset-password?token=...
در PHP:
$token = $_GET['token'] ?? '';
سپس باید Token را بررسی کنیم.
# Hash کردن Token دریافتی
Token دریافتشده را دوباره Hash میکنیم:
$tokenHash = hash('sha256', $token);
سپس آن را با مقدار ذخیرهشده در دیتابیس مقایسه میکنیم.
# پیدا کردن Token
$stmt = $pdo->prepare(
'SELECT id, user_id, token_hash, expires_at, used_at
FROM password_reset_tokens
WHERE token_hash = :token_hash
LIMIT 1'
);
$stmt->execute([
'token_hash' => $tokenHash
]);
$resetToken = $stmt->fetch(PDO::FETCH_ASSOC);
# بررسی وجود Token
اگر رکورد پیدا نشد:
if (!$resetToken) {
exit('لینک بازیابی نامعتبر است.');
}
# بررسی Token مصرفشده
اگر Token قبلاً استفاده شده باشد:
if ($resetToken['used_at'] !== null) {
exit('این لینک قبلاً استفاده شده است.');
}
این قسمت بسیار مهم است.
Token Password Reset باید یکبارمصرف باشد.
# بررسی تاریخ انقضا
برای مثال:
if (
strtotime($resetToken['expires_at']) < time()
) {
exit('این لینک منقضی شده است.');
}
بنابراین حتی اگر Token در اختیار فرد دیگری قرار بگیرد، بعد از پایان زمان اعتبار قابل استفاده نخواهد بود.
# مقایسه امن Token
در صورت نیاز به مقایسه مستقیم مقادیر حساس، میتوانیم از:
hash_equals()
استفاده کنیم.
مثلاً:
if (!hash_equals(
$storedHash,
$tokenHash
)) {
exit('Token نامعتبر است.');
}
در طراحی بالا چون Token Hash شده و Query بر اساس Hash انجام شده است، بررسی وجود رکورد نیز بخش اصلی اعتبارسنجی است.
# نمایش فرم تعیین رمز جدید
بعد از معتبر بودن Token، فرم رمز جدید را نمایش میدهیم:
<form method="POST" action="/reset-password">
<input
type="hidden"
name="token"
value="..."
>
<label>
رمز عبور جدید
</label>
<input
type="password"
name="password"
required
>
<label>
تکرار رمز عبور
</label>
<input
type="password"
name="password_confirmation"
required
>
<button type="submit">
تغییر رمز عبور
</button>
</form>
این فرم نیز باید دارای CSRF Protection باشد.
# اعتبارسنجی رمز جدید
مثلاً:
$password = $_POST['password'] ?? '';
$confirmation = $_POST['password_confirmation'] ?? '';
if ($password !== $confirmation) {
exit('رمزهای عبور یکسان نیستند.');
}
میتوانیم حداقل طول رمز را نیز بررسی کنیم:
if (strlen($password) < 12) {
exit('رمز عبور باید حداقل 12 کاراکتر باشد.');
}
البته سیاست دقیق Password Policy باید متناسب با پروژه تعیین شود.
# Hash کردن رمز جدید
هرگز رمز جدید را مستقیماً در دیتابیس ذخیره نکنید.
روش صحیح:
$passwordHash = password_hash(
$password,
PASSWORD_DEFAULT
);
سپس:
$stmt = $pdo->prepare(
'UPDATE users
SET password = :password
WHERE id = :user_id'
);
$stmt->execute([
'password' => $passwordHash,
'user_id' => $resetToken['user_id']
]);
# چرا PASSWORD_DEFAULT؟
به جای اینکه الگوریتم Hash را در همه جای برنامه به صورت سختکد کنیم، میتوانیم از:
PASSWORD_DEFAULT
استفاده کنیم.
این کار باعث میشود کد با سیاست پیشفرض PHP برای Password Hashing هماهنگ باشد.
# باطل کردن Token بعد از تغییر رمز
بعد از تغییر موفق رمز عبور، Token باید غیرقابل استفاده شود:
$stmt = $pdo->prepare(
'UPDATE password_reset_tokens
SET used_at = NOW()
WHERE id = :id
AND used_at IS NULL'
);
$stmt->execute([
'id' => $resetToken['id']
]);
وجود شرط:
AND used_at IS NULL
باعث میشود Token قبلاً مصرفشده دوباره فعال نشود.
# آیا باید Sessionهای قبلی را نیز باطل کنیم؟
بله، بهتر است بعد از Reset Password، Sessionهای فعال کاربر نیز مدیریت شوند.
فرض کنید مهاجم قبلاً Session کاربر را سرقت کرده باشد.
کاربر رمز خود را تغییر میدهد، اما Session مهاجم همچنان معتبر است.
بنابراین تغییر رمز به تنهایی همیشه کافی نیست.
حداقل برای Session فعلی میتوانیم از:
session_regenerate_id(true);
استفاده کنیم.
اما در سیستمهای حرفهای بهتر است مکانیزمی برای Invalidation کردن تمام Sessionهای فعال کاربر داشته باشیم.
برای مثال:
user_sessions
----------------
id
user_id
session_id
created_at
last_activity
revoked_at
و بعد از Reset Password:
UPDATE user_sessions
SET revoked_at = NOW()
WHERE user_id = ?
AND revoked_at IS NULL;
در این حالت کاربر باید دوباره Login کند.
این رفتار معمولاً از Login خودکار بعد از Password Reset قابل کنترلتر است.
# چرا بهتر است بعد از Reset به صورت خودکار Login نکنیم؟
ممکن است وسوسه شویم بعد از تغییر رمز:
Password Reset
↓
Login خودکار
↓
Dashboard
انجام دهیم.
اما در بسیاری از سیستمها بهتر است:
Password Reset
↓
Invalidation Sessionها
↓
پیام موفقیت
↓
Login مجدد
داشته باشیم.
این طراحی سادهتر و قابل کنترلتر است.
# نمونه کامل Flow
فرآیند کامل سیستم ما:
Forgot Password
│
▼
دریافت Email
│
▼
پیدا کردن User
│
┌───────────┴───────────┐
│ │
وجود دارد وجود ندارد
│ │
▼ ▼
تولید Token پاسخ عمومی
│
▼
Hash کردن Token
│
▼
ذخیره در Database
│
▼
ارسال Email
│
▼
کلیک روی لینک
│
▼
بررسی Token + Expiry
│
▼
دریافت Password جدید
│
▼
password_hash()
│
▼
تغییر Password
│
▼
مصرف Token
│
▼
ابطال Sessionهای قبلی
│
▼
Login مجدد
# یک پیادهسازی ساده با PDO
ساختار کلی بخش درخواست Reset میتواند به شکل زیر باشد:
<?php
session_start();
$email = trim($_POST['email'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
exit('ایمیل نامعتبر است.');
}
$stmt = $pdo->prepare(
'SELECT id, email
FROM users
WHERE email = :email
LIMIT 1'
);
$stmt->execute([
'email' => $email
]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user) {
// باطل کردن Tokenهای قبلی
$stmt = $pdo->prepare(
'UPDATE password_reset_tokens
SET used_at = NOW()
WHERE user_id = :user_id
AND used_at IS NULL'
);
$stmt->execute([
'user_id' => $user['id']
]);
// تولید Token امن
$token = bin2hex(random_bytes(32));
// ذخیره Hash
$tokenHash = hash('sha256', $token);
// اعتبار 30 دقیقه
$expiresAt = date(
'Y-m-d H:i:s',
time() + 1800
);
$stmt = $pdo->prepare(
'INSERT INTO password_reset_tokens
(user_id, token_hash, expires_at)
VALUES
(:user_id, :token_hash, :expires_at)'
);
$stmt->execute([
'user_id' => $user['id'],
'token_hash' => $tokenHash,
'expires_at' => $expiresAt
]);
$resetUrl =
'https://example.com/reset-password?token='
. urlencode($token);
// ارسال ایمیل را اینجا انجام دهید.
}
echo 'اگر حسابی با این ایمیل وجود داشته باشد، لینک بازیابی برای شما ارسال خواهد شد.';
# بخش تغییر رمز عبور
حالا بخش دوم:
<?php
$token = $_POST['token'] ?? '';
$password = $_POST['password'] ?? '';
$confirmation = $_POST['password_confirmation'] ?? '';
if ($token === '') {
exit('Token نامعتبر است.');
}
if ($password !== $confirmation) {
exit('رمزهای عبور یکسان نیستند.');
}
if (strlen($password) < 12) {
exit('رمز عبور باید حداقل 12 کاراکتر باشد.');
}
$tokenHash = hash('sha256', $token);
$stmt = $pdo->prepare(
'SELECT id, user_id, token_hash, expires_at, used_at
FROM password_reset_tokens
WHERE token_hash = :token_hash
LIMIT 1'
);
$stmt->execute([
'token_hash' => $tokenHash
]);
$resetToken = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$resetToken) {
exit('Token نامعتبر است.');
}
if ($resetToken['used_at'] !== null) {
exit('این لینک قبلاً استفاده شده است.');
}
if (strtotime($resetToken['expires_at']) < time()) {
exit('این لینک منقضی شده است.');
}
if (!hash_equals(
$resetToken['token_hash'],
$tokenHash
)) {
exit('Token نامعتبر است.');
}
$passwordHash = password_hash(
$password,
PASSWORD_DEFAULT
);
$stmt = $pdo->prepare(
'UPDATE users
SET password = :password
WHERE id = :user_id'
);
$stmt->execute([
'password' => $passwordHash,
'user_id' => $resetToken['user_id']
]);
$stmt = $pdo->prepare(
'UPDATE password_reset_tokens
SET used_at = NOW()
WHERE id = :id
AND used_at IS NULL'
);
$stmt->execute([
'id' => $resetToken['id']
]);
echo 'رمز عبور شما با موفقیت تغییر کرد.';
این کد یک نمونه آموزشی است و در پروژه واقعی باید CSRF، Transaction، Rate Limiting، مدیریت Sessionها و ارسال ایمیل واقعی نیز به آن اضافه شود.
# استفاده از Transaction
تغییر رمز عبور و مصرف Token بهتر است در یک Transaction انجام شود.
برای مثال:
$pdo->beginTransaction();
try {
$passwordHash = password_hash(
$password,
PASSWORD_DEFAULT
);
$stmt = $pdo->prepare(
'UPDATE users
SET password = :password
WHERE id = :user_id'
);
$stmt->execute([
'password' => $passwordHash,
'user_id' => $resetToken['user_id']
]);
$stmt = $pdo->prepare(
'UPDATE password_reset_tokens
SET used_at = NOW()
WHERE id = :id
AND used_at IS NULL'
);
$stmt->execute([
'id' => $resetToken['id']
]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
مزیت Transaction این است که اگر یکی از عملیاتهای مهم شکست بخورد، وضعیت سیستم ناقص باقی نمیماند.
# Rate Limiting برای Password Reset
یک مشکل مهم دیگر، ارسال تعداد زیادی درخواست Reset است.
مهاجم میتواند:
1000 درخواست
↓
1000 ایمیل
↓
Spam
ایجاد کند.
بنابراین باید روی Password Reset محدودیت قرار دهیم.
مثلاً:
حداکثر 3 درخواست در 15 دقیقه
البته مقدار واقعی باید بر اساس نوع پروژه تعیین شود.
Rate Limit میتواند بر اساس مواردی مانند:
IP
Email
User ID
ترکیب IP + Email
انجام شود.
# آیا برای هر درخواست باید Token جدید بسازیم؟
بهتر است سیستم Tokenهای قبلی را مدیریت کند.
مثلاً:
درخواست اول
↓
Token A
درخواست دوم
↓
Token A باطل
↓
Token B فعال
در نتیجه فقط آخرین Token فعال باقی میماند.
# اگر کاربر ایمیل را دریافت نکرد چه کنیم؟
چند دلیل رایج وجود دارد:
- ایمیل وارد Spam شده است.
- سرویس ایمیل مشکل دارد.
- آدرس ایمیل اشتباه وارد شده است.
- ارسال ایمیل با تأخیر انجام شده است.
- محدودیت Rate Limit فعال شده است.
اما سیستم نباید به شکل ناامن وجود حساب را فاش کند.
میتوانیم پیام عمومی زیر را نشان دهیم:
اگر حسابی با این ایمیل وجود داشته باشد،
ایمیل بازیابی ارسال خواهد شد.
در صورت عدم دریافت، پوشه Spam را بررسی کنید.
# Token چقدر باید معتبر باشد؟
مدت اعتبار Token به نوع سیستم بستگی دارد.
برای مثال:
15 دقیقه
30 دقیقه
60 دقیقه
مقادیر رایجی برای طراحی هستند.
هرچه زمان اعتبار طولانیتر باشد، پنجره زمانی سوءاستفاده نیز بیشتر میشود.
بنابراین بهتر است Token عمر محدودی داشته باشد.
# آیا میتوان Token را در دیتابیس خام ذخیره کرد؟
از نظر طراحی امنیتی بهتر است:
Token خام
↓
Hash
↓
Database
داشته باشیم.
نه:
Token خام
↓
Database
به این ترتیب اگر اطلاعات دیتابیس افشا شود، Tokenهای بازیابی مستقیماً قابل استفاده نیستند.
# آیا Password Reset با JWT انجام شود؟
از نظر فنی میتوان از JWT استفاده کرد، اما برای Password Reset معمولاً یک Token تصادفی و یکبارمصرف بسیار سادهتر است.
Password Reset معمولاً به این ویژگیها نیاز دارد:
Random
Short-lived
Single-use
Revocable
Server-side validation
جدول Token اختصاصی این ویژگیها را به شکل سادهای فراهم میکند.
# مراقبت از URL بازیابی
Token در URL قرار میگیرد:
/reset-password?token=...
بنابراین باید توجه کنیم که این URL ممکن است در محیطهایی مانند Log، History یا Referrer دیده شود.
برای کاهش ریسک:
- از HTTPS استفاده کنید.
- Token را در Logهای برنامه ثبت نکنید.
- روی صفحه Reset منابع شخص ثالث غیرضروری بارگذاری نکنید.
- از سیاست مناسب
Referrer-Policyاستفاده کنید. - بعد از مصرف، Token را باطل کنید.
- Token را برای مدت طولانی معتبر نگه ندارید.
برای مثال:
Referrer-Policy: no-referrer
میتواند برای چنین صفحهای مورد استفاده قرار گیرد.
# چرا نباید Password را در URL قرار دهیم؟
این کار کاملاً اشتباه است:
/reset-password?email=user@example.com&password=123456
رمز عبور نباید در URL قرار بگیرد.
URL میتواند در:
- Browser History
- Proxy
- Server Log
- Analytics
- Referrer
ثبت شود.
فقط Token موقت باید در URL قرار داشته باشد.
# CSRF را فراموش نکنید
دو فرم مهم داریم:
Forgot Password
Reset Password
هر دو باید در صورت مناسب بودن معماری برنامه، با CSRF Protection محافظت شوند.
برای مثال:
<input
type="hidden"
name="csrf_token"
value="..."
>
و در PHP Token را بررسی میکنیم.
جزئیات CSRF در مقاله مستقل مربوط به امنیت CSRF بررسی شده است.
# HTTPS الزامی است
Password Reset بدون HTTPS طراحی مناسبی نیست.
لینک باید:
https://example.com/reset-password?token=...
باشد.
نه:
http://example.com/reset-password?token=...
زیرا Token در URL قرار دارد و نباید در مسیر شبکه به صورت ناامن منتقل شود.
# اشتباهات رایج در Password Reset
1. ذخیره Token خام
اشتباه:
$token = bin2hex(random_bytes(32));
// ذخیره مستقیم Token
بهتر:
$token = bin2hex(random_bytes(32));
$tokenHash = hash(
'sha256',
$token
);
// ذخیره $tokenHash
2. استفاده از rand()
اشتباه:
$token = rand(100000, 999999);
برای Token امنیتی مناسب نیست.
3. Token بدون تاریخ انقضا
Token نباید برای همیشه معتبر باشد.
4. امکان استفاده چندباره
Token باید بعد از موفقیت مصرف شود:
used_at = NOW()
5. اعلام وجود ایمیل
نباید به مهاجم بگوییم:
این ایمیل در سیستم وجود دارد.
6. تغییر مستقیم Password بدون Hash
اشتباه:
UPDATE users
SET password = :password
در صورتی که مقدار password خام باشد.
صحیح:
$passwordHash = password_hash(
$password,
PASSWORD_DEFAULT
);
7. عدم محدودیت درخواستها
بدون Rate Limiting، مهاجم میتواند درخواستهای زیادی ارسال کند.
8. ساخت لینک با Host ورودی کاربر
بهتر است:
$baseUrl = 'https://example.com';
از Configuration برنامه بیاید.
نه اینکه بدون کنترل:
$_SERVER['HTTP_HOST']
مبنای ساخت لینک حساس باشد.
# چکلیست Password Reset امن
قبل از انتشار سیستم Password Reset این موارد را بررسی کنید:
- [ ] Token با
random_bytes()ساخته میشود. - [ ] Token خام در دیتابیس ذخیره نمیشود.
- [ ] Hash Token در دیتابیس ذخیره میشود.
- [ ] Token تاریخ انقضا دارد.
- [ ] Token یکبارمصرف است.
- [ ] Tokenهای قبلی مدیریت میشوند.
- [ ] وجود حساب کاربری افشا نمیشود.
- [ ] Password با
password_hash()ذخیره میشود. - [ ] Password در URL قرار نمیگیرد.
- [ ] HTTPS فعال است.
- [ ] فرمها CSRF Protection دارند.
- [ ] Rate Limiting وجود دارد.
- [ ] Token در Log ذخیره نمیشود.
- [ ] Sessionهای قبلی بعد از Reset مدیریت میشوند.
- [ ] لینک Reset با Domain ثابت و قابل اعتماد ساخته میشود.
- [ ] ارسال ایمیل در سیستمهای بزرگ با Queue انجام میشود.
- [ ] عملیات تغییر Password و مصرف Token با Transaction مدیریت میشود.
# تفاوت Password Reset با Password Change
این دو مفهوم را نباید با هم اشتباه گرفت.
Password Change
کاربر معمولاً وارد حساب خود شده و رمز فعلی را میداند:
Login
↓
Current Password
↓
New Password
Password Reset
کاربر رمز فعلی را فراموش کرده است:
Forgot Password
↓
Email
↓
Reset Token
↓
New Password
در Password Reset، مالکیت یا دسترسی به کانال بازیابی مانند ایمیل باید مبنای اعتبارسنجی قرار گیرد.
# آیا بعد از Reset باید رمز قبلی را بررسی کنیم؟
خیر.
هدف Password Reset این است که وقتی کاربر رمز قبلی را در اختیار ندارد، بتواند با استفاده از مکانیزم بازیابی معتبر رمز جدید تعیین کند.
بنابراین:
Current Password
در فرآیند Reset الزاماً در دسترس نیست.
اما در Password Change معمولاً باید رمز فعلی نیز بررسی شود.
# FAQ
آیا میتوان رمز عبور قبلی را به کاربر ایمیل کرد؟
خیر.
رمز عبور نباید به صورت قابل بازیابی ذخیره شود. باید یک رمز جدید تعیین شود.
Token بازیابی چند دقیقه معتبر باشد؟
مدت اعتبار را میتوان بر اساس نیاز سیستم تعیین کرد؛ برای بسیاری از سیستمها بازهای مانند 15 تا 60 دقیقه قابل بررسی است.
آیا Token خام را در دیتابیس ذخیره کنیم؟
بهتر است فقط Hash آن ذخیره شود.
آیا بعد از Reset کاربر را خودکار Login کنیم؟
ضرورتی ندارد و در بسیاری از سیستمها بهتر است Sessionهای قبلی مدیریت/باطل شوند و کاربر دوباره Login کند.
آیا Password Reset نیاز به Rate Limit دارد؟
بله. هم درخواستهای Reset و هم Login باید در برابر تلاشهای بیش از حد محافظت شوند.
آیا JWT برای Password Reset لازم است؟
خیر. یک Token تصادفی، کوتاهعمر و یکبارمصرف معمولاً طراحی ساده و مناسبی برای این کاربرد است.
# جمعبندی
یک Password Reset امن فقط شامل یک فرم و یک لینک نیست.
یک پیادهسازی مناسب باید چند اصل مهم را همزمان رعایت کند:
random_bytes()
+
Token Hash
+
Expiration
+
Single-use
+
HTTPS
+
CSRF Protection
+
Rate Limiting
+
password_hash()
+
Session Invalidation
+
Generic Response
فرآیند اصلی نیز باید به شکل زیر باشد:
Forgot Password
↓
Email
↓
Secure Token
↓
Token Hash
↓
Database
↓
Email Link
↓
Token Validation
↓
New Password
↓
password_hash()
↓
Invalidate Token
↓
Invalidate Sessions
↓
Login
اگر این اصول رعایت شوند، Password Reset شما بسیار قابل اعتمادتر از پیادهسازیهایی خواهد بود که صرفاً یک Token ساده تولید میکنند و مستقیماً آن را در دیتابیس ذخیره میکنند.
در پروژههای بزرگتر نیز بهتر است این منطق از Controller جدا شده و در یک Service مستقل قرار بگیرد تا مدیریت Token، ارسال ایمیل، Rate Limiting، Session Invalidation و ثبت رویدادهای امنیتی قابل توسعهتر باشد.





