Repository navigation
Commit 0e8ef10
feat(login): 连接登录方式 + 新用户邮箱验证页 + 登录 CSRF 防护 (#394)
* feat(settings): 登录方式加"连接"按钮(M2b 绑定入口)
配套后端的 /oauth/bind/{provider}。此前设置页只有"解绑",没有绑定入口——
组件注释自己也写着"现在加会把用户登成新账号(分叉)",因为绑定流程还没做。
现在绑定走 /oauth/bind/{provider}(后端要求已登录,把身份挂到当前账号,
不建号、不换会话),所以按钮可以放出来了。
可绑列表来自新端点 GET /api/user-center/identities/providers,返回后端已注册的
provider——前端不再维护"有哪些登录方式"的写死列表,接入新 provider 时按钮会自动
出现。PROVIDER_LABEL 退化成纯展示名,查不到就显示原始 key,保证新 provider 不会
静默消失(顺带用 Object.hasOwn 查表,避免原型链键)。
绑定回调结果 ?bind=ok / ?bind_error=<code> 给出可区分文案,其中 bind_taken
(该第三方账号已绑到别人)必须说清楚"先去那个账号解绑",否则用户只会看到
一个无法自助解决的失败。
用 Suspense 包裹(新增 useSearchParams)。settings 页改动前后均为 ƒ 动态,
非本次引入。72 测试通过 / typecheck / lint 0 error / build 通过。
* fix(settings): "连接"改为 POST /oauth/bind 取授权地址,绑定结果不再被冲掉
- 连接按钮改为带 satoken header POST /oauth/bind/{provider},拿到 data.url 后
location.assign 跳转;GET 入口后端已下线(GET 可被跨站触发)。401 提示登录
已过期并给出 /login 链接,其它失败显示后端 message 或通用文案。
- ?bind=ok / ?bind_error= 改为渲染时从 URL 派生,不再写进 error state:
load() 成功后 setError(null) 会在约 200ms 后把绑定失败提示冲掉。
- bindTaken 文案不再让用户去另一个账号解绑(后端禁止解绑最后一种登录方式,
照做也走不通),改为说明该登录方式属于另一个账号、合并请联系管理员。
- 导出 labelOf 供新用户邮箱验证页复用。
* fix(auth): #token 只接受本标签页发起的登录,堵住 login CSRF
此前 use-auth 无条件接受任意 /#token=,攻击者发一个带自己 token 的链接就能把
受害者静默登进攻击者账号;配合设置页的"连接",受害者会把自己的第三方身份绑到
攻击者账号上。
- SignInButton 跳 /oauth/render/{provider} 前往 sessionStorage 写一次性 nonce
(发起时间戳,10 分钟有效)。
- AuthProvider 只有在 nonce 存在且未过期时才存 #token,取出即删;否则丢弃
token,仍从 URL 清掉 fragment,继续走已存 token 的正常流程。
- SSO 授权页的 takePending 续接不受影响:它在登录确认之后才执行。
- 新增 tests/login-nonce.test.ts 覆盖接受 / 一次性 / 无 nonce / 过期 / 损坏值。
* feat(login): 新用户邮箱验证页 /login/verify(OTP 注册)
后端对全新用户(身份、用户名、已验证邮箱都没命中)不再直接建号,而是 302 到
/login/verify 并下发 httpOnly 的 ih_reg_pending cookie,验证完邮箱才创建账号。
- page.tsx:SSG 壳,满足 next-intl 三条件(await params / setRequestLocale 先于
getTranslations / generateStaticParams),robots noindex。
- VerifyEmailForm:loading → email → code → expired。GET /auth/register/pending
预填第三方邮箱并显示 provider;POST send-otp 发码;POST verify 成功后 arm 登录
nonce 再 location.replace('/{locale}#token=…'),复用 use-auth 的 #token 流程
(含 SSO 续接)。email / code 两步始终提示"已有账号请用原方式登录后在设置里连接 {provider}",
这是邮箱为空的老账号防分叉的唯一手段。
- 导出纯函数 resolveVerifyError 把 400/409/410/429/503 映射到 i18n key,
tests/verify-email.test.ts 覆盖映射、key 存在性和 zh/en key 集合一致。
- 新增 zh/en verifyEmail 命名空间。
- LinkedAccounts 的 /login 链接改用 next/link:@/i18n/navigation 在 vitest
node 环境下无法解析,而验证页要复用它导出的 labelOf。
* feat(login): 登录页识别 account_disabled 错误码
后端 OAuth 回调遇到被停用的账号会 302 到 /login?error=account_disabled,
此前会落到通用的"请重试",而重试永远不会成功。新增 zh/en 文案提示账号已停用、
联系管理员;映射仍走显式白名单 + Object.hasOwn。
* fix(login): 验证页 / 设置页的 /login 链接带上当前 locale
改用 next/link 后 href="/login" 不带 locale 前缀,要靠 middleware 再按 cookie /
Accept-Language 猜一次语言并 307:英文页面上的用户可能被送到 /zh/login。
直接拼 /{locale}/login,和当前页面语言保持一致,也省一次跳转。
* fix(login): 邮箱验证返回 409 时进入终态,不再留下必然失败的按钮
后端在 409(邮箱已被占用)前已消费掉注册会话,之后"验证"/"重发"/"换邮箱"
只会拿到 410,并把"该邮箱已有账号"的提示换成错误的"会话已过期"。409 改为
与 410 一样切到 expired 步骤,保留 errorEmailTaken 文案和返回登录链接。
* fix(auth): 登录 nonce 改为随机值并由后端回传逐次比对,被拒的 #token 跳回登录页
时间戳 nonce 只能证明"本标签页 10 分钟内发起过登录":登录未带回 #token
(报错、新用户验证页、授权页停留)时 nonce 一直有效,持有窗口句柄的打开者
可在此期间把标签页导向 /#token=<攻击者 token> 完成 login CSRF。
- SignInButton 生成 16 字节随机 nonce(base64url)存 sessionStorage,
跳 /oauth/render/{provider}?n=<nonce>;后端登录回调回跳 #token=…&n=…。
- AuthProvider 只在 n 与存的 nonce 完全一致时接受 token;只要带 #token 就
消费掉已存 nonce,并从 URL 清掉 token 和 n。
- 被拒时 location.replace 到 /<locale>/login?error=oauth_state,而不是静默
以未登录继续;不取 SSO pending,重新登录成功后照常续接授权请求。
- 新用户验证页验证成功后同样生成 nonce,带 &n= 跳回。
- 测试覆盖:完全一致才接受 / 缺失或不一致拒绝 / 任何一次校验都消费 nonce /
格式与长度 / 被拒后 SSO pending 仍在。
需先部署后端(回传 n),再部署前端。
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>1 parent 63da533 commit 0e8ef10
12 files changed
Lines changed: 572 additions & 28 deletions
File tree
- app
- [locale]
- login/verify
- settings
- components
- lib
- messages
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
0 commit comments