前言用户注册看起来是最简单不过的功能一张表单、两条 SQL。但上线后最常见的几种事故都出在这里数据库里躺着明文密码一次拖库全部泄露用户名里带个单引号就把 SQL 语句拼断了或者更糟——被注入 OR 11同一个人连点两次提交按钮库里出现两条一模一样的账号有人把密码超过 72 个字符时的行为当成 bug 报上来。这些问题的根因集中在四个地方密码存储方式、SQL 的构造方式、唯一性约束缺失、对输入长度和类型的假设。它们都不是注册逻辑写错了而是边界没处理。本文用 PHP 8.0在 PHP 8.5 上同样可以运行实现一个完整可跑的注册功能覆盖输入校验、密码哈希、唯一索引、并发重复提交、CSRF 防护。示例用 SQLite 落库这样你可以直接保存成.php运行不需要装 MySQL换成 MySQL 只需要改 DSN 和建表语句文末会给出 MySQL 版的 DDL。一、注册流程的四个环节一个能上生产的注册流程最少要覆盖这四步缺一不可环节做什么做错了会怎样输入校验邮箱格式、密码强度与长度、两次输入一致脏数据入库后续功能全被拖累密码哈希password_hash()单向哈希明文泄露、彩虹表破解持久化预处理语句 唯一索引SQL 注入、重复账号会话与令牌CSRF Token、注册后登录态处理跨站请求伪造、会话固定攻击其中唯一索引是唯一可靠的防重手段。很多实现只在前端用 JS 校验、或者只写一句SELECT ... WHERE email ?再插入这在并发下必然出现重复两个请求同时查到不存在然后都插入成功。数据库的唯一索引是最后一道、也是唯一一道真正有效的防线。二、密码存储只用 password_hash密码字段绝对不能用md5()、sha1()也不能用自己加盐再 md5——这些都算得飞快配合彩虹表或 GPU 爆破弱密码几分钟就被还原。PHP 提供的password_hash()会自动生成随机盐并把盐、算法、代价参数一起编码进结果字符串password_verify()再用同一个哈希去比对。整套用法只有三个函数?php declare(strict_types1); // 需要 PHP 5.5这里用 PHP 8 语法 $password S3cret!passw0rd; // 生成哈希每次调用结果都不同因为盐是随机的 $hash password_hash($password, PASSWORD_DEFAULT); echo $hash, PHP_EOL; // 形如 $2y$10$...60 个字符 // 校验把用户输入和哈希一起传进去由函数负责提取盐和参数 var_dump(password_verify($password, $hash)); // bool(true) var_dump(password_verify(wrong-password, $hash)); // bool(false) // 算法或代价参数升级后老哈希需要重新生成 var_dump(password_needs_rehash($hash, PASSWORD_DEFAULT)); // bool(false)三个必须知道的细节PASSWORD_DEFAULT目前对应 bcrypt随 PHP 版本升级可能会变。所以数据库字段必须留足空间规范写法是VARCHAR(255)不要按60 个字符去卡长度否则将来换算法会写不进去。bcrypt 只使用密码的前 72 字节超出部分会被静默忽略。如果你的产品允许超长密码需要自己做截断或改用 Argon2 系列PASSWORD_ARGON2ID前提是编译 PHP 时开启了 Argon2 支持。要在登录成功后调用password_needs_rehash()发现参数过旧就用新参数重新哈希并更新这一行这样算法升级不需要用户改密码。三、完整可运行示例下面这段是完整的注册服务实现可以直接保存为register.php运行用 SQLite自动建库建表?php declare(strict_types1); // 需要 PHP 8.1构造函数属性提升 readonly final class RegistrationService { public function __construct(private readonly PDO $pdo) {} /** * return array{ok: bool, errors: liststring, userId: int|null} */ public function register(string $email, string $password, string $confirm): array { $errors []; // 1. 归一化 格式校验 $email strtolower(trim($email)); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] 邮箱格式不正确; } // 2. 密码强度长度按字符数算不能用 strlen多字节字符会误判 $length mb_strlen($password); if ($length 8) { $errors[] 密码至少 8 个字符; } elseif ($length 72) { // bcrypt 只认前 72 字节超长必须明确拒绝而不是静默截断 $errors[] 密码不能超过 72 个字符; } if (!preg_match(/[A-Za-z]/, $password) || !preg_match(/\d/, $password)) { $errors[] 密码需同时包含字母和数字; } if ($password ! $confirm) { $errors[] 两次输入的密码不一致; } if ($errors ! []) { return [ok false, errors $errors, userId null]; } // 3. 友好提示先查一次让重复邮箱得到明确的业务提示 $check $this-pdo-prepare(SELECT id FROM users WHERE email ?); $check-execute([$email]); if ($check-fetchColumn() ! false) { return [ok false, errors [该邮箱已注册], userId null]; } // 4. 入库唯一索引是并发下的最后防线 $hash password_hash($password, PASSWORD_DEFAULT); $stmt $this-pdo-prepare( INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?) ); try { $stmt-execute([$email, $hash, (new DateTimeImmutable())-format(Y-m-d H:i:s)]); } catch (PDOException $e) { // SQLSTATE 23000 完整性约束冲突MySQL / SQLite 通用 if (($e-errorInfo[0] ?? ) 23000) { // 并发下另一个请求抢先插入了同一个邮箱 return [ok false, errors [该邮箱已注册], userId null]; } throw $e; } return [ok true, errors [], userId (int) $this-pdo-lastInsertId()]; } } // ---------------- 建库并演示 ---------------- $dbFile sys_get_temp_dir() . /register_demo.sqlite; unlink($dbFile); $pdo new PDO(sqlite: . $dbFile, null, null, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, ]); // SQLite 必须显式打开外键与唯一约束的检查 $pdo-exec(PRAGMA foreign_keys ON); $pdo-exec( CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL ) ); $service new RegistrationService($pdo); $cases [ [tomexample.com, Abcd1234, Abcd1234], // 正常注册 [tomexample.com, Abcd1234, Abcd1234], // 重复邮箱 [not-an-email, Abcd1234, Abcd1234], // 邮箱格式错 [amyexample.com, abcdefgh, abcdefgh], // 缺数字 [amyexample.com, Abcd1234, Abcd4321], // 两次不一致 ]; foreach ($cases as [$email, $pwd, $confirm]) { $result $service-register($email, $pwd, $confirm); $tag $result[ok] ? OK : FAIL; printf( %s email%-16s userId%-5s errors%s\n, $tag, $email, $result[userId] ?? -, $result[errors] [] ? - : implode( | , $result[errors]) ); } // 验证库里存的确实是哈希而不是明文 $row $pdo-query(SELECT email, password_hash FROM users)-fetch(); echo stored hash: , substr($row[password_hash], 0, 7), ..., PHP_EOL; echo verify ok : , password_verify(Abcd1234, $row[password_hash]) ? yes : no, PHP_EOL; unlink($dbFile);预期输出OK emailtomexample.com userId1 errors- FAIL emailtomexample.com userId- errors该邮箱已注册 FAIL emailnot-an-email userId- errors邮箱格式不正确 FAIL emailamyexample.com userId- errors密码需同时包含字母和数字 FAIL emailamyexample.com userId- errors两次输入的密码不一致 stored hash: $2y$10$... verify ok : yes换成 MySQL 时把 DSN 改成mysql:host127.0.0.1;dbnameapp;charsetutf8mb4建表语句换成CREATE TABLE users ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_users_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;utf8mb4_unicode_ci排序规则下Tomexample.com和tomexample.com会被视为重复这正好和代码里的strtolower()归一化形成双重保险。四、表单层的三个细节把上面的服务接到网页上时还有三件事必须做。第一件是CSRF Token注册表单必须有令牌否则第三方站点可以诱导用户提交任意数据。?php declare(strict_types1); // 需要 PHP 8.0 session_start(); if ($_SERVER[REQUEST_METHOD] POST) { $token (string) ($_POST[csrf_token] ?? ); // hash_equals 是时间恒定的比较避免通过响应时间逐字节猜令牌 if (!hash_equals((string) ($_SESSION[csrf_token] ?? ), $token)) { http_response_code(403); exit(CSRF token mismatch); } // ... 调用 RegistrationService::register() } else { // 生成令牌每次渲染表单都换新的 $_SESSION[csrf_token] bin2hex(random_bytes(32)); } // 模板里把表单的隐藏字段 csrf_token 设成这个值用模板引擎时交给自动转义即可 $escapedToken htmlspecialchars((string) ($_SESSION[csrf_token] ?? ), ENT_QUOTES, UTF-8);第二件是注册成功后的会话处理先session_regenerate_id(true)换一个会话 ID再写入登录态否则会留下会话固定Session Fixation漏洞。第三件是防刷注册接口是典型的机器人目标。最简做法是按 IP 或邮箱做频率限制例如 5 分钟内最多 3 次把计数放 Redis 并设置过期时间不要只依赖图形验证码验证码有专门的打码平台可以绕过。常见坑点1. 密码字段用VARCHAR(60)❌ 按 bcrypt 的长度卡死字段将来PASSWORD_DEFAULT换成更长的算法就插入失败 ✅VARCHAR(255)2. 用password_hash()生成后自己再md5()一层❌ 觉得多加一层更安全结果password_verify()永远返回false✅ 哈希结果直接入库校验只用password_verify()3. 只靠SELECT查重忽略唯一索引❌ 并发双提交时两条都插进去出现同一邮箱两个账号 ✅ 建唯一索引并捕获 SQLSTATE23000作为兜底SELECT仅用于给出友好提示4. 密码长度用strlen()判断❌strlen(密码密码密码密码)得到 24字节数中文密码被误判为够长 ✅ 用mb_strlen()按字符数判断同时确认数据库连接字符集是utf8mb45. 忘了 bcrypt 的 72 字节上限❌ 用户设置一个 200 字符的长密码前 72 字节之后的全部被忽略安全性没有提升却让人误以为更强 ✅ 显式限制长度并在超长时给出提示或改用 Argon2 系列算法6. 直接输出用户提交的内容❌ 把邮箱回显到页面上时不做转义用户提交的 script 标签被当成标记解析执行 ✅ 所有输出走htmlspecialchars($v, ENT_QUOTES, UTF-8)7. 用比较 CSRF 令牌❌if ($_POST[csrf_token] $_SESSION[csrf_token])—— 松散比较可能让0e123类字符串相等且比较耗时随字符变化 ✅hash_equals($known, $user)8. 注册成功后没有换会话 ID且把原始异常直接回显❌ 沿用匿名会话直接写$_SESSION[uid]攻击者可先诱导用户使用自己知道的会话 ID报错时又把SQLSTATE[23000]: Duplicate entry tomexample.com原样吐在页面上暴露表结构和字段名 ✅ 成功后立刻session_regenerate_id(true)面向用户只给业务级提示原始异常写日志总结环节关键做法常见错误输入校验filter_var校验邮箱、mb_strlen判断长度用strlen、只在前端校验密码存储password_hash()password_verify()md5/sha1、明文、自定义加盐字段宽度VARCHAR(255)按 60 字符卡死防重复唯一索引 捕获 SQLSTATE23000只做SELECT查重防注入PDO 预处理 关闭模拟预处理字符串拼接 SQL防 CSRF/XSShash_equals校验令牌、输出转义用比令牌、直接回显注册功能的复杂度不在写入一条记录而在把不可信输入、并发写入和会话安全这三件事同时处理干净。记住三条底线密码只经password_hash()、防重只信唯一索引、所有输入输出都要过校验和转义——这个功能就足够稳了。