☰
零基础入门python22:注册接口与密码哈希——为什么不能保存明文密码
2026/9/25 8:54:16 网站建设 项目流程

零基础入门python22:注册接口与密码哈希——为什么不能保存明文密码

一、上一篇课后练习讲解

给账目增加日期时使用date类型,并让数据库列建立索引;测试应创建两天数据,查询某一天只返回对应记录。日期过滤要使用范围或等值比较,不要把日期转换成字符串再模糊搜索。

上一篇课后练习完整答案

上一篇练习的要求已落实到下面完整文件;先运行项目测试,再用 curl 对照状态码和数据库持久化结果
答案要点:User、Category、Entry 用外键和联合唯一约束表达所有权;金额 Numeric、用户 id 索引以及可空分类都在模型中落地。

文件:app/models.py

完整参考答案文件

完整文件:app/models.py

from.extensionsimportdbclassUser(db.Model):__tablename__="users"id=db.Column(db.Integer,primary_key=True)email=db.Column(db.String(254),unique=True,nullable=False)classCategory(db.Model):__tablename__="categories"__table_args__=(db.UniqueConstraint("user_id","name"),)id=db.Column(db.Integer,primary_key=True)user_id=db.Column(db.ForeignKey("users.id"),nullable=False)name=db.Column(db.String(30),nullable=False)classEntry(db.Model):__tablename__="entries"id=db.Column(db.Integer,primary_key=True)user_id=db.Column(db.ForeignKey("users.id"),nullable=False)amount=db.Column(db.Numeric(12,2),nullable=False)kind=db.Column(db.String(10),nullable=False)

完整参考答案文件

本篇对应的交付源码完整文件:flask-ledger/app/models.py

fromdatetimeimportdate,datetime,timezonefromdecimalimportDecimalfromflask_loginimportUserMixinfromwerkzeug.securityimportcheck_password_hash,generate_password_hashfrom.extensionsimportdb,login_managerclassUser(UserMixin,db.Model):id=db.Column(db.Integer,primary_key=True)email=db.Column(db.String(120),unique=True,nullable=False,index=True)password_hash=db.Column(db.String(255),nullable=False)created_at=db.Column(db.DateTime,default=lambda:datetime.now(timezone.utc))defset_password(self,password:str)->None:self.password_hash=generate_password_hash(password)defcheck_password(self,password:str)->bool:returncheck_password_hash(self.password_hash,password)@login_manager.user_loaderdefload_user(user_id:str):returndb.session.get(User,int(user_id))classCategory(db.Model):__table_args__=(db.UniqueConstraint("user_id","name"),)id=db.Column(db.Integer,primary_key=True)name=db.Column(db.String(40),nullable=False)user_id=db.Column(db.Integer,db.ForeignKey("user.id"),nullable=False,index=True)classTransaction(db.Model):id=db.Column(db.Integer,primary_key=True)kind=db.Column(db.String(10),nullable=False)amount=db.Column(db.Numeric(12,2),nullable=False)note=db.Column(db.String(200),default="")happened_on=db.Column(db.Date,default=date.today,nullable=False,index=True)user_id=db.Column(db.Integer,db.ForeignKey("user.id"),nullable=False,index=True)category_id=db.Column(db.Integer,db.ForeignKey("category.id"),nullable=False)defto_dict(self)->dict:return{"id":self.id,"kind":self.kind,"amount":str(Decimal(self.amount).quantize(Decimal("0.01"))),"note":self.note,"happened_on":self.happened_on.isoformat(),"category_id":self.category_id,}

验收命令:python -m pytest -q(Django 项目使用 python manage.py test)。预期测试通过;若失败先检查迁移、配置和事务回滚。

二、本篇完成什么

实现注册接口:规范化邮箱、校验密码、检查重复用户、使用 Werkzeug 哈希、提交数据库并返回用户 id。

三、关键代码

fromwerkzeug.securityimportgenerate_password_hashdefset_password(self,password:str)->None:"""只保存哈希,不保存原密码;哈希算法和盐由 Werkzeug 管理。"""self.password_hash=generate_password_hash(password)

注册接口先检查输入,再查询数据库,最后提交。密码不能出现在响应、日志或异常信息中;重复邮箱返回 409,表示资源冲突,而不是服务器错误。

四、运行结果

curl-X POST http://127.0.0.1:5000/api/auth/register-H"Content-Type: application/json"-d"{\"email\":\"alice@example.com\",\"password\":\"secret123\"}"

首次返回 201;再次提交相同邮箱返回 409;短密码返回 400。验收还要查询数据库确认password_hash不是secret123。课后练习:增加邮箱大小写归一化测试。

项目增量:注册事务和密码哈希

注册先规范化邮箱,再使用 Werkzeug 哈希密码;数据库 UNIQUE 处理并发重复,IntegrityError 后必须 rollback。

user=User(email=email.strip().casefold())user.set_password(password)db.session.add(user)try:db.session.commit()exceptIntegrityError:db.session.rollback()raiseEmailAlreadyUsed()

验收成功 201、重复邮箱 409、密码列不是明文。课后练习写两个 Session 的重复注册测试。

五、注册流程为什么要分成“校验—哈希—提交”

注册接口接收的是不可信输入。正确顺序是:先做格式和长度校验,再生成密码哈希,最后在一个事务中写入用户。不能先INSERT再校验,否则异常回滚会让日志和错误响应变得混乱;也不能把密码原文临时写入日志。

fromflaskimportrequestfromwerkzeug.securityimportgenerate_password_hashfromsqlalchemy.excimportIntegrityError@auth_bp.post("/register")defregister():body=request.get_json(silent=True)or{}email=body.get("email","").strip().lower()password=body.get("password","")if"@"notinemailorlen(email)>254:return{"error":"email_invalid"},400iflen(password)<8:return{"error":"password_too_short"},400user=User(email=email,password_hash=generate_password_hash(password))db.session.add(user)try:db.session.commit()exceptIntegrityError:db.session.rollback()return{"error":"email_exists"},409return{"id":user.id,"email":user.email},201

这里的lower()是为了统一邮箱大小写,但不要擅自去掉邮箱中的+tag;具体规则应该在产品需求里确定。generate_password_hash默认使用带盐算法,即使两个用户密码相同,哈希结果也不同。数据库唯一约束是最后一道防线,因为并发请求可能同时通过“先查询是否存在”的检查。

六、用 curl 观察真实 HTTP 行为

curl-Method Post http://127.0.0.1:5000/api/auth/register `-ContentType'application/json'`-Body'{"email":"alice@example.com","password":"correct-horse"}'

首次请求返回201和用户 id;再次发送同样请求应返回409。把密码改成123应返回400,而不是数据库错误。若返回415 Unsupported Media Type,是因为缺少Content-Type: application/json;若request.get_json()得到None,先查看请求体是否是合法 JSON。

七、密码验证和时间成本

登录时使用check_password_hash(user.password_hash, supplied_password),不要自行比较哈希字符串。哈希算法故意较慢,用来增加撞库成本;因此注册接口应设置速率限制,且不要在一个请求里重复计算多次。生产环境还应记录失败次数和 IP,但日志只记录邮箱的掩码形式,如a***@example.com。

八、上一篇练习的验收

上一篇要求两个 Session 并发重复注册。测试可使用两个独立客户端同时发送请求,预期一条 201、一条 409,数据库最终只有一条用户记录;如果两条都 201,说明数据库唯一约束没有迁移到当前环境。

九、本篇练习

  1. 增加username字段,要求 3—30 个字且唯一;
  2. 为注册接口补充邮箱格式、密码过短、重复邮箱三个 pytest;
  3. 在响应中加入created_at的 ISO 8601 字符串,但不要暴露password_hash。

下一篇将把注册成功的用户放进会话,解释 cookie、session 和退出登录的差别。

十、把错误变成稳定的接口契约

建议统一返回{"error": "email_exists", "message": "邮箱已注册"},状态码表达类别,error供前端判断,message供人阅读。测试不要断言完整中文句子,而应断言error和状态码。生产日志记录request_id、邮箱掩码和异常类型,绝不记录密码。这样既方便排障,也不会因为换文案而破坏客户端。

注册成功后可以发送欢迎邮件,但邮件服务属于外部依赖,不应该包在创建用户的数据库事务里;正确做法是先提交用户,再投递异步任务,失败时记录待重试状态。本篇的事务边界只覆盖用户记录,后续订单事务会复用同样原则。

本篇结束:完整模块文件

下面是交付项目中真实存在的完整文件 flask-ledger/app/auth.py。它覆盖本篇新增逻辑以及前文已经完成的依赖代码;复制单个函数会丢失上下文,因此这里提供整份文件。

fromflaskimportBlueprint,requestfromflask_loginimportcurrent_user,login_required,login_user,logout_userfrom.extensionsimportdbfrom.modelsimportUser bp=Blueprint("auth",__name__,url_prefix="/api/auth")@bp.post("/register")defregister():data=request.get_json(silent=True)or{}email=str(data.get("email","")).strip().lower()password=str(data.get("password",""))if"@"notinemail:return{"message":"邮箱格式不正确"},400iflen(password)<8:return{"message":"密码至少8位"},400ifdb.session.scalar(db.select(User).where(User.email==email)):return{"message":"邮箱已注册"},409user=User(email=email)user.set_password(password)db.session.add(user)db.session.commit()return{"id":user.id,"email":user.email},201@bp.post("/login")deflogin():data=request.get_json(silent=True)or{}email=str(data.get("email","")).strip().lower()user=db.session.scalar(db.select(User).where(User.email==email))ifnotuserornotuser.check_password(str(data.get("password",""))):return{"message":"邮箱或密码错误"},401login_user(user)return{"id":user.id,"email":user.email}@bp.post("/logout")@login_requireddeflogout():logout_user()return{"message":"已退出"}@bp.get("/me")@login_requireddefme():return{"id":current_user.id,"email":current_user.email}

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询