资讯动态

Django登录页面实现:从表单到安全防护的完整指南

发布时间:2026/10/13 19:32:43 来源:尧图企业网站定制
1. 登录页不只是个表单先想清楚它到底要解决什么问题很多人做登录页面第一反应就是打开编辑器噼里啪啦写一个 form两个 input 加一个 submit 按钮然后接一个视图函数验证一下用户名密码对了就跳首页错了就报个错。代码不到三十行跑起来也没毛病。但如果你真拿这套东西去上线用不了几天就会遇到一堆让你头皮发麻的问题用户狂点登录按钮导致重复提交、密码明文出现在日志里、登录状态莫名其妙丢失、被暴力破解毫无还手之力、多设备登录互相踢下线……这些问题没有一个是验证用户名密码本身能解决的。所以这一篇我不打算只给你一段能跑的代码而是想把这几年在 Django 项目里做登录模块踩过的坑、想明白的道理完整地摊开讲一遍。登录页面的逻辑实现本质上是一套身份确认 状态维持 安全防护的组合拳表单只是最外面那层皮。你把这套逻辑想透了后面做注册、找回密码、第三方登录、权限控制全都是同一套思路的延伸。这篇文章适合谁看如果你已经跟着前面的内容把 Django 项目的骨架搭起来了模板能渲染、路由能跑通、数据库能读写那现在正好到了把用户这个概念真正接进来的时候。如果你是从别的语言转过来的对 Django 的认证体系还比较陌生也没关系我会把每个关键决策背后的原因讲清楚让你知道为什么这么写而不是照抄。全栈开发里登录是第一个真正把前端、后端、数据库、会话、安全全部串起来的模块把它吃透你对整个 Web 请求生命周期的理解会上一个台阶。我下面讲的方案是基于 Django 自带的认证系统来做的但不会止步于调个 authenticate 就完事。我会带你走一遍从 URL 设计、视图分层、表单校验、会话维持到安全加固、异常处理、体验优化的完整链路。中间会穿插大量实际项目里才会遇到的细节比如为什么登录失败不能提示用户名不存在、为什么要在服务端做频率限制、为什么 remember me 不能简单地把 session 过期时间调长。这些经验文档里通常不会写但线上出事的时候每一条都能救命。2. 把登录拆成四层URL、视图、表单、模板各管什么在动手写代码之前我习惯先把一个功能拆成清晰的层次每一层只干一件事。登录这个功能我一般拆成四层URL 层负责路由分发视图层负责流程编排表单层负责数据校验和清洗模板层负责展示和交互。这么拆的好处是任何一层出问题你都能快速定位而且后面要加验证码、加二次验证、加第三方登录都只需要在对应层里扩展不会牵一发动全身。2.1 URL 设计为什么登录和登录处理要分开新手最容易犯的错是把登录页面的展示和登录请求的处理塞进同一个 URL用if request.method POST来区分。这么写确实省事但有几个隐患。第一刷新页面时会重复提交上一次的 POST 请求浏览器会弹是否重新提交表单体验很差。第二你没法给登录成功后的跳转做一个干净的落地页。第三从安全和可维护性角度看GET 和 POST 混在一个视图里逻辑会越来越臃肿。我的做法是拆成两个 URL一个负责展示登录页GET一个负责处理登录提交POST。在 Django 里其实用同一个视图函数配合request.method判断也能实现但更清晰的方式是用 Django 自带的LoginView或者自己写一个基于类的视图。如果你坚持用函数视图那至少要保证 POST 处理完之后用redirect跳转而不是直接render这样刷新就不会重复提交。# urls.py from django.urls import path from . import views app_name accounts urlpatterns [ path(login/, views.login_view, namelogin), path(logout/, views.logout_view, namelogout), ]这里我用了app_name做命名空间后面在模板里用{% url accounts:login %}引用避免不同 app 之间 URL 名称冲突。这个习惯一定要早养成项目一大没有命名空间的 URL 就是灾难。2.2 视图层流程编排才是核心视图层是整个登录逻辑的大脑。它要做的事情按顺序是接收请求、判断请求方法、调用表单校验、执行认证、处理认证结果、维持会话、决定跳转目标。我把这个流程写成一个函数视图方便你理解每一步在干什么。# views.py from django.shortcuts import render, redirect from django.contrib.auth import authenticate, login from django.urls import reverse from .forms import LoginForm def login_view(request): # 已经登录的用户直接跳走不用再看登录页 if request.user.is_authenticated: return redirect(home) # 记录登录后要跳转的目标默认首页 next_url request.GET.get(next) or request.POST.get(next) or reverse(home) if request.method POST: form LoginForm(request.POST) if form.is_valid(): username form.cleaned_data[username] password form.cleaned_data[password] user authenticate(request, usernameusername, passwordpassword) if user is not None: login(request, user) return redirect(next_url) else: form.add_error(None, 用户名或密码不正确) else: form LoginForm() return render(request, accounts/login.html, {form: form, next: next_url})这段代码里有几个细节值得展开说。第一request.user.is_authenticated的判断放在最前面已经登录的用户访问登录页直接跳走避免重复登录。第二next参数的获取同时考虑了 GET 和 POST因为用户可能是直接访问登录页GET 带 next也可能是提交表单时 next 藏在隐藏字段里POST 带 next。第三认证失败时我用form.add_error(None, ...)把错误挂在表单的非字段错误上而不是具体某个字段这样模板里可以用form.non_field_errors统一展示也避免了提示用户名不存在这种泄露信息的做法。2.3 表单层校验和清洗的边界在哪表单层负责的是数据格式对不对和数据干不干净不负责这个人能不能登录。这个边界一定要划清楚。很多新手会把认证逻辑写进表单的clean方法里结果表单既管格式又管业务测试起来非常痛苦。# forms.py from django import forms class LoginForm(forms.Form): username forms.CharField( max_length150, widgetforms.TextInput(attrs{ class: form-control, placeholder: 请输入用户名, autocomplete: username, }) ) password forms.CharField( widgetforms.PasswordInput(attrs{ class: form-control, placeholder: 请输入密码, autocomplete: current-password, }) ) def clean_username(self): return self.cleaned_data[username].strip()注意autocomplete属性username和current-password是标准值浏览器和密码管理器能正确识别用户用起来会顺手很多。clean_username里做了strip()去掉首尾空格因为用户复制粘贴用户名时经常带空格这个小小的处理能减少大量我明明输对了却登不上的客服问题。2.4 模板层别让前端逻辑污染后端模板层只做展示和收集输入不做任何认证判断。登录模板里最关键的是 CSRF tokenDjango 默认开启 CSRF 保护表单里必须加{% csrf_token %}否则 POST 请求会被直接拒绝。这个坑几乎每个新手都会踩一次报错信息是CSRF verification failed看到这个别慌检查表单里有没有加 token 就行。!-- login.html -- form methodpost action{% url accounts:login %} {% csrf_token %} input typehidden namenext value{{ next }} {{ form.non_field_errors }} div classform-group {{ form.username.label_tag }} {{ form.username }} {{ form.username.errors }} /div div classform-group {{ form.password.label_tag }} {{ form.password }} {{ form.password.errors }} /div button typesubmit登录/button /formnext用隐藏字段传递这样提交表单时目标地址不会丢。form.non_field_errors放在最上面认证失败的错误就显示在这里。3. 会话维持login() 背后到底发生了什么很多人调用login(request, user)之后就不管了觉得登录状态自动就有了。但如果你不理解这行代码背后发生了什么遇到会话丢失、多设备冲突、过期时间不对这些问题时就会完全无从下手。我下面把 Django 会话机制的关键点拆开讲。3.1 session 的写入时机和存储位置login()函数做的事情简单说就是把用户的 ID 写进 session然后在响应里设置一个 sessionid 的 cookie。默认情况下session 数据存在数据库的django_session表里cookie 里只放一个随机字符串作为 key。这种设计的好处是数据在服务端客户端改不了代价是每次请求都要查一次数据库。如果你项目并发量上来了可以把 session 存到缓存里改一下SESSION_ENGINE配置就行。但我要提醒一句用缓存存 session 的话缓存一重启所有用户就掉线了所以生产环境一般用 Redis 这类可持久化的缓存而不是本地内存缓存。# settings.py SESSION_ENGINE django.contrib.sessions.backends.cache SESSION_CACHE_ALIAS default SESSION_COOKIE_AGE 60 * 60 * 24 * 7 # 一周 SESSION_EXPIRE_AT_BROWSER_CLOSE FalseSESSION_COOKIE_AGE默认是两周我一般调成一周平衡安全性和用户体验。SESSION_EXPIRE_AT_BROWSER_CLOSE设为 False意思是关掉浏览器再打开登录状态还在。如果你做的是银行类应用这个值应该设成 True关浏览器就退出。3.2 会话固定攻击登录时为什么要换 session key这是一个很多人不知道的细节。Django 的login()函数在用户登录成功时会自动调用cycle_key()换一个新的 session key。为什么要换因为如果攻击者事先诱导用户使用了一个已知的 session id用户登录后如果还沿用这个 id攻击者就能拿着这个 id 直接冒充用户。换 key 之后旧的 id 就失效了这个攻击路径就被堵死了。这个机制 Django 默认帮你做了但你要知道它的存在。如果你自己手写登录逻辑绕过了login()函数直接往 session 里塞 user_id那就把这个保护给丢了。所以我的建议是永远用login()不要自己造轮子。3.3 remember me 的正确实现方式记住我这个功能很多人的实现是把SESSION_COOKIE_AGE调大比如设成一个月。但这么做有个问题所有用户都变成一个月那些没勾记住我的用户也被记住了安全性下降。正确的做法是根据用户是否勾选动态设置 session 的过期时间。Django 支持在login()之后调用request.session.set_expiry()来单独设置这个会话的过期时间。if form.cleaned_data.get(remember_me): request.session.set_expiry(60 * 60 * 24 * 30) # 30天 else: request.session.set_expiry(0) # 关闭浏览器即失效set_expiry(0)是个特殊值表示会话在浏览器关闭时过期。set_expiry(None)表示用全局默认值。传一个整数表示秒数。这样每个用户的过期策略就可以不一样了。4. 安全加固登录接口是最容易被攻击的入口登录接口是整个系统里最值得攻击者花时间的地方因为一旦攻破后面所有权限都到手了。我下面讲的这几条都是实际项目里必须做的少一条都是隐患。4.1 错误提示的统一化别告诉攻击者用户名存不存在这是最基础也最容易被忽略的一条。如果登录失败时提示用户名不存在攻击者就可以拿一个字典跑一遍把所有存在的用户名筛出来然后再针对这些用户名做密码爆破。正确的做法是统一提示用户名或密码不正确让攻击者无法区分是哪个错了。我在视图里用form.add_error(None, 用户名或密码不正确)就是这个目的。注意连提示文案的措辞都要统一不要一会儿密码错误一会儿账号不存在那等于白做。4.2 登录频率限制给暴力破解上把锁光统一提示还不够攻击者可以慢慢试。所以必须做频率限制。Django 本身没有内置的登录限流需要自己实现或者用第三方库。我一般用缓存来实现一个简单的计数器。from django.core.cache import cache def check_login_attempts(username, ip): key flogin_attempts:{username}:{ip} attempts cache.get(key, 0) if attempts 5: return False return True def record_login_failure(username, ip): key flogin_attempts:{username}:{ip} attempts cache.get(key, 0) cache.set(key, attempts 1, 300) # 5分钟窗口这个逻辑是同一个用户名加同一个 IP5 分钟内失败超过 5 次就拒绝登录。窗口期设 5 分钟是因为正常用户输错几次之后会想起来密码不会一直被锁。这里用用户名加 IP 组合而不是只用 IP是因为同一个办公室可能很多人共用一个出口 IP只用 IP 会误伤。注意频率限制的计数要在认证失败时增加认证成功时清零。否则用户成功登录后之前的失败记录还在下次再失败一次就被锁了体验很怪。4.3 密码永远不要出现在日志里Django 默认的日志配置不会记录请求体但如果你自己打了日志比如logger.info(f用户 {username} 尝试登录密码 {password})那就把密码写进日志文件了。日志文件往往权限管理不严被拖库的风险很大。我的习惯是任何涉及密码的地方日志里只记录用户名和结果绝不记录密码本身。另外Django 的AUTH_PASSWORD_VALIDATORS里有一项是检查密码是否出现在常见密码列表中这个在注册时用登录时不用。但你要确保数据库里存的密码是哈希后的Django 默认用 PBKDF2 算法这个不用你操心只要别自己写set_password之外的存储逻辑就行。4.4 HTTPS 和 cookie 安全属性登录相关的 cookie 必须走 HTTPS否则中间人可以直接截获 sessionid。生产环境一定要配 HTTPS然后在 settings 里加上这几个配置SESSION_COOKIE_SECURE True CSRF_COOKIE_SECURE True SESSION_COOKIE_HTTPONLY True CSRF_COOKIE_HTTPONLY TrueSESSION_COOKIE_SECURE表示这个 cookie 只在 HTTPS 下发送。HTTPONLY表示 JavaScript 读不到这个 cookie能防住一部分 XSS 攻击。这几个配置在开发环境HTTP下会导致 cookie 不生效所以一般用环境变量区分开发环境关掉生产环境打开。5. 异常与边界那些让登录偶尔失灵的细节代码写完了测试也过了但上线之后总有一些用户反馈登录不了而且你本地怎么都复现不出来。这类问题往往出在边界情况上。我下面列几个我实际遇到过的。5.1 用户被禁用后还能不能登录Django 的authenticate()默认会检查user.is_active如果用户被标记为禁用认证会直接失败。这个行为是合理的但你要注意失败之后返回的是 None和密码错误返回的 None 是一样的。如果你想让被禁用的用户看到不同的提示需要在认证之前先查一下用户是否存在且被禁用。但我不建议这么做因为又会泄露这个账号存在的信息。我的做法是统一提示但在后台记录一条日志方便运营排查。5.2 大小写敏感和邮箱登录Django 默认的用户名是大小写敏感的Alice和alice是两个不同的用户。这在很多场景下会让用户困惑。如果你希望用户名不区分大小写可以在保存用户时统一转小写或者在认证时做处理。更常见的需求是允许用邮箱登录这需要自定义认证后端。from django.contrib.auth.backends import ModelBackend from django.contrib.auth import get_user_model from django.db.models import Q class EmailOrUsernameBackend(ModelBackend): def authenticate(self, request, usernameNone, passwordNone, **kwargs): UserModel get_user_model() try: user UserModel.objects.get(Q(usernameusername) | Q(emailusername)) except UserModel.DoesNotExist: UserModel().set_password(password) # 防时序攻击 return None if user.check_password(password) and self.user_can_authenticate(user): return user return None注意那个UserModel().set_password(password)这是一个防时序攻击的技巧。如果用户不存在就直接返回攻击者可以通过响应时间判断用户名是否存在存在的话要查数据库、验密码耗时长不存在的话直接返回耗时短。跑一次哈希计算让两种情况耗时接近就堵住了这个侧信道。5.3 登录后跳转的开放重定向问题next参数如果直接拿来跳转会有开放重定向漏洞。攻击者可以构造一个链接next指向一个钓鱼网站用户登录成功后直接被跳到钓鱼站很容易上当。所以跳转之前必须校验next是不是本站地址。from django.utils.http import url_has_allowed_host_and_scheme def safe_redirect(request, next_url): if url_has_allowed_host_and_scheme( urlnext_url, allowed_hosts{request.get_host()}, require_httpsrequest.is_secure(), ): return redirect(next_url) return redirect(home)Django 自带的LoginView已经帮你做了这个校验如果你自己写视图一定要加上。这个漏洞很隐蔽测试的时候不容易发现但被利用起来危害不小。5.4 并发登录和会话冲突同一个账号在多台设备登录默认情况下是允许的每个设备一个 session。但有些业务要求同一时间只能在一个地方登录这就需要额外的处理。常见的做法是用户登录时把当前 session key 存到用户记录里每次请求检查 session key 是否匹配不匹配就强制下线。这个功能实现起来不难但要想清楚业务上是否真的需要。强制单点登录会让用户在手机和电脑之间切换时很烦除非是付费账号共享这种场景否则我不建议默认开启。6. 体验优化让登录这件事不那么烦人安全做足了接下来要考虑的是用户用起来顺不顺手。登录是用户每天都要做的事哪怕每次省两秒钟累积起来也是很大的体验提升。6.1 表单错误的展示位置和文案错误提示要放在用户视线自然落点的地方。字段级的错误放在对应输入框下面非字段级的错误比如认证失败放在表单顶部。文案要具体不要只说输入有误要说用户名或密码不正确。但注意前面说的不能具体到泄露信息。另外错误提示的样式要明显但不刺眼。红色边框加一行小字比弹一个 alert 框体验好得多。alert 会打断用户操作而且关掉之后错误信息就没了用户还得重新想哪里错了。6.2 提交按钮的防重复点击用户网络慢的时候点一下登录没反应就会再点一下结果发了两个请求。如果第一个成功了第二个可能因为 session 已经建立而报错或者触发频率限制。前端加一个简单的防重复逻辑就能解决。document.querySelector(form).addEventListener(submit, function (e) { const btn this.querySelector(button[typesubmit]); btn.disabled true; btn.textContent 登录中...; });这个逻辑是提交后禁用按钮并改文案等页面跳转。如果登录失败页面重新渲染按钮自然恢复。简单有效不需要任何框架。6.3 密码可见性切换让用户能看一眼自己输的密码能显著减少输错的情况尤其是在手机上。实现很简单一个按钮切换 input 的 type 属性。function togglePassword(inputId, btn) { const input document.getElementById(inputId); if (input.type password) { input.type text; btn.textContent 隐藏; } else { input.type password; btn.textContent 显示; } }这个功能虽小但用户反馈普遍很好。注意切换的时候光标位置可能会跳到末尾如果在意这个细节可以在切换前记录 selectionStart切换后恢复。6.4 登录成功后的欢迎和跳转登录成功后直接跳首页用户有时候会懵一下我登上了吗。可以在跳转前设置一条一次性消息首页读取后展示欢迎回来XXX然后自动消失。Django 的 messages 框架就是干这个的。from django.contrib import messages messages.success(request, f欢迎回来{user.username}) return redirect(next_url)模板里遍历messages渲染即可。这个细节能让用户明确知道操作成功了减少我到底登没登上的疑惑。7. 测试与验证怎么确认登录逻辑真的没问题写完代码不测试等于没写。登录模块的测试要覆盖正常流程、异常流程、边界情况、安全防护几个方面。我下面给几个关键的测试用例思路。7.1 用 Django 测试客户端模拟登录Django 自带的测试客户端可以模拟请求非常适合测登录。from django.test import TestCase from django.urls import reverse from django.contrib.auth import get_user_model class LoginTest(TestCase): def setUp(self): self.user get_user_model().objects.create_user( usernametestuser, passwordtestpass123 ) def test_login_success(self): response self.client.post(reverse(accounts:login), { username: testuser, password: testpass123, }) self.assertRedirects(response, reverse(home)) self.assertTrue(response.wsgi_request.user.is_authenticated) def test_login_wrong_password(self): response self.client.post(reverse(accounts:login), { username: testuser, password: wrongpass, }) self.assertEqual(response.status_code, 200) self.assertContains(response, 用户名或密码不正确) self.assertFalse(response.wsgi_request.user.is_authenticated)这两个用例覆盖了成功和失败两条主路径。注意失败用例里断言了错误文案这样以后有人改了文案测试会提醒你避免不小心改成泄露信息的提示。7.2 频率限制的测试频率限制的测试要注意缓存的影响测试之间要清缓存否则用例之间会互相干扰。from django.core.cache import cache def test_login_rate_limit(self): cache.clear() for i in range(5): self.client.post(reverse(accounts:login), { username: testuser, password: wrongpass, }) response self.client.post(reverse(accounts:login), { username: testuser, password: testpass123, # 即使密码对了也应该被拦 }) self.assertContains(response, 尝试次数过多)这个用例验证的是达到失败上限后即使密码正确也会被拦。这是频率限制的核心行为必须测到。7.3 开放重定向的测试def test_login_redirect_safety(self): response self.client.post( reverse(accounts:login) ?nexthttps://evil.example.com, {username: testuser, password: testpass123} ) self.assertRedirects(response, reverse(home)) # 不能跳到外部站这个用例确保恶意的 next 参数被拦截跳回首页而不是外部地址。8. 从登录延伸出去这套逻辑还能怎么复用登录模块做完你会发现很多思路是可以直接复用的。注册流程本质上也是表单校验 数据写入 会话建立只是多了唯一性检查和邮件验证。找回密码是身份验证 令牌生成 令牌校验 密码重置核心还是那套校验和状态管理。第三方登录则是把密码验证换成了令牌验证后面的会话建立完全一样。所以我的建议是把登录模块里抽出来的东西沉淀成项目的基础设施。比如那个频率限制的装饰器可以复用到注册、找回密码、发送验证码等所有敏感接口上。那个安全跳转的函数可以复用到所有带 next 参数的地方。那个统一错误提示的思路可以用在所有涉及身份信息的接口上。我在实际项目里会把认证相关的公共逻辑放在一个accounts/services.py里视图层只做参数接收和结果返回业务逻辑全在 service 层。这样测试的时候可以直接测 service不用起 HTTP 请求速度快很多。视图层薄了也更容易替换比如以后要换成 API 接口视图层重写一下就行service 层原封不动。最后分享一个我踩过的坑。有一次上线后发现部分用户登录后立刻掉线排查了很久才发现是负载均衡后面有多台服务器session 存在各自的本地内存里用户请求被分发到不同机器就找不到 session 了。后来把 session 统一存到共享的缓存里才解决。所以如果你是多机部署session 存储一定要用共享存储别用本地内存。这个坑在单机开发时完全发现不了上线才会暴露提前知道能省很多事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑