不少刚学完 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 里对应SecureRandom,Python 则是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.ttf,Linux 服务器上常见的路径是/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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;插入一条测试用户数据时,password_hash的哈希值可以先用一个临时脚本生成,比如:
echo password_hash('your_password', PASSWORD_DEFAULT);把输出的哈希值填到password_hash字段就行。这里要强调一下:UNIQUE约束必须加在username上,否则两个相同用户名会同时存在,登录时查到的记录不唯一,后面很容易出幺蛾子。
3.2 登录表单页:验证码图片的展示与刷新
前端页面是登录功能的脸面,验证码的展示方式直接影响用户的第一感受。<img>标签直接指向生成验证码的接口就够了,但这里有个经典问题:浏览器会缓存图片,导致用户点击刷新后看到的还是同一张验证码。解决方案是在图片地址后面拼接一个时间戳参数:
<img src="captcha.php" alt="验证码" id="captchaImg" onclick="refreshCaptcha()" 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?error=captcha'); exit; } // 校验通过后,立即消费掉验证码,防止复用 unset($_SESSION['captcha_code']); // 第二步:按用户名查用户 $pdo = new PDO('mysql:host=localhost;dbname=your_db;charset=utf8mb4', '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?error=password'); 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 保存 Session |
5. 做完别急着上线:安全与体验的平衡
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 做成分布式验证码、实现滑块验证、或者把登录方式拓展成扫码登录。但不管扩展得多复杂,底层的逻辑和今天讲的这些核心原则是相通的。