恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Flask+SQLite实现单卡登录与账密登录双入口Web案例详解
首页
资讯中心
/
Flask+SQLite实现单卡登录与账密登录双入口Web案例详解
Flask+SQLite实现单卡登录与账密登录双入口Web案例详解
发布时间:2026/10/10 9:40:33
做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项目里最直接的方式是用sessionfrom 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请求体是JSONapp.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(code400, msg用户名和密码不能为空), 400 user User.query.filter_by(usernameusername).first() if user is None or not user.password_hash: return jsonify(code400, msg用户名或密码错误), 400 if not check_password_hash(user.password_hash, password): return jsonify(code400, msg用户名或密码错误), 400 create_login_session(user.id) return jsonify(code200, msg登录成功, data{username: user.username})这段代码有三个值得学习的点。第一是空值校验放在查询之前避免空字符串打到数据库。第二是用户名不存在、用户没有设置密码、密码错误这三种情况统一返回同样的提示防止攻击者通过错误信息差异去枚举用户名。第三是登录成功后才create_login_session失败时不产生任何会话副作用。可能有人会问连user.password_hash为空的用户也返回用户名或密码错误对故意这么做的。通过卡片自动创建的用户虽然存在但不应该在登录接口暴露这个用户存在但还没设置密码这件事。4.2 卡号卡密登录接口单卡登录的接口路径是POST /api/login/cardapp.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(code400, msg卡号和卡密不能为空), 400 card Card.query.filter_by(card_nocard_no).first() if card is None or not check_password_hash(card.card_key_hash, card_key): return jsonify(code400, msg卡号或卡密错误), 400 if card.expire_at and datetime.strptime(card.expire_at, %Y-%m-%d) datetime.now(): return jsonify(code400, msg卡片已过期), 400 if card.user_id is not None: create_login_session(card.user_id) return jsonify(code200, msg登录成功, data{username: card.user.username}) user User(usernamefcard_{card_no}, password_hashNone) 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(code200, 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(code401, msg未登录), 401 user User.query.get(user_id) if user is None: session.clear() return jsonify(code401, msg登录状态失效), 401 return jsonify(code200, data{username: user.username})登出接口是POST /api/logout逻辑更简单app.route(/api/logout, methods[POST]) def logout(): session.clear() return jsonify(code200, 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跨域问题。当前后端分别部署在不同域名时浏览器默认不会携带CookieFlask的session自然存不下来。开发阶段最简单的做法是同源访问也就是让Flask同时托管前端页面如果一定要前后端分离fetch要加credentials: include后端还要配置允许携带凭证的跨域响应头。这个坑基本会出现在所有第一次做前后端联调的人身上。第三个是浏览器自动填充干扰调试。你明明没输入账号页面却自动带出了以前保存过的密码提交结果自然不符合预期。调试时建议开无痕窗口或者给表单加上autocompleteoff。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、登录方式、是否成功都记录下来方便做安全审计。目前只有最核心的登录闭环但有了这个基础两块内容基本就是在现有表上多加字段和路由的事。最后分享一点我个人的体会这个项目从写第一行代码到整理成开源案例我最大的感受是登录功能难的根本不是技术而是把不同入口的设计意图理清楚。单卡登录和账户密码登录放在一起不是功能堆叠是让用户在不同场景下都有最省力的选择。后面不管你接多少种第三方登录这个思路都是一样的。