ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Python实现注册登录:文本文件存储与密码哈希实战

用Python实现注册登录:文本文件存储与密码哈希实战

最近帮一个刚学编程的朋友看作业,需求就一句话:"实现注册、登录功能。用户名、密码存储在 user_g.txt 文件中。"看完我笑了,越是这种看着简单的小需求,做起来越容易翻车。文件存用户信息,听起来谁都会,但真要交付一份能跑、能改、别人能看懂的代码,里面涉及的细节比想象中多得多:文件格式怎么定、密码要不要加密、重复注册怎么判断、读文件时编码和换行符怎么处理,甚至还要考虑多人同时写文件会不会互相覆盖。

这篇文章就把我完整实现这套注册登录功能的思路复盘一遍。讲解用 Python,从存储设计、注册流程、登录流程,到踩坑记录和后续扩展,一步一步拆开说。适合刚学完基础语法、想拿小项目练手的初学者,也适合做课程设计时直接参考。代码我会给出完整版本,你照着敲一遍,再改一改,基本就能变成自己的东西。

1. 先别急着写代码:这个需求的定位和存储选型

不管你拿到什么需求,第一反应不应该是打开编辑器,而是先想清楚这个需求到底在考什么、边界在哪里。注册登录是几乎所有系统都有的基础功能,但不同的存储方式,实现难度和适用场景完全不一样。

1.1 文本文件存储到底在解决什么问题

用 user_g.txt 这种文本文件存用户名和密码,本质上是把一个"用户表"搬到了文件里。数据库里一张 users 表,字段是 id、username、password_hash、created_at,而文本文件里的每一行,就相当于这张表里的一行记录。主题考察的是:你能不能把"增、查"这两个操作,用文件读写的方式实现出来。

注册就是"增"——往文件里追加一条记录;登录就是"查"——遍历文件,找到对应用户名,再比对密码。所以这个需求的核心并不是文件本身,而是围绕文件读写做一层简单的数据访问封装。后面你会发现,只要把这层封装做得规整,将来从文件切换到数据库,代价会小很多。

1.2 为什么不是数据库,也不是内存

我在帮朋友梳理方案的时候,他第一反应是想用 SQLite,被我拦住了。原因很简单:这个需求的难度等级,对应的学习阶段大概率还没到数据库。用一种技术不是因为"它更高级",而是因为它更适合当下的目标。

数据库的优势是查询、索引、并发控制,但代价是引入额外的概念:建表、连接、游标、事务。而文本文件的优势是零依赖、直观可见,打开文件就能看到所有用户数据,对初学者建立"数据到底存在哪、长什么样"的认知非常有帮助。内存存储(比如一个全局字典)虽然代码更简单,但程序一退出数据就没了,这就违背了"存储"的本意。文件刚好卡在中间:既持久化,又看得见,还能练到文件读写这个必备技能。

所以在选型之前,先问自己一个问题:"这个功能的生命周期是多久?数据量多大?要不要并发?"如果是教学练习或者体量很小的内部工具,文本文件完全够用。等数据量上来、查询变复杂了,再迁数据库也不迟。

1.3 这份需求里真正考核的点

根据我给不少人批改同类作业的经验,老师或面试官看这份代码,关注的通常不是功能本身,而是下面这几件事:

  • 文件读取是否健壮:文件不存在时会不会崩?有没有处理空行?
  • 密码是否明文存储:如果直接用户名,密码写进文件,基本一票否决。
  • 重复用户名检查是否有效:注册前是否真的读了文件?
  • 代码结构是否分层:读写逻辑和业务逻辑是不是揉在一起了?
  • 编码是否统一:中文字符会不会乱码?

想通这几点,再动手写代码,思路就会清晰很多。下面我从头开始,一步步把整个实现搭出来。

2. user_g.txt 的格式设计:改一次文件格式等于重写一遍逻辑

存储格式是整个项目的地基。格式一旦定下来,后面所有读写代码都要围绕它写。如果一开始随便拿个简单格式顶上,后面想加点字段,牵一发动全身。所以这部分我建议认真看。

2.1 三种存储格式对比:谁更抗折腾

常见的文本存储格式有下面几种,我也都试过:

格式方案示例优点缺点
空格分隔alice e10adc3949ba59abbe56e057f20f883e人眼可读,写起来最快用户名或内容一旦包含空格就全乱
逗号分隔alice,salt值,哈希值简单直观,兼容性好字段内容里不能出现逗号,需要约定
JSON 每行一个{"user":"alice","salt":"...","hash":"..."}结构清晰,可扩展对初学者偏复杂,手动编辑易出错
CSV 标准库用csv.writer写入自动处理引号和转义多一层抽象,反而不利于理解原理

我最推荐的是逗号分隔,定稿格式为用户名,盐值,密码哈希,一行一个用户。原因有三个:第一是足够简单,split(",")一下就拆开了;第二是字段顺序固定,读的时候按数组下标取就行;第三是以后想加字段(比如注册时间、登录失败次数),在行尾追加一个逗号字段就能兼容老数据。

2.2 定稿的存储结构与字段说明

我最终采用的每一行格式如下:

alice,5f4dcc3b5aa765d61d8327deb882cf99,e10adc3949ba59abbe56e057f20f883e

三个字段依次是:

  • 用户名:登录时用来定位记录的键,要求唯一。
  • 盐值:一段随机字符串,每个用户都不一样,用于防止哈希碰撞和彩虹表攻击。
  • 密码哈希:将盐值和密码拼接后做 SHA-256 得到的结果,绝不存明文密码。

这里需要解释一下为什么要有盐值。如果只做sha256(密码),那么两个密码相同的用户,哈希值完全一样,而且攻击者可以用一份预计算的"常见密码哈希表"反查。加上每个用户独有的盐值之后,即使密码都是123456,因为盐值不同,最终哈希也完全不同,安全性提升一大截。对于教学项目,能做到这一步,已经超过了绝大多数同类作业。

2.3 读写模块先抽象出来:read_users / write_user

动手写注册登录之前,我强烈建议你先写一个独立的文件读写模块。把"读取全部用户"和"追加一个用户"封装成函数,后面的业务代码只调用函数,不直接碰文件。这样改格式的时候,只需要改这一个地方。

读取函数如下:

import hashlib import secrets import os USER_FILE = "user_g.txt" def read_users(filepath=USER_FILE): """读取用户文件,返回 {用户名: (盐值, 密码哈希)} 的字典。""" users = {} try: with open(filepath, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue parts = line.split(",") if len(parts) != 3: continue username, salt, hashed = parts users[username] = (salt, hashed) except FileNotFoundError: # 文件不存在时返回空字典,而不是直接报错 pass return users

几个细节值得说明:

第一,encoding="utf-8"一定要写。如果不指定,在中文 Windows 环境下会用 GBK 读取,万一文件是 UTF-8 保存的,中文用户名就会乱码。

第二,line.strip()是为了去掉行尾的\n。如果不去掉,读出来的密码哈希末尾会莫名其妙多个回车,导致登录永远比对失败。

第三,if len(parts) != 3: continue是一个防御性判断。万一文件里混入一行格式错误的数据,程序直接跳过,而不是崩溃。文件数据源不可控,这种防御习惯越早养成越好。

追加写入就简单多了:

def append_user(username, salt, hashed, filepath=USER_FILE): with open(filepath, "a", encoding="utf-8") as f: f.write(f"{username},{salt},{hashed}\n")

这里用"a"追加模式,新用户直接落在文件末尾,不需要重写整个文件。这个设计对注册操作来说是最省的。

3. 注册功能落地:校验、加密、写文件一个都不能少

注册是用户数据的入口,入口没把好关,后面登录再怎么写都是白搭。一个合格的注册流程,至少要包含输入校验、密码加密、重复检查、写入文件四个环节。下面按顺序拆解。

3.1 用户名规则:我要拦截哪些脏数据

用户名直接决定了文件里记录的唯一性和可读性,所以第一步就是校验。我定的规则比较常规:

  • 用户名长度 3 到 20 个字符,太少没意义,太长影响文件阅读。
  • 用户名不能包含逗号,因为逗号是字段分隔符。
  • 用户名不能重复,这个后面单独做检查。
  • 密码长度至少 6 位,太短的项目直接拒绝,别给用户留隐患。

校验代码:

def validate_username(username): if len(username) < 3 or len(username) > 20: return False, "用户名长度需在3到20个字符之间" if "," in username: return False, "用户名不能包含逗号" return True, ""

这里限死"不能包含逗号",不是因为我保守,是真的会出问题。试想一下,如果用户名里带逗号,比如alice,admin,写入文件后这一行会变成:

alice,admin,盐值,哈希

读取时按逗号一拆,字段数量从 3 变成 4,直接被当成脏数据跳过,用户就莫名其妙"消失"了。这种问题排查起来非常头疼,所以入口处直接封死。

3.2 密码不能明文存:加盐哈希的正确姿势

密码这一环,教学项目里最常见的错误就是把密码原样写进文件。作为练习可以理解,但我还是建议养成好习惯:密码经过加盐哈希之后才落盘。

哈希函数我用 Python 标准库的 hashlib,盐值用 secrets 模块生成。secrets是专门用来生成密码学安全随机数的模块,比random更适合安全场景。代码如下:

def hash_password(password, salt): return hashlib.sha256((salt + password).encode("utf-8")).hexdigest()

顺序上我习惯把盐值放在密码前面拼salt + password。具体顺序其实不影响安全性,只要约定统一,注册和登录用同一套逻辑就行。还有一点,encode("utf-8")不能漏,因为 hashlib 接收的是字节串,不能直接处理字符串。

为什么不用 MD5?MD5 已经是公认的不安全算法,碰撞攻击成本低到离谱。SHA-256 虽然不是唯一的正确答案,但作为入门足够,而且标准库自带的实现连第三方库都不用装。

再强调一遍:真正的生产系统一般会选 bcrypt、scrypt 或 Argon2 这类专门为密码设计的慢哈希算法,而不是通用的 SHA-256。原因是通用哈希算得太快,攻击者可以每秒尝试海量密码。教学项目里 SHA-256 可以接受,但你要知道完善的方案长什么样,心里有个谱。

3.3 追加写入与重复检查的顺序问题

这里我想专门讲一个容易踩的坑:重复用户名检查,一定要在写入之前做,而且检查的数据必须来自文件本身。

很多初学者会写成这样:先让用户输入用户名和密码,然后立即打开文件追加,完全不做重复检查。结果是同一个用户名可以注册十次,登录的时候dict只会保留最后一个,前面的注册记录看起来还在,实际已经失效,文件和用户认知完全对不上。

正确的注册函数如下:

def register(username, password, filepath=USER_FILE): ok, msg = validate_username(username) if not ok: return False, msg if len(password) < 6: return False, "密码长度至少6位" users = read_users(filepath) if username in users: return False, "用户名已存在" salt = secrets.token_hex(16) hashed = hash_password(password, salt) append_user(username, salt, hashed, filepath) return True, "注册成功"

关键点是read_users在写入前先执行,把文件里已有的用户全部读进内存,然后判断username in users。这里有一个"先读后写"的时序问题:理论上在检查完和写入之间,如果有另一个进程也注册了同名用户,还是会产生重复。这就是并发问题,文本文件方案天然很难完美解决,我会在第 5 章详细讲。

另外要留意一个细节:读取整个文件再判断,对于这个小项目完全够用。如果文件变成几十万行,这个方案就不合适了,那会儿你应该考虑数据库的唯一索引,而不是继续在应用层做判断。

3.4 注册分支的完整代码

最后把注册分支放进一个简单的交互式菜单里:

def main(): while True: print("\n1. 注册 2. 登录 3. 退出") choice = input("请选择:").strip() if choice == "1": username = input("请输入用户名:").strip() password = input("请输入密码:").strip() ok, msg = register(username, password) print(msg) elif choice == "2": username = input("请输入用户名:").strip() password = input("请输入密码:").strip() ok, msg = login(username, password) print(msg) elif choice == "3": break else: print("无效选择,请重新输入") if __name__ == "__main__": main()

这里我把密码输入单独拎出来,是为了后续能替换成getpass之类的隐藏输入方式。实际使用的时候,可以让密码不显示在终端上,避免旁边有人偷看,这是真实系统里非常基础的安全习惯。

4. 登录功能落地:比对只是表面,关键是别泄露信息

注册做完,登录反而更简单,但简单不等于可以随便写。登录的核心是"根据用户名找到记录,再比对密码哈希",这个逻辑不复杂,真正复杂的是异常情况和安全性处理。

4.1 登录验证的代码逻辑

登录函数如下:

def login(username, password, filepath=USER_FILE): users = read_users(filepath) record = users.get(username) if record is None: return False, "用户名或密码错误" salt, hashed = record if hash_password(password, salt) == hashed: return True, "登录成功" return False, "用户名或密码错误"

整个流程可以拆成三步:读文件得到用户字典 → 用users.get(username)查找记录 → 重新计算密码哈希并比对。注意我用的是get而不是users[username],这样用户不存在时返回None,而不是抛KeyError。

这里有一个很多人忽略但非常重要的点:比对的时机。密码比对必须在找到用户之后立刻进行,而且比对用的是"取出盐值、拼接输入密码、再哈希",绝对不能直接把存的哈希拿出来和用户输入的密码比——存的本来就是哈希,你拿哈希和明文比,永远比不成功。我见过不少新手卡在这里,纠结半天发现是比对对象选错了。

4.2 为什么错误提示必须统一为"用户名或密码错误"

这个细节我说给每一个写登录功能的人:当用户不存在时,提示"用户名或密码错误";当密码错误时,也提示"用户名或密码错误"。两个场景共用一个提示,不是偷懒,是安全设计。

原因很简单:如果用户不存在时提示"用户名不存在",密码错误时提示"密码错误",那任何人都可以通过反复尝试,收集到大量有效用户名。拿到用户名列表之后,下一步就是针对性地爆破密码。统一提示等于把这条信息泄露的路堵死。这个案例在安全圈里已经讲过无数遍,但落实到一行return字符串时,还是有很多人写错。

这也是我在第 3 章注册阶段就要求用户名唯一的原因之一。用户名是公开信息,密码是机密信息,两者在错误提示上必须严格区分对待。

4.3 登录成功之后的"会话"怎么处理

登录验证通过之后,代码只返回了一个"登录成功",但在真实系统里,这里要做的事远不止输出一句话。你需要考虑:登录成功后怎么记住这个用户?下次操作怎么知道当前是谁?

最简单的方案是用一个全局变量记录当前登录用户:

current_user = None def login(username, password, filepath=USER_FILE): global current_user ok, msg = login_logic(username, password, filepath) if ok: current_user = username return ok, msg

登录之后的操作都可以先检查current_user是否为None,不是的话再放行。这个"会话状态"在命令行项目里用变量就能解决,在 Web 项目里则会换成 Session 或 Token 机制。虽然教学项目不需要做那么重,但我建议在代码里留出这个抽象,哪怕只是加一个current_user变量,后续想扩展登录态检查也会方便很多。

登录成功后还有个容易被忽略的小操作:密码输入错误次数多了要不要限制?比如连续输错 5 次就锁定账号 10 分钟。这个功能对文本文件存储来说实现成本略高,但不妨碍你先在用户字典里加一个"失败次数"字段,思路有了,代码都是水到渠成的事。我在第 6 章会具体展开。

5. 实操中一定会踩的坑:文件编码、空行、并发写

这个项目最难的部分其实不是注册和登录逻辑,而是文件本身。文件读写看起来基础,一踩坑就直接动摇整个功能。我把实操中遇到的和帮别人排查过的典型问题集中放在这里。

5.1 编码问题:为什么打开文件全是乱码

第一次写完代码在本机跑得好好的,换个环境一运行,中文字符全部变成乱码。问题基本都出在"读的时候没指定编码,写的时候也没指定编码"。

Python 在 Windows 上默认编码是gbk,在 Linux 和 macOS 上是utf-8。如果你的程序在 Windows 上创建文件,之后拿到 Linux 上用默认编码读取,编码不一致,必乱无疑。解决方案就是我在最开始强调的:所有文件操作统一显式声明encoding="utf-8"。

with open(filepath, "a", encoding="utf-8") as f:

这个习惯看着不起眼,但在跨平台项目里能帮你省掉非常多麻烦。顺带一提,打开文件以后先把内容print出来确认一下,排错效率远高于瞎猜。

5.2 空行和末尾换行符的处理

文件里的空行,是初学者的第一个隐形杀手。我见过有人手滑在 user_g.txt 末尾多敲了一个空行,结果注册的时候一切正常,登录的时候死活找不到用户。排查下来发现是read_users没有跳过空行,把一个空字符串当成了用户名记录。

解决办法就是我前面代码里写的:

line = line.strip() if not line: continue

strip()同时解决了另一个问题:去掉行尾\n。如果不去掉,哈希串末尾多了一个换行,长度变成 65,和注册时存的 64 位哈希完全对不上,比对必然失败。这一类问题排查起来特别隐蔽,因为你看文件内容的时候,行尾的不可见字符很难用肉眼发现。

所以我会建议所有的读文件代码都养成"先 strip 再判断空行"的习惯。不管文件是不是你程序生成的,都要按"外部不可信数据"来处理。

5.3 并发写用户文件时可能出现的覆盖

这个坑在单机作业里一般遇不到,但一旦部署成多人使用的服务,马上就会暴露。问题的根源在于append_user和read_users是两个独立操作,中间没有任何锁机制。

想象一下这个场景:用户 A 和用户 B 同时注册,进程 A 先读了文件、检查用户名、准备追加;进程 B 也读了文件、检查用户名、准备追加。两个进程同时以追加模式写同一个文件,操作系统层面的追加是原子的,但应用层的"检查后写入"并不是。更危险的情况是,如果你用了"读文件到内存 → 修改内存 → 整体写回文件"的方案,两个进程同时写回,后写的一方会直接覆盖先写的一方,其中一个用户的注册记录彻底丢失。这就是经典的"读-改-写"竞态问题。

追求极致安全的做法是给文件加锁。在 Linux 环境下可以用fcntl对一个锁文件做独占锁,Windows 下也可以用msvcrt.locking,但跨平台又是个麻烦事。对教学项目来说有个折中思路:不要把"读取整个内存再整体写回"作为默认写入策略,用追加模式写入只动文件尾部,冲突范围会小很多;同时可以在写入前再检查一次用户名,虽然不能根治并发问题,但能降低出问题的概率。

5.4 用临时文件 + 替换实现安全写入

如果你坚持要支持"整体写回"的场景(比如要修改某个用户的信息),那我建议用"临时文件 + 原子替换"的方式,而不是直接往原文件写。

def write_users_safely(users, filepath=USER_FILE): tmp_path = filepath + ".tmp" with open(tmp_path, "w", encoding="utf-8") as f: for username, (salt, hashed) in users.items(): f.write(f"{username},{salt},{hashed}\n") os.replace(tmp_path, filepath)

核心思路是:先写一个全新的临时文件,写完后用os.replace一次性替换旧文件。os.replace在同一个文件系统内是原子操作,要么替换成功,要么替换失败,不会出现"写到一半程序崩溃,旧文件和新文件都坏了"的中间状态。

这个模式在日常开发里非常常用。数据库事务解决的是类似的问题,文件系统层面能提供的"最低限度事务"就是这一招。我在做这个功能的时候一开始也是直接往原文件写,后来模拟了一次写入中途断电,立刻发现文件被截成了半截,从那以后就再也不敢裸写原文件了。

6. 验收之后还能怎么改:从教学 Demo 到可落地的小系统

如果你的目标是交作业,做到第 5 章其实已经超额达标了。但如果你是真心想把文件存储这套方案用到稍微真实一点的场景里,下面这几个改进方向值得继续做,而且难度都不高。

6.1 把哈希算法升级成专门的口令哈希函数

第 3 章我提到过 SHA-256 的局限:它太快了,不适合做密码哈希。如果你想把安全等级往上提,可以试试标准库里的hashlib.scrypt或者安装bcrypt库。scrypt的好处是它是标准库自带的,专门为口令设计,自带盐值参数和 CPU 开销控制。

def hash_password_scrypt(password, salt): return hashlib.scrypt( password.encode("utf-8"), salt=salt.encode("utf-8"), n=2**14, r=8, p=1 ).hex()

调高n参数,计算变慢,破解成本变高。真正的生产系统就是这么干的,用计算时间来换取攻击者的成本。对这个教学项目来说,你只要能讲清楚"为什么要用慢哈希替代快哈希",就已经比市面上很多半吊子教程专业了。

6.2 加上登录失败锁定和账号状态字段

第 4 章末尾提到的失败次数限制,落到文件里其实就是加一个字段。比如把存储格式扩成:

用户名,盐值,密码哈希,失败次数,锁定状态

添加字段后,注意老数据怎么办:读文件时parts长度可能只有 3,也可能是 5,所以解析逻辑要保持兼容。比较好的做法是给缺失字段补默认值:

if len(parts) == 3: username, salt, hashed = parts fail_count, locked = 0, "0" elif len(parts) == 5: username, salt, hashed, fail_count, locked = parts

这就是我为什么在第 2 章强调"行尾追加字段"的兼容性价值。只要你的解析代码对字段数量做了容错,老用户数据不用迁移也能继续用。这种"向后兼容"的意识,在真实项目里比多写几个功能都金贵。

6.3 从文件存储平滑迁移到数据库

终于做到需要换数据库的一天,你会发现前面抽象的read_users、append_user这些接口成了救命稻草。只要注册和登录的业务代码不直接操作文件,而是调用这几个函数,迁移时只需要把函数内部实现替换成 SQL 语句,业务层一行不用改。

def register(username, password, filepath=USER_FILE): # 业务逻辑不变 ... # 只有 storage 层从 append_user 换成 insert into users(...)

这也是我在全文反复强调封装和抽象的原因。初学者最容易犯的错,就是把文件读写逻辑直接铺在注册函数里,等数据量大了想换数据库,等于重新写一遍。而一开始花十分钟做一层薄薄的封装,后面能省下几个小时的重构时间。代码的设计价值,往往不在写出来的那一刻,而是在改需求的那一刻。

最后再说一个我自己的心得:这个项目虽然小,但它把数据持久化、输入校验、安全哈希、异常处理这些基础能力全部串了起来。做的时候别急着抄代码,先自己画一张流程草图,把"数据从哪来、存到哪去、怎么验证"这条线理清楚,再动手写。等你把 user_g.txt 改成 SQLite、再加上一个 Web 界面,回头再看最初的这一百来行代码,你会发现自己对"一个用户系统是怎么跑起来的"已经有了完整的认识。这比单纯会写一个登录接口,值钱得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进