速成课 · No. 08
「auth」这一个词背后,藏着两个问题。身份认证证明你是谁;授权决定你被允许做什么。把它们混为一谈、漏掉任何一个,或者轻信客户端,门就开着了。
只讲精髓 · 每个想法一幅画面 · 标准重于自造
「auth」这个词背后,藏着两个不同的问题。大多数安全 bug,都来自把它们混淆——或者答了一个、忘了另一个。
身份认证:你是谁?
在机场出示你的护照——证明你真的就是你所声称的那个人。
身份认证(authN) 是证明身份:一次登录、一个 passkey、一个说着「这真的是 Alice」的 token。它通常**每个会话只发生一次。**做错了,一个冒名顶替者就会以别人的身份走进来。但证明你是谁,对你被允许做什么只字未提——那完全是另一个问题。
授权:你被允许做什么?
一张酒店房卡——它对你的名字毫无证明,但它能打开你的房间、而打不开别的房间,也打不开经理办公室。
授权(authZ) 是决定一个身份被允许做什么:读这个、改那个、绝不碰管理面板。和 authN 不同,它必须在每一次请求时都核查,因为一个登录用户可能会碰上百样不同的东西。知道某人是谁,并不就是允许他做任何具体的事。
两个层次——绝不要把它们压成一个
一家夜店:门口的保安在你进门时查一次你的身份证,而调酒师在你每次点单时,仍要核实你够不够年龄。两次核查,两个时刻。
经典的错误是把两者融为一体——证明一次身份,就永远信任它;或者把每一项权限都塞进一个登录 token,从此再不复查。**authN 发生在门口;authZ 发生在里面的每一道门。**把它们分开,并且不停地核查第二个。
绝不要自己造
你不会在车库里用铁皮焊出自己的银行金库——你会去买一个,由那些除此之外什么都不干的人造好并测试过的。
身份是其余一切立足的根基,而它又极其容易在不知不觉中出微妙的错。用经过验证的标准和库——成熟的协议、一个久经沙场的 auth 库或 provider——而不是你自己发明的一个聪明方案。安全的历史,铺满了自造的 auth,它们看起来都好好的,直到突然就不好了。
身份认证是那道门。授权是里面的每一道门。漏掉第二个,第一个就毫无意义。
身份认证归根结底是证明你就是你。这些因子从又弱又通用的密码,一路排到现代的、抗钓鱼的 passkey。
密码:那个又弱又默认的选项
一个跟门房共享的暗号——挺好,直到有人偷听到、猜到,或者你到处都用同一个。
密码是通用的,也是最弱的常见因子:被复用、可被猜、能被钓鱼。如果你非用不可,那条不容商量的规则是绝不把它们存成明文——存一个慢速、加盐的哈希(Argon2、bcrypt),这样一个被偷走的数据库,不会把所有人的密码都拱手交出。(关于这点,崩坏那一章里还有更多。)
多因子:你知道的、你拥有的、你本身是的
一个保险柜需要你的钥匙和银行的钥匙。任何一把单独都打不开。
MFA(双因子)要求在密码之外再来一个证明——你拥有的东西(一个 app 里的验证码、一把安全密钥)或你本身是的东西(一枚指纹)。这样一来,就算密码被偷,单独它也没用了。验证器 app 的验证码(TOTP)很稳;短信验证码很弱(可被拦截),但聊胜于无。MFA 是你能加上的、单笔最大、最便宜的安全收益。
passkey:无密码的未来
一把只在它当初配出来的那一道门上才好使的钥匙——伪造网站、翻拍锁的照片,这把钥匙就是不会转。
passkey(建立在 WebAuthn 之上)用一对绑定到你设备和真实站点的密码学密钥对,取代了密码,于是没有什么可钓鱼、可复用、可泄露的——而 Apple、Google、Microsoft 如今都默认采用它。私钥永远不离开你的设备;站点只会见到一个签名。这就是身份认证正去往的方向:没有什么可偷的。
密码是一个你会弄丢的秘密。passkey 是一个你弄不丢的证明。无论哪种,都再加一个因子。
HTTP 在两次请求之间会忘掉你,所以你登录之后,系统需要一个办法来持续认出你。两种做法,有一个实打实的取舍。
问题:HTTP 没有记忆
一家店,你一转身店员就把你忘了——所以你需要一张票,好在下一步证明你已经付过钱了。
每一次 HTTP 请求都是独立的;服务器不记得你一秒钟前登录过。所以在身份认证之后,你被交到手里一样东西——一个 cookie 或一个 token——你在每次请求时带上它,来说「还是我」。下面的一切,都是做这件事的两种方式。(这就是协议那门课里讲的无状态性。)
会话:服务器记得
一处寄存衣帽间——你手里攥着一张小小的编号票,而所有真正的信息都坐在柜台后面、那个号码底下。
用会话时,服务器存着你是谁,只交给你一个装着随机会话 id 的 cookie。每次请求,它都去查一下这个 id。服务器是唯一的真相来源,所以登出或者封禁某人是即时的——把会话删掉就行。代价是服务器必须存储并查找那份状态。对大多数 web 应用来说,这是那个正确的默认选择。
JWT:token 自带真相
一条音乐节腕带,你的访问级别已经印在上面、还做了防撕封——保安核查的是那道封,而不是一份宾客名单。
一个 JWT 是客户端攥着的一个签名 token,它内含身份和声明;服务器只去验证签名,无需查找。这让它成为无状态的,也易于跨服务扩展——但**难以撤销:**一个泄露的 token,在过期之前一直有效。所以你把有效期设得很短,并搭配一个 refresh token。对 API 和微服务很棒;对一个简单的 web 应用则是杀鸡用牛刀。
你把它存在哪里,关系重大
一把锁在抽屉里的钥匙,对上一把用胶带贴在前门上的钥匙——同一把钥匙,安全性天差地别。
浏览器里的一个 token,该待在一个 httpOnly cookie 里(JavaScript 读不到它),而不是 localStorage 里,在那儿任何被注入的脚本都能把它偷走。存放方式是安全的一部分:做错了,一个 XSS bug 就把每一个会话都送到攻击者手上。你如何传输和存储这份凭据,和凭据本身一样重要。
会话:服务器记得,而且能即刻把你忘掉。JWT:token 记得,而你没法轻易把它收回。
很多时候你根本不想去碰密码——你想让用户用一个他们已经有的账号登录,或者让一个服务代表另一个行事。这正是 OAuth2 和 OIDC 的用处。
OAuth2:不共享密码就授予访问
一把代客泊车钥匙,能发动车、开车门,但开不了后备厢或储物箱——你交出去的是有限的访问,而不是你整串钥匙。
OAuth2 让一个用户授予你的 app 对他们在另一个服务上账号的有限访问——你的 app 读他们的 Google Calendar——而完全不用把他们的 Google 密码给你。那个服务交给你的 app 一个限定范围的访问 token(「读日历,别的不行」)。它讲的是委托授权,而不是身份。
OIDC:还告诉你你是谁的 OAuth2
那把代客泊车钥匙,如今别上了一张带照片的身份证——不只是「这把钥匙能开什么」,而是「以及它属于谁」。
单独的 OAuth2 告诉你一个 app 可以访问什么,而不是这个用户是谁。OpenID Connect(OIDC) 在它之上加了一层薄薄的身份层——一个带着已验证声明的 ID token(这是 Alice,这是她的邮箱)。这正是「Log in with Google」和单点登录背后的东西。当你想要社交登录或 SSO 时,OIDC 就是那个标准。
API key:简单的机器凭据
一把你交给信任的承包商的备用钥匙——不附名字,只是「谁攥着这个,谁就能进来」。
一个 API key 是一长串秘密字符串,它标识的是一个程序,而不是一个人——用于服务对服务以及程序化的访问。简单又有效,但它就是那份凭据:谁找到它,谁就进来了。所以你限定它的范围、轮换它,并且**绝不把它提交进 git、或放进一个 URL 里。**适合机器;不能替代真正的用户身份。
OAuth2 借来访问而不要密码。OIDC 加上「以及他们是谁」。用那个标准——别去发明你自己的一套 token 舞步。
一旦你知道某人是谁,你就决定他可以碰什么。这些模型从粗粒度的角色,排到细粒度的策略——而它们底下那条共同的规则,是默认拒绝。
默认拒绝,最小权限
一名新员工的工牌,什么都打不开,直到每一道门被明确授予——而不是什么都打开着,直到有人想起来去锁上几道。
授权的根基:一切都被禁止,除非被明确允许,而每一个身份只拿到它所需要的最小访问,不多分毫。默认开放的系统会漏——总有人忘了去限制那个新加的端点。默认关闭的系统则故障安全。从「不」起步,再刻意地去授予。
RBAC:按角色给权限
一座剧院,你票的类型——正厅、楼座、后台通行证——决定你能去哪里,而不点你个人的名。
基于角色的访问控制把权限归拢进角色——admin、editor、viewer——再把用户分配给角色。简单、可读,对大多数应用够用了:「editor 能发布,viewer 不能。」当规则要依赖上下文、或依赖具体那一条记录时,它就显得粗了,而那正是接下来这几个模型登场的地方。
ABAC:按策略给权限
一条规则,而不是一份名单——「manager 可以批准营业时间内、用公司设备发起的、一万以下的报销」——每一次都重新评估。
基于属性的访问控制从用户、资源和上下文的属性来作决定——角色以及金额以及时间以及设备。比固定的角色灵活得多,也复杂得多。该采用它的信号是:你正往你的角色核查上,叠加多于一两个自定义的 if 条件。
归属:你能碰这条记录吗?
你有一张有效的借书卡(一个角色),但那并不让你去读别人借走的书里、潦草写下的批注。
那个最常被漏掉的核查:就算一个有效的「editor」,也只能改他自己的文档,而不是所有人的。角色说的是「editor 可以改」;你仍得核实这个 editor 拥有这一行。忘掉这点,是最常见的那个访问控制 bug——改一改 URL 里的 id,你就进了别人的数据。永远核查那个对象,而不只是角色。
默认拒绝。授予最少。并且永远不只问「什么角色」,而要问「谁的记录」。
auth 以屈指可数的几种众所周知的方式失败——同样那几种,一遍又一遍,在一个又一个应用里。知道它们,防御就成了一半。
访问控制失效:忘了去核查
一栋楼,前门有人把守,但每一道内门都没锁——一旦进来,你哪儿都能走。
排第一的 web 漏洞:一个端点认证了用户,却**忘了核查他对自己所要求的东西是否有授权。**它最常见的形态是 IDOR——把 /orders/123 改成 /orders/124,就看到了别人的订单,因为服务器从没核查过归属。数以百万计记录的泄露,都追溯到正是这个。在每一次请求时、在服务器端、对着真实的那个对象,核查 authZ。
密码存错了
把整条街每户人家的备用钥匙,放在门廊上一个玻璃罐里。
把密码存成明文——或者用 MD5 这种快速哈希——意味着一次数据库泄露就暴露所有人,而被复用的密码还会连带攻破他们的其他账号。密码必须被慢速地、一个一个地哈希(Argon2、bcrypt),这样就算被偷,也代价高昂、难以破解。哈希慢正是重点,而不是一个缺陷。
泄露并轻信 token
把报警密码写在一张便利贴上、贴在门上——锁没问题;你只是把秘密发出去了。
token 和 key 会通过这些途径泄露:**URL(它们会被记进日志)、浏览器 localStorage(一个脚本能读它)、以及提交进 git 的秘密。**而且一个 token 必须被验证,而不是因为它出现了就被信任——一个被篡改的或过期的,必须被拒绝。把每一份凭据都当作总有一天会泄露的东西来对待:短有效期、轮换,并且绝不放在会被记日志或被抓取的地方。
轻信客户端
一家店,让顾客自己写价格标签,而在收银台从不核对它们。
客户端发来的任何东西——一个隐藏表单字段、一个 cookie 里的角色、一个 isAdmin=false 标志——都可能被伪造。浏览器、app、请求,全都在用户的掌控之下。每一个真正的核查,都必须发生在服务器上,那是用户改不了的。客户端是一个建议;服务器才是那个权威。
几乎每一起 auth 泄露,都是这几种之一:一个被跳过的核查、一个被存坏的秘密,或者一个被轻信的客户端。
好的 auth,主要是关于正确地使用经过验证的工具、并且不留情面地核查权限——而不是关于耍聪明。下面讲怎么选。
用一个库或一个 provider
你不会自己造安全带——你装的是专家设计、经过多年碰撞测试的认证产品。
对几乎所有人来说,正确的做法是不要自己造 auth:用你框架自带的 auth,或者一个 provider(Auth0、Clerk、Supabase、Cognito 之类),它处理哈希、会话、MFA、OAuth,以及那些你永远想不到的边角情况。你的活儿是把它正确地接进来,并扛起授权——那个只有你才知道的部分。
让机制匹配应用
一把房门钥匙、一把车钥匙、一张酒店房卡都是「钥匙」——你按门来挑,而不是按潮流。
会话用于一个普通的 web 应用(简单,可即时撤销);短命的 JWT 加 refresh 用于你需要跨服务的无状态扩展时;OIDC 用于社交登录和 SSO;API key 用于机器对机器。在每一个要紧的地方都加上 MFA,尤其是 admin 账号。按你应用的形状来选,而不是按听起来高级的。
- 密码是否被慢速哈希(Argon2/bcrypt),从不存成明文? - MFA 是否可用,并且对 admin 强制要求? - 每一个端点是否都在核查 authZ——角色以及对象的归属? - token 是否在 httpOnly cookie 里、短命,并且从不出现在 URL 或 git 里? - 真正的核查是否在服务器上,从不轻信客户端发来的角色?
- 我是否在用一个经过验证的库/provider 而不是一个自造的方案?
- 改一改 URL 里的 id 就显示出别人的数据。 - 一个权限住在一个 cookie 或 隐藏字段里,客户端能改它。 - 密码存成明文或 MD5。 - 除了等它过期,没有办法 撤销一个登录用户。 - 在那些能造成最大破坏的账号上,没有 MFA。 - 你自己写了一套 token 或加密方案。
- authN 和 authZ 是分开的,而 authZ 在每一次请求时都被核查。 - 每一次对象访问 都核实这个用户拥有这个东西。 - 凭据是哈希过的、限定范围的、短命的、可轮换的。 - 服务器是那个权威;没有什么轻信客户端。 - 你能撤销 访问,并能在一份日志里看到谁做了什么。
- 那些重活坐在一个经过验证的库或 provider 里,而不是你自己的代码里。
auth 不是你耍聪明的地方。它是你正确使用标准、默认拒绝、并且永远、永远不轻信客户端的地方。