ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

手写后台登录模块:从建表到部署全流程解析

2026/9/18 2:57:25 拓冰建站 浏览量
手写后台登录模块:从建表到部署全流程解析 网站搭建进行到后台管理这步登录是绕不开的第一道门。别看书上都写得简单真正把登录模块从选型、建表、写接口到部署上线跑通我还是踩了不少坑。这篇文章是“网站搭建实操”系列的第二篇也是后台管理的第一篇我把自己做登录功能的完整思路和关键代码都捋一遍希望能给正在自己搭后台管理的朋友省点时间。无论你是准备用宝塔面板一键部署还是本地IIS上调试登录模块的设计逻辑都一样本文会尽量把为什么这样做也讲清楚。1. 后台登录模块的技术选型为什么我放弃了一键安装的Admin模板1.1 先理清楚一个后台管理系统到底需要什么很多人一说后台管理第一反应就是装个现成的Admin模板比如Flask-Admin、Django Admin或者直接找个开源后台前端框架套上去。但我在开始写代码之前先花了一天把需求捋了一遍。后台管理不是只有登录登录只是整个后台管理系统的安全边界后面还有权限控制、内容管理、操作日志、文件上传这些功能。如果第一步就依赖一个黑盒插件后面想定制登录方式、做多角色权限、接手机号验证码都会变得很被动。所谓“登录”要解决的本质上是三件事你是谁、你凭什么访问、你能做什么。前两件事落在认证和会话管理上第三件事落在权限控制上。这篇文章先处理前两件。把这些边界想清楚之后我决定登录模块自己写不直接用后台管理插件。自己写并不意味着从零造轮子加密、表单校验、会话管理都可以用成熟库只是把认证逻辑掌握在手里。1.2 现成后台插件与手写登录的边界如果你的项目非常标准数据模型就是几张简单的表后台只需要增删改查那Django Admin这类工具确实很省事。我之前用Flask-Admin做过一个内部工具半小时就把管理界面跑起来了连登录都是插件自带的。但这次做网站后台需求没那么简单登录页要跟主站风格一致用户名密码之外后面还要接微信扫码、短信登录这些登录方式而且后台的路由权限希望自己控制。这种情况下现成插件的登录逻辑反而成了约束。我也对比过直接找一套开源后台模板登录页、权限框架都是现成的但随之而来的是要接受它的数据库结构、路由命名和权限模型。改造成本不算低尤其是遇到登录逻辑藏在第三方依赖里面想加一个登录失败锁定功能都要绕很久。自己写登录并不难核心工作就是把用户表建好、密码存安全、会话管好再写一个登录接口和一个登录页面。我把这个边界划得很清楚能用成熟库解决的密码哈希、CSRF防护、表单校验就用不用重复造轮子但认证流程的每一段自己都要看得懂、改得动。下面是我当时做的方案对比供参考方案开发速度定制能力安全可控性适合场景Django Admin快弱中内部数据维护工具Flask-Admin 自带登录较快弱中简单CRUD后台开源后台模板中中中项目风格与模板契合自己写登录权限慢强高长期维护、多登录方式、业务定制多我最终选择手写登录还有一个原因是后面的文章会讲到权限控制和操作日志这些和登录状态是紧密绑在一起的。如果登录模块不是自己写的后面扩展权限体系会很别扭。2. 用户表和会话状态登录功能的地基2.1 建一张最少能用的用户表登录模块的地基是用户表。我见过不少新手把用户名密码字段直接放在业务表里或者用明文存密码这个后面一定会出事。我一般会单独建一张用户表字段尽量精简但该有的一个不能少。当时用的是MySQL建表语句大概是这样的CREATE TABLE sys_user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(64) NOT NULL COMMENT 用户名, password_hash VARCHAR(255) NOT NULL COMMENT 密码哈希值, email VARCHAR(128) DEFAULT NULL COMMENT 邮箱, is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT 是否启用1启用0禁用, last_login_at DATETIME DEFAULT NULL COMMENT 最近一次登录时间, last_login_ip VARCHAR(64) DEFAULT NULL COMMENT 最近一次登录IP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT后台用户表;这张表看起来简单但有几个细节值得说一下。第一username必须有唯一索引否则注册时可能出现重复用户名这个约束要放在数据库层面而不是只靠应用层判断。第二is_active字段很重要后台用户被禁用之后即使密码正确也不能登录。第三last_login_at和last_login_ip不要省这两个字段做安全审计和“异地登录提醒”都很有用。第四我没有单独存salt因为现在的密码哈希库会把随机盐放在哈希字符串里面不需要单独占一个字段。如果你用的是现有框架比如Flask加上SQLAlchemy可以把这张表映射成模型。关键点是不要用用户名作为主键主键用自增id因为用户名将来可能允许修改而主键一旦生成不应该变。2.2 密码加密不再用MD5我用的是Werkzeug哈希方案密码存储是登录模块最不能凑合的地方。很多老教程还在用MD5或者SHA1直接加密密码这在今天看来非常危险。原因是这些算法速度太快攻击者拿到数据库后可以每秒跑几亿次字典攻击普通的常见密码很快就会被碰撞出来。我现在用的是Werkzeug自带的generate_password_hash和check_password_hash它在Flask项目里是标配默认使用pbkdf2算法并且会自动生成随机盐把盐和哈希结果保存在一起。from werkzeug.security import generate_password_hash, check_password_hash # 首次创建用户时保存密码哈希 password_hash generate_password_hash(你的初始密码) # 登录校验时进行比对 if check_password_hash(user.password_hash, input_password): # 密码正确 pass else: # 密码错误 pass为什么我不推荐自己写加密算法因为加密算法很容易写错而且密钥管理是个大坑。Werkzeug这个方案的好处是它会随机加盐即使两个用户设置了一样的密码存储的哈希字符串也不同降低批量破解的风险。同时它还能自动选择当前安全强度足够的哈希方法未来算法升级时只需要重新生成密码哈希即可。这里要提醒一个经验不要在登录功能上线后再想着“给密码加密”应该在一开始就按这个标准来。如果已经有明文或者弱哈希的用户表要尽快提供一个强制改密流程而不是直接迁移哈希结果。因为旧的穷举字典可能已经泄露强制重置密码是唯一稳妥的办法。2.3 Session与Token这次我为什么选了Session登录成功之后服务器需要在后续请求里认出“当前用户是谁”。常见两种方案服务端Session和无状态Token。这次网站后台是传统的服务端渲染页面后台管理界面和主站都在同一个域名下所以我选了Session方案。Session的工作方式可以简化理解为用户登录成功后服务器把用户ID保存在服务端同时生成一个随机session id写入浏览器Cookie。用户后续请求带上这个Cookie服务器根据session id找到用户信息请求就带有登录态了。Flask默认用签名Cookie保存会话数据不需要单独的Session服务但对敏感数据还是建议把session数据放Redis或数据库这会让会话能被集中管理也方便做“强制下线”。from flask import session, redirect, url_for # 登录成功后写入会话 session[user_id] user.id session[username] user.username session.permanent True # 会话有效期 # 退出登录时清除会话 session.clear()如果用前后端分离的SPA架构我会选Token比如JWT来做登录态因为它天然适合接口调用不依赖浏览器Cookie的同源策略。但后台管理页面是服务端渲染的Session更直接、更好控制。不要为了追新技术而用JWT关键是匹配自己的架构。其实后面如果需要给App或者小程序提供接口我可以在用户表再加一个API Token字段实现双轨登录不影响现有后台。3. 登录接口手写实录从表单校验到跳转后台3.1 登录页面的渲染和基础校验登录页面我用Flask的模板引擎来做。它不是前后端分离所以直接用render_template返回一个HTML页面里面放一个登录表单。表单里有用户名输入框、密码输入框、CSRF隐藏字段还有一个验证码的位置后面章节会讲怎么取舍。form methodpost action{{ url_for(admin.login) }} input typehidden namecsrf_token value{{ csrf_token() }} div label用户名/label input typetext nameusername required autofocus /div div label密码/label input typepassword namepassword required /div button typesubmit登录/button {% if error %} p stylecolor: red;{{ error }}/p {% endif %} /form这里有个小细节required属性只是浏览器端的原生校验它能提升用户体验但不能替代后端校验。请求完全可以用工具直接POST到接口跳过前端限制。所以后端校验才是安全底线。我通常在视图函数里先判断请求方法再取表单数据然后逐项校验最后才查数据库。登录页面的样式我没有用太复杂的东西保持简洁。登录这种页面不需要花里胡哨用户要的是快速找到输入框、输入信息、顺利进入后台。如果你想让登录弹窗和页面跳转两种交互都支持可以在前端做AJAX提交但要注意后端返回错误信息时要区分是整页跳转还是局部渲染。我在实际开发中优先用普通表单提交因为逻辑简单、便于排查后面需要登录弹窗时再改成AJAX。3.2 登录接口的完整处理流程附代码登录接口是整个登录模块的核心。我把它处理流程写成一个清晰的逻辑链这样后面排查问题会很方便。from flask import Blueprint, render_template, request, redirect, url_for, session, flash from .models import User from werkzeug.security import check_password_hash admin_bp Blueprint(admin, __name__) admin_bp.route(/login, methods[GET, POST]) def login(): # 已经登录的用户直接进后台 if session.get(user_id): return redirect(url_for(admin.index)) error None if request.method POST: username request.form.get(username, ).strip() password request.form.get(password, ) if not username or not password: error 用户名和密码不能为空 else: user User.query.filter_by(usernameusername).first() if user is None: error 用户名或密码错误 elif not user.is_active: error 该账号已被禁用 elif not check_password_hash(user.password_hash, password): error 用户名或密码错误 else: # 登录成功更新登录信息 user.last_login_at datetime.now() user.last_login_ip request.remote_addr db.session.commit() session[user_id] user.id session[username] user.username session.permanent True return redirect(url_for(admin.index)) return render_template(admin/login.html, errorerror)这个流程有几个细节想强调第一用户不存在和密码错误返回的信息要统一避免攻击者通过提示信息判断用户名是否存在。这是很多网站忽略的细节。第二用户被禁用的情况要单独提示因为这是运营行为不是密码错误。第三登录成功后要更新last_login_at和last_login_ip这两个字段对安全审计很重要。request.remote_addr在宝塔反向代理下可能拿到的是127.0.0.1这个坑我会在下一章展开讲。第四写session之前最好清一次session防止之前遗留的旧字段。如果担心会话固定攻击可以在登录成功后调用session.clear()再重新写入关键字段。第五接口要同时支持GET和POST这是常规做法。但要注意不要让登录接口的GET请求产生副作用。我这里的GET只渲染登录页面没有问题。3.3 验证码到底要不要上被热搜词里的“登录失败”“登录弹窗”戳到过的人应该不少验证码是登录模块里最影响体验的部分。我在第一版后台没有加图形验证码因为后台用户数量少暴力破解风险相对可控。但上线后我很快发现后台每天都有人尝试弱密码登录都是从公网扫过来的于是把验证码加上了。验证码的主要作用是提高暴力破解的成本但不能完全替代登录失败次数限制。图形验证码建议在以下几种情况下必须加后台地址暴露在公网、后台用户使用了弱密码、没有任何IP封禁策略。如果网站能保证只在内网访问那图形验证码可以后置。我当时用的是Flask-WTF自带的方式它能跟表单集成也能把验证码正确性和表单校验绑在一起。如果你不想引入重量级库也可以用captcha库生成验证码图片输出到模板。# 生成验证码示例伪代码 from captcha.image import ImageCaptcha import random, string def generate_captcha(): chars .join(random.choices(string.digits string.ascii_uppercase, k4)) # 将chars存入session供登录时校验 session[captcha] chars image ImageCaptcha(width120, height40) data image.generate(chars) return data验证码的校验逻辑要放在用户名密码校验之前因为验证码错误就无需继续查数据库可以减少数据库压力。不要因为是后台登录就把验证码复杂度设得太高后台用户输错几次就烦了。4位数字字母混合就够再配合失败次数限制安全性已经足够。4. 宝塔部署中踩过的登录相关坑4.1 HTTPS和Cookie Secure属性引发的“登录失效”我第一次用宝塔面板把后台Login模块部署到服务器上配好了SSL证书之后发现一个诡异的问题本地测试登录正常线上登录也提示成功但页面跳转之后又回到登录页感觉登录状态没有保存。查了很久最后发现是Cookie的Secure属性问题。当站点启用HTTPS后浏览器要求Cookie必须带上Secure标记否则跨协议传输时Cookie可能会被浏览器拒绝。Flask里配置Session Cookie的Secure属性是SESSION_COOKIE_SECURETrue。但如果你没有在配置文件里开启它浏览器会在某些情况下不保存这个Cookie尤其是当页面被重定向或者有跨协议请求时。反过来如果开启了Secure但后台管理页面某些静态资源还是HTTP地址或者反向代理没有正确传递HTTPS协议头也会导致Cookie无法写入。我最后的解决方式是在Flask配置里显式设置SESSION_COOKIE_SECURE True因为后台只走HTTPS同时在宝塔Nginx配置文件里添加下面的头部确保请求协议被正确传递location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }还有一点容易被忽略如果你在浏览器里通过http://访问后台页面然后某个链接跳转到https://登录状态可能只存在于第一个协议下。最好的做法是直接在宝塔里设置强制HTTPS让所有HTTP请求301到HTTPS这样登录状态只维护在一套协议下不容易出问题。4.2 反向代理下获取真实IP与会话保持宝塔面板的Nginx反向代理转发给Flask应用时request.remote_addr默认拿到的是Nginx服务器的地址也就是127.0.0.1。这个问题直接影响登录模块的last_login_ip记录还会影响IP封禁功能因为所有请求看起来都来自同一个IP。解决办法是在Flask里使用ProxyFix中间件让应用信任Nginx传递的X-Forwarded-For头并从中解析真实IP。需要在代码中单独设置代理层数通常设为1即可但要小心如果你信任了X-Forwarded-For而Nginx又没覆盖这个头攻击者可以直接伪造IP。正确做法是Nginx层覆盖X-Forwarded-For应用层再启用ProxyFix这样才能保证拿到的是Nginx处理后的真实客户端IP。from werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app ProxyFix(app.wsgi_app, x_for1, x_proto1)配置完成后我再登录后台查看last_login_ip拿到的就是真实的公网IP了。后面做IP封禁和登录失败次数限制时也是基于这个真实IP来判断的。至于会话保持宝塔的Nginx默认是短连接转发到后端每次请求都重新建立到Flask的连接。对于登录模块本身影响不大但如果后台后续有上传大文件或者长连接需求建议在Nginx配置里开启keepalive到后端的连接池减少握手开销。不用特别纠结普通登录场景下这一点可以后置。4.3 登录失败时的排查顺序从Nginx到应用日志后台登录部署之后总有各种登录失败的情况。我发现很多人一遇到“登录失败”就直接看代码浪费时间。我的排查顺序是固定的效率高很多。第一步打开浏览器开发者工具看Network面板。登录请求发出后看状态码是200还是302重点看响应里的Location和Cookie。如果302没有返回后台地址说明后端URL错了或者session写入失败。如果Cookie没有Set-Cookie响应头问题多半在HTTPS和Secure属性配置上。第二步看Nginx的访问日志和错误日志。宝塔面板在/www/wwwlogs/目录下能直接看到请求是否到达后端、返回什么状态码。如果看到499或者502说明后端进程挂了或连接超时如果看到403基本是权限或伪静态规则问题。第三步看Flask应用日志。建议在登录视图里打上结构化日志包括用户名、来源IP、处理结果方便回查。只用print很难排查我上线时直接用Python的logging模块输出到文件每天一个文件日志内容就包含登录成功的用户、失败的IP和错误原因。下面是一个简单的日志配置片段import logging logging.basicConfig( filename/www/wwwlogs/app.log, levellogging.INFO, format%(asctime)s %(levelname)s %(message)s )排查登录失败时还要注意两个容易被忽略的问题一是表单CSRF校验失败会返回400前端往往显示成泛化的“登录失败”二是POST请求体大小超限如果登录页带了很大的隐藏字段或者文件Nginx默认client_max_body_size不够也会失败。我遇到的“登录失败: login server error”多半不是服务器真的报错而是网络或代理层导致请求没有完整到达后端。这类问题靠日志定位最靠谱。5. 登录安全加固绕过登录与暴力破解的防御实测5.1 后端不能信任前端传来的“认证结果”热搜词里有一条“修改响应包中的认证结果直接绕过系统验证”这种攻击思路在旧系统里很常见。本质原因是后端把认证判断交给了前端比如前端页面里有个isLogin变量登录成功后由JS设置成true后端只检查请求里带了一个类似auth_result1的参数就返回后台数据。攻击者用抓包工具把响应包里的false改成true或者直接伪造请求参数就能绕过登录。我专门做了针对性自测在登录接口正常登录之后用抓包工具修改响应包里的某个字段看看页面会不会信任这个假结果。结论是只要后端在每次请求时都检查Session里的用户身份并且不把任何“认证结果”放在前端可控参数里就不可能被这种手段绕过。正确做法是后端对后台接口统一使用一个装饰器比如login_required在每个请求进来时先判断Session中是否有有效的user_id没有就重定向到登录页。不要用前端JS去保护任何需要权限的接口。from functools import wraps from flask import session, redirect, url_for def login_required(view): wraps(view) def wrapped(*args, **kwargs): if not session.get(user_id): return redirect(url_for(admin.login)) return view(*args, **kwargs) return wrapped这类“绕过登录”的问题根源不在加密算法而在于认证边界没有收紧。我给自己定了一个粗规矩凡是涉及后台数据的路由一律套上登录校验装饰器所有页面渲染前都要判断登录态不能依赖任何一个前端变量。5.2 登录失败次数限制与IP封禁后台登录是暴力破解的重点目标。单纯靠复杂密码是不够的因为攻击者可以用字典跑上亿次。我的做法是两层限制应用层基于IP用户名限制失败次数Nginx层再做一层IP并发限制。应用层的实现不复杂用Redis存计数器最合适。每次登录失败把login:fail:{ip}:{username}的过期时间设为15分钟连续失败5次就锁定15分钟。这个计数器最好是双键一个键按用户名记一个键按IP记任意一个超过阈值就拒绝登录。import redis r redis.Redis(host127.0.0.1, port6379, db0) FAIL_LIMIT 5 LOCK_TIME 900 # 15分钟 def is_login_locked(ip, username): key1 ffail_ip:{ip} key2 ffail_user:{username} return r.exists(key1) and r.exists(key2) or r.exists(flock:{ip}:{username}) def record_login_fail(ip, username): pipe r.pipeline() pipe.incr(ffail_ip:{ip}) pipe.expire(ffail_ip:{ip}, LOCK_TIME) pipe.incr(ffail_user:{username}) pipe.expire(ffail_user:{username}, LOCK_TIME) pipe.execute() if r.get(ffail_ip:{ip}) FAIL_LIMIT or r.get(ffail_user:{username}) FAIL_LIMIT: r.set(flock:{ip}:{username}, 1, exLOCK_TIME)这里要注意不要把Redis的计数和数据库查询绑定太久。当用户被锁定时直接在视图函数入口处返回“尝试次数过多请稍后再试”不需要查数据库。加了验证码之后暴力破解的成本已经高了很多失败次数限制作为兜底双保险。Nginx层的IP封禁可以用limit_req模块对登录接口做每秒1次、突发5次的限速。这样即使应用层被绕过攻击者的请求也到不了后端。宝塔面板里可以直接在Nginx配置里加也可以在防火墙里添加IP黑名单。我的建议是先靠应用层限制然后在Nginx层加粗粒度限速两层配合不要粗暴封禁所有国外IP。5.3 CSRF防护为什么也要加到登录表单里有些观点认为登录表单不需要CSRF防护因为没有登录态CSRF攻击的价值不大。但我不这么认为登录接口如果存在CSRF漏洞攻击者可以用你的Cookie向后台登录接口提交一个攻击者控制的用户名密码如果登录成功你的浏览器就会被带进攻击者账号这可能干扰你后续操作还有可能配合其他漏洞形成钓鱼场景。更实际的是加入CSRF防护可以统一整站的安全策略避免后台某些接口没有防护。在Flask里我直接使用Flask-WTF库。它的CSRFProtect扩展会为所有POST表单自动校验CSRF Token。登录表单里加一行{{ csrf_token() }}后端在视图里不需要手动校验扩展会拦截校验失败请求。from flask_wtf.csrf import CSRFProtect CSRFProtect(app)部署后要注意如果启用CSRF校验Nginx反向代理配置不对或者Cookie没写进去登录请求会一直报CSRF验证失败。排查时不要忘了看表单里的csrf_token是否成功渲染。使用HTTPS后Cookie的Secure属性也要和CSRF Token配合好否则CSRF Token存不到Session里校验必失败。登录模块安全加固之后我实测了几种常见扫描工具后台日志里暴力破解记录明显少了。真正的攻击者会转向其他入口但登录这道门至少要扎实不能让人一脚就能踹开。后台登录模块做下来最后分享一点实际体会登录功能看着简单但它牵涉到用户表设计、密码安全、会话管理、部署环境、安全加固等多个环节。我一开始也想着用一个现成插件糊弄过去但最后自己写完才发现后面做权限控制、操作日志、用户管理全都受益于当时把这些链路摸透了。这个模块花的时间是值得的。下一篇文章我会接着写后台管理的权限控制和主框架登录只是开始后台管理的骨架会在那个阶段真正立起来。