《互联网产品的服务端架构·产品、流量与基础能力》3.3 账号安全:撞库、风控与找回

从一次撞库事件讲起,说明账号维度、IP 维度和设备维度为什么要分别计数,给出滑动窗口、成功清零和可读拒绝原因的 Go 限流实现,并用测试覆盖同账号多 IP、同 IP 多账号、窗口过期与成功清零,最后附上本机运行输出和常见错误清单,让防爆破与防撞库不再互相替代。

一次撞库,账号限流没有报警

「回声」的登录接口有一条很早就加上的规则:同一个账号连续失败 5 次,锁定 15 分钟。这条规则运行了两年,安全团队一直认为登录入口是受保护的。某天运营发现,一批用户投诉“账号被盗,收货地址被改”,调查后确认攻击者掌握了一份从其他网站泄露的邮箱和密码,用它们在「回声」上试登录。

攻击者的做法很朴素:对每个账号只试一次。账号 A 失败一次就去试账号 B,账号 A 的失败计数永远到不了 5,锁定规则一次都没有触发。假设泄露数据里有 20 万个「回声」用户,密码复用率按案例假设 2% 计算,攻击者只需要一次批量请求,就能拿到约 4000 个有效账号。这个数字是案例假设值,用来演示攻击成本,不是真实统计。

更麻烦的是,攻击流量来自大量家用宽带和代理 IP,单个 IP 的请求量并不高。如果只按 IP 做限流,又会误伤公司出口、运营商 NAT 后面的正常用户。撞库的本质是分布式问题,不是单账号问题;只用一维计数,必然在某一面漏掉。

三个维度,三种攻击

登录风控要区分三个维度:账号、IP、设备(或设备指纹、子网)。它们对应的攻击方式不同,阈值和处置动作也应该不同。

维度主要防什么典型信号处置动作主要误伤
账号定向爆破单个账号同一账号短时间多次失败延迟、验证码、临时锁定用户忘记密码反复尝试
IP同一来源横扫多个账号同一 IP 高失败率、多账号限速、验证码、封禁NAT、公司出口、校园网
设备批量注册、养号、脚本同一设备大量新账号设备验证、注册限流共用设备、模拟器测试

账号维度防的是“盯着一个人试”。它的计数键是账号 ID,阈值可以低一些,因为正常人不会在几分钟内连续输错很多次。IP 维度防的是“一个来源扫很多人”。它的计数键是 IP 或子网,阈值要结合业务规模,太严会误伤 NAT 后的正常用户。

设备维度防的是“批量制造账号”。它不是登录失败计数,而是注册频率、设备与账号的关联数量、行为模式。设备维度的数据通常来自客户端采集,可信度低于账号和 IP,所以更适合做风险评分,而不是直接拒绝。

三个维度不是三选一,而是叠加。一个请求要同时通过账号、IP、设备的检查;任何一维触发,都应该返回可读的拒绝原因,并记录足够的风控上下文。只做账号维度,就会遇到本章开头的撞库;只做 IP 维度,就会在 NAT 和代理面前失效。

失败场景:每个账号只试一次

回到事故现场。第一版只按账号限流,攻击者每个账号试一次,规则完全不触发。第二版加了 IP 限流,阈值是“同一 IP 每分钟失败 20 次”。攻击者把请求分散到几百个代理 IP,每个 IP 每分钟只失败 3 次,仍然不触发。同时,某公司出口的 300 名员工共用一个公网 IP,午休时集中登录,反而被误封。

第三版才把维度拆开:账号维度阈值低、窗口短,主要拦截定向爆破;IP 维度阈值高、窗口长,主要拦截批量扫描;设备维度只做风险评分和注册限流,不直接拒绝登录。三层同时命中才升级处置,单层命中先要求验证码。这样既挡住了“每账号一次”的撞库,也没有把整个公司出口封死。

这个案例的教训是:限流的维度要跟攻击的分布对齐。攻击者可以控制每个账号试几次,也可以控制每个 IP 发多少请求,但他很难同时控制账号、IP、设备三个维度的分布。防御的价值不在于某一维的阈值多精确,而在于让攻击者必须同时绕过多个独立约束。

还有一个常被忽略的点:成功登录要清零。用户输错两次后想起来密码,第三次成功,如果失败计数不清零,接下来几天里再有两次手误就会被锁定。计数窗口应该只统计“最近一段时间内的连续失败”,成功是一次明确的状态切换。

Go 实现:滑动窗口与双维度计数

下面的实现来自 webapp-lab。Attempt 记录账号、IP、是否成功和时间;Throttle 分别维护账号与 IP 的失败时间列表;Record 在成功时删除两个维度的计数;Allow 先按当前时间裁剪窗口,再分别检查两个维度,返回布尔值和可读原因。

// file: internal/account/security.go
// tier: L1
// verify: go test ./internal/account/

package account

import (
	"fmt"
	"time"
)

// Attempt 是一次登录尝试的事实记录。密码是否正确只影响 Success,
// 账号与 IP 都必须保留,因为撞库和防爆破看的是不同维度。
type Attempt struct {
	AccountID string
	IP        string
	Success   bool
	At        time.Time
}

// Throttle 用滑动窗口分别统计账号维度和 IP 维度的失败次数。
// 账号维度防定向爆破,IP 维度防同一来源横扫多个账号。
type Throttle struct {
	Window      time.Duration
	MaxFailures int

	accounts map[string][]time.Time
	ips      map[string][]time.Time
}

// NewThrottle 创建窗口与阈值都为正数的限流器。
func NewThrottle(window time.Duration, maxFailures int) (*Throttle, error) {
	if window <= 0 {
		return nil, fmt.Errorf("account: throttle window must be positive")
	}
	if maxFailures <= 0 {
		return nil, fmt.Errorf("account: max failures must be positive")
	}
	return &Throttle{
		Window:      window,
		MaxFailures: maxFailures,
		accounts:    make(map[string][]time.Time),
		ips:         make(map[string][]time.Time),
	}, nil
}

func (t *Throttle) init() error {
	if t.Window <= 0 {
		return fmt.Errorf("account: throttle window must be positive")
	}
	if t.MaxFailures <= 0 {
		return fmt.Errorf("account: max failures must be positive")
	}
	if t.accounts == nil {
		t.accounts = make(map[string][]time.Time)
	}
	if t.ips == nil {
		t.ips = make(map[string][]time.Time)
	}
	return nil
}

// Record 记录一次尝试。成功会同时清零该账号与该 IP 的失败计数,
// 因为成功登录说明当前来源不再处于可疑状态。
func (t *Throttle) Record(a Attempt) error {
	if err := t.init(); err != nil {
		return err
	}
	if a.AccountID == "" || a.IP == "" {
		return fmt.Errorf("account: attempt account and ip are required")
	}
	if a.Success {
		delete(t.accounts, a.AccountID)
		delete(t.ips, a.IP)
		return nil
	}
	t.accounts[a.AccountID] = append(t.accounts[a.AccountID], a.At)
	t.ips[a.IP] = append(t.ips[a.IP], a.At)
	return nil
}

// Allow 判断当前账号与 IP 是否还能继续尝试。返回的第二个值是可读原因,
// 便于网关记录日志和前端提示,而不是只给一个布尔值。
func (t *Throttle) Allow(accountID, ip string, now time.Time) (bool, string) {
	if err := t.init(); err != nil {
		return false, err.Error()
	}
	if accountID == "" || ip == "" {
		return false, "account and ip are required"
	}

	cutoff := now.Add(-t.Window)
	t.accounts[accountID] = prune(t.accounts[accountID], cutoff)
	t.ips[ip] = prune(t.ips[ip], cutoff)

	if n := len(t.accounts[accountID]); n >= t.MaxFailures {
		return false, fmt.Sprintf("account %s: %d failures within %s", accountID, n, t.Window)
	}
	if n := len(t.ips[ip]); n >= t.MaxFailures {
		return false, fmt.Sprintf("ip %s: %d failures within %s", ip, n, t.Window)
	}
	return true, "allowed"
}

// prune 只保留严格晚于 cutoff 的记录,使窗口随 now 滑动。
func prune(times []time.Time, cutoff time.Time) []time.Time {
	kept := times[:0]
	for _, at := range times {
		if at.After(cutoff) {
			kept = append(kept, at)
		}
	}
	return kept
}

代码里有几个需要说明的取舍。

  • Allow 不记录尝试,Record 不判断是否允许。查询和写入分开,调用方可以在认证前后分别调用,也便于把判断逻辑复用到验证码、找回密码等入口。
  • 窗口用 At.After(cutoff) 裁剪,恰好等于窗口边界的记录会被释放。这个边界要在测试里固定,否则不同实现会在“刚好一分钟”时给出不同结果。
  • 账号维度先于 IP 维度检查。两个维度同时命中时,返回账号原因,因为对用户来说“这个账号失败太多”比“这个网络失败太多”更容易理解和处理。
  • Record 遇到成功就删除两个维度的全部计数,而不是只减一。成功说明凭据是对的,继续保留历史失败没有意义,还会误伤后续正常登录。
  • 返回原因里带上了次数和窗口。日志里能直接看出是哪一维触发、阈值是多少,排查误封时不需要再猜。

这段代码没有做持久化,也没有做分布式计数。生产环境通常把计数放在 Redis,用有序集合或时间桶实现滑动窗口;多实例之间必须共享同一份计数,否则攻击者只要把请求打到不同实例就能绕过。代码里的 prune 是内存版本的语义基准,迁移到 Redis 时要用等价的过期和范围删除。

测试:账号、IP、窗口与清零

测试覆盖四组行为:同一账号多 IP 会命中账号维度;同一 IP 多账号会命中 IP 维度;窗口滑过后计数释放;成功登录清零。每个拒绝用例都检查原因字符串,确保调用方拿到的是可解释的拒绝,而不是一个无法区分维度的布尔值。

// file: internal/account/security_test.go
// tier: L1
// verify: go test ./internal/account/

package account

import (
	"strings"
	"testing"
	"time"
)

func fail(t *testing.T, th *Throttle, account, ip string, at time.Time) {
	t.Helper()
	if err := th.Record(Attempt{AccountID: account, IP: ip, At: at}); err != nil {
		t.Fatal(err)
	}
}

func TestAllowCountsAccountDimension(t *testing.T) {
	th, err := NewThrottle(time.Minute, 3)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Date(2026, 10, 28, 10, 0, 0, 0, time.UTC)
	for i, ip := range []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"} {
		fail(t, th, "a1", ip, now.Add(time.Duration(i)*time.Second))
	}

	ok, reason := th.Allow("a1", "10.0.0.99", now.Add(5*time.Second))
	if ok {
		t.Fatal("want rejection after three account failures")
	}
	if !strings.Contains(reason, "account") {
		t.Fatalf("reason = %q, want account dimension", reason)
	}

	ok, _ = th.Allow("a2", "10.0.0.99", now.Add(5*time.Second))
	if !ok {
		t.Fatal("another account should still be allowed")
	}
}

func TestAllowCountsIPDimension(t *testing.T) {
	th, err := NewThrottle(time.Minute, 3)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Date(2026, 10, 28, 10, 0, 0, 0, time.UTC)
	for i, account := range []string{"a1", "a2", "a3"} {
		fail(t, th, account, "10.0.0.7", now.Add(time.Duration(i)*time.Second))
	}

	ok, reason := th.Allow("a-fresh", "10.0.0.7", now.Add(5*time.Second))
	if ok {
		t.Fatal("want rejection after three failures from one IP")
	}
	if !strings.Contains(reason, "ip") {
		t.Fatalf("reason = %q, want ip dimension", reason)
	}

	ok, _ = th.Allow("a-fresh", "10.0.0.8", now.Add(5*time.Second))
	if !ok {
		t.Fatal("fresh IP should still be allowed")
	}
}

func TestAllowSlideWindow(t *testing.T) {
	th, err := NewThrottle(time.Minute, 2)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Date(2026, 10, 28, 10, 0, 0, 0, time.UTC)
	fail(t, th, "a1", "10.0.0.1", now)
	fail(t, th, "a1", "10.0.0.1", now.Add(10*time.Second))

	if ok, _ := th.Allow("a1", "10.0.0.1", now.Add(20*time.Second)); ok {
		t.Fatal("want rejection inside the window")
	}
	if ok, _ := th.Allow("a1", "10.0.0.1", now.Add(2*time.Minute)); !ok {
		t.Fatal("both failures should have slid out of the window")
	}
}

func TestSuccessClearsCounters(t *testing.T) {
	th, err := NewThrottle(time.Minute, 2)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Date(2026, 10, 28, 10, 0, 0, 0, time.UTC)
	fail(t, th, "a1", "10.0.0.1", now)
	fail(t, th, "a1", "10.0.0.1", now.Add(time.Second))
	if ok, _ := th.Allow("a1", "10.0.0.1", now.Add(2*time.Second)); ok {
		t.Fatal("want rejection before success")
	}

	if err := th.Record(Attempt{AccountID: "a1", IP: "10.0.0.1", Success: true, At: now.Add(3 * time.Second)}); err != nil {
		t.Fatal(err)
	}
	if ok, reason := th.Allow("a1", "10.0.0.1", now.Add(4*time.Second)); !ok {
		t.Fatalf("success should clear counters, reason = %q", reason)
	}
}

func TestThrottleValidatesInput(t *testing.T) {
	if _, err := NewThrottle(0, 3); err == nil {
		t.Fatal("want error for zero window")
	}
	if _, err := NewThrottle(time.Minute, 0); err == nil {
		t.Fatal("want error for zero max failures")
	}
	th, _ := NewThrottle(time.Minute, 3)
	if err := th.Record(Attempt{At: time.Now()}); err == nil {
		t.Fatal("want error for empty account and ip")
	}
}

测试没有覆盖分布式计数、IP 归属识别和 IPv6。生产环境里,同一个用户可能在一个请求里携带 IPv4,另一个请求里携带 IPv6,IP 维度要能归一化;代理场景还要从可信头里取真实客户端地址,不能直接相信 X-Forwarded-For 的第一个值。这些都属于接入层和风控数据的问题,不应该塞进这个纯计数包。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/account/ -run 'TestAllow|TestSuccess' -count=1

一次本机运行的真实输出如下,耗时随机器变化:

ok  	webapp/internal/account	0.281s

ok 表示筛选出的用例全部通过。加 -v 可以看到账号维度、IP 维度、窗口滑动和成功清零四个场景分别执行;判定标准仍然是退出码为 0 且没有 FAIL。

小结

撞库是分布式问题:攻击者可以控制每个账号试几次,也可以控制每个 IP 发多少请求,但他很难同时绕过账号、IP、设备三个独立约束。账号维度防定向爆破,IP 维度防批量扫描,设备维度做风险评分和注册限流;三者叠加,比任何一维的精确阈值都更有价值。

实现上要记住三点:查询和记录分开,窗口按当前时间滑动,成功登录清零。拒绝时返回可读原因,让日志、客服和风控能解释“为什么这次登录被挡”。这些规则写进代码和测试,才不会在每次改版时靠回忆维持。

账号安全还有一半在登录之后:密码找回、改密、异常设备确认。它们和本节共享同一套思路——用多个独立信号判断“是不是本人”,而不是依赖单一凭证。

阅读导航:上一节:3.2 会话、设备与多端互踢 · 下一节:4.1 资料模型与可见性 。

常见错误与边界

  • 只按账号限流。攻击者每个账号只试一次,计数永远到不了阈值。
  • 只按 IP 限流。代理池、僵尸网络和 NAT 会让单一 IP 阈值既漏报又误伤。
  • 成功登录不清零。用户几次手误后正常登录,仍然背着历史失败计数。
  • 固定窗口而不是滑动窗口。整点重置会让攻击者在窗口边界集中打两倍请求。
  • 把限流当成风控的全部。限流只能降低尝试速度,不能识别已泄露的有效凭据。
  • 拒绝原因过于模糊。只返回“登录失败”会让客服无法区分密码错误、账号锁定和风控拦截。
  • 相信客户端传来的 IP 或设备 ID。接入层必须从可信链路取地址,设备标识只能作为弱信号。
  • 多实例各算各的计数。请求打到不同实例就能绕过阈值,计数必须共享。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查