账号生成规则这个词,很多后端开发者第一次听到是在接手老项目的时候。用户ID是从1开始的连续整数,API Key是时间戳加MD5拼出来的,登录Token干脆就是个UUID。看起来都能用,直到某天线上日志里出现一批枚举攻击请求,或者某个下游合作方拿着别人的密钥来调接口,你才会意识到:账号生成规则不是“能跑就行”的体力活,而是整个系统安全边界的地基。
这篇文章要聊的正是账号生成规则里的两个核心角色:哈希和密钥。我会把实际项目里沉淀下来的一套完整方案讲清楚——包括用户ID、登录Token、API密钥这三类核心标识的生成、存储、校验规则,以及我在真实环境里踩过的坑。适合正在搭用户体系、做开放平台、或者准备把账号模块重构成熟的开发者,看完可以直接照着落地。
先立一个总观点:账号生成规则的目标不是“生成一串不重复的字符”,而是“生成一串别人猜不到、伪造不了、泄露了能追查的字符”。这三件事分别对应随机性、签名和可识别性,而把它们串起来的,正是哈希和密钥这对固定搭档。
1. 账号生成规则的整体设计思路
1.1 先想清楚:你生成的到底是什么
打开数据库,账号相关的字段大概分三类。第一类是用户标识,比如用户ID、订单号、会话ID,要求唯一、不可枚举、不可预测。第二类是凭据,比如密码哈希、Token、API Key,要求不可伪造、不可逆推。第三类是签名,比如回调通知里的签名串、防篡改的校验字段,要求依赖一个只有自己知道的密钥。
这三类东西看起来都是“一串字符”,但设计规则完全不同。用户ID可以不用密钥,但必须不可预测;API Key必须依赖高质量随机数;签名必须依赖密钥和哈希的组合。很多人踩坑,就是把这三类混在一起处理,用同一个逻辑生成所有字段,结果要么ID可以枚举,要么密钥硬编码在代码里。
我见过一个真实案例:某个内部系统的订单号直接用“日期加三位随机数”生成,结果同一天内订单号出现重复,更严重的是通过连续请求可以推导出完整的生成规律,攻击者可以批量伪造订单查询接口的请求参数。这就是典型的“没有区分标识和凭据”的后果。
1.2 哈希与密钥:一对固定搭档
哈希函数解决的是“单向性”问题。给定任意长度的输入,输出固定长度的摘要,但反过来几乎不可能。这决定了它在账号体系里的两个用途:一是存储密码时只存摘要,不存明文;二是做完整性校验,判断一段内容有没有被改动过。
密钥解决的是“只有我知道”的问题。它是一段高熵的随机数据,只有持有者才有。单独用哈希没有密钥概念,任何人都能对任意内容算摘要;但把密钥和哈希组合成HMAC之后,就只有同时持有密钥和数据的人才能算出正确的签名。
整篇文章的底层逻辑其实就一条线:用哈希保证单向性和完整性,用密钥保证独占性,两者组合解决“别人伪造不出来”的问题。理解这条线之后,后面所有的代码和规则都只是它的具体形态。
1.3 一套可落地的账号生成规则长什么样
我在实际项目中沉淀出的规则可以概括成一句话:可识别的前缀 + 高熵随机主体 + 必要的哈希校验。
拿API Key举例,真实格式类似sk_live_9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e。前缀sk_live_用来标识这是生产环境的密钥;中间的主体是高熵随机数;校验位可以放在末尾,用来在接口层快速过滤手误输入的无效密钥,而不必每次都查数据库。
这套规则的好处是:前缀让运维和日志排查变得非常快,出问题瞄一眼就知道是哪个环境哪个业务的密钥;随机主体保证不可预测;校验位减少无效请求打到数据库。后面第四部分我会给出完整的代码实现。做设计的时候一定要记住:规则越简单越容易被复制,但越有结构越容易被运维和管理。
2. 哈希算法选型:把力气用在正确的地方
2.1 快哈希和慢哈希,目的完全不同
先看一张表,这是我在项目里给团队用的选型参考:
| 算法 | 摘要长度 | 速度 | 安全性现状 | 推荐用途 |
|---|---|---|---|---|
| MD5 | 128位 | 极快 | 碰撞已被攻破 | 不推荐,仅限非安全场景 |
| SHA-1 | 160位 | 快 | 已发现碰撞攻击 | 不建议新项目使用 |
| SHA-256 | 256位 | 快 | 目前安全 | 签名、完整性校验、哈希截断 |
| SHA-3 | 可变 | 较快 | 目前安全 | 与SHA-2互为备用 |
| bcrypt | 可变 | 刻意慢 | 安全 | 密码存储 |
| argon2 | 可变 | 刻意慢 | 安全 | 密码存储,内存占用更高 |
这里有一个关键认知:MD5、SHA-256这类哈希算法是“快哈希”,设计目标就是快;bcrypt、argon2这类是“慢哈希”,设计目标就是慢。用途完全不同。快哈希用于校验完整性和计算签名,因为系统里每秒可能要算成千上万次;慢哈希用于存储用户密码,因为攻击者也在用GPU拼命尝试破解,哈希速度越慢,暴力破解的成本就越高。
另外提醒一下,不要和数据结构里的“哈希表”混淆。哈希表是键值存储结构,利用哈希函数把key映射到数组下标,追求的是查询效率;文章里讨论的哈希函数是密码学工具,追求的是单向性和抗碰撞。名字相近,完全是两码事。
2.2 密码为什么不能直接哈希存储
直接存MD5摘要已经是十几年前的错误示范了。攻击者手里有彩虹表——预计算好的巨大字典,里面存着大量常见密码的哈希值,查询一个MD5摘要几乎瞬间就能反推出原始密码。解决办法就是加盐。盐是一段随机数据,每个用户独立生成,存储时和摘要放在一起:hash(密码 + 盐)。
盐的作用是让相同密码在不同用户身上产生不同摘要,彩虹表直接失效。而且盐要足够长,实践中我一般用16字节以上。这里有个连带的好处:因为盐是随机生成的,即使两个用户密码相同,数据库里存的哈希也完全不同,攻击者无法通过比对哈希值判断哪些账号用了同一个密码。
还有一个经常被问的问题:盐要不要保密?不需要。盐的目的是增加破解成本,不是保密。就算攻击者拿到了盐和摘要,要破解仍需要针对单个用户单独跑字典,成本比没有盐高出一个数量级。
2.3 哈希之后怎么编码和截断
哈希运算的输出是二进制字节,不能直接存数据库和拼URL,需要编码。常见的选择是hex和Base64。
hex的好处是定长、可读、无特殊字符,一个字节变两个字符;缺点是长度翻倍。Base64更紧凑,但标准Base64包含+和/,放在URL里要转义,所以做Token和Key时建议用base64.urlsafe_b64encode,把+换成-、/换成_。
截断时也要注意方向。截断哈希的低位还是高位,只要固定下来就行,关键是截断长度要匹配你的安全目标。校验位截断4字节,碰撞概率约十亿分之一;签名场景不建议截断超过一半,否则相当于人为降低安全强度。这一块看起来是小事,但真踩过坑。以前同事把标准Base64直接拼进URL参数,结果密钥带+号被解析成空格,调试了一下午。记住一句话:凡是会出现在URL、Header、文件名里的字符串,一律用URL安全编码。
3. 密钥从哪里来:随机性、派生与生命周期
3.1 高熵随机数才是密钥的根基
密钥的安全强度完全取决于随机源的质量。Python里有两个模块:random和secrets。前者是伪随机数生成器,适合模拟、抽样这类不涉及安全的场景;后者是专门为密码学设计的,基于操作系统提供的熵源。
我见过有人用random生成API Key,上线后被扫到大量可预测密钥,原因是random的种子可以预测。所以规则只有一条:凡是密钥、盐、Token、IV这类材料,一律只用secrets、os.urandom这类密码学安全随机源。
以API Key为例,推荐长度至少32字节。32字节等于256位熵,暴力枚举的空间是2的256次方,这在当前算力下是不现实的。很多平台用的是更长密钥,但32字节已经是底线,少了不建议。生成之后,还要注意密钥的编码方式,同一个字节序列用hex和Base64表示出来长度不同,存储和传输前先约定统一格式,避免后续比对时出现莫名其妙的差异。
3.2 HMAC:给数据盖一个只有你能盖的章
HMAC是Hash-based Message Authentication Code的缩写,简单说就是“带密钥的哈希”。它和普通哈希的区别在于:普通哈希任何人都能算,HMAC只有持有密钥的人才能算出正确结果。
以SHA-256为例,HMAC的大致过程是:把密钥通过填充和异或处理成内外两个填充块,分别与消息做两次哈希。具体的公式是HMAC(K, m) = H((K' ⊕ opad) || H((K' ⊕ ipad) || m))。这里不需要背公式,但需要理解它解决了一个裸哈希解决不了的问题:单纯把密钥拼在消息后面做哈希,存在长度扩展攻击的隐患;HMAC的双重哈希结构消除了这个隐患。
Python里用内置模块就可以:
import hashlib import hmac def sign_message(secret_key: bytes, message: str) -> str: return hmac.new(secret_key, message.encode("utf-8"), hashlib.sha256).hexdigest()这里有个细节:HMAC的密钥长度最好和哈希算法的输出长度一致或更长。比如用SHA-256,密钥低于32字节时HMAC会自动填充补零,虽然不影响安全性,但如果多个服务共用密钥,务必统一密钥的长度标准,否则容易出现“同一个密钥在两个系统里算出的签名不一致”的问题。
3.3 密钥派生:一套主密钥管理所有场景
一个系统里往往需要多个密钥:签名用的、加密用的、给各个服务用的。这里有两种做法。第一种是每个场景单独生成密钥,各自存储;第二种是用一个主密钥派生多个子密钥,子密钥不需要落地存储,随用随算。
派生使用HKDF(HMAC-based Key Derivation Function)比较合适。它接收三样东西:输入密钥材料(IKM)、盐、info参数。info用来区分用途,比如签名密钥的info是"token-signing-v1",加密密钥的info是"payload-encryption-v1",从同一个主密钥就能派生出互不相同的子密钥。
Python的cryptography库提供了hkdf实现。如果项目不允许引入额外依赖,也可以自己按RFC 5869实现,核心就是几轮HMAC,并不复杂。但我的建议是能用成熟库就用成熟库,密钥派生这种代码自己写容易出边界问题,尤其是info和盐的拼接顺序搞错了,派生的子密钥完全不同,线上排查起来非常痛苦。
4. 实操:从账号ID到API密钥的完整实现
这一部分给出我在项目里实际用过的三套生成规则,每套都附代码和设计说明,可以直接抄。
4.1 用户ID:不可枚举、有语义、可扩展
很多人偷懒直接用数据库自增ID充作用户ID,问题在于暴露了业务量,而且接口一旦不做权限校验,遍历ID就能把所有用户信息拖走。
我采用的规则是:前缀 + 时间戳 + 随机数 + 校验哈希:
import base64 import hashlib import secrets import struct import time def generate_user_id() -> str: # 41位毫秒时间戳,可以支撑约70年不重复 ts = int(time.time() * 1000) # 8字节高熵随机数 rand = secrets.token_bytes(8) # 拼接后做sha256,取前4字节作为校验位 payload = struct.pack(">Q", ts) + rand checksum = hashlib.sha256(payload).digest()[:4] raw = payload + checksum return "u_" + base64.urlsafe_b64encode(raw).rstrip("=").decode("ascii")设计说明:时间戳让ID具备粗略的时间排序能力,排查问题时能看出注册时间;随机数保证不可预测和唯一性;校验位让接口能快速验证ID格式是否合法,无效ID直接拒绝,不进数据库。前缀u_让日志里一眼就能区分用户ID和订单号。
这样生成的ID大约在28个字符左右,作为主键存进数据库完全没问题。如果后续需要隐藏注册时间,可以取消时间戳,只保留随机数和校验位,规则调整非常灵活。数据库里建议再加一层唯一约束兜底,万一极端情况下随机碰撞了,插入失败重试一次就行,成本极低。
4.2 API Key:只展示一次,数据库只存哈希
API Key的生成和存储是整个账号体系里最容易出问题的环节。我见过把明文Key直接存数据库的,也见过生成后无法再次展示、用户复制错一次就得重新生成的。
我的规则分成三步。第一步生成:sk_live_前缀加32字节随机数的URL安全Base64。第二步展示:密钥只在下发时完整展示一次,之后任何人都只能看到掩码,比如sk_live_9f8a****。第三步存储:数据库里不存明文,只存密钥的SHA-256哈希。校验时先哈希再比对。
import hashlib import secrets import base64 def generate_api_key(env: str = "live") -> str: raw = secrets.token_bytes(32) body = base64.urlsafe_b64encode(raw).rstrip("=").decode("ascii") return f"sk_{env}_{body}" def hash_api_key(api_key: str) -> str: return hashlib.sha256(api_key.encode("utf-8")).hexdigest()为什么数据库只存哈希?因为密钥一旦泄露,从数据库导出的数据如果包含明文,等于把全部密钥拱手送人。只存哈希后,即使数据库泄露,攻击者拿到的也只是哈希,无法反推原始密钥。
这里有一个需要权衡的点:既然校验时要对输入做哈希再比对,那么存储哈希和存储明文在校验流程上只差一步SHA-256,成本几乎可以忽略,但安全性完全两个级别。所以没有任何理由存明文。注意,这个哈希是给API Key做索引和比对用的,属于快哈希场景,用SHA-256没问题,千万不能用成bcrypt那种慢哈希,否则每次校验密钥都要等上百毫秒,接口根本扛不住。
4.3 签名Token:载荷加签名,防篡改且可过期
登录Token和API Key不同,Token往往需要携带业务信息(用户ID、角色、过期时间),同时又要防止客户端篡改。最直接的做法是HMAC签名。
我的规则是:JSON载荷 + 过期时间 + HMAC-SHA256签名。签名密钥独立于API Key,单独生成、单独存储、定期轮换。
import base64 import hashlib import hmac import json import time def create_signed_token(user_id: str, signing_key: bytes, ttl_seconds: int = 86400) -> str: payload = { "uid": user_id, "exp": int(time.time()) + ttl_seconds, "iat": int(time.time()), } body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip("=").decode() signature = hmac.new(signing_key, body.encode(), hashlib.sha256).hexdigest() return f"{body}.{signature}" def verify_signed_token(token: str, signing_key: bytes) -> dict | None: try: body, signature = token.split(".") expected = hmac.new(signing_key, body.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): return None payload = json.loads(base64.urlsafe_b64decode(body + "==")) if payload["exp"] < int(time.time()): return None return payload except Exception: return None校验流程三件事:验签、验过期、拿载荷。验签用恒定时间比较函数hmac.compare_digest,防止时序攻击;验过期保证Token有生命周期;载荷里额外带上签发时间iat,方便后续做踢人下线或审计。
这种方案比JWT库更轻量,也更容易讲清楚原理。如果项目里已经用JWT,本质是一样的:Header、Payload、Signature三段,只是编码格式换成了标准JWT。理解了HMAC签名,JWT对你来说就不再有黑盒感。
4.4 C#版本的加盐哈希存储示例
很多项目是C#技术栈,我把密码存储的常用写法也放出来。.NET从Core 2.0开始内置了Rfc2898DeriveBytes,可以直接实现基于PBKDF2的加盐哈希:
using System.Security.Cryptography; using System.Text; public static class PasswordHasher { private const int SaltSize = 16; private const int HashSize = 32; private const int Iterations = 100_000; public static string HashPassword(string password) { byte[] salt = RandomNumberGenerator.GetBytes(SaltSize); byte[] hash = Rfc2898DeriveBytes.Pbkdf2( password, salt, Iterations, HashAlgorithmName.SHA256, HashSize); return $"{Convert.ToHexString(salt)}:{Convert.ToHexString(hash)}"; } public static bool Verify(string password, string stored) { var parts = stored.Split(':'); byte[] salt = Convert.FromHexString(parts[0]); byte[] expected = Convert.FromHexString(parts[1]); byte[] actual = Rfc2898DeriveBytes.Pbkdf2( password, salt, Iterations, HashAlgorithmName.SHA256, HashSize); return CryptographicOperations.FixedTimeEquals(actual, expected); } }两个细节需要强调:一是用RandomNumberGenerator.GetBytes而不是Guid.NewGuid,Guid虽然是随机生成的,但不是为密码学设计的,不同版本的Guid随机性不同,有些是顺序生成的;二是校验时用CryptographicOperations.FixedTimeEquals做恒定时间比较,和Python里的hmac.compare_digest一个作用。迭代次数100000是当前比较稳妥的起步值,硬件升级后可以继续调高,但要注意兼容旧数据。
5. 常见问题与排查技巧实录
5.1 哈希冲突真的需要考虑吗
很多人在设计阶段会问:生成随机ID会不会碰撞,哈希截断会不会冲突。这里要把两个概念分开。
随机ID的碰撞是概率问题,取决于随机位数。以8字节随机数为例,要在产生约40亿个ID后才达到50%碰撞概率,对绝大多数业务来说完全够用。如果还担心,可以在数据库主键上加唯一约束,插入失败就重试,代价很小。
哈希截断的冲突是确定性计算问题,理论上存在,但概率极低。截断4字节校验位意味着合法组合是2的32次方分之一,随机输入通过校验的概率约十亿分之一。校验位本身不是用来保证数据合法的,只是用来快速过滤明显无效的输入。真正保证安全的是随机主体的熵,不要把校验位当成安全机制本身。
5.2 密钥泄露了怎么办
密钥泄露是迟早要面对的事。我的处置流程分四步:挂失、下线、轮换、复盘。
第一步马上在配置中心或密钥管理服务里把泄露的密钥标记为无效,新请求立即拒绝。第二步检查日志,确认泄露密钥在什么时间、哪些请求里被使用过,评估影响范围。第三步生成新密钥并灰度切换,先让新密钥生效,保留一段时间双密钥共存,给下游足够的迁移时间。第四步复盘泄露路径,是代码仓库里硬编码了密钥,还是日志里打印了密钥,或者第三方库把密钥带上传了,找到根因修掉。
日志里打印密钥这个坑我必须单独说。很多人排查问题顺手在日志里打印了请求参数,而参数里恰好有API Key或Token,于是密钥就这么进了日志系统。我在代码规范里加了一条:任何请求日志只记录掩码或哈希,永远不记录完整密钥。这条规范被违反过两次,每次都是加急处理,所以现在我在日志框架层做了统一的脱敏过滤器,从源头拦截。
5.3 为什么要恒定时间比较
校验签名时如果直接用==比较两个字符串,Python会在遇到第一个不同字符时就返回False,这个时间差异可以被网络测量,理论上能逐字符猜出正确签名。这就是时序攻击。
解决办法是用恒定时间比较函数,比如hmac.compare_digest、C#里的FixedTimeEquals。它们保证无论两个字符串在哪一位不同,比较耗时都基本一致。这是密码学里的基础卫生习惯。同样的问题也适用于密码校验,凡是要比对摘要、签名这类敏感数据,一律用恒定时间比较函数。
顺带说一句,库函数里这些细节早就处理好了,比如JWT库的验签逻辑内部就是恒定时间比较。所以能用标准库就用标准库,自己手写验签逻辑的时候才需要特别注意这一点。
5.4 问题排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 生成的Key和存储的哈希对不上 | 编码方式不一致 | 检查hex和Base64是否混用 |
| Token经常过期 | 服务器时间不准 | 检查NTP同步和时钟偏移 |
| URL传参后Key变了 | 标准Base64含特殊字符 | 改用URL安全的Base64 |
| 所有用户密码哈希相同 | 没有加盐或用固定盐 | 每个用户独立随机盐 |
| 线上密钥泄露 | 日志打印、仓库硬编码 | 全面检索代码和日志 |
| 签名偶尔校验失败 | 密钥长度或编码标准不统一 | 核对各服务密钥格式 |
这六条几乎覆盖了我在项目里遇到的80%账号体系问题。建表的时候多存一个key_hash字段,排查时能少走很多弯路。
6. 只有实操才会注意到的细节
最后分享几个写代码时容易忽略、但上线后很要命的细节。
第一个是密钥版本化。API Key不要只存一条,要给每条Key加版本号或者生成时间。这样做的好处是轮换时能精确知道哪些Key还在用、哪些已经过期,审计时也能追踪到具体是哪一批密钥出的事。我一般会在Key信息表里存id、key_hash、前缀、环境、状态、创建时间、最后使用时间七个字段,排查问题非常顺手。状态字段至少要有active和revoked两个值,取消一把Key只是更新一个状态位,而不是物理删除记录,审计记录才能保留下来。
第二个是哈希算法要预留升级空间。密码哈希算法几年就会被攻破一次,所以存储格式里最好带上算法标识。比如存argon2$v=19$m=65536,t=3,p=4$盐$摘要这样的格式,将来升级算法时旧数据依然能校验,新数据用新算法,平滑迁移。我见过一个老系统密码摘要格式是裸的MD5,升级时所有存量用户都面临“无法验证旧密码”的尴尬,最后只能做强制改密流程,费了很大劲。
第三个是不要自己造密码学算法。哈希、HMAC、密钥派生都有成熟标准和成熟库,自己发明的“混淆算法”几乎必然被攻破。我在代码评审里见过有人写了一个“自己研发的哈希变体”,只是把SHA-256的结果再做了一遍异或和反转。这种算法看着复杂,实际上安全性质完全没保证,既不能证明抗碰撞,也不能证明防长度扩展,一旦上线就是定时炸弹。
账号生成规则这件事,说难不难,说简单也不简单。核心就是把哈希和密钥用对地方:哈希负责单向和校验,密钥负责独占和签名,两者结合才能生成一套别人猜不到、伪造不了、泄露了能追查的账号体系。我个人在多次重构里的体会是,先把这一章的规则定清楚,后面不管是接第三方登录还是开放平台,都是在这个地基上添砖加瓦,不会再被推翻重来。