Email 驗證是用戶註冊流程的關鍵環節,確保信箱真實有效。本文介紹使用數位簽章 (HMAC) 方式實作安全的 Email 驗證機制,避免直接暴露資料庫 ID 或使用可預測的 Token。
傳統做法儲存隨機 Token 對照表,本方案使用 HMAC-SHA256 簽署用戶資訊,驗證時重新計算比對,無需資料庫儲存 Token:
// 簽章金鑰 (需妥善保管,建議放在環境變數)
const SIGN_KEY = 'your-secret-signing-key-256-bit';
/**
* 產生驗證連結簽章
* @param string $email 用戶 Email
* @param int $timestamp 過期時間戳 (可選)
* @return string 簽章字串
*/
function generateEmailSignature($email, $timestamp = null) {
$payload = $email . '|' . ($timestamp ?? (time() + 86400)); // 預設 24 小時有效
return hash_hmac('sha256', $payload, SIGN_KEY);
}
/**
* 驗證簽章
* @param string $email
* @param string $signature
* @param int|null $timestamp
* @return bool
*/
function verifyEmailSignature($email, $signature, $timestamp = null) {
$expected = generateEmailSignature($email, $timestamp);
return hash_equals($expected, $signature); // 等時間比對防時序攻擊
}
class AuthController extends Controller {
// 發送驗證信
public function actionSendVerification() {
$email = Yii::$app->request->get('email');
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new BadRequestHttpException('無效的 Email');
}
// 檢查用戶是否存在
$user = User::find()->where(['email' => $email])->one();
if (!$user) {
throw new NotFoundHttpException('用戶不存在');
}
if ($user->status === User::STATUS_ACTIVE) {
return $this->render('message', ['msg' => '帳號已啟用,請直接登入']);
}
// 產生簽章與連結
$expireTime = time() + 86400; // 24 小時
$signature = generateEmailSignature($email, $expireTime);
$verifyUrl = Url::to(['auth/verify-email',
'email' => $email,
'sig' => $signature,
'exp' => $expireTime
], true); // 產生絕對網址
// 發送郵件
$mail = Yii::$app->mailer->compose('verifyEmail', [
'user' => $user,
'verifyUrl' => $verifyUrl,
'expireHours' => 24
]);
$mail->setTo($email)
->setSubject('請驗證您的 Email 啟用帳號')
->send();
return $this->render('message', ['msg' => '驗證信已發送,請至信箱查收']);
}
// 驗證連結點擊處理
public function actionVerifyEmail() {
$email = Yii::$app->request->get('email');
$signature = Yii::$app->request->get('sig');
$expire = (int) Yii::$app->request->get('exp');
// 參數檢查
if (!$email || !$signature || !$expire) {
throw new BadRequestHttpException('驗證連結參數不完整');
}
// 檢查過期
if (time() > $expire) {
return $this->render('message', [
'msg' => '驗證連結已過期 (24小時有效),請重新申請',
'type' => 'error'
]);
}
// 驗證簽章
if (!verifyEmailSignature($email, $signature, $expire)) {
return $this->render('message', [
'msg' => '驗證連結無效或已被竄改',
'type' => 'error'
]);
}
// 啟用帳號
$user = User::find()->where(['email' => $email])->one();
if (!$user) {
return $this->render('message', [
'msg' => '用戶不存在',
'type' => 'error'
]);
}
if ($user->status === User::STATUS_ACTIVE) {
return $this->render('message', ['msg' => '帳號已啟用,請直接登入']);
}
$user->status = User::STATUS_ACTIVE;
$user->email_verified_at = date('Y-m-d H:i:s');
$user->save(false); // 跳過驗證直接儲存
return $this->render('message', [
'msg' => '帳號啟用成功!請登入',
'type' => 'success'
]);
}
}
// views/mail/verifyEmail.php
<?php
use yii\helpers\Html;
?>
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">歡迎加入我們!</h2>
<p>親愛的 <?= Html::encode($user->username) ?>,</p>
<p>感謝您註冊,請點擊下方按鈕驗證您的 Email 地址:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="<?= $verifyUrl ?>"
style="background: #007bff; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 4px; display: inline-block;">
驗證我的 Email
</a>
</div>
<p style="color: #666; font-size: 0.9rem;">
此連結將在 <?= $expireHours ?> 小時後失效。若非您本人操作,請忽略此信。
</p>
<hr>
<p style="color: #999; font-size: 0.8rem;">
如果按鈕無法點擊,請複製以下連結至瀏覽器:<br>
<a href="<?= $verifyUrl ?>"><?= $verifyUrl ?></a>
</p>
</div>
CREATE TABLE `user` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL,
`email` VARCHAR(100) NOT NULL UNIQUE,
`password_hash` VARCHAR(255) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未驗證, 10=啟用',
`email_verified_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
| 威脅 | 傳統 Token 做法 | 數位簽章做法 |
|---|---|---|
| Token 洩露 | 資料庫可被讀取 | 無 Token 儲存,無洩露風險 |
| Token 猜測 | 需足夠熵值 | HMAC-SHA256 不可逆推導 |
| 重放攻擊 | 需單獨失效機制 | 內建過期時間,自動失效 |
| 資料庫擴充 | 需額外表格 | 零儲存成本 |
| 時序攻擊 | 字串比較風險 | hash_equals() 等時間比對 |
// 通用簽章服務
class SignatureService {
private $key;
public function __construct($key = null) {
$this->key = $key ?? Yii::$app->params['signature.key'];
}
// 產生帶用途的簽章
public function sign($identifier, $purpose, $expireHours = 24, $extraData = []) {
$expire = time() + $expireHours * 3600;
$payload = [
'id' => $identifier,
'purpose' => $purpose, // email_verify, password_reset, email_change
'exp' => $expire,
'data' => $extraData
];
$payloadStr = base64_encode(json_encode($payload));
$sig = hash_hmac('sha256', $payloadStr, $this->key);
return $payloadStr . '.' . $sig;
}
// 驗證並解析
public function verify($token) {
[$payloadStr, $sig] = explode('.', $token, 2);
if (!hash_equals(hash_hmac('sha256', $payloadStr, $this->key), $sig)) {
return false;
}
$payload = json_decode(base64_decode($payloadStr), true);
if (time() > $payload['exp']) return false;
return $payload;
}
}
// 使用範例
$service = new SignatureService();
// 產生密碼重設連結
$resetToken = $service->sign($user->id, 'password_reset', 2, ['email' => $user->email]);
// 驗證
$payload = $service->verify($resetToken);
if ($payload && $payload['purpose'] === 'password_reset') {
// 允許重設密碼
}
數位簽章方式實作 Email 驗證具有無狀態、防竄改、自帶過期、零儲存等優勢,特別適合微服務架構或不想維護 Token 資料表的專案。關鍵在於妥善保管簽章金鑰,建議使用環境變數或密鑰管理系統,並定期輪換金鑰。