HTTP Basic Access Authentication (RFC 7617) 是最簡單的認證機制:
401 Unauthorized + WWW-Authenticate: Basic realm="..."Authorization: Basic base64(username:password) 加入後續請求缺點:Base64 非加密,必須搭配 HTTPS 使用;無登出機制,瀏覽器關閉前持續帶憑證。
<Directory "/var/www/html/protected">
AuthType Basic
AuthName "Admin Area"
AuthUserFile /etc/apache2/.htpasswd
Require valid-user
</Directory>
建立密碼檔:
htpasswd -c /etc/apache2/.htpasswd admin
# 輸入密碼兩次
# 新增用戶:htpasswd /etc/apache2/.htpasswd user2
<?php
// auth.php - 可 include 於任何需保護的腳本頂端
function requireBasicAuth(string $realm = 'Restricted Area', array $users = []): array {
// 預設用戶 (生產環境應從資料庫/配置讀取,密碼存 bcrypt 雜湊)
$defaultUsers = [
'admin' => '$2y$10$...', // password_hash('password', PASSWORD_BCRYPT)
];
$users = $users ?: $defaultUsers;
// 1. 檢查是否已提供認證標頭
if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
challenge($realm);
}
$username = $_SERVER['PHP_AUTH_USER'];
$password = $_SERVER['PHP_AUTH_PW'];
// 2. 驗證用戶存在
if (!isset($users[$username])) {
challenge($realm, '用戶不存在');
}
// 3. 驗證密碼 (支援 bcrypt 雜湊)
if (!password_verify($password, $users[$username])) {
// 防暴力:簡單延遲
sleep(1);
challenge($realm, '密碼錯誤');
}
// 4. 成功:返回用戶資訊
return ['username' => $username, 'authenticated' => true];
}
function challenge(string $realm, string $message = 'Authentication required'): never {
header('WWW-Authenticate: Basic realm="' . $realm . '"');
header('HTTP/1.0 401 Unauthorized');
// 可自訂錯誤頁面
echo "<h1>401 Unauthorized</h1><p>$message</p>";
exit;
}
// 使用範例
$user = requireBasicAuth('My Admin Panel');
echo "歡迎, {$user['username']}!";
<?php
function requireBasicAuthDB(PDO $pdo, string $realm = 'API'): array {
if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
challenge($realm);
}
$username = $_SERVER['PHP_AUTH_USER'];
$password = $_SERVER['PHP_AUTH_PW'];
// 查詢用戶 (含失敗次數、鎖定狀態)
$stmt = $pdo->prepare('
SELECT id, username, password_hash, failed_attempts, locked_until
FROM users WHERE username = ? AND deleted_at IS NULL
');
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
challenge($realm, 'Invalid credentials');
}
// 檢查鎖定
if ($user['locked_until'] && new DateTime() < new DateTime($user['locked_until'])) {
challenge($realm, '帳號暫時鎖定,請稍後再試');
}
// 驗證密碼
if (!password_verify($password, $user['password_hash'])) {
// 增加失敗次數
$attempts = $user['failed_attempts'] + 1;
$lockUntil = $attempts >= 5 ? date('Y-m-d H:i:s', strtotime('+15 minutes')) : null;
$pdo->prepare('UPDATE users SET failed_attempts = ?, locked_until = ? WHERE id = ?')
->execute([$attempts, $lockUntil, $user['id']]);
challenge($realm, 'Invalid credentials');
}
// 成功:重置失敗計數、更新最後登入
$pdo->prepare('UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login = NOW() WHERE id = ?')
->execute([$user['id']]);
return ['id' => $user['id'], 'username' => $user['username']];
}
Digest Authentication (RFC 7616) 比 Basic 安全:密碼不以明文/可逆編碼傳輸,而是雜湊挑戰回應。
<?php
function requireDigestAuth(string $realm = 'API', array $users = []): array {
if (!isset($_SERVER['PHP_AUTH_DIGEST'])) {
$nonce = bin2hex(random_bytes(16));
header('WWW-Authenticate: Digest realm="' . $realm . '", qop="auth", nonce="' . $nonce . '", opaque="' . bin2hex(random_bytes(16)) . '"');
header('HTTP/1.0 401 Unauthorized');
exit;
}
// 解析 Authorization 標頭
$data = parseDigestHeader($_SERVER['PHP_AUTH_DIGEST']);
if (!$data) { challenge($realm); }
// 檢查用戶
if (!isset($users[$data['username']])) { challenge($realm); }
// 計算預期回應
// A1 = MD5(username:realm:password)
// A2 = MD5(method:uri)
// response = MD5(A1:nonce:nc:cnonce:qop:A2)
$A1 = md5($data['username'] . ':' . $realm . ':' . $users[$data['username']]);
$A2 = md5($_SERVER['REQUEST_METHOD'] . ':' . $data['uri']);
$validResponse = md5($A1 . ':' . $data['nonce'] . ':' . $data['nc'] . ':' . $data['cnonce'] . ':' . $data['qop'] . ':' . $A2);
if (!hash_equals($validResponse, $data['response'])) {
challenge($realm);
}
// 防重放:檢查 nonce 計數器 nc 遞增 (需儲存上次 nc)
// 略...
return ['username' => $data['username']];
}
function parseDigestHeader(string $header): ?array {
$pattern = '/(\w+)=("(?:[^"\\\\]|\\\\.)*"|\w+)/';
preg_match_all($pattern, $header, $matches, PREG_SET_ORDER);
$data = [];
foreach ($matches as $m) {
$data[$m[1]] = trim($m[2], '"');
}
$required = ['username','realm','nonce','uri','response','qop','nc','cnonce'];
foreach ($required as $field) {
if (!isset($data[$field])) return null;
}
return $data;
}
| 風險 | 對策 |
|---|---|
| Base64 可解碼 | 強制 HTTPS (HSTS、SSL/TLS) |
| 暴力破解 | 失敗延遲、帳號鎖定、IP 限流、CAPTCHA |
| 認證繞過 | 所有入口統一驗證、中介軟體/基底類別強制 |
| 憑證洩漏 | 密碼存 bcrypt/argon2、不記錄日誌、定期輪換 |
| CSRF | Basic/Digest 非 Cookie 基礎,天然免疫 CSRF |
| 中間人攻擊 | HSTS、Certificate Pinning (API 客戶端) |
PHP 手動實作 HTTP Basic/Digest Auth 給予完全控制權,適合輕量級保護。核心原則:必須 HTTPS、密碼雜湊儲存、防暴力、統一入口。生產環境 API 建議遷移至 OAuth 2.0 / OpenID Connect 標準流程。