网站搭建进行到后台管理这步,登录是绕不开的第一道门。别看书上都写得简单,真正把登录模块从选型、建表、写接口到部署上线跑通,我还是踩了不少坑。这篇文章是“网站搭建实操”系列的第二篇,也是后台管理的第一篇,我把自己做登录功能的完整思路和关键代码都捋一遍,希望能给正在自己搭后台管理的朋友省点时间。无论你是准备用宝塔面板一键部署,还是本地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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 method="post" action="{{ url_for('admin.login') }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <div> <label>用户名</label> <input type="text" name="username" required autofocus> </div> <div> <label>密码</label> <input type="password" name="password" required> </div> <button type="submit">登录</button> {% if error %} <p style="color: 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(username=username).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', error=error)这个流程有几个细节想强调:
第一,用户不存在和密码错误返回的信息要统一,避免攻击者通过提示信息判断用户名是否存在。这是很多网站忽略的细节。
第二,用户被禁用的情况要单独提示,因为这是运营行为,不是密码错误。
第三,登录成功后要更新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, k=4)) # 将chars存入session,供登录时校验 session['captcha'] = chars image = ImageCaptcha(width=120, height=40) 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_SECURE=True。但如果你没有在配置文件里开启它,浏览器会在某些情况下不保存这个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_for=1, x_proto=1)配置完成后,我再登录后台查看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', level=logging.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_result=1的参数,就返回后台数据。攻击者用抓包工具把响应包里的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(host='127.0.0.1', port=6379, db=0) FAIL_LIMIT = 5 LOCK_TIME = 900 # 15分钟 def is_login_locked(ip, username): key1 = f'fail_ip:{ip}' key2 = f'fail_user:{username}' return r.exists(key1) and r.exists(key2) or r.exists(f'lock:{ip}:{username}') def record_login_fail(ip, username): pipe = r.pipeline() pipe.incr(f'fail_ip:{ip}') pipe.expire(f'fail_ip:{ip}', LOCK_TIME) pipe.incr(f'fail_user:{username}') pipe.expire(f'fail_user:{username}', LOCK_TIME) pipe.execute() if r.get(f'fail_ip:{ip}') >= FAIL_LIMIT or r.get(f'fail_user:{username}') >= FAIL_LIMIT: r.set(f'lock:{ip}:{username}', 1, ex=LOCK_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里,校验必失败。
登录模块安全加固之后,我实测了几种常见扫描工具,后台日志里暴力破解记录明显少了。真正的攻击者会转向其他入口,但登录这道门至少要扎实,不能让人一脚就能踹开。
后台登录模块做下来,最后分享一点实际体会:登录功能看着简单,但它牵涉到用户表设计、密码安全、会话管理、部署环境、安全加固等多个环节。我一开始也想着用一个现成插件糊弄过去,但最后自己写完才发现,后面做权限控制、操作日志、用户管理全都受益于当时把这些链路摸透了。这个模块花的时间是值得的。下一篇文章我会接着写后台管理的权限控制和主框架,登录只是开始,后台管理的骨架会在那个阶段真正立起来。