4. 登录功能实现:用户名密码校验、记住我功能、登录失败次数限制

登录功能,说白了就是系统的大门。门设计得不好,要么用户进不来,要么坏人随便进。我这些年做过的项目里,登录这块踩过的坑最多,也最有意思。

今天咱们就聊聊三个核心点:用户名密码怎么校验记住我功能怎么实现、以及登录失败次数怎么限制。嗯,一个一个来。

4.1 用户名密码校验——别只比字符串

很多人觉得校验就是「用户输入的用户名和密码,跟数据库里存的一比对,完事」。其实没那么简单。

我习惯把校验拆成三步:

  1. 格式校验——用户名不能为空、密码长度够不够、有没有特殊字符
  2. 存在性校验——这个用户到底注册过没有
  3. 密码匹配校验——用哈希算法比对,而不是明文比对

你想想看,如果第一步格式校验都没过,就直接去查数据库,那不是浪费资源吗?

核心原则:密码永远不要存明文。用 bcrypt、Argon2 这类慢哈希算法。我见过有人用 MD5 存密码,说实话,那跟没锁门差不多。

下面是一个简单的校验逻辑示例:

def validate_login(username, password):
    # 第一步:格式校验
    if not username or len(username) < 3:
        return {"success": False, "msg": "用户名至少3个字符"}
    if not password or len(password) < 6:
        return {"success": False, "msg": "密码至少6个字符"}
    
    # 第二步:查询用户
    user = db.query("SELECT * FROM users WHERE username = ?", username)
    if not user:
        return {"success": False, "msg": "用户名或密码错误"}
    
    # 第三步:密码比对(用 bcrypt)
    if not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
        return {"success": False, "msg": "用户名或密码错误"}
    
    return {"success": True, "user": user}

注意看,用户名不存在和密码错误,我返回的是同一个提示。为什么?防止坏人通过提示信息猜出哪些用户存在。这是安全设计的基本常识。

4.2 记住我功能——别用简单的布尔值

「记住我」这个功能,很多新手直接用一个 checkbox 的布尔值来控制。其实这里有个坑。

我曾经在一个项目中,直接用 localStorage.setItem('rememberMe', true) 来存。结果呢?用户勾选了记住我,关掉浏览器再打开,登录状态还在。但问题是,这个 token 永远不会过期。用户如果在公共电脑上登录……嗯,后果你懂的。

正确的做法是:

  • 生成一个长期有效的 token(比如30天),存到数据库
  • 把这个 token 写入客户端的 cookie(设置 httpOnly、Secure 标志)
  • 每次请求时,检查这个 token 是否有效、是否过期

我的习惯:记住我的 token 和普通 session token 分开存储。普通 token 有效期2小时,记住我 token 有效期30天。这样即使记住我 token 泄露,损失也有限。

代码实现大概长这样:

def handle_remember_me(user_id, remember_me):
    if remember_me:
        # 生成一个30天有效的 token
        token = secrets.token_urlsafe(32)
        expires_at = datetime.now() + timedelta(days=30)
        db.execute(
            "INSERT INTO remember_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
            user_id, token, expires_at
        )
        # 设置 cookie,有效期30天
        response.set_cookie('remember_token', token, 
                           expires=expires_at, 
                           httponly=True, 
                           secure=True)
    else:
        # 普通登录,只生成 session
        session['user_id'] = user_id

4.3 登录失败次数限制——别让暴力破解得逞

这个功能,说白了就是防暴力破解的。我见过最粗暴的做法是「连续输错3次就永久封号」——结果用户自己把自己封了,还得找客服解封,体验极差。

我个人建议用渐进式锁定策略

连续失败次数 锁定时间 说明
1-3次 无锁定 正常输入错误,给提示就行
4-5次 30秒 轻微限制,防止快速重试
6-8次 5分钟 明显限制,用户可能需要冷静一下
9次以上 30分钟 严重限制,建议同时触发验证码

为什么会这样设计?因为真正的暴力破解工具,每秒能试几百次。你锁定30秒,对它来说跟没锁一样。但锁定5分钟,它就受不了了。

注意:锁定策略要基于「用户名+IP」双重维度。只锁用户名的话,坏人可以换着IP试。只锁IP的话,一个公司的人共用同一个IP,一个人输错了全公司都登不了。我当年就吃过这个亏。

实现逻辑可以参考:

def check_login_attempt(username, ip_address):
    key = f"login_fail:{username}:{ip_address}"
    attempts = redis.get(key) or 0
    
    if attempts >= 9:
        return {"locked": True, "wait_minutes": 30}
    elif attempts >= 6:
        return {"locked": True, "wait_minutes": 5}
    elif attempts >= 4:
        return {"locked": True, "wait_seconds": 30}
    
    return {"locked": False}

def record_failed_attempt(username, ip_address):
    key = f"login_fail:{username}:{ip_address}"
    redis.incr(key)
    # 设置过期时间,防止无限累积
    redis.expire(key, 1800)  # 30分钟后自动重置

这里用 Redis 来存失败次数,而不是数据库。为什么?因为 Redis 快,而且可以自动过期。用数据库的话,你还得写个定时任务去清理过期的记录,麻烦。

4.4 把三个功能串起来

好了,三个核心点都讲完了。咱们把它们串成一个完整的登录流程:

  1. 用户提交用户名、密码、是否记住我
  2. 检查登录失败次数——如果被锁了,直接返回「请稍后再试」
  3. 格式校验——用户名密码格式对不对
  4. 查询用户——用户存不存在
  5. 密码比对——哈希值匹配吗
  6. 如果失败——记录失败次数,返回错误提示
  7. 如果成功——清除失败次数,生成 session,处理记住我 token

最后说一句:登录功能看着简单,但它是系统的第一道防线。我见过太多项目因为登录做得太糙,上线第一天就被撞库了。花点心思在这上面,绝对值得。

下一章咱们聊聊权限管理的核心——RBAC 模型怎么落地。到时候我会分享一个我踩过的坑,关于角色继承的,挺有意思。