知识广场

按学科筛选:计算机科学 / 后端开发 / 安全
清除筛选

«计算机科学 / 后端开发 / 安全» 分类下共 2 篇帖子

JWT:HS256 vs RS256 vs EdDSA,啥时候哪个

## 起因 JWT 签名算法常见三个: - **HS256**:HMAC-SHA256,对称密钥 - **RS256**:RSA + SHA256,非对称 - **EdDSA (Ed25519)**:现代椭圆曲线,非对称 选哪个?默认 HS256 行不行?为啥很多场景非 RS256 不可? ## HS256 (HMAC) ```python import jwt SECRET = 'shared-secret-key' # 签 token = jwt.encode({'user_id': 42}, SECRET, algorithm='HS256') # 验 payload = jwt.decode(token, SECRET, algorithms=['HS256']) ``` 对称:**签和验用同一 secret**。 ### 优势 - 简单(一个 secret) - 性能极好(HMAC 比 RSA 快 100x) - token 短 ### 劣势 / 适用边界 - 任何能验 token 的服务都能签 token → **不能给第三方验** - 单 monolith 内部用 OK;微服务要严格区分签发方与验证方 适合:单一服务 + 用同一 secret 的自家系统。 ## RS256 (RSA 非对称) ```python # 服务端:private key 签 with open('private.pem', 'rb') as f: private_key = f.read() token = jwt.encode({'user_id': 42}, private_key, algorithm='RS256') # 任何方:public key 验 with open('public.pem', 'rb') as f: public_key = f.read() payload = jwt.decode(token, public_key, algorithms=['RS256']) ``` 非对称:**private key 签,public key 验**。 public key 公开(其它服务 / 客户端能拿到),但拿到也不能伪造 token。 ### 优势 - 安全分离:auth server 持 private key,resource server 只持 public key - public key 可发布(JWKS endpoint) - 大组织 / OAuth / SSO 标配 ### 劣势 - 性能:RSA 签验比 HMAC 慢 100-1000x - token 大(RSA 签名 256 字节起) - key 管理更复杂 适合:微服务 / OAuth / 第三方需验 token。 ## EdDSA / Ed25519 ```python token = jwt.encode({'user_id': 42}, ed25519_private_key, algorithm='EdDSA') ``` 非对称,基于 Ed25519 椭圆曲线。 RS256 的现代替代: | | RS256 (2048 bit) | EdDSA (Ed25519) | |---|---|---| | 签名速度 | 慢 | 快(10x+) | | 验证速度 | 较快 | 极快 | | key size | 2048 bit | 256 bit | | 签名 size | 256 byte | 64 byte | | 安全性 | 强 | 强(现代设计) | 新项目能用 EdDSA 就用,性能 + size 全面优于 RS256。 RS256 仍是 OAuth 兼容性最广(IdP / library 都支持)。 ## 决策 ``` 监控你的 token 谁签 / 谁验 单 monolith / 自家服务全套: → HS256 跨服务 / 公开 API / 给第三方 / SSO: → RS256(兼容性)或 EdDSA(性能) OAuth provider / IdP: → RS256(事实标准) ``` ## 错配灾难(algorithm confusion attack) ```python # 危险:alg 不锁定 payload = jwt.decode(token, public_key) # 不指定 algorithms ``` 攻击者把 token 的 alg header 改成 `HS256`,用 public key 当 secret 签 (因为 RS256 → HS256 confusion)→ 假 token 通过验证。 **正确**: ```python payload = jwt.decode(token, public_key, algorithms=['RS256']) # 指定接受的算法,不让 attacker 选 ``` 库默认大多防御此攻击,但**永远显式指定 algorithms**。 ## key rotation JWT 没内置 rotation。常见做法: - token 包含 `kid` (key ID) header - JWKS endpoint 暴露多个 active public key - 验证时根据 kid 选 key ```python # 假设 JWKS 已 cache def verify(token): header = jwt.get_unverified_header(token) kid = header['kid'] key = jwks_cache[kid] return jwt.decode(token, key, algorithms=['RS256']) ``` 旧 key 留 grace period 后删 → 老 token 平滑过渡。 ## token 内容设计 ```python { "iss": "https://auth.example.com", # issuer "sub": "user-12345", # subject (user) "aud": "https://api.example.com", # audience (intended verifier) "iat": 1716000000, # issued at "exp": 1716003600, # expiry "nbf": 1716000000, # not before "jti": "unique-id", # JWT ID(防重放) // custom "roles": ["admin", "editor"], "tenant": "acme" } ``` 验证时检查: - `exp` 没过 - `aud` 是自己 - `iss` 是受信任 issuer 很多 lib 自动。 ## refresh token JWT 短 expiry(15min - 1h)+ refresh token(长期,不透明 random string)。 access token 过期 → 用 refresh token 换新。 refresh token 存数据库(能撤销);access token 是 stateless JWT。 ## stateless vs session JWT 优势:服务端不存 session → 横向扩展无 sticky session 问题。 劣势:撤销难(除非短 expiry + refresh token 模式)。 中小项目其实 session cookie + Redis store 简单 + 够用。 微服务 / 多 IdP / OAuth 才必要 JWT。 ## 不要存敏感数据 ```python # bad jwt.encode({'password': 'secret123', ...}, ...) ``` JWT body **未加密**只签名。`base64decode` 立刻看到内容。 不要放:password / API secret / PII。 需要加密 → JWE (JSON Web Encryption),但复杂得多,少用。 ## 实战 case:迁移 HS256 → RS256 老项目 monolith HS256。 拆出 mobile API + 计划开放第三方 API → 需要 RS256(合作伙伴验 token 不该知 secret)。 迁移: 1. 生成 RSA key pair 2. JWKS endpoint 暴露 public key 3. token issue 支持双 alg:老 token HS256 + 新 token RS256 4. token verify 接受 HS256(kid="legacy")+ RS256 5. 等所有老 token expire(30 day) 6. 删 HS256 支持 平滑过渡,无服务中断。 ## 性能数据 签 / 验 100k token / single core: | | sign | verify | |---|---|---| | HS256 | 0.5s | 0.5s | | RS256 (2048) | 80s | 5s | | EdDSA | 3s | 1s | 签贵很多,验也比 HS256 慢。 高 QPS 验 token 时考虑 cache verified token 或者用 EdDSA。 ## 库 - Python: `PyJWT` / `python-jose` / `authlib` - Node: `jsonwebtoken` - Go: `github.com/golang-jwt/jwt` - Rust: `jsonwebtoken` PyJWT 简单 + 维护好。authlib 大全(含 OAuth)。 ## 踩过的坑 1. **algorithms 没指定**:confusion attack。永远 `algorithms=['XX']`。 2. **exp 时区**:服务器时间错 → 立刻过期 / 永不过期。NTP sync 必备。 3. **public key 写错**:copy 时 \n 丢 → 验失败。pem 用 file 加载, 不要在 env var 里塞 multi-line。 4. **JWT 当 session**:放 admin_panel=True 类敏感权限 → token leak 后 攻击者直接获取。敏感操作必须 DB 二次验证。 5. **长 expiry + 无 revoke**:30 day expiry JWT 用户改密码后老 token 仍有效。要么短 expiry + refresh,要么 blacklist 表(牺牲 stateless)。

OAuth 2.0 + PKCE:给 SPA / mobile 做 third-party 登录

## 起因 应用要"用 Google / GitHub 账号登录"。 OAuth 2.0 有多种 flow: - Authorization Code(有 backend,推荐) - Implicit(弃用) - Resource Owner Password(弃用) - Client Credentials(machine-to-machine) SPA / mobile 之前用 implicit flow(token 直接放 URL),现在标准是 **Authorization Code + PKCE**。 ## PKCE 是什么 Authorization Code flow 需要 client secret 验证 client 身份。 SPA / mobile 不能藏 secret(公共 client)。 PKCE (Proof Key for Code Exchange) 用临时 challenge 替代 secret: 1. client 生成随机 `code_verifier`(高熵 string) 2. SHA256 hash 得到 `code_challenge` 3. authorize 请求带 `code_challenge` 4. callback 拿到 code 后换 token 时带原 `code_verifier` 5. server 验 hash(verifier) == challenge → 确认是同一 client 防止"中间人截获 authorization code 后冒充换 token"。 ## flow 详细 ### 1. client 生成 verifier + challenge ```js const codeVerifier = generateRandomString(64); const codeChallenge = base64UrlEncode(await sha256(codeVerifier)); // 存到 sessionStorage(callback 时取) sessionStorage.setItem('pkce_verifier', codeVerifier); ``` ### 2. 跳转 authorize URL ```js const params = new URLSearchParams({ client_id: 'my-client-id', redirect_uri: 'https://app.example.com/callback', response_type: 'code', scope: 'openid profile email', state: randomState, // 防 CSRF code_challenge: codeChallenge, code_challenge_method: 'S256', }); window.location = `https://auth.example.com/authorize?${params}`; ``` 用户在 IdP 登录 → 同意授权 → IdP redirect 回: ``` https://app.example.com/callback?code=ABC123&state=... ``` ### 3. callback 换 token ```js async function callback() { const code = new URLSearchParams(location.search).get('code'); const verifier = sessionStorage.getItem('pkce_verifier'); const res = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, client_id: 'my-client-id', redirect_uri: 'https://app.example.com/callback', code_verifier: verifier, }), }); const tokens = await res.json(); // { access_token, refresh_token, id_token, expires_in } sessionStorage.removeItem('pkce_verifier'); storeTokens(tokens); } ``` server 验 `hash(verifier) == challenge` → 发 token。 ## token 存哪 SPA 痛点:access_token 存哪? - **localStorage**:XSS 一漏全暴露 - **sessionStorage**:同 localStorage + tab close 丢 - **memory (JS variable)**:刷新丢 - **httpOnly cookie**:最安全但需要 same-origin 或者 cross-origin 配置 + BFF backend 主流推荐:**BFF (Backend-for-Frontend) 模式** ``` [SPA] ↔ same-origin httpOnly cookie ↔ [BFF Node/Python] ↓ access_token [Resource API] ``` - BFF 持 token,SPA 仅 session cookie - XSS 拿不到 token - CSRF 用 sameSite=strict cookie 防 复杂但最安全。如果纯前端:access_token in memory + refresh 在 httpOnly cookie。 ## refresh token access_token 短(15 min - 1 h),过期用 refresh_token 换新。 PKCE flow 也能给 refresh_token:`scope=offline_access`。 refresh_token rotation(每次用过失效)防 leak 后无限续。 现代 OAuth 服务器(Auth0 / Okta / Keycloak)默认开。 ## state 防 CSRF ```js // 跳 authorize 之前 const state = generateRandom(); sessionStorage.setItem('oauth_state', state); // 加到 URL params // callback 验 const returnedState = new URLSearchParams(location.search).get('state'); const expectedState = sessionStorage.getItem('oauth_state'); if (returnedState !== expectedState) throw 'CSRF'; ``` 不验 state → 攻击者可让用户登入攻击者账号(account takeover)。 **必须验**。 ## scope OAuth scope 控制 token 能干啥: ``` scope=openid profile email # OIDC 标准 scope=read:user user:email # GitHub scope=https://www.googleapis.com/auth/drive.readonly # Google API ``` **最小权限**:只要 `email`,不要 `read:user`。 ## OIDC vs OAuth - OAuth 2.0:授权("app 能代你访问 API") - OpenID Connect (OIDC):在 OAuth 上面加身份层("who is the user") OIDC 加 `id_token`(JWT containing 用户信息)+ `userinfo` endpoint。 login 场景用 OIDC(need user identity),不仅是 API 授权。 scope 加 `openid` 启用 OIDC。 ## Library 不要手写 OAuth,用库: - JS: `oidc-client-ts`, `@auth0/auth0-spa-js` - Python: `authlib`, `httpx-auth` - Go: `golang.org/x/oauth2` PKCE flow 几行代码: ```js import { UserManager } from 'oidc-client-ts'; const userManager = new UserManager({ authority: 'https://auth.example.com', client_id: 'my-client', redirect_uri: 'https://app.example.com/callback', response_type: 'code', scope: 'openid profile email', // PKCE 自动 }); await userManager.signinRedirect(); // 跳 authorize const user = await userManager.signinRedirectCallback(); // callback 处理 ``` ## device code flow CLI / TV / IoT 没浏览器,用 device flow: 1. 应用问 server 拿 `device_code` + `user_code`(如 "ABC-123") 2. 用户开浏览器去 `https://example.com/device` 输 user_code 3. 应用 poll `/token` endpoint 等用户授权完成 GitHub CLI `gh auth login` 就是 device flow。 ## 实战 case:第三方"用 GitHub 登录" ```python # backend (FastAPI + authlib) from authlib.integrations.starlette_client import OAuth oauth = OAuth() oauth.register( 'github', client_id=GITHUB_CLIENT_ID, client_secret=GITHUB_CLIENT_SECRET, access_token_url='https://github.com/login/oauth/access_token', authorize_url='https://github.com/login/oauth/authorize', api_base_url='https://api.github.com/', client_kwargs={'scope': 'user:email'}, ) @app.get('/auth/github') async def login(request): redirect = request.url_for('callback') return await oauth.github.authorize_redirect(request, redirect) @app.get('/auth/github/callback') async def callback(request): token = await oauth.github.authorize_access_token(request) user = await oauth.github.get('user', token=token) user_data = user.json() # create / find local user + issue session ... ``` backend 持 client_secret(GitHub OAuth app 经典 confidential client), 不需要 PKCE。 如果纯 SPA 直接调 GitHub → PKCE。但 GitHub OAuth app 不支持 PKCE, 要用 GitHub App + device flow / 后端中转。 ## 踩过的坑 1. **redirect_uri 没 register**:authorize 返回 invalid redirect。 到 IdP console 加 callback URL(每环境一个:dev/staging/prod)。 2. **state 验缺失**:CSRF 风险。库一般帮做,但自己写时容易漏。 3. **token 存 localStorage**:XSS 后即失。BFF 或者 httpOnly cookie。 4. **scope 过宽**:要 email 却 request 整个 drive 访问 → 用户怕 + 不授权。最小 scope。 5. **PKCE verifier 没存好**:刷新页 / 跨 tab → callback 找不到 verifier。sessionStorage 同 tab 内 OK;BFF 模式用 server session。