PHP 表单提交与安全注册
本文目录
原始发布日期保留为 2018-07-08。本文于 2026-10-19 重整为 PHP 8.x 教学稿。旧草稿中的明文密码文件存储、直接输出用户输入和不完整文件表单均已移除,不应继续使用。
先建立正确的信任边界
浏览器端的 required、maxlength、input type、单选/复选控件只改善输入体验,不构成安全校验。客户端可以绕过界面直接构造请求;服务端必须验证请求方法、字段类型、长度、允许值、权限和业务规则。
旧版示例还有几个危险点:
- 把用户名与原始密码写入 names.txt,导致任何能读取该文件的进程或备份都可能泄露密码。
- 将用户名和提示信息原样插入 HTML,可能造成跨站脚本注入。
- 直接把服务器变量用于表单 action,容易引入不必要的反射输出。
- 文件控件没有 name,也没有 multipart/form-data,因此不会按预期上传文件。
- 表单中没有 CSRF 防护;登录/注册反馈也不应暴露数据库内部错误。
| 字段 | 浏览器端职责 | 服务端必须做的事 |
|---|---|---|
| 用户名 | 提示必填和长度 | 检查字符串、格式、唯一约束和账户策略 |
| 密码 | 使用 password 输入类型 | 检查长度/策略;只保存慢速哈希,不回显、不记录 |
| 确认密码 | 帮助用户发现输入错误 | 再次比较;不能只依赖浏览器校验 |
| 同意条款 | 提供明确的勾选选项 | 验证字段存在且值在允许范围内 |
| CSRF token | 随表单提交隐藏 token | 与会话 token 做常量时间比较 |
| 文件 | accept 提示可选类型 | 校验错误码、大小、实际内容、存储路径和授权 |
表单字段和 CSRF
下面的会话初始化必须在输出 HTML 之前执行。示例假设站点始终使用 HTTPS;本地开发若使用 HTTP,应单独设置仅开发环境的 Cookie 配置,不能把不安全配置带到生产。
<?php
session_set_cookie_params([
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$_SESSION['csrf_token'] ??= bin2hex(random_bytes(32));
function escapeHtml(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
注册表单只回显经过 HTML 转义的非敏感用户名;密码和确认密码绝不回填。复选框未勾选时不会出现在 POST 中,服务端要把“缺失”当作正常的未选择状态。
<form method="post" action="" autocomplete="on">
<input
type="hidden"
name="csrf_token"
value="<?= escapeHtml($_SESSION['csrf_token']) ?>"
>
<label for="username">用户名</label>
<input
id="username"
name="username"
type="text"
autocomplete="username"
maxlength="32"
required
>
<label for="password">密码</label>
<input
id="password"
name="password"
type="password"
autocomplete="new-password"
required
>
<label for="confirm_password">确认密码</label>
<input
id="confirm_password"
name="confirm_password"
type="password"
autocomplete="new-password"
required
>
<label>
<input type="checkbox" name="agree" value="yes" required>
我已阅读并同意服务条款
</label>
<button type="submit" name="action" value="register">注册</button>
</form>
CSRF token 应由服务端生成、放在用户会话中,并随表单提交。所有改变状态的请求都要验证 token;GET 请求不应执行注册、修改资料或删除等状态变更。SameSite Cookie 是纵深防御,不能取代 token 校验。
服务端校验和密码保存
PHP 的 POST 字段不保证是字符串。攻击者可以把普通字段改成数组,因此先做类型检查,再 trim、校验格式和长度。密码不要 trim,因为空格可能是用户密码的一部分。
<?php
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? null;
$csrfToken = $_POST['csrf_token'] ?? null;
if (
!is_string($csrfToken)
|| !hash_equals($_SESSION['csrf_token'], $csrfToken)
) {
$message = '表单已过期,请刷新后重试。';
} elseif ($action === 'register') {
$username = $_POST['username'] ?? null;
$password = $_POST['password'] ?? null;
$confirmation = $_POST['confirm_password'] ?? null;
$agreed = $_POST['agree'] ?? null;
if (
!is_string($username)
|| !is_string($password)
|| !is_string($confirmation)
) {
$message = '请检查表单内容。';
} else {
$username = trim($username);
if (!preg_match('/\A[A-Za-z0-9_.-]{3,32}\z/', $username)) {
$message = '用户名格式不符合要求。';
} elseif ($password !== $confirmation) {
$message = '两次输入的密码不一致。';
} elseif (
!mb_check_encoding($password, 'UTF-8')
|| mb_strlen($password, 'UTF-8') < 12
|| strlen($password) > 72
) {
$message = '密码长度不符合要求。';
} elseif ($agreed !== 'yes') {
$message = '请先同意服务条款。';
} else {
// 校验通过后,执行下方的数据库写入。
}
}
}
}
?>
示例依赖 mbstring 扩展,以 UTF-8 字符数检查最小长度,并以字节限制最大长度。当前常见 bcrypt 配置最多处理 72 字节,避免更长输入被算法截断;PASSWORD_DEFAULT 的具体算法可能随 PHP 版本改变,升级运行时或算法时要重新核对边界。password_hash() 会自动生成随机 salt,不要自行固定或保存 salt;只保存返回的哈希。数据库中哈希字段应预留足够空间,例如 VARCHAR(255),不要按旧版固定长度截断。
生产数据库连接信息从部署环境读取,不要写进公开文章、源码或版本库。下面展示的 PDO 配置不包含真实凭据:
<?php
$dsn = getenv('APP_DSN');
$dbUser = getenv('APP_DB_USER');
$dbPassword = getenv('APP_DB_PASSWORD');
if (!is_string($dsn) || $dsn === '') {
throw new RuntimeException('Database configuration is missing.');
}
$pdo = new PDO(
$dsn,
is_string($dbUser) ? $dbUser : null,
is_string($dbPassword) ? $dbPassword : null,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
);
?>
使用参数化语句传递字段值,不要把用户输入拼接进 SQL。用户名应由数据库唯一约束兜底;冲突时把异常映射成安全、克制的反馈,不能把 SQL、堆栈、连接信息或原始异常展示给访客。
<?php
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$statement = $pdo->prepare(
'INSERT INTO users (username, password_hash) VALUES (:username, :password_hash)'
);
$statement->execute([
'username' => $username,
'password_hash' => $passwordHash,
]);
// 仅在 INSERT 成功后更新 token 并显示成功。
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$message = '注册成功。';
?>
注册成功后重新生成 CSRF token。不要自动打印密码、哈希或数据库记录;日志中也不要记录密码、验证码、Cookie 或授权头。
登录验证和会话
登录逻辑应先通过与注册相同的 CSRF 校验,并检查字段是有界字符串。下面的查询是登录分支的核心:使用参数化查询读取用户,再用 password_verify() 校验;不要重新 hash 输入密码后做字符串比较。对不存在的账号和错误密码返回相同的外部提示,避免泄露账号是否注册。
<?php
$username = $_POST['username'] ?? null;
$password = $_POST['password'] ?? null;
if (
!is_string($username)
|| !is_string($password)
|| strlen($username) > 32
|| strlen($password) > 72
) {
$message = '用户名或密码错误。';
} else {
$statement = $pdo->prepare(
'SELECT id, password_hash FROM users WHERE username = :username'
);
$statement->execute(['username' => $username]);
$user = $statement->fetch();
if (
!is_array($user)
|| !is_string($user['password_hash'] ?? null)
|| !password_verify($password, $user['password_hash'])
) {
$message = '用户名或密码错误。';
} else {
if (!session_regenerate_id()) {
throw new RuntimeException('Unable to rotate session ID.');
}
$_SESSION['user_id'] = (int) $user['id'];
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
}
?>
登录成功后,在设置认证状态之前轮换 session ID,降低 session fixation 风险。Cookie 应启用 Secure、HttpOnly 和合适的 SameSite 策略;敏感操作还需重新验证身份、速率限制、审计和失效机制。若更换 PASSWORD_DEFAULT 的哈希参数,可在成功登录后用 password_needs_rehash() 检查并更新旧哈希。
安全渲染响应
HTML 文本、属性值等输出位置都要按 HTML 上下文转义。校验失败后可以保留用户名,但不能回显密码。不要直接输出 _SERVER['PHP_SELF']。
<?php if ($message !== ''): ?>
<p role="status"><?= escapeHtml($message) ?></p>
<?php endif; ?>
<label for="username">用户名</label>
<input
id="username"
name="username"
value="<?= escapeHtml($username ?? '') ?>"
autocomplete="username"
>
escapeHtml() 使用 ENT_QUOTES、ENT_SUBSTITUTE 和 UTF-8,适用于 HTML 文本或引号包围的普通 HTML 属性值;它不是 JavaScript、CSS、URL 或 SQL 的通用转义函数。SQL 参数应使用 PDO 参数绑定,URL 应按 URL 规则校验和编码。
文件上传表单
文件控件必须有 name,表单必须使用 multipart/form-data。accept 只影响浏览器选择界面,不是服务端安全策略;隐藏字段 MAX_FILE_SIZE 也不能代替服务器限制。
<form method="post" enctype="multipart/form-data">
<input
type="hidden"
name="csrf_token"
value="<?= escapeHtml($_SESSION['csrf_token']) ?>"
>
<label for="avatar">头像</label>
<input
id="avatar"
type="file"
name="avatar"
accept="image/png,image/jpeg"
required
>
<button type="submit" name="action" value="upload">上传</button>
</form>
服务端至少要检查上传错误码、大小、临时文件确实由 HTTP POST 上传、内容类型 allowlist、存储目录权限和移动结果。不要信任浏览器提交的文件名或 $_FILES['type'],也不要直接用原文件名作为磁盘路径。生成随机文件名,将文件存到 Web 根目录之外;若必须公开访问,应通过受控下载路由提供。图片还应按业务做实际解码/重编码、尺寸限制和必要的恶意文件检查。
<?php
$file = $_FILES['avatar'] ?? null;
if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload failed.');
}
$size = $file['size'] ?? null;
$tempPath = $file['tmp_name'] ?? null;
if (!is_int($size) || $size > 5 * 1024 * 1024) {
throw new RuntimeException('File is too large.');
}
if (!is_string($tempPath) || !is_uploaded_file($tempPath)) {
throw new RuntimeException('Invalid upload.');
}
$mimeType = (new finfo(FILEINFO_MIME_TYPE))->file($tempPath);
$extensions = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
if (!is_string($mimeType) || !isset($extensions[$mimeType])) {
throw new RuntimeException('Unsupported file type.');
}
$destination = $privateUploadDirectory
. DIRECTORY_SEPARATOR
. bin2hex(random_bytes(16))
. '.'
. $extensions[$mimeType];
if (!move_uploaded_file($tempPath, $destination)) {
throw new RuntimeException('Unable to store uploaded file.');
}
?>
私有目录路径、权限、配额和清理策略应通过部署配置确定,不要照抄示例目录。PHP 的 upload_max_filesize、post_max_size 以及 Nginx/代理请求体上限需要协调;post_max_size 应能容纳文件和 multipart 开销。请求可能在进入 PHP 前就被代理层拒绝,所以排查上传问题要同时看浏览器、反向代理和 PHP 配置。
最小生产检查清单
- 密码只存慢速、带随机 salt 的哈希;不用 MD5、SHA-1 或可逆加密。
- 表单字段先做类型、长度和业务校验;用户输入永远不因浏览器控件而可信。
- 写数据库使用 PDO 参数绑定;敏感配置从环境或密钥管理系统读取。
- 所有输出按上下文编码;错误页不泄露 SQL、堆栈、密码和 session 内容。
- 状态变更请求校验 CSRF token;登录启用 HTTPS、安全 Cookie、速率限制和 session ID 轮换。
- 文件上传校验错误、大小、实际内容和随机目标名,并将文件放到不可直接执行的受控目录。
- 生产还需要邮箱验证、密码找回、并发限制、审计、备份、数据保留和账户删除流程;本文示例不替代完整认证方案。
原始流程图
下图只展示浏览器 GET/POST 与服务端校验、响应的概念流程,不是可直接部署的认证实现:

参考资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。