做Web登录功能的时候,最让人纠结的往往不是代码本身,而是到底该选哪种登录方式。很多新手一上来就只做账号密码登录,结果业务侧临时说"我们还要支持一种卡密登录",整个流程又得推翻重写。最近我把一个练手的Web登录案例整理成了全开源项目,它同时实现了单卡登录(也就是卡号加卡密快速认证)和账户密码登录,两个功能在一个网页版项目里无缝切换,后端对接逻辑完整公开,特别适合正在学验证对接的小白直接抄作业。这篇文章会把项目从设计到落地的思路完整复盘一遍,包括表结构怎么建、接口怎么写、前端怎么切、上线要防什么坑,顺着读下来基本可以自己复现一套。
既然标题里有"全网独家首创",我也坦白说明一点:这个说法更多是项目定位的表达。我不能拍胸脯保证全宇宙没人做过,但在我搜到的开源教学案例里,把单卡登录和账密登录这种双入口、同一个用户体系、还专门写给小白的网页版完整案例,确实很少见。这也是我整理这个项目的初衷。
1. 这个项目解决的是什么问题:单卡登录和账密登录的区别与适用场景
1.1 单卡登录到底是什么
"单卡登录"如果放到搜索引擎里查,叫法非常多,有人叫卡密登录,有人叫一卡一密,也有人直接叫卡片认证。我在这个项目里把它定义成一种轻量化的免注册登录方式:每个用户手里持有一张唯一卡,卡上有两个关键信息,分别是卡号和卡密。登录的时候不需要注册账号、不需要设置密码,只要输入卡号和卡密,系统验证通过就直接登录成功。
这和会员制门店刷会员卡、网吧用会员号上机是一个逻辑,只不过我们把实体卡变成了网页上一组可输入的字符串。为什么强调"单卡"?因为在项目设计里,一个用户最多绑定一张有效卡,卡和用户是严格的一一对应关系。这既避免了一个人拿多张卡导致数据混乱,也方便后续做激活码、兑换码、内部邀请码等场景扩展。单卡的实现比多卡要简单,流程短、判断少,更适合小白把核心登录链路看清楚。
1.2 账密登录为什么还需要保留
既然单卡登录这么方便,是不是账密登录就可以不要了?答案是反过来:账密登录仍然是Web系统最通用、最基础的能力,它承载着注册、找回密码、修改资料这一整套账号体系。单卡登录更像是"快捷入口"或"批量发卡"场景下的补充方案,它很难实现自助注册,也解决不了"用户的卡丢了怎么办""卡被借给别人怎么办"这类问题。
所以项目里把两种登录方式同时做出来,并且共用同一个用户体系。你可以先用账密登录创建账号,再给这个账号绑定一张卡;也可以在发卡系统里批量生成卡片,拿到卡的人首次登录时自动创建账号。两条路径最终到达同一个登录结果,这才是完整的产品逻辑,而不是简单地把两个表单堆在页面上。
1.3 适合承接这个案例的典型业务场景
结合我接触过的需求,这类双登录方式最常出现在三个场景里。
第一个是会员管理系统。门店给会员发实体卡或电子卡,会员既可以用卡号卡密快速登录,也可以自己设置账号密码做长期管理。第二个是软件授权和激活码系统。用户购买软件后获得一个激活卡密,打开网页输入卡号卡密完成设备绑定和登录;官方账号则走账密登录,用来查看授权记录和续费历史。第三个是内部工具平台。公司给临时员工或者外部协作者发放一次性卡片,收到卡的人用卡密登录,内部正式员工走账号密码,两个入口互不干扰。
如果你正在做的项目恰好属于这几类,这个开源案例可以直接拿来改,后面我会把具体改造点标注出来。
2. 技术选型和项目骨架:为什么用这套组合
2.1 技术栈选择和理由
为了照顾小白用户,后端我选了Python Flask加SQLite的组合,前端用原生HTML/CSS/JavaScript,没有引入任何前端框架和构建工具。原因很简单。
Flask是微框架,路由、请求、响应之间的逻辑很清晰,不会像重型框架那样让新手看不懂"这一步到底是谁在调用我"。SQLite是文件型数据库,不需要单独安装数据库服务,代码拉下来就能跑。原生前端不需要node_modules,不需要打包工具,打开浏览器就能看效果。这套组合虽然朴素,但登录验证的核心原理跟大型项目完全一致:密码哈希、会话token、接口校验、前端联调,在Flask里学一遍,换到其他后端框架一样能用。所以选它不是因为生产环境能力多强,而是最适合用来把原理看清楚。
2.2 目录结构与核心文件划分
项目打开后的目录结构大致是这样的:
web-login-demo/ ├── app.py ├── models.py ├── auth.py ├── utils.py ├── requirements.txt ├── init_db.py └── templates/ ├── login.html └── dashboard.html这个划分是刻意的:models只负责数据映射,auth只做认证逻辑,app.py里的路由只负责把浏览器请求分发到对应逻辑,utils放卡号生成、格式校验这类与业务无关的工具函数。小白在读代码时不需要一次看完所有文件,顺着一条链路读下来即可:前端发起请求,路由接收,auth校验,models读写,返回结果。这个顺序和大部分真实项目的请求链路是一样的。
2.3 环境准备和启动方式
本地运行需要Python 3.8以上版本。先建虚拟环境,再装依赖:
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txtrequirements.txt里就两行:flask和flask-sqlalchemy。SQLite不需要额外装驱动,Python内置了sqlite3。初始化数据库之后启动服务:
python init_db.py python app.py默认跑在5000端口,浏览器打开 http://127.0.0.1:5000 就能看到登录页。init_db里我会顺手插入一张测试卡和一组测试账号,方便你第一次跑通。
3. 数据库设计与登录核心原理
3.1 用户表和卡片表的结构设计
数据库我建了两张表:users存用户,cards存卡。之所以把卡单独拆出来,而不是塞进用户表里,是因为卡有自己的生命周期,需要记录签发时间、过期时间、冻结状态这些独立属性。
CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, card_no TEXT UNIQUE NOT NULL, card_key_hash TEXT NOT NULL, user_id INTEGER, status TEXT DEFAULT 'unused', expire_at TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) );注意users表中的password_hash允许为空,这是两种登录方式共存的关键:通过卡片首次登录自动创建的用户,一开始没有密码,等它后续设置了账密再补上。在代码里我用Flask-SQLAlchemy对应的模型类来映射这两个表,核心字段和上面SQL保持一致。实际项目中你可以根据业务需要继续加字段,但这两张表已经是登录闭环的最小集。
3.2 密码和卡密的存储策略
先说结论:不管密码还是卡密,一律不以明文形式入库。密码用Werkzeug提供的generate_password_hash做哈希,底层是PBKDF2加随机盐,验证时用check_password_hash对比,绝不自己拼接字符串做md5。卡密也一样处理,只不过卡密通常由系统生成,是12位随机强字符串,所以它的哈希格式和密码可以统一。
这里有一个容易忽略的点:卡号本身是可读的,比如"VIP001001",所以卡号不需要哈希,但必须建立唯一索引;前端和后端都要限制卡号格式。卡密则必须哈希,因为一旦数据库泄露,明文卡密就全完了。哪怕只是开发阶段临时表,我都不建议存明文,坏习惯一旦养成很容易带到生产环境。
再补充一个操作细节:初始化数据库时插入测试卡,不能用"先算好哈希再写死到SQL里"的方式,因为每次生成随机盐会导致哈希不同。正确做法是写一个seed脚本,用同一条代码路径生成卡片并做哈希,这样既能保证发卡逻辑和登录校验逻辑一致,也能避免两处实现跑偏。
3.3 登录会话的生成与校验逻辑
登录成功后,后端会生成一个随机会话凭证并交给浏览器,之后的请求浏览器自动携带这个凭证,后端解析出用户身份后放行。在Flask项目里,最直接的方式是用session:
from flask import session def create_login_session(user_id): session['uid'] = user_id session.permanent = TrueFlask的session把数据用SECRET_KEY签名后存在Cookie里,所以要记住两件事。第一,SECRET_KEY必须用足够长的随机字符串。第二,它不能写在仓库的代码里,要放到环境变量里,否则一旦代码泄露,任何人都可能伪造登录状态。这个机制是把数据放在客户端,服务端无状态,所以水平扩展时不需要考虑session同步问题,这也是它适合小白起步的原因之一。
4. 后端接口逐段拆解:账密登录与卡密登录的对接代码
4.1 账户密码登录接口
账密登录的接口路径是POST /api/login,请求体是JSON:
@app.route('/api/login', methods=['POST']) def login_by_password(): data = request.get_json() username = data.get('username', '').strip() password = data.get('password', '') if not username or not password: return jsonify(code=400, msg='用户名和密码不能为空'), 400 user = User.query.filter_by(username=username).first() if user is None or not user.password_hash: return jsonify(code=400, msg='用户名或密码错误'), 400 if not check_password_hash(user.password_hash, password): return jsonify(code=400, msg='用户名或密码错误'), 400 create_login_session(user.id) return jsonify(code=200, msg='登录成功', data={'username': user.username})这段代码有三个值得学习的点。第一是空值校验放在查询之前,避免空字符串打到数据库。第二是用户名不存在、用户没有设置密码、密码错误这三种情况,统一返回同样的提示,防止攻击者通过错误信息差异去枚举用户名。第三是登录成功后才create_login_session,失败时不产生任何会话副作用。
可能有人会问:连user.password_hash为空的用户也返回"用户名或密码错误"?对,故意这么做的。通过卡片自动创建的用户虽然存在,但不应该在登录接口暴露"这个用户存在但还没设置密码"这件事。
4.2 卡号卡密登录接口
单卡登录的接口路径是POST /api/login/card:
@app.route('/api/login/card', methods=['POST']) def login_by_card(): data = request.get_json() card_no = data.get('card_no', '').strip() card_key = data.get('card_key', '').strip() if not card_no or not card_key: return jsonify(code=400, msg='卡号和卡密不能为空'), 400 card = Card.query.filter_by(card_no=card_no).first() if card is None or not check_password_hash(card.card_key_hash, card_key): return jsonify(code=400, msg='卡号或卡密错误'), 400 if card.expire_at and datetime.strptime(card.expire_at, '%Y-%m-%d') < datetime.now(): return jsonify(code=400, msg='卡片已过期'), 400 if card.user_id is not None: create_login_session(card.user_id) return jsonify(code=200, msg='登录成功', data={'username': card.user.username}) user = User(username=f'card_{card_no}', password_hash=None) db.session.add(user) db.session.flush() card.user_id = user.id card.status = 'active' db.session.commit() create_login_session(user.id) return jsonify(code=200, msg='首次绑定登录成功', data={'username': user.username})这段的逻辑重点在最后六行:如果卡从未绑定过用户,系统自动创建一个以card_开头的用户,并把卡标记为active。这么做的好处是发卡方不需要预先逐个创建账号,拿到卡的第一个人就是绑定者,天然实现"一卡一人"。
还有一个细节,卡密校验通过后要检查有没有过期时间。如果expire_at为空,说明不限制有效期;如果有值,项目里统一用'YYYY-MM-DD'这种字符串格式,解析简单也不容易踩时区坑。
4.3 登录状态校验接口与登出
有了登录态之后,前端还需要知道当前用户是不是已经登录了。这里加了一个GET /api/me接口:
@app.route('/api/me') def me(): user_id = session.get('uid') if user_id is None: return jsonify(code=401, msg='未登录'), 401 user = User.query.get(user_id) if user is None: session.clear() return jsonify(code=401, msg='登录状态失效'), 401 return jsonify(code=200, data={'username': user.username})登出接口是POST /api/logout,逻辑更简单:
@app.route('/api/logout', methods=['POST']) def logout(): session.clear() return jsonify(code=200, msg='已退出')别小看/api/me这个接口,它决定了前端联调是否顺滑。浏览器一打开页面就应该请求一次,如果返回401就停留在登录页,如果返回200就跳到欢迎页。很多新手把登录跳转逻辑写死成"登录成功后跳转",一刷新页面就回到登录页,就是因为缺少这个状态恢复接口。
4.4 避免暴力破解:IP冻结和失败次数限制
核心对接完成之后,我建议再加一个最简单的暴力破解防御:按IP记录失败次数,连续失败5次后冻结120秒。代码量不大,用的是内存字典:
from collections import defaultdict import time fail_counter = defaultdict(list) LOCK_THRESHOLD = 5 LOCK_SECONDS = 120 def is_ip_locked(ip): now = time.time() fail_counter[ip] = [t for t in fail_counter[ip] if now - t < LOCK_SECONDS] return len(fail_counter[ip]) >= LOCK_THRESHOLD def record_fail(ip): fail_counter[ip].append(time.time())在登录接口最开始调用is_ip_locked判断,校验失败时调用record_fail记录。这个方案简单有效,但它基于全局内存字典,只适用于单机部署;以后要水平扩展,再把计数器迁到Redis里。对教学项目来说,这个复杂度刚刚好。
5. 前端页面实现:交互细节和对接联调
5.1 登录页面的双Tab交互
登录页顶部是两个Tab按钮,一个"账号密码登录",一个"卡密登录"。点击按钮时,对应的表单区域显示,另一个隐藏。实现方式不复杂,核心是给两个表单不同的id,用一小段JavaScript控制display切换。
这里最值得提醒的是两个表单的输入框name不能重名。账密表单用username、password,卡密表单用card_no、card_key。如果从网上抄了一段现成的表单生成代码,很容易把两个登录表单的字段名写成一样,结果切换Tab以后发起请求,后端永远只拿到第一个表单的数据。
页面底部可以放一行使用提示:账密登录需要先注册,卡密登录需要先获得一张有效卡。别小看这句引导文案,它能挡掉大量"我为什么登不上"的反馈。
5.2 表单校验和错误提示
我没有引入校验库,而是用原生input事件写了一批简单规则。比如账密登录要求用户名至少2个字符、密码至少6位;卡密登录要求卡号以VIP开头且后面为6位数字、卡密长度必须为12位。规则在前端先拦截一次,可以减少无效请求,但真正的安全校验永远在后端。
错误提示展示统一用一个固定区域的div。后端返回的msg直接写入这个div,并用红色边框强调。这里有一个经验:前端不要自己对后端的错误码做二次翻译,直接用后端msg。因为后端可能根据业务规则调整文案,前端一旦写死一份文案,两边迟早会不一致。
5.3 联调过程中常被忽视的细节
我联调时遇到过三个问题,这里集中说一下。
第一个是请求头Content-Type。很多小白用fetch发送JSON时忘记设置Content-Type为application/json,导致Flask的request.get_json()拿到的永远是None。正确写法是:
fetch('/api/login/card', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({card_no: cardNo, card_key: cardKey}) })第二个是session跨域问题。当前后端分别部署在不同域名时,浏览器默认不会携带Cookie,Flask的session自然存不下来。开发阶段最简单的做法是同源访问,也就是让Flask同时托管前端页面;如果一定要前后端分离,fetch要加credentials: 'include',后端还要配置允许携带凭证的跨域响应头。这个坑基本会出现在所有第一次做前后端联调的人身上。
第三个是浏览器自动填充干扰调试。你明明没输入账号,页面却自动带出了以前保存过的密码,提交结果自然不符合预期。调试时建议开无痕窗口,或者给表单加上autocomplete="off"。
6. 上线前必须自查的安全漏洞和实测时踩过的坑
6.1 登录接口的安全漏洞自查
登录接口是系统最暴露的部分,至少要做四步自查。第一,是否限制登录频率。没有频率限制的接口会被脚本批量枚举,哪怕卡密是12位随机串,也存在被撞库的风险。第二,是否返回了过多信息。"用户名已存在""密码错误"这类提示看似友好,实际上在给攻击者递情报。第三,是否在日志里打印了敏感信息。不要把完整卡密和密码输出到控制台或日志文件,调试时也要打码。第四,是否启用了HTTPS。登录页如果还是HTTP明文传输,任何中间人都可以抓包拿到凭证,所有哈希方案都白做了。
这四步里,前两步在这个项目里已经做了,后两步属于部署阶段必须补上的配置。
6.2 我实测过程中遇到的几个坑
写这个案例时我踩了三个坑,一个一个说。
第一个坑出在卡密校验上。我一开始直接用明文卡密对比,数据库里存的也是明文,后来改成哈希校验,但发卡脚本里没有同步改,导致老卡全部无法登录。排查了一个多小时才反应过来,教训就是:卡密生成和卡密校验必须共用同一个哈希工具函数,不要在两处各写一遍。
第二个坑是卡片状态机不完整。最开始我只定义了unused和active两种状态,结果测试时用户把卡冻结后无法解冻,登录接口返回的提示也混乱。后来补上了frozen状态,并把状态检查统一收敛到一个函数里,逻辑才清晰。
第三个坑是SQLite并发写入。项目虽小,但我开两个浏览器窗口同时登录时偶尔会提示database is locked。这是因为SQLite在多个连接同时写库时会锁库。解决办法是尽量缩短事务时间,单条语句完成更新后立刻提交,避免在事务里做耗时的网络请求。
6.3 安全加固的进阶建议
如果要把这个项目用到正式生产环境,优先处理三件事。
第一件,把SQLite换成生产级关系型数据库。原因不是SQLite性能差,而是多进程、高并发场景下文件锁的局限性会被放大。第二件,给会话有效期加滑动过期策略。用户连续操作自动延长过期时间,长时间不操作强制重新登录,比固定超时体验好很多。第三件,接入验证码和异地登录提醒。登录失败次数超过阈值后触发图形验证码;登录成功时如果检测到新的IP或新的浏览器环境,推一条通知给用户。这些功能不复杂,但对账号安全感的提升非常明显。
7. 从本地跑通到生产部署:开源案例的后续扩展
7.1 部署上线的基本操作
最简单的上线路径是买一台云服务器,装好Python环境,把代码传上去,用gunicorn启动Flask应用,再用Nginx做反向代理。这里有两个容易卡住小白的地方。
第一个是服务器防火墙要放行80/443端口。很多服务器默认只开了内网端口,外部访问表现成"网站打不开"。第二个是app.py里host要改成0.0.0.0。如果保持默认的127.0.0.1,那只有本机自己能访问。gunicorn启动命令大致是:
gunicorn -w 2 -b 0.0.0.0:8000 app:app再用Nginx做一层反向代理,负责HTTPS证书、静态资源缓存和日志切割。上线前记得关掉Flask的debug模式,把SECRET_KEY改成环境变量读取。这些做完,登录功能才算正式面向用户。
7.2 从单卡登录扩展到更多登录方式
登录系统的骨架搭好之后,扩展其他登录方式非常快。比如手机验证码登录,只需要增加一个验证码表,再增加一个/api/login/sms接口,最后复用create_login_session就能完成。再比如对接第三方登录,拿到第三方回调里的唯一标识后,按照"先查用户、再建用户、最后建会话"三步走,也能很快接进来。
这个项目真正的价值,是把登录系统的最小闭环做完整了。后续所有新登录方式,本质上都是往这个闭环上挂新的认证入口,核心的会话创建、状态恢复、安全防御逻辑都可以复用。
7.3 这个案例还能怎么继续完善
我后续打算在这个开源案例上补两块内容。一块是发卡管理后台,让管理员可以批量生成卡片、导出卡号卡密、查看每张卡的绑定用户。另一块是统一登录日志,把每一次登录的时间、IP、登录方式、是否成功都记录下来,方便做安全审计。目前只有最核心的登录闭环,但有了这个基础,两块内容基本就是在现有表上多加字段和路由的事。
最后分享一点我个人的体会:这个项目从写第一行代码到整理成开源案例,我最大的感受是登录功能难的根本不是技术,而是把不同入口的设计意图理清楚。单卡登录和账户密码登录放在一起,不是功能堆叠,是让用户在不同场景下都有最省力的选择。后面不管你接多少种第三方登录,这个思路都是一样的。