资讯详情 用户登录案例与随机验证码结合:从表单校验到会话管理
📅 2026/10/18 3:53:46
不少刚学完 Web 基础的朋友都会拿“用户登录”当第一个综合项目来做。但很多人做到最后发现自己写的登录功能除了能查一下数据库、比对一下密码根本谈不上“系统”。原因很简单真实场景里的登录永远不会只靠“用户名 密码”这两样东西就放行验证码就是最常见、也最有效的一道防线。这个项目标题——“用户登录案例和随机产生验证码结合”正好把登录功能的完整闭环补上了从表单提交、服务端校验、会话建立到动态生成一张带干扰的图片验证码并完成比对。做完它你才算真正把“请求—处理—响应”这条链路走通了。先说清楚这个内容能解决什么问题。如果你正准备做一个完整的登录模块或者想搞明白验证码是怎么从一串随机字符变成一张图片、又是怎么和用户的会话关联起来的那这篇文章就是给你准备的。我用 PHP 作为示例语言因为 GD 库绘制图片的代码最直观和 Session 的结合也最自然但核心思路换成 Java 的 BufferedImage、Python 的 Pillow 完全成立。下面我会把整个项目拆开从设计思路、验证码生成细节、登录校验主流程到实战中一定会踩的坑都过一遍。1. 项目拆解登录与验证码为什么要放在一起做1.1 没有验证码的登录系统到底会出什么问题很多人最初不理解登录就登录为什么非要填那几个歪歪扭扭的字母数字我见过太多初学者在项目里直接砍掉验证码理由是“影响用户体验”。如果你只是本地写个 Demo确实不影响但只要系统一上线哪怕只是挂在一个小办公室里问题马上就会暴露出来。最典型的是暴力破解。密码就那么多位程序可以每秒尝试成百上千次组合人工根本扛不住。还有撞库——攻击者从其他网站泄露的库里面拿到一大批“用户名 密码”的组合然后到你的登录接口批量试。这种请求是脚本发起的没有任何人类行为特征单纯靠限制登录次数也能挡一部分但验证码是最直接、成本最低的“人机识别”手段。你完全可以把验证码理解成小区门禁真正的住户真人用户刷一下卡就能进但想混进去的推销员恶意脚本总得先过门卫这一关。门卫不会盘问每个住户但一定会拦住批量闯入的可疑人员。验证码就是这个门卫而且它只在“异常流量”出现时才显得重要平时你甚至感觉不到它的存在。1.2 方案选型自研图片验证码还是第三方验证码这是做项目时第一个要做的决策。自研方案就是自己用图像库画一张带随机字符的图片第三方方案则是接入现成的验证码服务滑块、点选、无感验证这类。两者没有绝对的好坏只看你的场景。维度自研图片验证码第三方验证码服务开发成本低几百行代码搞定需要申请账号、对接 SDK维护成本自己管生成和校验逻辑依赖服务商稳定性对抗能力一般能挡住批量脚本强能识别复杂人机行为适用场景学习项目、内部系统、小型应用对外正式系统、高价值业务用户体验中规中矩滑块/无感体验更好我个人的建议是如果这是学习项目或者公司内部系统自研完全够用如果是面向公网的生产系统至少考虑一下第三方方案。更重要的是就算你最终接入了第三方验证码也应该自己动手实现一遍自研方案——因为验证码生成、会话存储、校验时机这些逻辑在任何方案里都是相通的你理解了底层原理对接任何 SDK 都只是看文档的事。1.3 技术栈选择和基础数据流设计这个项目的技术栈我推荐“后端生成 前端展示 会话存储校验”的组合后端用 PHP 演示前端就是最基础的 HTML JavaScript数据库用 MySQL。整体流程是用户请求登录页前端img标签指向一个生成验证码的接口后端生成一串随机字符同时把字符的小写形式存进 Session然后把字符画成图片返回用户看到图片把字符输入表单连同用户名、密码一起提交后端先比对验证码忽略大小写通过后再查数据库、验密码登录成功后后端写入登录态前端跳转到主页。这里最关键的一个认知是验证码的值不是存在数据库里的也不是通过某种方式“藏”在图片里的而是存在服务端会话中的。图片只是把这串字符画出来给用户看真正做比对依据的是 Session 里那份原始值。像存包柜柜子Session 数据在服务端手牌Cookie在客户端存包的人凭手牌去取对应的柜子。2. 随机验证码生成核心细节与实现原理2.1 随机字符集设计为什么必须排除易混淆字符验证码看起来只是“随机取几个字符”但这里的坑比你想的多。第一个问题是字符集。如果你把0、O、1、l、I这些长相接近的字符都放进候选池用户就很容易看错、输错然后陷入“看不清—猜错—换一张—再看错”的循环。我常用的字符集是这样定义的ABCDEFGHJKLMNPQRSTUVWXYZ23456789去掉了0、O、1、I剩下 32 个字符。别看只是少几个字母它对用户体验的提升是立竿见影的。另外我建议字符集里区分大小写以外的字母和数字但在比对的时候统一转成小写再比较这样用户输入时按 Shift 或者大写锁定也不影响校验结果。关于随机数生成还有一点要提醒不要用rand()或者mt_rand()直接生成验证码字符尤其是安全敏感场景。PHP 里应该用random_int()Java 里对应SecureRandomPython 则是secrets模块。它们的共同点是基于系统熵源生成不可预测的随机数而普通的伪随机数生成器在已知种子和算法的情况下是可以被预测的。这里多说一句验证码本身就是防机器攻击的如果生成验证码的随机数还能被攻击者预测那这道防线就等于没建。2.2 绘图核心参数画布、字体、背景、干扰线生成图片验证码本质上就是用图像库在画布上绘制字符再叠加一些干扰元素。我用 PHP 的 GD 库写一个最典型的版本?php session_start(); $width 120; $height 40; $length 4; $chars ABCDEFGHJKLMNPQRSTUVWXYZ23456789; $code ; $maxIndex strlen($chars) - 1; for ($i 0; $i $length; $i) { $code . $chars[random_int(0, $maxIndex)]; } // 存 Session统一小写 $_SESSION[captcha_code] strtolower($code); // 创建画布 $image imagecreatetruecolor($width, $height); // 背景色浅灰 $bgColor imagecolorallocate($image, 245, 245, 245); imagefill($image, 0, 0, $bgColor); // 画干扰线 for ($i 0; $i 5; $i) { $lineColor imagecolorallocate($image, random_int(100, 180), random_int(100, 180), random_int(100, 180)); imageline($image, random_int(0, $width), random_int(0, $height), random_int(0, $width), random_int(0, $height), $lineColor); } // 画字符用 TTF 字体实现旋转 $fontPath __DIR__ . /assets/arial.ttf; for ($i 0; $i $length; $i) { $fontSize random_int(18, 20); $angle random_int(-20, 20); $x 12 $i * 25; $y random_int(28, 34); $textColor imagecolorallocate($image, random_int(0, 80), random_int(0, 80), random_int(0, 80)); imagettftext($image, $fontSize, $angle, $x, $y, $textColor, $fontPath, $code[$i]); } // 画噪点 for ($i 0; $i 100; $i) { $noiseColor imagecolorallocate($image, random_int(120, 220), random_int(120, 220), random_int(120, 220)); imagesetpixel($image, random_int(0, $width - 1), random_int(0, $height - 1), $noiseColor); } header(Content-Type: image/png); imagepng($image); imagedestroy($image);这里逐个解释参数画布尺寸 120×40放 4 个字符比较合适。画布太小字符挤在一起难辨认太大图片加载慢、也没必要。背景用浅色系字符用深色系保证对比度足够。有些教程把背景做得花里胡哨结果用户第一眼根本看不清字符这是反面教材。干扰线画 5 条左右颜色是 100~180 的中灰色调太黑会抢字符的视觉焦点太浅容易被图像处理算法滤掉。噪点 100 个左右模拟图像噪声。这个数量对真人几乎无感但对简单的 OCR 识别是有效的干扰。字符旋转角度 ±20 度太小没效果太大用户难辨认。关于字体文件imagettftext需要指定一个 TTF 字体路径。Windows 环境下可以用系统自带的arial.ttfLinux 服务器上常见的路径是/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf。如果项目环境里实在找不到字体文件可以退回用imagestring()画内置字体虽然字符不能旋转、效果略单调但先跑通流程没问题。2.3 Session 存储细节存什么、存多久、什么时候清验证码的值要存进 Session这一点前面说过。但有几个细节值得展开。存储键名我建议命名得具体一点比如captcha_code而不是code因为一个系统里可能同时存在多个验证码场景登录验证码、找回密码验证码、注册验证码键名太泛容易互相覆盖。如果场景多更规范的做法是在 Session 里存一个数组比如$_SESSION[captcha][login]和$_SESSION[captcha][register]每个场景独立。过期时间方面验证码不宜长期有效一般设置 5 分钟。因为验证码的目的是证明“当前这次请求是人发出来的”而不是给用户一个永久有效的通行证。实现方式有两种一种是 Session 本身配置了过期时间另一种是在 Session 里额外存一个生成时间戳校验时判断是否超时。我推荐后者因为 Session 的过期时间是全局的你不可能为了验证码单独把整个 Session 生命周期改短。校验通过之后记得把 Session 里的验证码值删掉或者置空。这一步很多人忽略导致同一个验证码可以反复使用。虽然验证码有效期只有几分钟攻击面有限但“一次性”是验证码的基本自我修养别在这种地方偷懒。3. 登录主流程实操从数据库设计到前后端联调3.1 数据库准备用户表的基本设计登录功能绕不开用户存储。很多新手会直接明文存密码这是绝对错误的做法。密码必须用不可逆算法生成哈希后再存储PHP 里直接用password_hash()和password_verify()就行不需要自己设计任何加盐逻辑这两个函数已经把加盐和哈希强度处理好了。用户表最小化的结构如下CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;插入一条测试用户数据时password_hash的哈希值可以先用一个临时脚本生成比如echo password_hash(your_password, PASSWORD_DEFAULT);把输出的哈希值填到password_hash字段就行。这里要强调一下UNIQUE约束必须加在username上否则两个相同用户名会同时存在登录时查到的记录不唯一后面很容易出幺蛾子。3.2 登录表单页验证码图片的展示与刷新前端页面是登录功能的脸面验证码的展示方式直接影响用户的第一感受。img标签直接指向生成验证码的接口就够了但这里有个经典问题浏览器会缓存图片导致用户点击刷新后看到的还是同一张验证码。解决方案是在图片地址后面拼接一个时间戳参数img srccaptcha.php alt验证码 idcaptchaImg onclickrefreshCaptcha() title看不清点击刷新 script function refreshCaptcha() { var img document.getElementById(captchaImg); img.src captcha.php?t new Date().getTime(); } /script这个t参数后端根本不需要接收它的存在只是为了打破浏览器的 URL 缓存——URL 变了浏览器就会重新发一次请求。这个手法在 Web 开发里非常常见不只是验证码任何“需要强制刷新动态内容”的 GET 请求都可以用。表单部分我就不贴完整 HTML 了需要注意的点是用户名、密码、验证码三个输入框都要设置required但前端校验只是装饰真正的校验逻辑必须放在后端。我见过有人在前端用 JavaScript 校验验证码这是非常危险的——攻击者根本不需要走你的页面接口直接构造 HTTP 请求就能绕过前端校验。3.3 后端校验逻辑先验验证码再验密码登录处理接口是整个项目的核心很多人一上来就写查数据库、比对密码把验证码校验放在最后甚至忘了。这是逻辑顺序的问题。正确的顺序是先验验证码再查用户再验密码。先验验证码有两个原因。第一省资源。如果验证码不对说明这次请求大概率不是真人操作没必要再走一次数据库查询。第二防时序泄露。如果验证码正确但密码错误和验证码错误时返回的提示应该尽量一致避免攻击者通过不同的响应时间或返回信息判断“这个用户名是否存在”。核心代码如下?php session_start(); if ($_SERVER[REQUEST_METHOD] ! POST) { exit(非法请求); } $username trim($_POST[username] ?? ); $password $_POST[password] ?? ; $captcha strtolower(trim($_POST[captcha] ?? )); // 第一步验证码校验 if (empty($captcha) || $captcha ! ($_SESSION[captcha_code] ?? )) { header(Location: login.php?errorcaptcha); exit; } // 校验通过后立即消费掉验证码防止复用 unset($_SESSION[captcha_code]); // 第二步按用户名查用户 $pdo new PDO(mysql:hostlocalhost;dbnameyour_db;charsetutf8mb4, root, ); $stmt $pdo-prepare(SELECT id, username, password_hash FROM users WHERE username ?); $stmt-execute([$username]); $user $stmt-fetch(); // 第三步密码校验 if ($user password_verify($password, $user[password_hash])) { // 登录成功写入会话 session_regenerate_id(true); $_SESSION[user_id] $user[id]; $_SESSION[username] $user[username]; header(Location: index.php); exit; } header(Location: login.php?errorpassword); exit;注意几个容易被忽略的细节trim()对输入做去首尾空格处理用户名和验证码都可能被手滑敲出空格。strtolower()统一转小写配合生成时的小写存储实现大小写不敏感校验。session_regenerate_id(true)在登录成功后重置 Session ID。这是防止会话固定攻击的重要步骤攻击者在登录前给用户塞一个已知的 Session ID登录后被重置就会失效。用 PDO 预处理语句查库杜绝 SQL 注入。关于“验证码错误”和“用户名或密码错误”返回什么提示日常使用中两个提示可以不一样毕竟验证码输错是正常摩擦但“用户名不存在”和“密码错误”这两种提示建议统一否则攻击者可以通过提示枚举出系统里存在的用户名。3.4 让代码更健壮同一 Session 多标签页冲突问题这里有个容易踩的坑如果用户同时打开两个登录标签页A 页面加载时生成了验证码B 页面加载时又覆盖了 Session 里的验证码然后用户回到 A 页面输入验证码提交会发现永远校验失败。解决思路是放弃“Session 里只存一个验证码”的模型改成“Session 里存一个验证码列表”每个验证码关联一个 ID。图片 URL 里带上这个 ID$_SESSION[captcha][$captchaId] $code;提交时表单带上captcha_id字段后端根据 ID 去 Session 数组里找到对应的验证码做比对。这样多个标签页各自持有不同的captcha_id互不干扰。这个设计在复杂场景下是必须的如果你只想做基础功能可以先不实现但要意识到这个问题的存在。4. 排查实录验证码登录最常见的五个坑4.1 验证码图片不显示这是新人遇到最多的问题。可能的原因有好几个第一个是输出被污染。生成验证码的 PHP 文件里如果在header(Content-Type: image/png)之前有任何输出比如 BOM 头、空格、HTML 片段图片就会被破坏。仔细检查 PHP 文件开头?php之前不能有任何字符文件编码也不要有 UTF-8 BOM。第二个是 GD 库未启用。确认环境里php -m | grep gd能输出gd没有就安装 PHP GD 扩展。第三个是路径问题。如果通过 URL 访问captcha.php时返回 404检查路由配置和文件路径。还有一个隐蔽问题如果页面引用了captcha.php但接口返回的是错误信息而不是图片可以先在浏览器里单独访问这个 URL看响应头是不是Content-Type: image/png。4.2 验证码明明输对了却总是校验失败这通常不是“校验失败”而是“比对的值不一致”。最常见的原因是大小写处理失误生成时存的是小写输入时转了小写但如果有一方没转就会永远对不上。检查会话里实际存的值可以在校验前加个临时日志打出来看。另一个高频原因是 Session 被覆盖。多标签页同时打开登录页后打开页面的验证码覆盖了先打开页面的。排查方法是在生成验证码的文件里打印$_SESSION[captcha_code]然后分别在两个标签页刷新图片看值的变化。如果确认是这个原因参照前文的多验证码方案解决。4.3 点击刷新验证码但图片不变这个就是前面说的浏览器缓存问题。解决办法很简单刷新图片时在src后拼接时间戳。如果拼了时间戳还是不变检查是不是请求根本没发出去——有些浏览器对img的onclick刷新事件做了限制可以把refreshCaptcha()改成一个function确认能正常调用或者在 JS 里加一个console.log看看有没有执行到。4.4 登录成功但跳转后又回到登录页这个问题的本质是登录态没有建立成功。可能的原因有几类Session 没有正确写入检查$_SESSION赋值前后的日志Cookie 没有保存检查浏览器是否禁用了 Cookie或者 Session Cookie 的Domain、Path配置错误服务器时间和客户端时间差异过大导致 Cookie 立即过期。还有一个冷门的如果你在session_start()之前有过任何输出Session 会写入失败导致登录态丢失。这和验证码图片不显示的“输出污染”是同源问题。4.5 分布式部署下验证码失效如果系统部署在多个服务器节点后面每台服务器的 Session 各自独立用户第一次请求落到 A 服务器生成验证码提交时却转发到 B 服务器B 服务器的 Session 里没有这个验证码必然校验失败。解决办法是把 Session 存到公共存储里比如 Redis。PHP 可以配置 Session 处理器为redis代码里其他逻辑完全不用改。这个改动虽然只有几行配置但涉及环境依赖本地开发时一般不会触发只有上了集群架构才会遇到。做项目的时候先知道有这回事别到时候一脸懵。症状常见原因解决思路图片无法显示输出污染 / GD 未启用 / URL 路径错误检查 BOM 和输出、安装 GD、单测 URL 响应头校验永远失败大小写不一致 / Session 被覆盖统一转小写、打印日志排查、多验证码方案刷新图片不变浏览器缓存拼接时间戳参数登录后仍回登录页Session 写入失败 / Cookie 问题检查输出、Cookie 配置、Session 存储多节点下随机失败Session 不共享使用 Redis 保存 Session5. 做完别急着上线安全与体验的平衡5.1 验证码强度不是越高越好验证码的本质是“拦住机器但不要拦人”。有些开发者生怕被破解把干扰线画了十几条、字符扭成麻花、背景色和文字色混在一起结果真人也看不清用户流失率高得吓人。合理的参数范围我实测下来是这样的字符 4 个字体 18~20 号旋转 ±20 度干扰线 5 条左右噪点密度每 120×40 画布约 100 个。这个配置能让绝大多数真人一眼看明白同时让简单的图文识别工具需要额外写不少处理逻辑才能提取字符。必须认清一个现实自研字符验证码的对抗能力是有上限的专业的 OCR 技术配上图像预处理破解率并不低。所以它适合的定位是“挡批量脚本”不是“挡专业人士”。如果你的业务价值足够高、有人愿意花时间专门破解你的验证码那你就需要更重的人机验证方案了。5.2 前端校验是助手的保险杠不是安全边界我在几家公司都见过把验证码校验逻辑写在 JavaScript 里的设计这属于典型的自欺欺人。攻击者根本不会打开你的网页直接拿工具构造 POST 请求就够了你的一切前端逻辑对他来说都是透明的。正确的理解是前端校验存在的意义是减少无效请求、提升用户体验比如验证码为空时不用等后端返回真正能挡人的校验必须发生在后端。凡是“为了安全”写在前端的逻辑都要默认攻击者可以绕过核心逻辑必须放在后端。5.3 登录频率限制验证码之后还应该有的那道闸门验证码并不是登录安全的全部。即使有了验证码一个攻击者还是可以“人工”慢慢试密码——只是从每秒上百次变成了每分钟几次。更合理的做法是同时加频率限制同一个用户名在一定时间内连续失败 N 次就锁定一段时间同一个 IP 在短时间内登录失败次数超过阈值就临时限制登录。这样即使密码被猜到一定程度攻击节奏也会被打断给你和用户争取到处理时间。具体的策略不难写但要注意别误伤正常用户。比如连续失败 5 次后锁 10 分钟这个阈值对正常用户来说足够宽裕又能挡住大部分无脑爆破。如果要在生产环境用建议参考成熟的限流算法而不是简单用一个计数器。5.4 上线前检查清单根据我多次做这类模块的经验最终上线前至少要把下面这几项过一遍密码字段是否用了password_hash数据库里是否存在明文密码验证码是否是“一次性”的校验成功后有没有立即清除登录成功后有没有调用session_regenerate_id(true)是否设置了合理的 Session 过期时间比如 30 分钟无操作自动退出是否对登录失败做了频率限制是否所有数据库查询都使用了预处理语句。最后分享一个我自己在实际项目中踩过的坑。一个内部系统上线后很多用户反馈验证码永远是一张图——所有人都看到同一个验证码。查了半天发现是 CDN 把验证码接口当静态资源缓存了。后来在生成验证码的接口响应头上显式加了Cache-Control: no-store, no-cache, must-revalidate才算解决。这个经验让我意识到验证码接口不能只是“不设置缓存”因为默认情况下前置代理和浏览器可能会按自己的策略缓存它必须显式地告诉所有缓存层这个请求不可缓存。验证码这个小模块做完你会发现自己对会话管理、图像处理、请求校验的理解都会上升一个台阶。后面想继续深入可以在这个基础上扩展接上 Redis 做成分布式验证码、实现滑块验证、或者把登录方式拓展成扫码登录。但不管扩展得多复杂底层的逻辑和今天讲的这些核心原则是相通的。