访问控制

企业需求 🔐

一个周一的上午,我收到一封企业客户的邮件:

“你好,我们的内部文档链接,不想让外部人员访问。能不能加个密码?”

另外,有些链接只想让公司内部的人访问,可以按IP限制吗?

我意识到,短链接服务已经不能只是简单的”点击即跳转”了。企业用户有强烈的安全需求——他们需要访问控制。

访问控制(Access Control)是保护资源不被未授权用户访问的机制。在短链接场景中,我需要实现多层防护:

  • 密码保护:只有知道密码的人才能访问
  • IP白名单:只允许特定IP地址访问
  • 访问次数限制:只允许前N个人访问
  • 时间窗口限制:只在特定时间段内可访问

让我逐一实现这些功能。

URL Mapping

安全治理字段

secure
字段类型说明
idBIGINT全局 ID
short_codeVARCHAR(16)短码,唯一索引
long_urlVARCHAR(2048)原始 URL
statusVARCHAR(20)跳转状态
risk_levelVARCHAR(20)风险等级
audit_statusVARCHAR(20)审核状态
blocked_reasonTEXT封禁原因

访问控制上线后的治理指标

secure
拦截量3.4k/h

恶意链接和异常来源

误杀申诉7

进入人工复核队列

缓存刷新< 10s

封禁后尽快影响跳转


密码保护 🔑

为什么需要密码保护?

密码保护是最基础也是最常用的访问控制方式。企业客户用它来保护:

  • 内部文档链接
  • 限时优惠链接
  • 私密分享的文件
  • 测试环境的入口

我面临的第一个问题是:如何安全地存储密码?

数据库设计

我需要在 urls 表中添加密码相关字段:

数据设计要点

  • urls 表在这一步补上密码相关字段:密码哈希和 salt,明文密码永远不进库。
  • 哈希字段存的是 bcrypt 的结果和生成它的 salt,验证时用同一套参数重新计算再比对。

密码哈希:为什么不用 MD5?

在实现密码存储时,我需要考虑安全性。很多人会用 MD5 或 SHA1 来哈希密码:

落地思路

  • MD5/SHA1 的设计目标是快速校验和,不是安全存储密码:算得越快,撞库越省事。
  • 彩虹表是预先算好的「常见密码 → 哈希」对照表,一旦库里用的是可预测的哈希,一次查表就还原原文。
  • 关键差别在「快」:快算法让攻击者一秒能试几十亿次,慢算法把每一次尝试的成本抬到不可接受。

为什么不这样做?

MD5 和 SHA1 是快速哈希算法,设计初衷是快速计算校验和,而非安全存储密码。攻击者可以:

  1. 彩虹表攻击:预先计算常见密码的哈希值(如 “123456”、“password”),然后用查表方式破解
  2. 暴力破解:因为计算速度快,一秒钟可以尝试数十亿次

使用 bcrypt:正确的做法

我选择了 bcrypt——专门为密码哈希设计的算法:

落地思路

  • 用 bcrypt 存密码,数据库里只有哈希值和 salt,永远不存明文。
  • 每个密码独立生成随机 salt,相同密码的哈希也不同,彩虹表直接失效。
  • work factor 可调:每加 1,计算时间大约翻一倍,用来把暴力破解成本抬到可接受区间。
  • 兜底是失败次数限制加递增延迟,撞库拿到的哈希再多也解不动。

bcrypt 的优势:

  1. 自动加盐:每次生成的哈希都不同,防止彩虹表攻击
  2. 计算成本可调:通过 work factor 参数控制计算时间,增加暴力破解成本
  3. 抗GPU攻击:算法设计使得GPU并行计算优势不明显

创建带密码的短链接

落地思路

  • 创建时把密码一起提交,服务端用 bcrypt 算出哈希后随链接一起写入 urls。
  • 明文密码只在请求里出现一次,服务端不记录、不回显、不写日志。
  • 返回给用户的是短链本身,不含密码;密码要重新设置也只能整体重写哈希,不能反查原值。

访问流程:密码验证

当用户访问带密码的短链接时,我需要:

  1. 检查链接是否设置了密码
  2. 检查用户是否已经通过验证(Session)
  3. 如果未验证,显示密码输入页面
  4. 验证密码正确性,防止暴力破解

落地思路

  • 命中带密码的链接时,先看会话里有没有该短码的验证通过标记,有就直接跳 302。
  • 没有就返回密码输入页,并在会话里记下尝试次数和首次尝试时间。
  • 校验用 bcrypt 逐个比对哈希;同一短码连续失败达到上限就临时锁定该短码的密码尝试。
  • 每次校验都要重新算一遍 bcrypt,这是有意保留的成本——它正是让暴力破解变慢的原因。

密码输入页面

落地思路

  • 页面只回显短码,不回显目标长 URL——否则密码没输对就把目标地址泄露了。
  • 表单只提交密码字段,不带任何隐藏的长链接参数,避免用户改参数跳过校验。
  • 密码框不回显、不预填;输错只提示「密码不正确」,不区分「短码不存在」和「密码错」。
  • 校验通过后才写入会话标记并 302 跳转到目标地址。

IP 白名单 🌐

需求场景

密码保护可以防止未授权访问,但企业客户有更高的安全需求:

“我们的内部链接,只允许公司网络访问。能不能按IP限制?”

IP 白名单是更严格的访问控制方式,常用于:

  • 内网资源保护:只允许公司内网IP访问
  • 地域限制:只允许特定国家/地区的IP访问
  • API访问控制:只允许合作伙伴服务器的IP调用

IP 地址的挑战

实现 IP 白名单有几个技术难点:

  1. 获取真实IP:需要处理代理、负载均衡器的情况
  2. IP范围匹配:单个IP不够,需要支持CIDR格式(如 192.168.1.0/24)
  3. IPv4和IPv6兼容:需要同时支持两种协议

获取真实 IP

在 Flask 中,直接使用 request.remote_addr 可能不准确:

落地思路

  • request.remote_addr 拿到的是 TCP 连接的来源 IP:前面有 Nginx 或负载均衡时,它只会是代理的地址。
  • 真实客户端 IP 要从 X-Forwarded-For 这类代理头里取。
  • 这类头由客户端可以伪造,只有在确认请求经过自己的 Nginx 时才可信。

正确做法是检查 X-Forwarded-For 头:

落地思路

  • X-Forwarded-For 的格式是「客户端IP, 代理1, 代理2」,越靠左越接近真实来源。
  • 取最左边一个作为客户端 IP;自己这一跳由 Nginx 以 X-Real-IP 补齐,避免信任头被链上其他节点伪造。
  • 头缺失时退回 request.remote_addr,宁可取到代理 IP,也不要凭空造一个。
  • 白名单判断前先过这一步,否则整条 IP 规则会退化成「只允许代理 IP」。

CIDR 匹配算法

企业客户不会只允许单个IP,而是整个IP段。CIDR(无类别域间路由)是标准表示法:

  • 192.168.1.0/24:表示 192.168.1.0 到 192.168.1.255(256个IP)
  • 10.0.0.0/8:表示 10.0.0.0 到 10.255.255.255(1600万个IP)

我需要实现CIDR匹配算法:

落地思路

  • 先把候选 IP 和规则里的网段都转成整数(IPv4 转无符号 32 位),再按掩码长度做按位与。
  • 命中条件是 (ip & mask) == (network & mask),这样 192.168.1.0/24 能覆盖整段地址。
  • IPv6 走同一套按位逻辑,只是位宽从 32 位换成 128 位。
  • 规则数量不多时逐条匹配即可;规则多了再按前缀长度建索引,不必一开始就上复杂结构。

数据库设计

数据设计要点

  • 核心是在 urls 里保存业务事实,而不是把规则散落在应用逻辑里。
  • ip_logs 记录每次访问的判定结果,allowed 字段区分放行和拦截,审计和调规则都靠它。
  • 索引服务于高频查询:idx_ip_logs_short_code 支撑按短码查访问明细,idx_ip_logs_time 支撑按时间范围做窗口统计。
  • 关键字段包括 id、short_code、ip_address、access_time、allowed,它们决定了后续查询和管理能力。

IP 白名单实现

落地思路

  • 白名单规则随链接配置一起存放,一条链接对应一组允许访问的网段。
  • 命中任意一条即放行,全部不命中就返回拦截页——默认拒绝,不能默认放行。
  • 拦截页只说明「该链接限制了访问范围」,不回显命中的具体规则。
  • 每次判定结果都落一条访问记录,写明该次是放行还是拦截,后面才能审计和调规则。

创建带IP限制的短链接

落地思路

  • 创建接口接收网段列表,服务端先校验每个网段格式合法(IP 合法且掩码长度在允许范围内),再落库。
  • 非法的网段整单拒绝并指出是哪一条不合法,不做静默丢弃。
  • 规则存成规范化的网段形式而不是原始字符串,避免同一段网因写法不同被当成两条。
  • 至少留一条规则:一条没有任何允许网段的链接,等于把自己锁死。

IP 访问统计

落地思路

  • 统计口径是按链接和来源 IP 聚合:放行次数、拦截次数、命中的规则分别计数。
  • 明细落在 ip_logs 表,字段包括 id、short_code、ip_address、access_time、allowed。
  • 查询走 idx_ip_logs_short_code 和 idx_ip_logs_time 两个索引,避免全表扫描。
  • 统计查询走异步或预聚合,绝不放在跳转路径上。

访问次数限制 🎫

需求场景

一个营销客户找到我:

“我们要发限时优惠券,只给前100名用户。能不能实现’前100人可以访问,其他人不行’?”

这就是访问次数限制的需求:

  • 限量抢购:只允许前N人访问
  • 试用期限制:免费版只能访问100次
  • 配额管理:防止资源被过度消耗

数据库设计

我在密码保护部分已经添加了相关字段:

数据设计要点

  • 次数限制的字段和密码字段一起加在 urls 上:允许的最大次数,以及当前已用次数。
  • 两个字段必须一起写入,避免配额和实际用量对不上,也避免默认值被后续改动带偏。

访问次数限制实现

落地思路

  • 次数限制有两个字段:允许的最大次数,以及当前已用次数。
  • 每次访问在同一事务里先读已用次数、判上限、再自增写入,并发下靠这层事务保证不会超发。
  • 计数达到上限后的状态是「已用尽」,此时返回限制提示页而不是 302。
  • 兜底是计数不能只存在缓存里:缓存重启归零就等于限次失效,计数必须落在数据库。

限制页面模板

落地思路

  • 页面要说明限制原因:已用次数、允许的总次数,以及该链接是否还能继续使用。
  • 不显示目标长 URL——限次链接的信息同样不该在通过校验前泄露出去。
  • 提示文案给出可执行的下一步(换链接或联系创建者),而不是只说一句「访问次数已用完」。
  • 页面上的计数器从数据库读,和跳转路径上的判定用同一份数据,不能各算各的。

创建限次访问的短链接

落地思路

  • 创建时同时写入允许次数和已用次数(初始为 0),两个字段一并入库,避免默认值和实际配置不一致。
  • 已用次数必须显式写入,不能依赖数据库默认值——默认值一旦被改动,历史链接的配额会跟着变。
  • 创建后校验一次:立即访问一次,确认计数从 0 变成 1,说明计数链路是通的。
  • 允许次数为 0 的链接在创建时就要拦下来,而不是创建一个天然不可用的链接。

实时访问计数查询

落地思路

  • 实时计数用 Redis 计数器,数据库里的已用次数是最终事实来源。
  • 每次访问先 INCR Redis 计数,再异步把增量落库;Redis 计数只作为加速读,落库失败时以数据库为准。
  • 判定「是否还有剩余次数」读数据库的已用次数,避免缓存与事实不一致导致超发。
  • Redis 计数器要设 TTL 并在链接失效时清掉,否则过期链接的计数会一直占着内存。

时间窗口限制 ⏰

需求场景

又有一个企业客户提出需求:

“我们的内部链接,只在工作时间可以访问,下班后不行。”

这就是时间窗口限制:

  • 工作时间限制:只在周一到周五的9:00-18:00可访问
  • 活动时间限制:限时促销,只在一个日期范围内有效
  • 试用期限制:新用户注册后7天内可访问

数据库设计

数据设计要点

  • 时间窗口的字段也加在 urls 上:生效起点、失效终点,以及每周生效时段的配置。
  • 时间统一存 UTC,比较时再按配置时区换算,避免服务器换时区后窗口整体偏移。

时间范围限制

落地思路

  • 时间窗口用两个时间戳表达:生效起点和失效终点。
  • 每次访问把当前时间与这两个时间戳比较:在窗口内才 302,窗口外走拦截分支。
  • 两个时间戳都存 UTC,比较时统一时区,避免服务器换时区后窗口整体偏移。
  • 只有起点没有终点的链接表示长期有效,终点可空但语义要明确,不能靠 NULL 隐式区分。

每周时间段限制

落地思路

  • 每周时间段比时间范围多两个条件:星期几和每天的起止时刻。
  • 判断顺序是先看日期是否落在指定星期,再看当前时刻是否在当天的起止区间内,两个条件都满足才放行。
  • 跨零点的时段(比如 22:00–02:00)要拆成两段判断,否则凌晨那一段会被判成不在窗口内。
  • 时区同样存成明确的配置项,服务器时区和用户时区不一致时按配置换算。

创建时间限制的短链接

落地思路

  • 创建时把生效和失效时间一并写入 urls 上的限制字段,和短码在同一次插入里落库。
  • 失效时间早于生效时间的配置在创建时就被拒绝,不允许写进库再靠访问时兜底。
  • 到期后链接不删除,只把状态置为「已过期」——保留记录才能解释历史点击统计。
  • 过期链接的访问返回过期提示页而不是 404,避免用户以为链接写错了。

完整的时间检查流程

落地思路

  • 完整判断链:取链接配置 → 判断是否受限 → 未受限直接 302 → 受限则依次检查密码、IP、次数、时间四类规则。
  • 每一类规则命中拦截就立即返回对应的提示页,不再往下走后面的判断。
  • 所有规则都通过才返回 302,并按本次通过情况写一条访问记录。
  • 判断链要在跳转路径上完成,所以每一步只能查一次已取到的数据,不能为每条规则单独回库。

访问控制方案对比 📊

我已经实现了四种访问控制方案,每种都有其适用场景:

方案实现难度安全性性能影响适用场景
密码保护⭐⭐ 中等⭐⭐⭐ 中等⭐⭐ 低内部文档、私密分享
IP白名单⭐⭐ 中等⭐⭐⭐⭐ 高⭐⭐ 低企业内网、地域限制
访问次数限制⭐ 简单⭐⭐ 低⭐ 低限量抢购、试用版
时间窗口限制⭐⭐⭐ 较难⭐⭐ 低⭐ 低限时活动、工作时间

组合使用

这些方案可以组合使用:

落地思路

  • 组合的顺序就是优先级:先校验密码(最便宜),再判 IP 和次数,最后才做时间窗口换算。
  • 短码命中后一次取出该链接的全部限制配置,四类规则共用这份数据,避免每类规则各查一次。
  • 校验通过后把结果写进会话,后续访问同一链接不必重复校验密码。
  • 任一规则不通过都返回各自的提示页,但跳转响应本身的结构保持一致。

性能考虑

访问控制会增加响应延迟,我做了优化:

  1. 数据库索引:在 short_code 上建索引,快速查询
  2. 缓存Session:密码验证后缓存,避免重复查询
  3. IP匹配优化:使用高效的CIDR匹配算法
  4. 异步日志:访问日志异步写入,不影响主流程

下一步:批量管理 ⏭️

单个链接的访问控制已经实现了。但企业客户又提出新问题:

“我们有1000个内部链接,能不能批量设置IP白名单?” “每个月都要更新密码,能不能批量操作?”

单个链接管理已经不够了,我需要实现批量管理功能。

下一节,我会实现:

  • 批量设置访问控制
  • 批量更新密码
  • 批量导入导出
  • 权限组管理

(继续下一节:批量操作)

发现内容有问题?

告诉我们哪里需要改进。