From a654f4aa31355c813c37138a37fc4cea80f0088b Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 15 Sep 2026 11:36:06 +0800 Subject: [PATCH 01/23] feat(auth): add independent email verification-code sign-in across clients --- Cargo.lock | 57 ++ Cargo.toml | 1 + deploy/miniapp-market/README.md | 20 + deploy/miniapp-market/docker-compose.yml | 2 + deploy/miniapp-market/market.env.example | 8 + .../nginx-auth.openbitfun.com.conf | 4 +- src/apps/cli/README.md | 6 + src/apps/cli/src/actions.rs | 2 +- src/apps/cli/src/modes/chat/account.rs | 12 +- src/apps/cli/src/ui/login_form.rs | 14 +- src/apps/cli/src/ui/startup.rs | 8 +- src/apps/mobile/README.md | 5 + .../app/src/main/res/values-zh/strings.xml | 14 +- .../app/src/main/res/values/strings.xml | 14 +- .../entry/src/main/ets/i18n/EnUsMessages.ets | 30 +- .../entry/src/main/ets/i18n/ZhCnMessages.ets | 30 +- .../main/ets/services/CloudAccountClient.ets | 6 +- .../App/MobileLaunchConfiguration.swift | 2 +- .../Account/AccountSettingsView.swift | 10 +- .../Features/Pairing/PairingSheet.swift | 2 +- .../Features/Shell/RemoteSettingsViews.swift | 2 +- .../Features/Shell/SidebarView.swift | 2 +- .../Infrastructure/MobileAppModel.swift | 2 +- .../Resources/Localizable.xcstrings | 48 +- .../core/transport/CloudAccountClient.kt | 4 +- .../core/transport/CloudAccountClientTest.kt | 4 +- .../contracts/product-domains/src/account.rs | 44 ++ .../product-domains/src/appearance_market.rs | 8 +- .../miniapp-market-service/Cargo.toml | 2 + .../services/miniapp-market-service/README.md | 34 + .../migrations/0002_email_identity.sql | 44 ++ .../miniapp-market-service/src/auth.rs | 64 +- .../src/auth_admission.rs | 8 +- .../services/miniapp-market-service/src/db.rs | 106 ++- .../miniapp-market-service/src/email_auth.rs | 702 ++++++++++++++++++ .../miniapp-market-service/src/lib.rs | 1 + .../miniapp-market-service/src/routes.rs | 120 ++- src/crates/services/relay-service/AGENTS.md | 8 + .../src/account_transport_tests.rs | 441 +++++------ .../services/relay-service/src/identity.rs | 55 +- .../services/relay-service/src/routes/auth.rs | 26 +- .../src/routes/page_auth_client.bundle.js | 2 +- .../relay-service/src/routes/pages.rs | 8 +- .../web/page-auth-client.test.mjs | 2 +- .../relay-service/web/page-auth-client.ts | 8 +- .../src/account_identity/mod.rs | 9 +- .../src/remote_connect/account.rs | 21 +- .../services/skin-market-service/README.md | 8 + .../migrations/0002_email_identity.sql | 16 + .../services/skin-market-service/src/auth.rs | 2 +- .../services/skin-market-service/src/db.rs | 98 ++- .../skin-market-service/src/routes.rs | 15 +- src/miniapp-market-web/src/AccountSignIn.tsx | 75 ++ src/miniapp-market-web/src/App.tsx | 22 +- src/miniapp-market-web/src/api.ts | 6 +- src/miniapp-market-web/src/i18n.ts | 81 +- src/miniapp-market-web/src/styles.css | 6 + src/miniapp-market-web/src/types.ts | 2 + src/mobile-web/README.md | 4 + src/mobile-web/src/i18n/messages.ts | 74 +- .../src/services/CloudAccountClient.ts | 4 +- src/mobile-web/tests/account-login.test.mjs | 8 +- src/skin-market-web/README.md | 2 +- src/skin-market-web/src/AdminPage.tsx | 4 +- src/skin-market-web/src/App.tsx | 6 +- src/skin-market-web/src/i18n.ts | 8 +- src/skin-market-web/src/types.ts | 1 + .../components/DeviceStatusControl.tsx | 4 +- .../RemoteConnectDialog/AccountPanel.tsx | 2 +- .../ensureAccountSession.ts | 4 +- .../miniapps/views/MiniAppSubmissionsView.tsx | 2 +- .../AccountIdentityService.ts | 2 +- .../account/useAccountLoginState.ts | 4 +- .../api/service-api/AccountIdentityAPI.ts | 1 + .../api/service-api/MiniAppMarketAPI.ts | 1 + src/web-ui/src/locales/en-US/common.json | 16 +- src/web-ui/src/locales/zh-CN/common.json | 16 +- src/web-ui/src/locales/zh-TW/common.json | 16 +- 78 files changed, 2017 insertions(+), 515 deletions(-) create mode 100644 src/crates/services/miniapp-market-service/migrations/0002_email_identity.sql create mode 100644 src/crates/services/miniapp-market-service/src/email_auth.rs create mode 100644 src/crates/services/skin-market-service/migrations/0002_email_identity.sql create mode 100644 src/miniapp-market-web/src/AccountSignIn.tsx diff --git a/Cargo.lock b/Cargo.lock index 44c195a8ac..1c469be2f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -617,6 +617,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -2293,6 +2299,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "embed-resource" version = "3.0.11" @@ -4534,6 +4556,33 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.9", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -6302,7 +6351,9 @@ dependencies = [ "base64 0.22.1", "chrono", "hex", + "hmac", "image 0.25.10", + "lettre", "openbitfun-product-domains", "openbitfun-services-core", "rand 0.8.7", @@ -7996,6 +8047,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" diff --git a/Cargo.toml b/Cargo.toml index 52a1e18ea1..5a0a7167fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,6 +135,7 @@ indexmap = "2" include_dir = "0.7" aes = "0.8" hmac = "0.12" +lettre = { version = "0.11.23", default-features = false } hex = "0.4" chardetng = "0.1.17" encoding_rs = "0.8.35" diff --git a/deploy/miniapp-market/README.md b/deploy/miniapp-market/README.md index 3833b94be1..c93632d22f 100644 --- a/deploy/miniapp-market/README.md +++ b/deploy/miniapp-market/README.md @@ -497,3 +497,23 @@ API 的 Relay/客户端。两个市场各自的网页仍在所属 Compose 项目 统一 callback 迁移时,先部署 auth 回调路由和服务配置,再在 GitHub OAuth App 设置中添加上述精确 Redirect URI。设置更新前,新发起的登录暂时无法完成。 旧市场 callback 路由作为 Cookie 会话完成端点保留,不扩大 Cookie Domain。 + +## 邮箱验证码发布补充 + +邮箱登录与 GitHub 登录是独立账号,不自动绑定或合并。部署前需同时验证身份服务、 +当前版本 Relay 和 Skin 身份消费者;保留旧 GitHub 数字账号及旧授权启动行为。 +客户端使用 `methods=all` 主动选择统一登录页,旧客户端继续走原 GitHub URL。 + +SMTP 配置单独存放在 `/etc/openbitfun-miniapp-market/smtp.env`,root:root 0600, +Compose 以可选 env_file 加载;不要将其复制到 checkout,也不要打印内容。 +使用现有企业邮箱的 SMTP 配置,变量参见后端 README。不要复制收件人列表、测试 +主题或 BCC 等与认证发信无关的配置。发件配置更新后需重建容器,核对 +`emailAuthConfigured=true`。真实收件验收只向操作者明确指定的地址发送。 + +`/sign-in` 改为统一登录网页;auth vhost 仅新增 `/config` 和指定 login/email API, +验证码完成后经一次性 grant 跳转至 market host 设置 Cookie。登录票据放在 fragment, +Nginx 日志必须继续只记录 `$uri`,不能记录验证码 body 或 grant query。 + +migration 0002 保留原用户 ID 和外键,但旧 binary 不认识邮箱用户;发布后不能把 +回退镜像当作数据库回滚。先完成两个市场的一致性备份和恢复演练,保留旧镜像, +出现问题优先向前修复;不得未经确认恢复备份或丢弃新增用户。 diff --git a/deploy/miniapp-market/docker-compose.yml b/deploy/miniapp-market/docker-compose.yml index 05873377f8..bcb1c2cb74 100644 --- a/deploy/miniapp-market/docker-compose.yml +++ b/deploy/miniapp-market/docker-compose.yml @@ -19,6 +19,8 @@ services: - "127.0.0.1:9710:9710" env_file: - /etc/openbitfun-miniapp-market/market.env + - path: /etc/openbitfun-miniapp-market/smtp.env + required: false environment: MARKET_GITHUB_CALLBACK_URL: https://auth.openbitfun.com/api/v1/auth/github/callback MARKET_BIND: 0.0.0.0:9710 diff --git a/deploy/miniapp-market/market.env.example b/deploy/miniapp-market/market.env.example index b0fa282335..54a12f43fa 100644 --- a/deploy/miniapp-market/market.env.example +++ b/deploy/miniapp-market/market.env.example @@ -7,3 +7,11 @@ MARKET_GITHUB_CLIENT_SECRET= MARKET_SESSION_SECRET=replace-with-at-least-32-random-bytes MARKET_ADMIN_GITHUB_IDS=24753352 RUST_LOG=openbitfun_miniapp_market=info,tower_http=info + +# Optional email-code sign-in. Production stores these in a separate root-only smtp.env. +# SMTP_HOST=smtp.qiye.aliyun.com +# SMTP_PORT=465 +# SMTP_SECURITY=ssl +# SMTP_USERNAME=hello@openbitfun.com +# SMTP_PASSWORD= +# SMTP_FROM_NAME=OpenBitFun diff --git a/deploy/miniapp-market/nginx-auth.openbitfun.com.conf b/deploy/miniapp-market/nginx-auth.openbitfun.com.conf index dfe4be5ae8..e58843718d 100644 --- a/deploy/miniapp-market/nginx-auth.openbitfun.com.conf +++ b/deploy/miniapp-market/nginx-auth.openbitfun.com.conf @@ -61,7 +61,7 @@ server { } location = /sign-in { - proxy_pass http://127.0.0.1:9710/miniapp/api/v1/auth/github/start; + proxy_pass http://127.0.0.1:9710/miniapp/auth/sign-in; } location = / { proxy_pass http://127.0.0.1:9710/miniapp/auth/complete; @@ -70,7 +70,7 @@ server { proxy_pass http://127.0.0.1:9710/miniapp/auth/complete; } # Bearer-token APIs only; marketplace mutations are not exposed here. - location ~ ^/api/v1/(me|auth/desktop/(start|poll)|auth/(refresh|logout))$ { + location ~ ^/api/v1/(me|config|auth/desktop/(start|poll)|auth/login/(start|github)|auth/email/(send|verify)|auth/github/(start|callback)|auth/(refresh|logout))$ { rewrite ^/api/v1/(.*)$ /miniapp/api/v1/$1 break; proxy_pass http://127.0.0.1:9710; } diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 4abd78f6b8..57a3469fee 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -271,3 +271,9 @@ before replacing either entrypoint. Use `doctor` for product/runtime assembly diagnostics and `health` for required capability registration. They do not claim that external Network, Git, or MCP services are currently reachable. + +Account sign-in opens the shared OpenBitFun page, where you can choose GitHub or +an email verification code. Email sign-in creates an independent account without +a password; it does not link to a GitHub account. Use the same method and account +on every device you want to connect. A terminal without a browser can display the +authorization URL for opening on another device. diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 3ff84d4a20..1bf527c6ce 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -918,7 +918,7 @@ static ACTION_SPECS: &[ActionSpec] = &[ id: "logout", name: "Logout", aliases: &["/logout"], - description: "Log out of GitHub account", + description: "Log out of OpenBitFun account", contexts: BOTH, availability: ActionAvailability::Always, handler: ActionHandler::Logout, diff --git a/src/apps/cli/src/modes/chat/account.rs b/src/apps/cli/src/modes/chat/account.rs index 849e6782a9..31a45d3c8c 100644 --- a/src/apps/cli/src/modes/chat/account.rs +++ b/src/apps/cli/src/modes/chat/account.rs @@ -62,10 +62,16 @@ impl ChatMode { }); use openbitfun_core::service::remote_connect::account_runtime::AccountLoginProgress; match result { - Ok(AccountLoginProgress::Authorization(authorization)) => chat_view.login_form_set_authorization(authorization), - Ok(AccountLoginProgress::Waiting) => chat_view.login_form_set_status("Waiting for GitHub authorization. Complete it in your browser, then press Enter."), + Ok(AccountLoginProgress::Authorization(authorization)) => { + chat_view.login_form_set_authorization(authorization) + } + Ok(AccountLoginProgress::Waiting) => chat_view.login_form_set_status( + "Waiting for sign-in. Complete it in your browser, then press Enter.", + ), Ok(AccountLoginProgress::Complete(login)) => { - chat_state.add_system_message(crate::account::account_login_status_message(&login)); + chat_state.add_system_message( + crate::account::account_login_status_message(&login), + ); self.open_login_or_account_panel(chat_view, chat_state, rt_handle); } Err(error) => chat_view.login_form_set_error(format!("Login failed: {error}")), diff --git a/src/apps/cli/src/ui/login_form.rs b/src/apps/cli/src/ui/login_form.rs index 8739444e55..18d04f8e08 100644 --- a/src/apps/cli/src/ui/login_form.rs +++ b/src/apps/cli/src/ui/login_form.rs @@ -1,4 +1,4 @@ -//! Full-viewport GitHub account panel (Login / Account status). +//! Full-viewport OpenBitFun account panel (Login / Account status). //! //! Opened by `/login`. When already logged in, shows account info and connected devices instead of the credential form. @@ -20,7 +20,7 @@ pub(crate) enum LoginFormAction { None, /// Close the panel (Esc on most views). Cancel, - /// Start GitHub sign-in or check an existing transaction. + /// Start account sign-in or check an existing transaction. Submit(Option), /// User requested logout from the account page. Logout, @@ -108,7 +108,7 @@ impl LoginFormState { pub(crate) fn set_authorization(&mut self, authorization: GitHubAuthStart) { self.authorization = Some(authorization); - self.set_status("Complete GitHub authorization, then press Enter."); + self.set_status("Complete OpenBitFun authorization, then press Enter."); } pub(crate) fn insert_paste(&mut self, _text: &str) {} @@ -188,7 +188,7 @@ impl LoginFormState { let outer = Block::default() .borders(Borders::ALL) .border_style(theme.style(StyleKind::Primary)) - .title(" OpenBitFun · GitHub Sign-in ") + .title(" OpenBitFun · Account Sign-in ") .title_alignment(Alignment::Center); let inner = outer.inner(area); frame.render_widget(outer, area); @@ -202,7 +202,7 @@ impl LoginFormState { ]) .split(inner); frame.render_widget( - Paragraph::new("Use the same GitHub account as the OpenBitFun marketplaces.") + Paragraph::new("Use the same OpenBitFun account as the OpenBitFun marketplaces.") .style(theme.style(StyleKind::Muted)) .wrap(Wrap { trim: false }), rows[0], @@ -211,7 +211,7 @@ impl LoginFormState { .authorization .as_ref() .map(|a| format!("Open this link in your browser:\n\n{}", a.authorization_url)) - .unwrap_or_else(|| "Press Enter to sign in with GitHub.".to_string()); + .unwrap_or_else(|| "Press Enter to sign in with email or GitHub.".to_string()); frame.render_widget( Paragraph::new(text) .style(theme.style(StyleKind::Primary)) @@ -231,7 +231,7 @@ impl LoginFormState { let outer = Block::default() .borders(Borders::ALL) .border_style(theme.style(StyleKind::Primary)) - .title(" GitHub Account ") + .title(" OpenBitFun Account ") .title_alignment(Alignment::Center); let inner = outer.inner(area); frame.render_widget(outer, area); diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 084918c377..1b7038c196 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1420,8 +1420,12 @@ impl StartupPage { }); use openbitfun_core::service::remote_connect::account_runtime::AccountLoginProgress; match result { - Ok(AccountLoginProgress::Authorization(authorization)) => self.login_form.set_authorization(authorization), - Ok(AccountLoginProgress::Waiting) => self.login_form.set_status("Waiting for GitHub authorization. Complete it in your browser, then press Enter."), + Ok(AccountLoginProgress::Authorization(authorization)) => { + self.login_form.set_authorization(authorization) + } + Ok(AccountLoginProgress::Waiting) => self.login_form.set_status( + "Waiting for sign-in. Complete it in your browser, then press Enter.", + ), Ok(AccountLoginProgress::Complete(login)) => { self.status = Some(account_login_status_message(&login)); self.show_login_form(); diff --git a/src/apps/mobile/README.md b/src/apps/mobile/README.md index 8293f8819f..166102dcb1 100644 --- a/src/apps/mobile/README.md +++ b/src/apps/mobile/README.md @@ -138,3 +138,8 @@ Native iOS and Android controllers probe idle session lists while the app is in foreground. An open transcript uses its durable session stream for recovery instead of duplicating the health request. Temporary transport failures keep the displayed list or transcript; a successful response restores the connected state. + +Account sign-in on Android, iOS and HarmonyOS opens the shared authorization page +with separate GitHub and email-code options. Email users need no password and are +not automatically linked to GitHub users. Sign in with the same method and account +on the phone and the controlled desktop/CLI. diff --git a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml index 5467e1ce55..73a0413b8c 100644 --- a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml @@ -24,15 +24,15 @@ 浅色 深色 账号 - 使用 GitHub 登录 - 使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。 + 使用邮箱或 GitHub 登录 + 使用邮箱或 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。 登录服务器 OpenBitFun 用户名 OpenBitFun 密码 Relay 地址,例如 https://relay.example.com 正在登录… 个人资料 - GitHub 账号 + OpenBitFun 账号 当前连接已通过账号 %1$s 验证。密码不会保存到手机。 连接 当前控制 @@ -40,7 +40,7 @@ 中继地址 用户名 密码 - 通过 GitHub 登录 + 使用邮箱或 GitHub 登录 退出登录 离线 在线 @@ -329,7 +329,7 @@ 未命名会话 聊天 新聊天 - 使用 GitHub 登录 + 使用邮箱或 GitHub 登录 扫码连接 选择连接方式 打开侧边栏 @@ -431,9 +431,9 @@ OpenBitFun 用户 已认证 加载更早消息 - 打开 GitHub 授权 + 打开 OpenBitFun 授权 请使用当前版本的 OpenBitFun 设备二维码。 - 该设备已离线,或不属于当前 GitHub 账户。 + 该设备已离线,或不属于当前 OpenBitFun 账户。 无法处理这张图片,请选择其他图片。 极简 标准 diff --git a/src/apps/mobile/android/app/src/main/res/values/strings.xml b/src/apps/mobile/android/app/src/main/res/values/strings.xml index 0b8c9b5b31..778f7943f6 100644 --- a/src/apps/mobile/android/app/src/main/res/values/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values/strings.xml @@ -25,15 +25,15 @@ Light Dark Account - Sign in with GitHub - Sign in with GitHub to connect to your computers.\nTasks and model settings stay on the controlled computer. + Sign in with email or GitHub + Sign in with email or GitHub to connect to your computers.\nTasks and model settings stay on the controlled computer. Sign-in server OpenBitFun username OpenBitFun password Relay URL, for example https://relay.example.com Signing in… Profile - GitHub account + OpenBitFun account This connection is verified by account %1$s. Your password is not stored on this phone. Connect Controlling @@ -41,7 +41,7 @@ Relay URL Username Password - Sign in with GitHub + Sign in with email or GitHub Sign out Offline Online @@ -342,7 +342,7 @@ Untitled session Chat New chat - Sign in with GitHub + Sign in with email or GitHub Scan to connect Choose how to connect Open sidebar @@ -446,9 +446,9 @@ This device ID OpenBitFun user Authenticated - Open GitHub authorization + Open OpenBitFun authorization Use a current OpenBitFun device QR code. - This device is offline or does not belong to your GitHub account. + This device is offline or does not belong to your OpenBitFun account. This image could not be prepared. Choose another image. Minimal Standard diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index 1665e9777f..d169674f5d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -219,7 +219,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['sidebar.noSearchResult', 'No matching sessions'], ['sidebar.signedOutNewChat', 'New chat'], ['sidebar.scanToConnect', 'Scan to connect a computer'], - ['sidebar.signInOpenBitFunAccount', 'Sign in with GitHub'], + ['sidebar.signInOpenBitFunAccount', 'Sign in with email or GitHub'], ['sidebar.collapse', 'Collapse sidebar'], ['sidebar.restore', 'Expand sidebar'], @@ -317,16 +317,16 @@ export const EN_US_MESSAGES: [string, string][] = [ ['remote.settings.noDesktop', 'No desktop connected yet'], ['remote.settings.openbitfunUser', 'OpenBitFun user'], ['remote.settings.profileDetails', 'Details'], - ['remote.settings.account', 'GitHub account'], + ['remote.settings.account', 'OpenBitFun account'], ['remote.settings.accountSignedIn', 'Signed in'], ['remote.settings.accountNotSignedIn', 'Signed out'], ['remote.settings.accountTemporary', 'Temporary access'], ['remote.settings.accountSignedInBody', 'This connection is verified as account {0}. The password is not saved on the phone.'], ['remote.settings.accountNotSignedInBody', 'If the desktop asks for account verification while scanning, the phone completes it for this pairing.'], - ['remote.settings.accountLoginTitle', 'Sign in with GitHub'], - ['remote.settings.accountLoginBody', 'Sign in with GitHub to connect to your computers.\nTasks and model settings stay on the controlled computer.'], + ['remote.settings.accountLoginTitle', 'Sign in with email or GitHub'], + ['remote.settings.accountLoginBody', 'Sign in with email or GitHub to connect to your computers.\nTasks and model settings stay on the controlled computer.'], ['remote.settings.loginServer', 'Sign-in server'], - ['remote.settings.accountSignIn', 'Sign in with GitHub'], + ['remote.settings.accountSignIn', 'Sign in with email or GitHub'], ['remote.settings.accountSigningIn', 'Signing in…'], ['remote.settings.accountLoginFailed', 'Unable to sign in right now. Try again later.'], ['remote.settings.relayUrlPlaceholder', 'Relay URL, for example https://relay.example.com'], @@ -349,8 +349,8 @@ export const EN_US_MESSAGES: [string, string][] = [ ['remote.settings.deviceUnavailable', 'The target desktop is unavailable. Refresh the device list and retry.'], ['remote.settings.deviceName', 'Device'], ['remote.settings.deviceManagementBody', 'This device stores account credentials encrypted with HUKS. Signing out clears them immediately.'], - ['remote.settings.githubId', 'GitHub ID: {0}'], - ['remote.settings.githubAccount', 'GitHub account'], + ['remote.settings.githubId', 'Account ID: {0}'], + ['remote.settings.githubAccount', 'OpenBitFun account'], ['remote.settings.userId', 'User ID'], ['remote.settings.deviceId', 'This device ID'], ['remote.settings.connectedDesktop', 'Current desktop'], @@ -373,7 +373,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['connect.localTitle', 'Connect a local workspace'], ['connect.chooseConnectionTitle', 'Connect a computer'], - ['connect.legacySignInRequired', 'Sign in with GitHub to reconnect this device. Previous connection records have been retained.'], + ['connect.legacySignInRequired', 'Sign in with email or GitHub to reconnect this device. Previous connection records have been retained.'], ['connect.subtitle', 'After connecting the desktop, OpenBitFun can access repositories, run commands, and handle local development tasks.'], ['connect.obtainPairCode', 'Get a pairing code'], ['connect.obtainPairCodeBody', 'Open Remote Control in OpenBitFun Desktop to get a QR code or pairing code.'], @@ -389,13 +389,13 @@ export const EN_US_MESSAGES: [string, string][] = [ ['connect.otherConnectionMethods', 'Other ways to connect'], ['connect.switchManualPair', 'Pair manually instead'], ['connect.deviceLinkInvalid', 'Use a current OpenBitFun device QR code.'], - ['connect.deviceLinkUnavailable', 'This device is offline or does not belong to your GitHub account.'], + ['connect.deviceLinkUnavailable', 'This device is offline or does not belong to your OpenBitFun account.'], ['connect.manualPair', 'Manual pairing'], ['connect.manualPairBody', 'Enter the pairing code shown on the desktop.'], ['connect.accountPairTitle', 'Account pairing'], - ['connect.accountPairIntro', 'The desktop is signed in to a GitHub account. Verify the same account to continue.'], + ['connect.accountPairIntro', 'The desktop is signed in to a OpenBitFun account. Verify the same account to continue.'], ['connect.accountPairBody', 'The password is only used for this encrypted pairing and is not saved on the phone.'], - ['connect.enterAccountToPair', 'Sign in with GitHub to connect this device'], + ['connect.enterAccountToPair', 'Sign in with email or GitHub to connect this device'], ['connect.accountUsernamePlaceholder', 'OpenBitFun username'], ['connect.accountPasswordPlaceholder', 'OpenBitFun password'], ['connect.pairCodePlaceholder', 'Pairing code'], @@ -665,7 +665,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['errors.tooManyAttempts', 'Too many attempts. Try again in {0} seconds.'], ['errors.userIdRequired', 'Enter a user ID.'], ['errors.accountUsernameRequired', 'Enter a OpenBitFun username.'], - ['errors.accountPasswordRequired', 'Sign in with GitHub to continue.'], + ['errors.accountPasswordRequired', 'Sign in with email or GitHub to continue.'], ['errors.remoteDataInvalid', 'The relay returned data that could not be parsed. Confirm the desktop and mobile versions match.'], ['errors.pairRejected', 'The relay rejected the pairing request. Generate a new connection QR code on the desktop and retry.'], ['errors.roomNotFound', 'This remote room was not found. Confirm the QR code has not expired, or reopen mobile connect on the desktop.'], @@ -688,7 +688,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['errors.voiceInputUnavailable', 'Speech recognition is unavailable. Try again later.'], ['errors.voiceInputFailed', 'Speech recognition failed ({0}). Try again later.'], - ['watchProvision.title', 'Sign in with GitHub on this watch?'], + ['watchProvision.title', 'Sign in with email or GitHub on this watch?'], ['watchProvision.deviceId', 'Device {0}'], ['watchProvision.body', 'If you allow this, the watch will sign in to your account and can connect to a desktop. Confirm the watch is in your hands.'], ['watchProvision.approve', 'Allow'], @@ -701,10 +701,10 @@ export const EN_US_MESSAGES: [string, string][] = [ ['watchProvision.doneBody', '{0} is signed in and ready to use OpenBitFun.'], ['watchProvision.rejected', 'Denied on the phone.'], ['watchProvision.busy', 'The phone is handling another device request. Try again later.'], - ['watchProvision.errors.noDesktop', 'Sign in with GitHub on the phone, or connect a desktop signed in to the same account.'], + ['watchProvision.errors.noDesktop', 'Sign in with email or GitHub on the phone, or connect a desktop signed in to the same account.'], ['watchProvision.errors.accountUnavailable', 'The account could not be verified. Check the phone network and try again.'], ['watchProvision.errors.desktopUnreachable', 'The desktop could not be reached. Make sure it is online and try again.'], - ['watchProvision.errors.desktopAuthorizationFailed', 'The desktop could not sign in the watch. Make sure it uses the same GitHub account.'], + ['watchProvision.errors.desktopAuthorizationFailed', 'The desktop could not sign in the watch. Make sure it uses the same OpenBitFun account.'], ['watchProvision.errors.passwordFailed', 'Account verification failed. Check the password or network and retry.'], ['watchProvision.errors.handoffFailed', 'Authorization finished, but the credential could not be sent to the watch. Retry on the watch.'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index 56908ba4db..978bf692a7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -219,7 +219,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['sidebar.noSearchResult', '没有匹配的会话'], ['sidebar.signedOutNewChat', '新聊天'], ['sidebar.scanToConnect', '扫码连接电脑'], - ['sidebar.signInOpenBitFunAccount', '使用 GitHub 登录'], + ['sidebar.signInOpenBitFunAccount', '使用邮箱或 GitHub 登录'], ['sidebar.collapse', '收起侧边栏'], ['sidebar.restore', '展开侧边栏'], @@ -317,16 +317,16 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['remote.settings.noDesktop', '尚未连接桌面端'], ['remote.settings.openbitfunUser', 'OpenBitFun 用户'], ['remote.settings.profileDetails', '资料'], - ['remote.settings.account', 'GitHub 账号'], + ['remote.settings.account', 'OpenBitFun 账号'], ['remote.settings.accountSignedIn', '已登录'], ['remote.settings.accountNotSignedIn', '未登录'], ['remote.settings.accountTemporary', '临时登录'], ['remote.settings.accountSignedInBody', '当前连接已通过账号 {0} 验证。密码不会保存到手机。'], ['remote.settings.accountNotSignedInBody', '扫码连接时,如果桌面端要求账号验证,手机会在本次配对中完成认证。'], - ['remote.settings.accountLoginTitle', '使用 GitHub 登录'], - ['remote.settings.accountLoginBody', '使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。'], + ['remote.settings.accountLoginTitle', '使用邮箱或 GitHub 登录'], + ['remote.settings.accountLoginBody', '使用邮箱或 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。'], ['remote.settings.loginServer', '登录服务器'], - ['remote.settings.accountSignIn', '使用 GitHub 登录'], + ['remote.settings.accountSignIn', '使用邮箱或 GitHub 登录'], ['remote.settings.accountSigningIn', '正在登录…'], ['remote.settings.accountLoginFailed', '暂时无法登录,请稍后重试。'], ['remote.settings.relayUrlPlaceholder', 'Relay 地址,例如 https://relay.example.com'], @@ -349,8 +349,8 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['remote.settings.deviceUnavailable', '目标桌面暂时不可用,请刷新设备列表后重试。'], ['remote.settings.deviceName', '设备'], ['remote.settings.deviceManagementBody', '此设备的账号凭据已使用 HUKS 加密保存。退出账号后会立即清理。'], - ['remote.settings.githubId', 'GitHub ID: {0}'], - ['remote.settings.githubAccount', 'GitHub 账号'], + ['remote.settings.githubId', 'Account ID: {0}'], + ['remote.settings.githubAccount', 'OpenBitFun 账号'], ['remote.settings.userId', '用户 ID'], ['remote.settings.deviceId', '本机设备 ID'], ['remote.settings.connectedDesktop', '当前桌面端'], @@ -373,7 +373,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['connect.localTitle', '连接本地工作区'], ['connect.chooseConnectionTitle', '连接电脑'], - ['connect.legacySignInRequired', '请使用 GitHub 登录后重新连接设备。原有连接记录已保留。'], + ['connect.legacySignInRequired', '请使用邮箱或 GitHub 登录后重新连接设备。原有连接记录已保留。'], ['connect.subtitle', '连接桌面端后,OpenBitFun 可以访问代码仓库、运行命令和处理本地开发任务。'], ['connect.obtainPairCode', '获取配对码'], ['connect.obtainPairCodeBody', '在电脑端打开 OpenBitFun 的远程控制,获取二维码或配对码。'], @@ -389,13 +389,13 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['connect.otherConnectionMethods', '其他连接方式'], ['connect.switchManualPair', '改为手动配对'], ['connect.deviceLinkInvalid', '请使用当前版本的 OpenBitFun 设备二维码。'], - ['connect.deviceLinkUnavailable', '该设备已离线,或不属于当前 GitHub 账户。'], + ['connect.deviceLinkUnavailable', '该设备已离线,或不属于当前 OpenBitFun 账户。'], ['connect.manualPair', '手动配对'], ['connect.manualPairBody', '请输入桌面上显示的配对码。'], ['connect.accountPairTitle', '账号配对验证'], - ['connect.accountPairIntro', '桌面端已使用 GitHub 登录,需要验证同一账号后继续连接。'], + ['connect.accountPairIntro', '桌面端已使用邮箱或 GitHub 登录,需要验证同一账号后继续连接。'], ['connect.accountPairBody', '密码只用于本次加密配对,不会保存到手机。'], - ['connect.enterAccountToPair', '请使用 GitHub 登录后连接设备'], + ['connect.enterAccountToPair', '请使用邮箱或 GitHub 登录后连接设备'], ['connect.accountUsernamePlaceholder', 'OpenBitFun 用户名'], ['connect.accountPasswordPlaceholder', 'OpenBitFun 密码'], ['connect.pairCodePlaceholder', '配对码'], @@ -665,7 +665,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['errors.tooManyAttempts', '尝试次数过多,请 {0} 秒后再试。'], ['errors.userIdRequired', '请输入用户 ID。'], ['errors.accountUsernameRequired', '请输入 OpenBitFun 用户名。'], - ['errors.accountPasswordRequired', '请使用 GitHub 登录以继续。'], + ['errors.accountPasswordRequired', '请使用邮箱或 GitHub 登录以继续。'], ['errors.remoteDataInvalid', '中继返回了无法解析的数据,请确认桌面端和移动端版本匹配。'], ['errors.pairRejected', '中继拒绝了配对请求,请在桌面端重新生成连接二维码后再试。'], ['errors.roomNotFound', '没有找到这个远程房间,请确认二维码未过期,或在桌面端重新打开移动端连接。'], @@ -688,7 +688,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['errors.voiceInputUnavailable', '语音识别暂不可用,请稍后重试。'], ['errors.voiceInputFailed', '语音识别失败({0}),请稍后重试。'], - ['watchProvision.title', '在这块手表上使用 GitHub 登录?'], + ['watchProvision.title', '在这块手表上使用邮箱或 GitHub 登录?'], ['watchProvision.deviceId', '设备编号 {0}'], ['watchProvision.body', '同意后,手表会登录你的账号并可以连接桌面端。请确认这块手表就在你手里。'], ['watchProvision.approve', '允许'], @@ -701,10 +701,10 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['watchProvision.doneBody', '{0} 已登录,可以在手表上使用 OpenBitFun 了。'], ['watchProvision.rejected', '已在手机上拒绝。'], ['watchProvision.busy', '手机正在处理另一台设备的请求,请稍后再试。'], - ['watchProvision.errors.noDesktop', '请先在手机上使用 GitHub 登录,或连接已登录同一账号的桌面端。'], + ['watchProvision.errors.noDesktop', '请先在手机上使用邮箱或 GitHub 登录,或连接已登录同一账号的桌面端。'], ['watchProvision.errors.accountUnavailable', '暂时无法验证账号,请检查手机网络后重试。'], ['watchProvision.errors.desktopUnreachable', '暂时无法连接桌面端,请确认桌面端在线后重试。'], - ['watchProvision.errors.desktopAuthorizationFailed', '桌面端没能完成手表登录,请确认桌面端已登录同一 GitHub 账号。'], + ['watchProvision.errors.desktopAuthorizationFailed', '桌面端没能完成手表登录,请确认桌面端已登录同一 OpenBitFun 账号。'], ['watchProvision.errors.passwordFailed', '账号验证失败,请检查密码或网络后重试。'], ['watchProvision.errors.handoffFailed', '授权已完成,但没能把凭证发给手表,请在手表上重试一次。'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets index 79d68887dd..01006eb76d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -171,12 +171,12 @@ export class CloudAccountClient { async login(relayUrl: string, deviceId: string, openAuthorization: (url: string) => Promise, deviceSecret: Uint8Array): Promise { const generation = ++this.authorizationGeneration; const startRequest: Record = {}; - const start = await this.post(relayUrl, '/api/auth/github/start', startRequest); + const start = await this.post(relayUrl, '/api/auth/github/start?methods=all', startRequest); if (generation !== this.authorizationGeneration) throw new Error('Sign-in cancelled.'); - if (!start.authorizationUrl.startsWith('https://github.com/login/oauth/authorize?')) { + if (!start.authorizationUrl.startsWith('https://github.com/login/oauth/authorize?') && !/^https:\/\/auth\.openbitfun\.com\/sign-in#ticket=[A-Za-z0-9_-]+$/.test(start.authorizationUrl)) { throw new Error('Untrusted account authorization URL.'); } - if (!await openAuthorization(start.authorizationUrl)) throw new Error('Could not open GitHub sign-in.'); + if (!await openAuthorization(start.authorizationUrl)) throw new Error('Could not open account sign-in.'); const deadline = Math.min(start.expiresAt * 1000, Date.now() + 600000); while (Date.now() < deadline && generation === this.authorizationGeneration) { await new Promise((resolve) => setTimeout(resolve, Math.min(30, Math.max(1, start.pollIntervalSeconds)) * 1000)); diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift index dff0f58063..fa82570b0b 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift @@ -138,7 +138,7 @@ enum MobileLaunchConfiguration { ComposerModelOption( id: "preview-codex", primaryLabel: "GPT-5.6 Codex", - secondaryLabel: "GitHub 账号", + secondaryLabel: "OpenBitFun 账号", source: "ACCOUNT", selected: true ), diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift index 9f56c6e1b0..232103758b 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift @@ -26,13 +26,13 @@ struct AccountSettingsView: View { ConnectionSheetHeader(onClose: close, uniformGlyph: true) VStack(spacing: 0) { - Text(model.localized("使用 GitHub 登录")) + Text(model.localized("使用邮箱或 GitHub 登录")) .font(.system(size: loginTitleSize, weight: .bold)) .padding(.vertical, MobileDesignTypography.displayMedium.lineSpacing / 2) .foregroundStyle(OpenBitFunTheme.ink) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) - Text(model.localized("使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。")) + Text(model.localized("使用邮箱或 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。")) .font(.system(size: loginBodySize)) .padding(.vertical, MobileDesignTypography.bodyMedium.lineSpacing / 2) .foregroundStyle(OpenBitFunTheme.muted) @@ -57,8 +57,8 @@ struct AccountSettingsView: View { .frame(minHeight: MobileDesignGeometry.loginSheetBodyMinHeight, alignment: .top) ConnectionSheetFooter( - label: model.localized(model.accountAuthorizationURL != nil ? "打开 GitHub 授权" - : model.accountBusy ? "正在登录" : "使用 GitHub 登录"), + label: model.localized(model.accountAuthorizationURL != nil ? "打开 OpenBitFun 授权" + : model.accountBusy ? "正在登录" : "使用邮箱或 GitHub 登录"), elevated: false, primary: true, enabled: canLogin, onAction: model.loginAccount ) .accessibilityIdentifier("account.login") @@ -152,7 +152,7 @@ struct AccountSettingsView: View { VStack(alignment: .leading, spacing: 10) { HStack { - Text(model.localized("GitHub 账号")) + Text(model.localized("OpenBitFun 账号")) .font(.system(size: 17, weight: .bold)) .foregroundStyle(OpenBitFunTheme.ink) Spacer() diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift index 6f537eeac9..993db4156f 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift @@ -260,7 +260,7 @@ struct PairingSheet: View { .foregroundStyle(OpenBitFunTheme.ink) SignedOutConnectionActions( scanTitle: model.localized("扫码连接电脑"), - accountTitle: model.localized("使用 GitHub 登录"), + accountTitle: model.localized("使用邮箱或 GitHub 登录"), onScan: { scanError = nil; step = .scan }, onOpenAccount: model.openAccountFromPairing, primaryScan: true, diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift index 0dbdc8dce3..4b4fbe9038 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift @@ -222,7 +222,7 @@ struct RemoteControlSettingsView: View { .font(.system(size: 28, weight: .regular)) .foregroundStyle(OpenBitFunTheme.muted) .frame(width: 34, height: 34) - Text(model.localized(model.accountUser == nil ? "使用 GitHub 登录" : "个人资料")) + Text(model.localized(model.accountUser == nil ? "使用邮箱或 GitHub 登录" : "个人资料")) .font(.system(size: 18, weight: .medium)) .foregroundStyle(OpenBitFunTheme.ink) Spacer() diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift index 0255a30026..af059c8e03 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift @@ -867,7 +867,7 @@ struct SidebarView: View { if !showsPrimaryNavigation { SignedOutConnectionActions( scanTitle: model.localized("扫码连接"), - accountTitle: model.localized("使用 GitHub 登录"), + accountTitle: model.localized("使用邮箱或 GitHub 登录"), onScan: model.scanRemote, onOpenAccount: { model.accountSheetOpen = true; model.drawerOpen = false }, showScan: !model.remoteConnected diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index 99fbfcab91..393df32e02 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -321,7 +321,7 @@ final class MobileAppModel: ObservableObject { pendingDeviceLink = nil pairingError = result.status == .invalid ? localized("请使用当前版本的 OpenBitFun 设备二维码。") - : localized("该设备已离线,或不属于当前 GitHub 账户。") + : localized("该设备已离线,或不属于当前 OpenBitFun 账户。") } } diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings index eb70702acc..0ad840c6cb 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -2681,12 +2681,12 @@ } } }, - "使用 GitHub 登录": { + "使用邮箱或 GitHub 登录": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in with GitHub" + "value": "Sign in with email or GitHub" } } } @@ -2981,12 +2981,12 @@ } } }, - "此桌面要求使用 GitHub 账号验证身份。": { + "此桌面要求使用 OpenBitFun 账号验证身份。": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This desktop requires your GitHub account to verify your identity." + "value": "This desktop requires your OpenBitFun account to verify your identity." } } } @@ -3031,12 +3031,12 @@ } } }, - "GitHub 账号": { + "OpenBitFun 账号": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "GitHub account" + "value": "OpenBitFun account" } } } @@ -5101,12 +5101,12 @@ } } }, - "桌面端已使用 GitHub 登录,需要验证同一账号后继续连接。": { + "桌面端已使用邮箱或 GitHub 登录,需要验证同一账号后继续连接。": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The desktop is signed in to a GitHub account. Verify the same account to continue." + "value": "The desktop is signed in to a OpenBitFun account. Verify the same account to continue." } } } @@ -5121,12 +5121,12 @@ } } }, - "请使用 GitHub 登录后连接设备": { + "请使用邮箱或 GitHub 登录后连接设备": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in with GitHub to connect this device" + "value": "Sign in with email or GitHub to connect this device" } } } @@ -7151,12 +7151,12 @@ } } }, - "请使用 GitHub 登录以继续。": { + "请使用邮箱或 GitHub 登录以继续。": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in with GitHub to continue." + "value": "Sign in with email or GitHub to continue." } } } @@ -7371,12 +7371,12 @@ } } }, - "把这块手表加入你的 GitHub 账号?": { + "把这块手表加入你的 OpenBitFun 账号?": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Add this watch to your GitHub account?" + "value": "Add this watch to your OpenBitFun account?" } } } @@ -7889,22 +7889,22 @@ } } }, - "打开 GitHub 授权": { + "打开 OpenBitFun 授权": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open GitHub authorization" + "value": "Open OpenBitFun authorization" } } } }, - "通过 GitHub 登录": { + "使用邮箱或 GitHub 登录": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in with GitHub" + "value": "Sign in with email or GitHub" } } } @@ -7925,18 +7925,18 @@ } } }, - "该设备已离线,或不属于当前 GitHub 账户。": { + "该设备已离线,或不属于当前 OpenBitFun 账户。": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This device is offline or does not belong to your GitHub account." + "value": "This device is offline or does not belong to your OpenBitFun account." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "该设备已离线,或不属于当前 GitHub 账户。" + "value": "该设备已离线,或不属于当前 OpenBitFun 账户。" } } } @@ -8051,18 +8051,18 @@ } } }, - "使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。": { + "使用邮箱或 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in with GitHub to connect to your computers.\nTasks and model settings stay on the controlled computer." + "value": "Sign in with email or GitHub to connect to your computers.\nTasks and model settings stay on the controlled computer." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。" + "value": "使用邮箱或 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。" } } } diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt index 29d5c5037c..58d9f08044 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt @@ -183,11 +183,11 @@ public class CloudAccountClient internal constructor( } public suspend fun startAuthorization(relayUrl: String): GitHubAuthorization = request( - relayUrl, "/api/auth/github/start", HttpMethod.Post, + relayUrl, "/api/auth/github/start?methods=all", HttpMethod.Post, JsonObject.serializer(), JsonObject(emptyMap()), GitHubAuthorization.serializer(), "", RELAY_DEFAULT_TIMEOUT_MS, ).also { val url = io.ktor.http.Url(it.authorizationUrl) - require(url.protocol.name == "https" && url.host == "github.com" && url.encodedPath == "/login/oauth/authorize" && url.port == 443 && url.user == null && url.password == null) + require(url.protocol.name == "https" && ((url.host == "github.com" && url.encodedPath == "/login/oauth/authorize") || (url.host == "auth.openbitfun.com" && url.encodedPath == "/sign-in")) && url.port == 443 && url.user == null && url.password == null) } public suspend fun pollAuthorization(relayUrl: String, start: GitHubAuthorization): GitHubAuthorizationPoll = request( diff --git a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt index 09a4650df2..cdaff4f9b2 100644 --- a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt +++ b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt @@ -50,10 +50,10 @@ class CloudAccountClientTest { @Test fun authorizationAcceptsTheIdentityAuthorityGithubUrlAndRejectsOtherDestinations() = runTest { - for (url in listOf("https://github.com/login/oauth/authorize?state=test", "https://github.com.evil.example/login/oauth/authorize", "https://github.com/login", "https://user@github.com/login/oauth/authorize", "http://github.com/login/oauth/authorize")) { + for (url in listOf("https://github.com/login/oauth/authorize?state=test", "https://auth.openbitfun.com/sign-in#ticket=test", "https://auth.openbitfun.com.evil.example/sign-in", "https://user@auth.openbitfun.com/sign-in", "https://auth.openbitfun.com:444/sign-in", "https://github.com.evil.example/login/oauth/authorize", "https://github.com/login", "https://user@github.com/login/oauth/authorize", "http://github.com/login/oauth/authorize")) { val engine = MockEngine { json("""{"transactionId":"txn","transactionSecret":"secret","authorizationUrl":"$url","expiresAt":9999999999,"pollIntervalSeconds":3}""") } val client = CloudAccountClient(relayHttpClient(engine)) - if (url == "https://github.com/login/oauth/authorize?state=test") assertEquals(url, client.startAuthorization(DEFAULT_CLOUD_RELAY_URL).authorizationUrl) + if (url == "https://github.com/login/oauth/authorize?state=test" || url == "https://auth.openbitfun.com/sign-in#ticket=test") assertEquals(url, client.startAuthorization(DEFAULT_CLOUD_RELAY_URL).authorizationUrl) else assertFailsWith { client.startAuthorization(DEFAULT_CLOUD_RELAY_URL) } } } diff --git a/src/crates/contracts/product-domains/src/account.rs b/src/crates/contracts/product-domains/src/account.rs index 72bc378da5..034ddbd679 100644 --- a/src/crates/contracts/product-domains/src/account.rs +++ b/src/crates/contracts/product-domains/src/account.rs @@ -44,6 +44,9 @@ pub struct AccountLoginProjection { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitHubUser { + /// Provider-independent identity; absent in legacy GitHub payloads. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, pub github_id: i64, pub login: String, pub avatar_url: String, @@ -69,3 +72,44 @@ pub struct GitHubAuthPollRequest { pub struct GitHubAuthPollResponse { pub status: String, } + +impl GitHubUser { + pub fn identity_id(&self) -> Option { + if self.github_id > 0 { + return Some(self.github_id.to_string()); + } + self.account_id + .as_ref() + .filter(|id| { + id.starts_with("email-") + && id.len() <= 64 + && id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-') + }) + .cloned() + } +} + +#[cfg(test)] +mod identity_tests { + use super::*; + #[test] + fn legacy_profile_round_trip_and_independent_email_identity() { + let legacy = r#"{"githubId":42,"login":"alice","avatarUrl":""}"#; + let user: GitHubUser = serde_json::from_str(legacy).unwrap(); + assert_eq!(user.identity_id().as_deref(), Some("42")); + assert_eq!( + serde_json::to_value(&user).unwrap(), + serde_json::from_str::(legacy).unwrap() + ); + let email: GitHubUser = serde_json::from_str( + r#"{"accountId":"email-7","githubId":0,"login":"user-7","avatarUrl":""}"#, + ) + .unwrap(); + assert_eq!(email.identity_id().as_deref(), Some("email-7")); + let invalid: GitHubUser = serde_json::from_str( + r#"{"accountId":"42","githubId":0,"login":"user","avatarUrl":""}"#, + ) + .unwrap(); + assert!(invalid.identity_id().is_none()); + } +} diff --git a/src/crates/contracts/product-domains/src/appearance_market.rs b/src/crates/contracts/product-domains/src/appearance_market.rs index 16d4b16856..e81aa5ccae 100644 --- a/src/crates/contracts/product-domains/src/appearance_market.rs +++ b/src/crates/contracts/product-domains/src/appearance_market.rs @@ -61,13 +61,7 @@ pub enum AppearancePackageMode { Dark, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AppearanceMarketUserSummary { - pub github_id: i64, - pub login: String, - pub avatar_url: String, -} +pub use crate::account::GitHubUser as AppearanceMarketUserSummary; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src/crates/services/miniapp-market-service/Cargo.toml b/src/crates/services/miniapp-market-service/Cargo.toml index 2b99c818b7..5ead8b9b4a 100644 --- a/src/crates/services/miniapp-market-service/Cargo.toml +++ b/src/crates/services/miniapp-market-service/Cargo.toml @@ -16,6 +16,8 @@ openbitfun-product-domains = { path = "../../contracts/product-domains", feature openbitfun-services-core = { path = "../services-core", features = ["tls-provider"] } chrono = { workspace = true } hex = { workspace = true } +hmac = { workspace = true } +lettre = { workspace = true, features = ["builder", "smtp-transport", "tokio1-rustls", "ring", "webpki-roots"] } image = { workspace = true, features = ["jpeg", "png", "webp"] } rand = { workspace = true } reqwest = { workspace = true, features = ["form", "http2", "json", "rustls-no-provider"] } diff --git a/src/crates/services/miniapp-market-service/README.md b/src/crates/services/miniapp-market-service/README.md index 33d775a728..df4eb819b6 100644 --- a/src/crates/services/miniapp-market-service/README.md +++ b/src/crates/services/miniapp-market-service/README.md @@ -136,3 +136,37 @@ yank、上传和 migration 变化不能只测试成功路径。 不要在生产服务器直接执行 `cargo run`,不要手工替换 binary,也不要直接编辑 SQLite/artifacts。完整流程见 [生产部署手册](../../../../deploy/miniapp-market/README.md)。 + +## 邮箱验证码登录 + +统一登录页提供两个独立入口:GitHub OAuth 和邮箱验证码。首次验证邮箱后创建独立 +账号;不创建密码,不按邮箱或昵称自动绑定、合并 GitHub 账号。跨设备必须使用同一种 +登录方式和同一个账号。 + +新版客户端向 `POST /auth/desktop/start?methods=all` 请求统一登录页,继续使用原有 +事务 secret 和一次性轮询 token。未携带该参数的旧客户端仍直接收到 GitHub OAuth +URL。Relay 的旧命名 `/api/auth/github/start` 同样仅在 `methods=all` 时转发新能力。 +旧服务器忽略该参数时保留原 GitHub 路径,不向旧客户端发放邮箱身份。 + +`POST /auth/login/start` 创建浏览器授权票据;`POST /auth/login/github` 选择 GitHub; +`POST /auth/email/send` 发码,`POST /auth/email/verify` 验码。邮箱地址按 ASCII 小写 +规范化,不删除加号标签或点。六位随机验证码十分钟有效,单次最多五次尝试,只存 +基于 session secret 的 HMAC 摘要。每个邮箱一分钟一次、一天十次;服务器整体 +一分钟三十封、一天一千封,发送失败也计入配额。发送记录保留一天,配额跨重启有效。 +SMTP 错误不得包含地址、凭据或验证码。认证接口保留 body、并发、期限和全局限流。 + +浏览器授权票据与设备轮询 secret 分离。邮箱验证完成后使用六十秒、一次性 grant +在市场 origin 建立 host-only Cookie;不在 URL 中传输长期 session 或 API token。 + +新增 migration 0002 保留旧 users 内部 ID、会话和产品外键,将 github_id 改为可空。 +`/me` 增加可选 `accountId`:GitHub 用户保持原数字 ID 字符串,邮箱用户为独立 +`email-` 命名空间。兼容字段 `githubId=0` 表示没有 GitHub 身份,不是 +合成 GitHub ID;旧消费者必须拒绝该身份。管理员仍仅由正数 GitHub ID 决定。 +已产生邮箱用户后不得回滚到旧 binary;应向前修复,恢复备份需单独的数据恢复决策。 + +发信使用 `SMTP_HOST`、`SMTP_PORT`、`SMTP_SECURITY`(ssl/starttls)、`SMTP_USERNAME`、 +`SMTP_PASSWORD` 和可选 `SMTP_FROM_NAME`。默认阿里企业邮箱 TLS 主机 +`smtp.qiye.aliyun.com:465`,不允许关闭证书验证。未配置密码时只关闭邮箱入口, +`/config` 和 `/health` 的 `emailAuthConfigured` 明确报告能力。 + +重点回归:`cargo test -p openbitfun-miniapp-market-service --lib email_`。 diff --git a/src/crates/services/miniapp-market-service/migrations/0002_email_identity.sql b/src/crates/services/miniapp-market-service/migrations/0002_email_identity.sql new file mode 100644 index 0000000000..3b4352c77f --- /dev/null +++ b/src/crates/services/miniapp-market-service/migrations/0002_email_identity.sql @@ -0,0 +1,44 @@ +-- Preserve all internal IDs and foreign-key references. The runner disables +-- foreign keys on its exclusive migration connection, then checks them before commit. +CREATE TABLE users_next ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + github_id INTEGER UNIQUE, + login TEXT NOT NULL, + avatar_url TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +INSERT INTO users_next SELECT * FROM users; +DROP TABLE users; +ALTER TABLE users_next RENAME TO users; +CREATE TABLE email_identities ( + email TEXT PRIMARY KEY, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id), + verified_at INTEGER NOT NULL +); +CREATE TABLE login_flows ( + ticket_hash TEXT PRIMARY KEY, + transaction_id TEXT REFERENCES desktop_auth_transactions(id), + return_to TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER +); +CREATE INDEX login_flows_expiry ON login_flows(expires_at); +CREATE TABLE email_challenges ( + id TEXT PRIMARY KEY, + ticket_hash TEXT NOT NULL REFERENCES login_flows(ticket_hash) ON DELETE CASCADE, + email TEXT NOT NULL, + code_hash TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER +); +CREATE INDEX email_challenges_address_time ON email_challenges(email, created_at); +CREATE INDEX email_challenges_time ON email_challenges(created_at); +CREATE TABLE email_browser_grants ( + grant_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + return_to TEXT NOT NULL, + expires_at INTEGER NOT NULL +); diff --git a/src/crates/services/miniapp-market-service/src/auth.rs b/src/crates/services/miniapp-market-service/src/auth.rs index 3b7e176ab2..840d3d57f0 100644 --- a/src/crates/services/miniapp-market-service/src/auth.rs +++ b/src/crates/services/miniapp-market-service/src/auth.rs @@ -27,9 +27,10 @@ const REFRESH_TOKEN_DAYS: i64 = 30; #[derive(Debug, Clone)] pub(crate) struct AuthService { - config: MarketConfig, - db: Database, + pub(super) config: MarketConfig, + pub(super) db: Database, client: reqwest::Client, + pub(super) mailer: Option, } #[derive(Debug, Clone)] @@ -143,11 +144,11 @@ struct GitHubUser { } #[derive(Debug)] -struct OAuthFlowRecord { - flow_kind: String, - transaction_id: Option, - code_verifier: String, - return_to: String, +pub(super) struct OAuthFlowRecord { + pub(super) flow_kind: String, + pub(super) transaction_id: Option, + pub(super) code_verifier: String, + pub(super) return_to: String, } impl AuthService { @@ -160,7 +161,12 @@ impl AuthService { .timeout(std::time::Duration::from_secs(20)) .build() .map_err(MarketError::internal)?; - Ok(Self { config, db, client }) + Ok(Self { + config, + db, + client, + mailer: crate::email_auth::Mailer::from_env()?, + }) } pub(crate) async fn optional_auth( @@ -202,7 +208,7 @@ impl AuthService { pub(crate) async fn require_auth(&self, headers: &HeaderMap) -> MarketResult { self.optional_auth(headers) .await? - .ok_or_else(|| MarketError::unauthorized("Sign in with GitHub to continue.")) + .ok_or_else(|| MarketError::unauthorized("Sign in to continue.")) } pub(crate) fn require_csrf(&self, headers: &HeaderMap, auth: &RequestAuth) -> MarketResult<()> { @@ -229,9 +235,11 @@ impl AuthService { } pub(crate) fn is_admin(&self, user: &AuthenticatedUser) -> bool { - self.config - .admin_github_ids - .contains(&user.profile.github_id) + user.profile.github_id > 0 + && self + .config + .admin_github_ids + .contains(&user.profile.github_id) } pub(crate) async fn start_web_oauth(&self, return_to: &str) -> MarketResult { @@ -239,8 +247,18 @@ impl AuthService { self.create_oauth_flow("web", None, &return_to).await } + #[cfg(test)] pub(crate) async fn start_desktop_oauth(&self) -> MarketResult { - self.ensure_github_configured()?; + self.start_desktop_login(false).await + } + + pub(crate) async fn start_desktop_login( + &self, + all_methods: bool, + ) -> MarketResult { + if !all_methods { + self.ensure_github_configured()?; + } let transaction_id = Uuid::new_v4().to_string(); let transaction_secret = random_token(32); let now = Utc::now().timestamp(); @@ -273,14 +291,20 @@ impl AuthService { "Sign-in is busy. Please try again shortly.", )); } - let authorization_url = self - .create_oauth_flow_in_transaction( + let authorization_url = if all_methods { + let ticket = self + .create_login_flow(&mut transaction, Some(&transaction_id), "/miniapp/") + .await?; + format!("https://auth.openbitfun.com/sign-in#ticket={ticket}") + } else { + self.create_oauth_flow_in_transaction( &mut transaction, "desktop", Some(&transaction_id), "https://auth.openbitfun.com/complete", ) - .await?; + .await? + }; transaction.commit().await.map_err(MarketError::internal)?; Ok(DesktopAuthStart { transaction_id, @@ -291,7 +315,7 @@ impl AuthService { }) } - async fn create_oauth_flow( + pub(super) async fn create_oauth_flow( &self, kind: &str, transaction_id: Option<&str>, @@ -379,7 +403,7 @@ impl AuthService { self.finish_verified_oauth(flow, user.internal_id).await } - async fn finish_verified_oauth( + pub(super) async fn finish_verified_oauth( &self, flow: OAuthFlowRecord, user_id: i64, @@ -747,13 +771,13 @@ async fn bounded_github_json( serde_json::from_slice(&bytes).map_err(MarketError::internal) } -fn random_token(bytes: usize) -> String { +pub(super) fn random_token(bytes: usize) -> String { let mut value = vec![0_u8; bytes]; OsRng.fill_bytes(&mut value); URL_SAFE_NO_PAD.encode(value) } -fn safe_return_to(value: &str) -> String { +pub(super) fn safe_return_to(value: &str) -> String { const FALLBACK: &str = "/miniapp/"; if value.len() > 2_048 || !value.starts_with('/') diff --git a/src/crates/services/miniapp-market-service/src/auth_admission.rs b/src/crates/services/miniapp-market-service/src/auth_admission.rs index 74b9036069..c95233c184 100644 --- a/src/crates/services/miniapp-market-service/src/auth_admission.rs +++ b/src/crates/services/miniapp-market-service/src/auth_admission.rs @@ -59,7 +59,13 @@ pub(crate) async fn admit(mut request: Request, next: Next) -> Response { } if matches!( path, - "/auth/github/start" | "/auth/desktop/start" | "/auth/github/callback" + "/auth/github/start" + | "/auth/desktop/start" + | "/auth/github/callback" + | "/auth/login/start" + | "/auth/login/github" + | "/auth/email/send" + | "/auth/email/verify" ) { static RATE: OnceLock> = OnceLock::new(); let allowed = RATE diff --git a/src/crates/services/miniapp-market-service/src/db.rs b/src/crates/services/miniapp-market-service/src/db.rs index b5d6b9cf67..12eea8425a 100644 --- a/src/crates/services/miniapp-market-service/src/db.rs +++ b/src/crates/services/miniapp-market-service/src/db.rs @@ -69,11 +69,50 @@ impl Database { .await?; transaction.commit().await?; } + let applied: Option<(i64,)> = + sqlx::query_as("SELECT version FROM schema_migrations WHERE version = 2") + .fetch_optional(&self.pool) + .await?; + if applied.is_none() { + use sqlx::Acquire; + let mut connection = self.pool.acquire().await?; + // This connection must never return to the pool with FK checks disabled. + connection.close_on_drop(); + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut *connection) + .await?; + let mut transaction = connection.begin().await?; + sqlx::raw_sql(include_str!("../migrations/0002_email_identity.sql")) + .execute(&mut *transaction) + .await?; + let violations = sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&mut *transaction) + .await?; + anyhow::ensure!( + violations.is_empty(), + "Identity migration violated foreign keys" + ); + sqlx::query("INSERT INTO schema_migrations(version, applied_at) VALUES(2, ?)") + .bind(Utc::now().timestamp()) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } Ok(()) } pub(crate) async fn cleanup_expired_auth(&self) -> anyhow::Result<()> { let now = Utc::now().timestamp(); + sqlx::query("DELETE FROM email_browser_grants WHERE expires_at <= ?") + .bind(now) + .execute(&self.pool) + .await?; + // Keep one day of send history to enforce persistent address/global quotas. + sqlx::query("DELETE FROM email_challenges WHERE created_at <= ?") + .bind(now - 86400) + .execute(&self.pool) + .await?; + sqlx::query("DELETE FROM login_flows WHERE expires_at <= ? AND NOT EXISTS (SELECT 1 FROM email_challenges c WHERE c.ticket_hash = login_flows.ticket_hash)").bind(now - 86400).execute(&self.pool).await?; sqlx::query("DELETE FROM web_sessions WHERE expires_at <= ?") .bind(now) .execute(&self.pool) @@ -93,7 +132,7 @@ impl Database { .await?; // Keep a bounded grace period for clients polling an expired sign-in. // These are transient authorization transactions, never product records. - sqlx::query("DELETE FROM desktop_auth_transactions WHERE expires_at <= ?") + sqlx::query("DELETE FROM desktop_auth_transactions WHERE expires_at <= ? AND NOT EXISTS (SELECT 1 FROM login_flows f WHERE f.transaction_id = desktop_auth_transactions.id)") .bind(now - 3600) .execute(&self.pool) .await?; @@ -262,7 +301,11 @@ fn user_from_row(row: sqlx::sqlite::SqliteRow) -> AuthenticatedUser { AuthenticatedUser { internal_id: row.get("id"), profile: MarketUserSummary { - github_id: row.get("github_id"), + account_id: Some(match row.get::, _>("github_id") { + Some(id) => id.to_string(), + None => format!("email-{}", row.get::("id")), + }), + github_id: row.get::, _>("github_id").unwrap_or_default(), login: row.get("login"), avatar_url: row.get("avatar_url"), }, @@ -272,3 +315,62 @@ fn user_from_row(row: sqlx::sqlite::SqliteRow) -> AuthenticatedUser { pub(crate) fn token_hash(token: &str) -> String { hex::encode(Sha256::digest(token.as_bytes())) } + +#[cfg(test)] +mod email_migration_tests { + use super::*; + #[tokio::test] + async fn legacy_database_preserves_users_sessions_and_foreign_keys_on_repeated_startup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("legacy.sqlite"); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with( + SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true), + ) + .await + .unwrap(); + sqlx::raw_sql(INITIAL_MIGRATION) + .execute(&pool) + .await + .unwrap(); + sqlx::raw_sql("CREATE TABLE schema_migrations(version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL); INSERT INTO schema_migrations VALUES(1, 0); INSERT INTO users VALUES(7, 42, 'legacy-user', '', 1, 1);").execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO web_sessions VALUES(?, 7, ?, ?, 1)") + .bind(token_hash("legacy-session")) + .bind(token_hash("csrf")) + .bind(Utc::now().timestamp() + 600) + .execute(&pool) + .await + .unwrap(); + pool.close().await; + for _ in 0..2 { + let db = Database::open(&path).await.unwrap(); + let (user, _, _) = db + .web_session_user("legacy-session") + .await + .unwrap() + .unwrap(); + assert_eq!(user.internal_id, 7); + assert_eq!(user.profile.identity_id().as_deref(), Some("42")); + assert!(sqlx::query("PRAGMA foreign_key_check") + .fetch_all(db.pool()) + .await + .unwrap() + .is_empty()); + assert!(db + .create_web_session(999, "invalid", "csrf", Utc::now().timestamp() + 600) + .await + .is_err()); + assert_eq!( + db.upsert_github_user(42, "legacy-user", "") + .await + .unwrap() + .internal_id, + 7 + ); + db.pool.close().await; + } + } +} diff --git a/src/crates/services/miniapp-market-service/src/email_auth.rs b/src/crates/services/miniapp-market-service/src/email_auth.rs new file mode 100644 index 0000000000..bd0c337ea6 --- /dev/null +++ b/src/crates/services/miniapp-market-service/src/email_auth.rs @@ -0,0 +1,702 @@ +//! Passwordless identity. Codes are bound to one browser/device authorization, +//! persisted as keyed digests, and consumed atomically with account creation. +use crate::{ + auth::{random_token, safe_return_to, AuthService, CompletedOAuth, OAuthFlowRecord}, + db::token_hash, + error::{MarketError, MarketResult}, +}; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use lettre::{ + message::Mailbox, transport::smtp::authentication::Credentials, AsyncSmtpTransport, + AsyncTransport, Message, Tokio1Executor, +}; +use rand::{rngs::OsRng, Rng}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use sqlx::Row; + +#[derive(Clone)] +pub(crate) struct Mailer { + transport: AsyncSmtpTransport, + from: Mailbox, +} +impl std::fmt::Debug for Mailer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Mailer()") + } +} +impl Mailer { + pub(crate) fn from_env() -> MarketResult> { + let get = |key| std::env::var(key).ok().filter(|s| !s.is_empty()); + let Some(password) = get("SMTP_PASSWORD") else { + return Ok(None); + }; + let username = get("SMTP_USERNAME") + .ok_or_else(|| MarketError::internal("SMTP_USERNAME is required"))?; + let host = get("SMTP_HOST").unwrap_or_else(|| "smtp.qiye.aliyun.com".into()); + let security = get("SMTP_SECURITY").unwrap_or_else(|| "ssl".into()); + let builder = match security.as_str() { + "ssl" => AsyncSmtpTransport::::relay(&host), + "starttls" => AsyncSmtpTransport::::starttls_relay(&host), + _ => { + return Err(MarketError::internal( + "SMTP_SECURITY must be ssl or starttls", + )) + } + } + .map_err(|_| MarketError::internal("Invalid SMTP TLS configuration"))?; + let port = get("SMTP_PORT") + .unwrap_or_else(|| if security == "ssl" { "465" } else { "587" }.into()) + .parse::() + .map_err(|_| MarketError::internal("Invalid SMTP_PORT"))?; + let from = Mailbox::new( + Some(get("SMTP_FROM_NAME").unwrap_or_else(|| "OpenBitFun".into())), + username + .parse() + .map_err(|_| MarketError::internal("Invalid SMTP_USERNAME"))?, + ); + Ok(Some(Self { + transport: builder + .port(port) + .timeout(Some(std::time::Duration::from_secs(15))) + .credentials(Credentials::new(username, password)) + .build(), + from, + })) + } + async fn send(&self, email: &str, code: &str) -> MarketResult<()> { + let message = Message::builder().from(self.from.clone()).to(email.parse().map_err(|_| invalid_email())?) + .subject("OpenBitFun sign-in verification code") + .body(format!("Your OpenBitFun verification code is: {code}\n\nIt expires in 10 minutes and can only be used once. Do not share this code.\nIf you did not request it, ignore this email.\n\nOpenBitFun")) + .map_err(|_| MarketError::internal("Could not compose verification email"))?; + tokio::time::timeout( + std::time::Duration::from_secs(20), + self.transport.send(message), + ) + .await + .map_err(|_| delivery_error())? + .map_err(|_| delivery_error())?; + Ok(()) + } +} +fn delivery_error() -> MarketError { + MarketError::service_unavailable( + "email_delivery_failed", + "Could not send the verification email. Please try again later.", + ) +} +fn invalid_email() -> MarketError { + MarketError::bad_request("invalid_email", "Enter a valid email address.") +} +fn invalid_code() -> MarketError { + MarketError::bad_request( + "invalid_email_code", + "The verification code is incorrect, expired, or already used.", + ) +} +fn rate_limit() -> MarketError { + MarketError::new( + axum::http::StatusCode::TOO_MANY_REQUESTS, + "email_rate_limit", + "Too many verification requests. Please try again later.", + ) +} +fn normalize_email(value: &str) -> MarketResult { + let email = value.trim().to_ascii_lowercase(); + if email.len() > 254 + || !email.is_ascii() + || email + .bytes() + .any(|b| b.is_ascii_whitespace() || b.is_ascii_control()) + || !email.contains('@') + || email.parse::().is_err() + { + return Err(invalid_email()); + } + Ok(email) +} +fn code_digest(secret: &str, id: &str, code: &str) -> String { + let mut mac = + Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC accepts arbitrary keys"); + mac.update(id.as_bytes()); + mac.update(b":"); + mac.update(code.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LoginRequest { + pub ticket: String, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EmailSendRequest { + pub ticket: String, + pub email: String, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EmailVerifyRequest { + pub ticket: String, + pub challenge_id: String, + pub code: String, +} +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EmailSent { + pub challenge_id: String, + pub retry_after_seconds: u32, +} + +impl AuthService { + pub(crate) async fn create_login_flow( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + transaction_id: Option<&str>, + return_to: &str, + ) -> MarketResult { + let ticket = random_token(32); + let now = Utc::now().timestamp(); + let inserted = sqlx::query("INSERT INTO login_flows(ticket_hash, transaction_id, return_to, expires_at) SELECT ?, ?, ?, ? WHERE (SELECT COUNT(*) FROM login_flows WHERE expires_at > ?) < 8192") + .bind(token_hash(&ticket)).bind(transaction_id).bind(safe_return_to(return_to)).bind(now + 600).bind(now).execute(&mut **tx).await.map_err(MarketError::internal)?; + if inserted.rows_affected() != 1 { + return Err(rate_limit()); + } + Ok(ticket) + } + pub(crate) async fn start_web_login(&self, return_to: &str) -> MarketResult { + let mut tx = self + .db + .pool() + .begin() + .await + .map_err(MarketError::internal)?; + let ticket = self.create_login_flow(&mut tx, None, return_to).await?; + tx.commit().await.map_err(MarketError::internal)?; + Ok(ticket) + } + async fn login_flow(&self, ticket: &str) -> MarketResult { + let row = sqlx::query("SELECT transaction_id, return_to FROM login_flows WHERE ticket_hash = ? AND expires_at > ? AND consumed_at IS NULL") + .bind(token_hash(ticket)).bind(Utc::now().timestamp()).fetch_optional(self.db.pool()).await.map_err(MarketError::internal)?.ok_or_else(invalid_code)?; + let transaction_id: Option = row.get("transaction_id"); + Ok(OAuthFlowRecord { + flow_kind: if transaction_id.is_some() { + "desktop" + } else { + "web" + } + .into(), + transaction_id, + return_to: row.get("return_to"), + code_verifier: String::new(), + }) + } + pub(crate) async fn login_github(&self, ticket: &str) -> MarketResult { + let flow = self.login_flow(ticket).await?; + self.create_oauth_flow( + &flow.flow_kind, + flow.transaction_id.as_deref(), + &flow.return_to, + ) + .await + } + pub(crate) async fn send_email_code( + &self, + request: EmailSendRequest, + ) -> MarketResult { + let mailer = self.mailer.as_ref().ok_or_else(|| { + MarketError::service_unavailable( + "email_not_configured", + "Email sign-in is unavailable on this server.", + ) + })?; + let (sent, code) = self.prepare_email_code(request).await?; + // Failure consumes the code; quota remains charged even on delivery failure. + if let Err(error) = mailer.send(&sent.1, &code).await { + sqlx::query("UPDATE email_challenges SET consumed_at = ? WHERE id = ?") + .bind(Utc::now().timestamp()) + .bind(&sent.0.challenge_id) + .execute(self.db.pool()) + .await + .map_err(MarketError::internal)?; + return Err(error); + } + Ok(sent.0) + } + async fn prepare_email_code( + &self, + request: EmailSendRequest, + ) -> MarketResult<((EmailSent, String), String)> { + let email = normalize_email(&request.email)?; + self.login_flow(&request.ticket).await?; + let now = Utc::now().timestamp(); + let id = random_token(24); + let code = format!("{:06}", OsRng.gen_range(0..1_000_000u32)); + let mut tx = self + .db + .pool() + .begin() + .await + .map_err(MarketError::internal)?; + // INSERT is the first statement: serialize competing requests before quota evaluation. + let inserted = sqlx::query("INSERT INTO email_challenges(id, ticket_hash, email, code_hash, created_at, expires_at) SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM email_challenges WHERE email = ? AND created_at > ?) AND (SELECT COUNT(*) FROM email_challenges WHERE email = ? AND created_at > ?) < 10 AND (SELECT COUNT(*) FROM email_challenges WHERE created_at > ?) < 30 AND (SELECT COUNT(*) FROM email_challenges WHERE created_at > ?) < 1000") + .bind(&id).bind(token_hash(&request.ticket)).bind(&email).bind(code_digest(&self.config.session_secret, &id, &code)).bind(now).bind(now + 600) + .bind(&email).bind(now - 60).bind(&email).bind(now - 86400).bind(now - 60).bind(now - 86400).execute(&mut *tx).await.map_err(MarketError::internal)?; + if inserted.rows_affected() != 1 { + return Err(rate_limit()); + } + sqlx::query("UPDATE email_challenges SET consumed_at = ? WHERE ticket_hash = ? AND id != ? AND consumed_at IS NULL").bind(now).bind(token_hash(&request.ticket)).bind(&id).execute(&mut *tx).await.map_err(MarketError::internal)?; + tx.commit().await.map_err(MarketError::internal)?; + Ok(( + ( + EmailSent { + challenge_id: id, + retry_after_seconds: 60, + }, + email, + ), + code, + )) + } + pub(crate) async fn verify_email_code( + &self, + request: EmailVerifyRequest, + ) -> MarketResult { + if request.code.len() != 6 || !request.code.bytes().all(|b| b.is_ascii_digit()) { + return Err(invalid_code()); + } + let flow = self.login_flow(&request.ticket).await?; + let now = Utc::now().timestamp(); + let mut tx = self + .db + .pool() + .begin() + .await + .map_err(MarketError::internal)?; + let row = sqlx::query("UPDATE email_challenges SET attempts = attempts + 1 WHERE id = ? AND ticket_hash = ? AND expires_at > ? AND consumed_at IS NULL AND attempts < 5 RETURNING email, code_hash") + .bind(&request.challenge_id).bind(token_hash(&request.ticket)).bind(now).fetch_optional(&mut *tx).await.map_err(MarketError::internal)?; + let Some(row) = row else { + return Err(invalid_code()); + }; + let expected: String = row.get("code_hash"); + let digest = code_digest( + &self.config.session_secret, + &request.challenge_id, + &request.code, + ); + if expected.len() != digest.len() + || expected + .as_bytes() + .iter() + .zip(digest.as_bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + != 0 + { + tx.commit().await.map_err(MarketError::internal)?; + return Err(invalid_code()); + } + let consumed = sqlx::query("UPDATE login_flows SET consumed_at = ? WHERE ticket_hash = ? AND consumed_at IS NULL AND expires_at > ?").bind(now).bind(token_hash(&request.ticket)).bind(now).execute(&mut *tx).await.map_err(MarketError::internal)?; + if consumed.rows_affected() != 1 { + return Err(invalid_code()); + } + sqlx::query("UPDATE email_challenges SET consumed_at = ? WHERE id = ?") + .bind(now) + .bind(&request.challenge_id) + .execute(&mut *tx) + .await + .map_err(MarketError::internal)?; + let email: String = row.get("email"); + let existing: Option<(i64,)> = + sqlx::query_as("SELECT user_id FROM email_identities WHERE email = ?") + .bind(&email) + .fetch_optional(&mut *tx) + .await + .map_err(MarketError::internal)?; + let user_id = if let Some((id,)) = existing { + id + } else { + // Never auto-link to GitHub by an unverified/public profile email. + let user = sqlx::query("INSERT INTO users(github_id, login, avatar_url, created_at, updated_at) VALUES(NULL, ?, '', ?, ?)").bind(format!("user-{}", uuid::Uuid::new_v4().simple())).bind(now).bind(now).execute(&mut *tx).await.map_err(MarketError::internal)?; + let id = user.last_insert_rowid(); + sqlx::query( + "INSERT INTO email_identities(email, user_id, verified_at) VALUES(?, ?, ?)", + ) + .bind(&email) + .bind(id) + .bind(now) + .execute(&mut *tx) + .await + .map_err(MarketError::internal)?; + id + }; + tx.commit().await.map_err(MarketError::internal)?; + self.finish_verified_oauth(flow, user_id).await + } +} + +impl AuthService { + pub(crate) async fn email_browser_redirect( + &self, + completed: CompletedOAuth, + ) -> MarketResult { + let (session_token, return_to) = match completed { + CompletedOAuth::Web { + session_token, + return_to, + .. + } => (session_token, return_to), + CompletedOAuth::Desktop { session_token, .. } => { + (session_token, "https://auth.openbitfun.com/complete".into()) + } + }; + let grant = random_token(32); + let now = Utc::now().timestamp(); + let mut tx = self + .db + .pool() + .begin() + .await + .map_err(MarketError::internal)?; + let inserted = sqlx::query("INSERT INTO email_browser_grants(grant_hash, user_id, return_to, expires_at) SELECT ?, user_id, ?, ? FROM web_sessions WHERE token_hash = ? AND expires_at > ?") + .bind(token_hash(&grant)).bind(return_to).bind(now + 60).bind(token_hash(&session_token)).bind(now).execute(&mut *tx).await.map_err(MarketError::internal)?; + if inserted.rows_affected() != 1 { + return Err(invalid_code()); + } + sqlx::query("DELETE FROM web_sessions WHERE token_hash = ?") + .bind(token_hash(&session_token)) + .execute(&mut *tx) + .await + .map_err(MarketError::internal)?; + tx.commit().await.map_err(MarketError::internal)?; + Ok(format!( + "{}/api/v1/auth/email/complete?grant={grant}", + self.config.public_base_url + )) + } + pub(crate) async fn complete_email_browser(&self, grant: &str) -> MarketResult { + let row = sqlx::query("DELETE FROM email_browser_grants WHERE grant_hash = ? AND expires_at > ? RETURNING user_id, return_to") + .bind(token_hash(grant)).bind(Utc::now().timestamp()).fetch_optional(self.db.pool()).await.map_err(MarketError::internal)?.ok_or_else(invalid_code)?; + self.finish_verified_oauth( + OAuthFlowRecord { + flow_kind: "web".into(), + transaction_id: None, + code_verifier: String::new(), + return_to: row.get("return_to"), + }, + row.get("user_id"), + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{auth::DesktopAuthPollRequest, config::MarketConfig, db::Database}; + async fn setup() -> (tempfile::TempDir, AuthService) { + let dir = tempfile::tempdir().unwrap(); + let config = MarketConfig { + bind: "127.0.0.1:0".parse().unwrap(), + public_base_url: "https://market.openbitfun.com/miniapp".into(), + database_path: dir.path().join("db"), + artifact_dir: dir.path().join("artifacts"), + web_dir: dir.path().into(), + github_callback_url: None, + github_client_id: Some("id".into()), + github_client_secret: Some("secret".into()), + session_secret: "test-only-session-secret-at-least-24".into(), + admin_github_ids: [42, 0].into_iter().collect(), + public_browse: true, + web_submissions_enabled: false, + }; + let db = Database::open(&config.database_path).await.unwrap(); + (dir, AuthService::new(config, db).unwrap()) + } + async fn code(service: &AuthService, ticket: &str, email: &str) -> (String, String) { + let ((sent, _), code) = service + .prepare_email_code(EmailSendRequest { + ticket: ticket.into(), + email: email.into(), + }) + .await + .unwrap(); + (sent.challenge_id, code) + } + fn verify(ticket: &str, id: &str, code: &str) -> EmailVerifyRequest { + EmailVerifyRequest { + ticket: ticket.into(), + challenge_id: id.into(), + code: code.into(), + } + } + #[tokio::test] + async fn email_is_independent_one_use_and_preserves_device_token_protocol() { + let (_dir, service) = setup().await; + let github = service + .db + .upsert_github_user(42, "alice", "") + .await + .unwrap(); + let start = service.start_desktop_login(true).await.unwrap(); + let ticket = start.authorization_url.split("#ticket=").nth(1).unwrap(); + let (id, code) = code(&service, ticket, "Alice@Example.com").await; + let stored: (String,) = + sqlx::query_as("SELECT code_hash FROM email_challenges WHERE id = ?") + .bind(&id) + .fetch_one(service.db.pool()) + .await + .unwrap(); + assert_ne!(stored.0, code); + assert_eq!(stored.0.len(), 64); + let completed = service + .verify_email_code(verify(ticket, &id, &code)) + .await + .unwrap(); + assert!(service + .verify_email_code(verify(ticket, &id, &code)) + .await + .is_err()); + let redirect = service.email_browser_redirect(completed).await.unwrap(); + let grant = url::Url::parse(&redirect) + .unwrap() + .query_pairs() + .find(|(k, _)| k == "grant") + .unwrap() + .1 + .into_owned(); + let browser = service.complete_email_browser(&grant).await.unwrap(); + assert!(service.complete_email_browser(&grant).await.is_err()); + let CompletedOAuth::Web { session_token, .. } = browser else { + panic!("Expected browser session") + }; + let (user, _, _) = service + .db + .web_session_user(&session_token) + .await + .unwrap() + .unwrap(); + assert_ne!(github.internal_id, user.internal_id); + assert_eq!(user.profile.github_id, 0); + assert!(user.profile.identity_id().unwrap().starts_with("email-")); + assert!(!service.is_admin(&user)); + let poll = service + .poll_desktop(DesktopAuthPollRequest { + transaction_id: start.transaction_id.clone(), + transaction_secret: start.transaction_secret.clone(), + }) + .await + .unwrap(); + assert_eq!(poll.status, "authorized"); + let tokens = poll.tokens.unwrap(); + let (token_user, _) = service + .db + .api_token_user(&tokens.access_token, "access") + .await + .unwrap() + .unwrap(); + assert_eq!(token_user.internal_id, user.internal_id); + assert!(service + .poll_desktop(DesktopAuthPollRequest { + transaction_id: start.transaction_id, + transaction_secret: start.transaction_secret + }) + .await + .unwrap() + .tokens + .is_none()); + } + #[tokio::test] + async fn wrong_codes_exhaust_budget_and_cannot_cross_flows() { + let (_dir, service) = setup().await; + let a = service.start_web_login("/miniapp/").await.unwrap(); + let b = service.start_web_login("/skin/").await.unwrap(); + let (id, code) = code(&service, &a, "alice@example.com").await; + assert!(service + .verify_email_code(verify(&b, &id, &code)) + .await + .is_err()); + let wrong = if code == "000000" { "111111" } else { "000000" }; + for _ in 0..5 { + assert!(service + .verify_email_code(verify(&a, &id, wrong)) + .await + .is_err()); + } + assert!(service + .verify_email_code(verify(&a, &id, &code)) + .await + .is_err()); + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(service.db.pool()) + .await + .unwrap(); + assert_eq!(count.0, 0); + } + #[tokio::test] + async fn rate_limits_persist_and_expired_codes_fail() { + let (dir, service) = setup().await; + let a = service.start_web_login("/miniapp/").await.unwrap(); + let (id, code) = code(&service, &a, "alice@example.com").await; + let reopened = AuthService::new( + service.config.clone(), + Database::open(&dir.path().join("db")).await.unwrap(), + ) + .unwrap(); + assert_eq!( + reopened + .prepare_email_code(EmailSendRequest { + ticket: a.clone(), + email: "ALICE@example.com".into() + }) + .await + .unwrap_err() + .code, + "email_rate_limit" + ); + sqlx::query("UPDATE email_challenges SET expires_at = 0 WHERE id = ?") + .bind(&id) + .execute(service.db.pool()) + .await + .unwrap(); + assert!(service + .verify_email_code(verify(&a, &id, &code)) + .await + .is_err()); + } + #[tokio::test] + async fn concurrent_code_consumption_has_one_winner_and_relogin_reuses_email_account() { + let (_dir, service) = setup().await; + let ticket = service.start_web_login("/miniapp/").await.unwrap(); + let (id, code) = code(&service, &ticket, "alice@example.com").await; + let (a, b) = tokio::join!( + service.verify_email_code(verify(&ticket, &id, &code)), + service.verify_email_code(verify(&ticket, &id, &code)) + ); + assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1); + sqlx::query("UPDATE email_challenges SET created_at = created_at - 61") + .execute(service.db.pool()) + .await + .unwrap(); + let ticket = service.start_web_login("/miniapp/").await.unwrap(); + let (id, code) = self::code(&service, &ticket, "Alice@example.com").await; + service + .verify_email_code(verify(&ticket, &id, &code)) + .await + .unwrap(); + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(service.db.pool()) + .await + .unwrap(); + assert_eq!(count.0, 1); + } + #[tokio::test] + async fn http_routes_preserve_legacy_start_and_set_scoped_browser_cookies() { + use axum::{ + body::{to_bytes, Body}, + http::{header, Request, StatusCode}, + }; + use std::sync::Arc; + use tower::ServiceExt; + let (_dir, mut auth) = setup().await; + auth.mailer = None; + let state = Arc::new(crate::routes::MarketState { + config: auth.config.clone(), + db: auth.db.clone(), + artifacts: crate::artifacts::ArtifactStore::open(auth.config.artifact_dir.clone()) + .await + .unwrap(), + auth: auth.clone(), + }); + let app = crate::routes::api_router(state); + for (path, expected) in [ + ( + "/auth/desktop/start", + "https://github.com/login/oauth/authorize?", + ), + ( + "/auth/desktop/start?methods=all", + "https://auth.openbitfun.com/sign-in#ticket=", + ), + ] { + let response = app + .clone() + .oneshot(Request::post(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let value: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), 16384).await.unwrap()) + .unwrap(); + assert!(value["authorizationUrl"] + .as_str() + .unwrap() + .starts_with(expected)); + } + let response = app + .clone() + .oneshot( + Request::post("/auth/email/send") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"ticket":"missing","email":"alice@example.com"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let ticket = auth.start_web_login("/skin/submissions").await.unwrap(); + let (id, code) = code(&auth, &ticket, "alice@example.com").await; + let completed = auth + .verify_email_code(verify(&ticket, &id, &code)) + .await + .unwrap(); + let target = + url::Url::parse(&auth.email_browser_redirect(completed).await.unwrap()).unwrap(); + let response = app + .oneshot( + Request::get(format!("/auth/email/complete?{}", target.query().unwrap())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(response.status().is_redirection()); + assert_eq!(response.headers()[header::LOCATION], "/skin/submissions"); + let cookies: Vec<_> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!(cookies.len(), 4); + assert!(cookies.iter().all(|v| !v.contains("Domain="))); + assert!(cookies.iter().any(|v| v.contains("Path=/skin"))); + assert!(cookies.iter().any(|v| v.contains("Path=/miniapp"))); + } + + #[test] + fn address_validation_and_keyed_hash() { + for address in [ + "a@example.com\r\nBcc: x@example.com", + "", + "a b@example.com", + "Name ", + ] { + assert!(normalize_email(address).is_err()); + } + assert_eq!( + normalize_email(" Alice@Example.com ").unwrap(), + "alice@example.com" + ); + assert_ne!( + code_digest("key", "a", "123456"), + code_digest("other", "a", "123456") + ); + assert_ne!( + code_digest("key", "a", "123456"), + code_digest("key", "b", "123456") + ); + } +} diff --git a/src/crates/services/miniapp-market-service/src/lib.rs b/src/crates/services/miniapp-market-service/src/lib.rs index 024201faa4..cc90be7f56 100644 --- a/src/crates/services/miniapp-market-service/src/lib.rs +++ b/src/crates/services/miniapp-market-service/src/lib.rs @@ -5,6 +5,7 @@ mod auth; mod auth_admission; pub mod config; mod db; +mod email_auth; mod error; mod package; mod request_id; diff --git a/src/crates/services/miniapp-market-service/src/routes.rs b/src/crates/services/miniapp-market-service/src/routes.rs index 792a52f57c..b8a6706c32 100644 --- a/src/crates/services/miniapp-market-service/src/routes.rs +++ b/src/crates/services/miniapp-market-service/src/routes.rs @@ -112,6 +112,7 @@ struct ModerationReason { #[serde(rename_all = "camelCase")] struct MarketConfigResponse { github_auth_configured: bool, + email_auth_configured: bool, public_browse: bool, web_submissions_enabled: bool, categories: &'static [&'static str], @@ -186,6 +187,11 @@ pub(crate) fn api_router(state: Arc) -> Router { .route("/auth/github/start", get(start_github_oauth)) .route("/auth/github/callback", get(github_oauth_callback)) .route("/auth/desktop/start", post(start_desktop_auth)) + .route("/auth/login/start", post(start_web_login)) + .route("/auth/login/github", post(login_github)) + .route("/auth/email/send", post(send_email_code)) + .route("/auth/email/verify", post(verify_email_code)) + .route("/auth/email/complete", get(complete_email_browser)) .route("/auth/desktop/poll", post(poll_desktop_auth)) .route("/auth/refresh", post(refresh_tokens)) .route("/auth/logout", post(logout)) @@ -285,6 +291,7 @@ async fn health(State(state): State>) -> impl IntoResponse { "status": if database_ready { "ok" } else { "degraded" }, "database": database_ready, "githubAuthConfigured": state.config.github_configured(), + "emailAuthConfigured": state.auth.mailer.is_some(), })), ) } @@ -292,6 +299,7 @@ async fn health(State(state): State>) -> impl IntoResponse { async fn config(State(state): State>) -> Json { Json(MarketConfigResponse { github_auth_configured: state.config.github_configured(), + email_auth_configured: state.auth.mailer.is_some(), public_browse: state.config.public_browse, web_submissions_enabled: state.config.web_submissions_enabled, categories: MARKET_CATEGORIES, @@ -324,7 +332,7 @@ async fn list_listings( "SELECT l.id AS listing_id, l.slug, r.id AS release_id, r.release_number, r.metadata_json, r.package_sha256, r.package_size, r.review_bundle_hash, r.published_at, - u.github_id, u.login, u.avatar_url, + u.id AS owner_identity_id, u.github_id, u.login, u.avatar_url, COALESCE((SELECT AVG(value) FROM ratings WHERE listing_id = l.id), 0.0) AS rating_average, (SELECT COUNT(*) FROM ratings WHERE listing_id = l.id) AS rating_count, (SELECT COUNT(*) FROM favorites WHERE listing_id = l.id) AS favorite_count, @@ -540,14 +548,30 @@ async fn github_oauth_callback( let oauth_state = query.state.ok_or_else(|| { MarketError::bad_request("missing_oauth_state", "GitHub did not return state.") })?; - match state.auth.complete_oauth(&code, &oauth_state).await? { + complete_login_response( + &state, + state.auth.complete_oauth(&code, &oauth_state).await?, + false, + ) +} + +fn complete_login_response( + state: &MarketState, + completed: CompletedOAuth, + json: bool, +) -> MarketResult { + match completed { CompletedOAuth::Web { return_to, session_token, csrf_token, expires_at, } => { - let mut response = Redirect::to(&return_to).into_response(); + let mut response = if json { + Json(serde_json::json!({"redirectUrl": return_to})).into_response() + } else { + Redirect::to(&return_to).into_response() + }; state.auth.append_web_session_cookies( response.headers_mut(), &session_token, @@ -561,7 +585,12 @@ async fn github_oauth_callback( csrf_token, expires_at, } => { - let mut response = Redirect::to("https://auth.openbitfun.com/complete").into_response(); + let mut response = if json { + Json(serde_json::json!({"redirectUrl": "https://auth.openbitfun.com/complete"})) + .into_response() + } else { + Redirect::to("https://auth.openbitfun.com/complete").into_response() + }; state.auth.append_web_session_cookies( response.headers_mut(), &session_token, @@ -573,10 +602,21 @@ async fn github_oauth_callback( } } +#[derive(Default, Deserialize)] +struct LoginMethods { + methods: Option, +} + async fn start_desktop_auth( State(state): State>, + Query(query): Query, ) -> MarketResult> { - Ok(Json(state.auth.start_desktop_oauth().await?)) + Ok(Json( + state + .auth + .start_desktop_login(query.methods.as_deref() == Some("all")) + .await?, + )) } async fn poll_desktop_auth( @@ -1284,7 +1324,12 @@ async fn summary_from_row( category: metadata.category, tags: metadata.tags, owner: MarketUserSummary { - github_id: row.get("github_id"), + account_id: Some( + row.get::, _>("github_id") + .map(|id| id.to_string()) + .unwrap_or_else(|| format!("email-{}", row.get::("owner_identity_id"))), + ), + github_id: row.get::, _>("github_id").unwrap_or_default(), login: row.get("login"), avatar_url: row.get("avatar_url"), }, @@ -1315,7 +1360,7 @@ async fn listing_detail_by_slug( "SELECT l.id AS listing_id, l.slug, r.id AS release_id, r.release_number, r.metadata_json, r.package_sha256, r.package_size, r.review_bundle_hash, r.published_at, - u.github_id, u.login, u.avatar_url, + u.id AS owner_identity_id, u.github_id, u.login, u.avatar_url, COALESCE((SELECT AVG(value) FROM ratings WHERE listing_id = l.id), 0.0) AS rating_average, (SELECT COUNT(*) FROM ratings WHERE listing_id = l.id) AS rating_count, (SELECT COUNT(*) FROM favorites WHERE listing_id = l.id) AS favorite_count, @@ -1436,7 +1481,10 @@ async fn admin_submission_from_row( row: sqlx::sqlite::SqliteRow, ) -> MarketResult { let submitter = MarketUserSummary { - github_id: row.get("submitter_github_id"), + account_id: None, + github_id: row + .get::, _>("submitter_github_id") + .unwrap_or_default(), login: row.get("submitter_login"), avatar_url: row.get("submitter_avatar_url"), }; @@ -1593,10 +1641,9 @@ async fn approve_submission( ) })?; let owner_user_id: i64 = submission.get("owner_user_id"); - let submitter_is_admin = state - .config - .admin_github_ids - .contains(&submission.get::("submitter_github_id")); + let submitter_is_admin = submission + .get::, _>("submitter_github_id") + .is_some_and(|id| id > 0 && state.config.admin_github_ids.contains(&id)); let slug: String = submission.get("slug"); let release_number: i64 = submission.get("release_number"); let metadata_json: String = submission.get("metadata_json"); @@ -2786,3 +2833,52 @@ mod tests { assert!(matches!(desktop_auth.kind, RequestAuthKind::Bearer { .. })); } } + +async fn start_web_login( + State(state): State>, + Json(query): Json, +) -> MarketResult> { + let ticket = state + .auth + .start_web_login(query.return_to.as_deref().unwrap_or("/miniapp/")) + .await?; + Ok(Json( + serde_json::json!({"ticket": ticket, "emailEnabled": state.auth.mailer.is_some(), "githubEnabled": state.config.github_configured()}), + )) +} +async fn login_github( + State(state): State>, + Json(request): Json, +) -> MarketResult> { + Ok(Json( + serde_json::json!({"authorizationUrl": state.auth.login_github(&request.ticket).await?}), + )) +} +async fn send_email_code( + State(state): State>, + Json(request): Json, +) -> MarketResult> { + Ok(Json(state.auth.send_email_code(request).await?)) +} +async fn verify_email_code( + State(state): State>, + Json(request): Json, +) -> MarketResult { + let completed = state.auth.verify_email_code(request).await?; + // Establish market host-only cookies through a short-lived, one-use grant, + // just as the GitHub callback is bridged to the market origin by Nginx. + let redirect_url = state.auth.email_browser_redirect(completed).await?; + Ok(Json(serde_json::json!({"redirectUrl": redirect_url})).into_response()) +} + +#[derive(Deserialize)] +struct EmailBrowserGrant { + grant: String, +} +async fn complete_email_browser( + State(state): State>, + Query(query): Query, +) -> MarketResult { + let completed = state.auth.complete_email_browser(&query.grant).await?; + complete_login_response(&state, completed, false) +} diff --git a/src/crates/services/relay-service/AGENTS.md b/src/crates/services/relay-service/AGENTS.md index cebd2a8753..393c74a04c 100644 --- a/src/crates/services/relay-service/AGENTS.md +++ b/src/crates/services/relay-service/AGENTS.md @@ -33,3 +33,11 @@ and embedded hosts. Run `cargo test -p openbitfun-relay-service` and `node scripts/check-core-boundaries.mjs` after changes. + +Account identity supports independent GitHub and email-code users. Preserve legacy +numeric GitHub Relay IDs; email `accountId` values occupy the `email-` namespace. +Never derive identity from the caller's claimed user ID or display name. New +clients opt into the hosted chooser with `methods=all` on the existing start route; +legacy calls keep direct GitHub URLs. Run `cargo test -p openbitfun-relay-service +--lib account_transport_tests::` for account isolation, device RPC and revocation +across both identity kinds and both route layouts. diff --git a/src/crates/services/relay-service/src/account_transport_tests.rs b/src/crates/services/relay-service/src/account_transport_tests.rs index 131a0d815d..f0c17cc32f 100644 --- a/src/crates/services/relay-service/src/account_transport_tests.rs +++ b/src/crates/services/relay-service/src/account_transport_tests.rs @@ -118,253 +118,268 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { let authority = Router::new().route( "/me", axum::routing::get(|headers: HeaderMap| async move { - let id = match headers + let (id, account_id) = match headers .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) { - Some("Bearer account-a") => 123, - Some("Bearer account-b") => 456, + Some("Bearer account-a") => (123, None), + Some("Bearer account-b") => (456, None), + Some("Bearer email-a") => (0, Some("email-123")), + Some("Bearer email-b") => (0, Some("email-456")), _ => return (StatusCode::UNAUTHORIZED, Json(json!({}))), }; ( StatusCode::OK, - Json(json!({"user":{"githubId":id,"login":format!("user-{id}")}})), + Json(json!({"user":{"githubId":id,"accountId":account_id,"login":format!("user-{}", account_id.map(str::to_owned).unwrap_or_else(|| id.to_string()))}})), ) }), ); let authority_task = tokio::spawn(async move { axum::serve(listener, authority).await.unwrap(); }); - for prefix in ["", "/v/1.0.1"] { - let db = Arc::new(db::connect(":memory:").await.unwrap()); - let shared = build_relay_router( - Arc::new(MemoryAssetStore::new()), - Instant::now(), - db, - "test", - ) - .layer(Extension( - IdentityVerifier::with_url(&authority_url).unwrap(), - )); - let app = if prefix.is_empty() { - shared - } else { - Router::new().nest(prefix, shared) - }; - let route = |path: &str| format!("{prefix}{path}"); - assert_eq!( - request(&app, "GET", &route("/api/devices"), "", json!(null)) - .await - .status(), - StatusCode::UNAUTHORIZED - ); - for retired in [ - "/api/rooms", - "/api/pair", - "/api/auth/login/challenge", - "/ws", - ] { + for email in [false, true] { + for prefix in ["", "/v/1.0.1"] { + let db = Arc::new(db::connect(":memory:").await.unwrap()); + let shared = build_relay_router( + Arc::new(MemoryAssetStore::new()), + Instant::now(), + db, + "test", + ) + .layer(Extension( + IdentityVerifier::with_url(&authority_url).unwrap(), + )); + let app = if prefix.is_empty() { + shared + } else { + Router::new().nest(prefix, shared) + }; + let route = |path: &str| format!("{prefix}{path}"); assert_eq!( - request(&app, "POST", &route(retired), "", json!({})) + request(&app, "GET", &route("/api/devices"), "", json!(null)) .await .status(), - StatusCode::NOT_FOUND + StatusCode::UNAUTHORIZED ); - } - let mut tokens = Vec::new(); - for (device, account) in [ - ("desktop", "account-a"), - ("mobile", "account-a"), - ("outsider", "account-b"), - ] { - let response = request(&app, "POST", &route("/api/auth/login"), "", json!({ - "access_token":account, "user_id":"untrusted", "device_id":device, + for retired in [ + "/api/rooms", + "/api/pair", + "/api/auth/login/challenge", + "/ws", + ] { + assert_eq!( + request(&app, "POST", &route(retired), "", json!({})) + .await + .status(), + StatusCode::NOT_FOUND + ); + } + let mut tokens = Vec::new(); + for (device, account) in [ + ("desktop", "account-a"), + ("mobile", "account-a"), + ("outsider", "account-b"), + ] { + let response = request(&app, "POST", &route("/api/auth/login"), "", json!({ + "access_token":if email { account.replace("account", "email") } else { account.into() }, "user_id":"untrusted", "device_id":device, "device_name":device, "device_kind":"desktop", "public_key":BASE64.encode([9u8;32]), "request_id":uuid::Uuid::new_v4().to_string() })).await; - assert_eq!(response.status(), StatusCode::OK); - let response = json_body(response).await; + assert_eq!(response.status(), StatusCode::OK); + let response = json_body(response).await; + assert_eq!( + response["user_id"], + if email { + if account == "account-a" { + "email-123" + } else { + "email-456" + } + } else if account == "account-a" { + "123" + } else { + "456" + } + ); + tokens.push(response["token"].as_str().unwrap().to_owned()); + } + for retired in ["/api/devices/desktop/rpc", "/api/devices/mobile/messages"] { + assert_eq!( + request(&app, "POST", &route(retired), &tokens[1], json!({})) + .await + .status(), + StatusCode::NOT_FOUND + ); + } + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server_app = app.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, server_app).await.unwrap(); + }); + let mut socket = connect_scope(address, prefix, &tokens[0], "machine-scoped").await; + event( + &mut socket, + 1, + "rpc-register", + json!({"method":"desktop:remote-command"}), + ) + .await; + assert_eq!(acknowledgement(&mut socket, 1).await["ok"], true); + let mut controller = connect_scope(address, prefix, &tokens[1], "user-scoped").await; + let mut outsider = connect_scope(address, prefix, &tokens[2], "user-scoped").await; + let devices = json_body( + request(&app, "GET", &route("/api/devices"), &tokens[1], json!(null)).await, + ) + .await; + assert!(devices + .as_array() + .unwrap() + .iter() + .any(|device| device["device_id"] == "desktop" && device["online"] == true)); + assert!(!devices + .as_array() + .unwrap() + .iter() + .any(|device| device["device_id"] == "outsider")); assert_eq!( - response["user_id"], - if account == "account-a" { "123" } else { "456" } + request( + &app, + "GET", + &route("/api/devices/desktop/key"), + &tokens[1], + json!(null) + ) + .await + .status(), + StatusCode::OK ); - tokens.push(response["token"].as_str().unwrap().to_owned()); - } - for retired in ["/api/devices/desktop/rpc", "/api/devices/mobile/messages"] { assert_eq!( - request(&app, "POST", &route(retired), &tokens[1], json!({})) - .await - .status(), + request( + &app, + "GET", + &route("/api/devices/desktop/key"), + &tokens[2], + json!(null) + ) + .await + .status(), StatusCode::NOT_FOUND ); - } - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let server_app = app.clone(); - let server = tokio::spawn(async move { - axum::serve(listener, server_app).await.unwrap(); - }); - let mut socket = connect_scope(address, prefix, &tokens[0], "machine-scoped").await; - event( - &mut socket, - 1, - "rpc-register", - json!({"method":"desktop:remote-command"}), - ) - .await; - assert_eq!(acknowledgement(&mut socket, 1).await["ok"], true); - let mut controller = connect_scope(address, prefix, &tokens[1], "user-scoped").await; - let mut outsider = connect_scope(address, prefix, &tokens[2], "user-scoped").await; - let devices = - json_body(request(&app, "GET", &route("/api/devices"), &tokens[1], json!(null)).await) - .await; - assert!(devices - .as_array() - .unwrap() - .iter() - .any(|device| device["device_id"] == "desktop" && device["online"] == true)); - assert!(!devices - .as_array() - .unwrap() - .iter() - .any(|device| device["device_id"] == "outsider")); - assert_eq!( - request( - &app, - "GET", - &route("/api/devices/desktop/key"), - &tokens[1], - json!(null) - ) - .await - .status(), - StatusCode::OK - ); - assert_eq!( - request( - &app, - "GET", - &route("/api/devices/desktop/key"), - &tokens[2], - json!(null) - ) - .await - .status(), - StatusCode::NOT_FOUND - ); - event(&mut outsider,2,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ=="},"timeoutMs":200})).await; - assert_eq!( - acknowledgement(&mut outsider, 2).await["ok"], - false, - "cross-account RPC must not reach the host" - ); - event(&mut controller,3,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ==","nonce":"bg=="},"timeoutMs":3000})).await; - let (ack_id, incoming) = loop { - let message = packet(&mut socket).await; - if let Some(rest) = message.strip_prefix("42") { - let index = rest.find('[').unwrap(); - let data: Value = serde_json::from_str(&rest[index..]).unwrap(); - if data[0] == "rpc-request" { - break (rest[..index].to_string(), data[1].clone()); + event(&mut outsider,2,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ=="},"timeoutMs":200})).await; + assert_eq!( + acknowledgement(&mut outsider, 2).await["ok"], + false, + "cross-account RPC must not reach the host" + ); + event(&mut controller,3,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ==","nonce":"bg=="},"timeoutMs":3000})).await; + let (ack_id, incoming) = loop { + let message = packet(&mut socket).await; + if let Some(rest) = message.strip_prefix("42") { + let index = rest.find('[').unwrap(); + let data: Value = serde_json::from_str(&rest[index..]).unwrap(); + if data[0] == "rpc-request" { + break (rest[..index].to_string(), data[1].clone()); + } } - } - }; - assert_eq!(incoming["sourceDeviceId"], "mobile"); - assert_eq!(incoming["params"]["encryptedData"], "YQ=="); - // ACK identity belongs to the target socket. A same-account caller - // or another account cannot spoof the target's response correlation. - let forged = format!("43{ack_id}{}", json!([{"forged":true}])); - outsider - .send(Message::Text(forged.clone().into())) - .await - .unwrap(); - controller.send(Message::Text(forged.into())).await.unwrap(); - assert!(tokio::time::timeout( - Duration::from_millis(50), - acknowledgement(&mut controller, 3) - ) - .await - .is_err()); - let payload = BASE64.encode(vec![7u8; 32 * 1024]); - socket - .send(Message::Text( - format!( - "43{ack_id}{}", - json!([{ "encryptedData":payload,"nonce":"bg=="}]) - ) - .into(), - )) - .await - .unwrap(); - let response = acknowledgement(&mut controller, 3).await; - assert_eq!(response["ok"], true); - assert_eq!(response["result"]["encryptedData"], payload); - assert_eq!( - request( - &app, - "DELETE", - &route("/api/devices/desktop"), - &tokens[1], - json!(null) + }; + assert_eq!(incoming["sourceDeviceId"], "mobile"); + assert_eq!(incoming["params"]["encryptedData"], "YQ=="); + // ACK identity belongs to the target socket. A same-account caller + // or another account cannot spoof the target's response correlation. + let forged = format!("43{ack_id}{}", json!([{"forged":true}])); + outsider + .send(Message::Text(forged.clone().into())) + .await + .unwrap(); + controller.send(Message::Text(forged.into())).await.unwrap(); + assert!(tokio::time::timeout( + Duration::from_millis(50), + acknowledgement(&mut controller, 3) ) .await - .status(), - StatusCode::NO_CONTENT - ); - loop { - let update = packet(&mut controller).await; - if let Some(data) = update.strip_prefix("42") { - let value: Value = serde_json::from_str(data).unwrap(); - if value[0] == "ephemeral" - && value[1]["type"] == "device-presence" - && !value[1]["devices"] - .as_array() - .unwrap() - .iter() - .any(|entry| entry["device_id"] == "desktop") - { - break; + .is_err()); + let payload = BASE64.encode(vec![7u8; 32 * 1024]); + socket + .send(Message::Text( + format!( + "43{ack_id}{}", + json!([{ "encryptedData":payload,"nonce":"bg=="}]) + ) + .into(), + )) + .await + .unwrap(); + let response = acknowledgement(&mut controller, 3).await; + assert_eq!(response["ok"], true); + assert_eq!(response["result"]["encryptedData"], payload); + assert_eq!( + request( + &app, + "DELETE", + &route("/api/devices/desktop"), + &tokens[1], + json!(null) + ) + .await + .status(), + StatusCode::NO_CONTENT + ); + loop { + let update = packet(&mut controller).await; + if let Some(data) = update.strip_prefix("42") { + let value: Value = serde_json::from_str(data).unwrap(); + if value[0] == "ephemeral" + && value[1]["type"] == "device-presence" + && !value[1]["devices"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["device_id"] == "desktop") + { + break; + } } } - } - assert_eq!( - packet(&mut socket).await, - "41", - "device removal disconnects its active namespace" - ); - assert_eq!( - request( - &app, - "POST", - &route("/api/auth/logout"), - &tokens[1], - json!({}) - ) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - request(&app, "GET", &route("/api/devices"), &tokens[1], json!(null)) + assert_eq!( + packet(&mut socket).await, + "41", + "device removal disconnects its active namespace" + ); + assert_eq!( + request( + &app, + "POST", + &route("/api/auth/logout"), + &tokens[1], + json!({}) + ) .await .status(), - StatusCode::UNAUTHORIZED - ); - // Revocation applies to already-open namespaces, not only future HTTP - // requests. The server's authorization monitor disconnects idle peers. - loop { - let revoked = packet(&mut controller).await; - if revoked == "41" { - break; - } - assert!( - revoked.starts_with("42"), - "unexpected packet after revocation" + StatusCode::NO_CONTENT ); + assert_eq!( + request(&app, "GET", &route("/api/devices"), &tokens[1], json!(null)) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + // Revocation applies to already-open namespaces, not only future HTTP + // requests. The server's authorization monitor disconnects idle peers. + loop { + let revoked = packet(&mut controller).await; + if revoked == "41" { + break; + } + assert!( + revoked.starts_with("42"), + "unexpected packet after revocation" + ); + } + outsider.close(None).await.unwrap(); + let _ = socket.close(None).await; + server.abort(); } - outsider.close(None).await.unwrap(); - let _ = socket.close(None).await; - server.abort(); } authority_task.abort(); } diff --git a/src/crates/services/relay-service/src/identity.rs b/src/crates/services/relay-service/src/identity.rs index cfe0c42fef..50ca6881f7 100644 --- a/src/crates/services/relay-service/src/identity.rs +++ b/src/crates/services/relay-service/src/identity.rs @@ -16,6 +16,8 @@ pub(crate) struct IdentityVerifier { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct VerifiedIdentity { + #[serde(default)] + pub account_id: Option, pub github_id: i64, pub login: String, } @@ -26,9 +28,19 @@ struct IdentityResponse { } impl IdentityVerifier { - pub(crate) async fn start_auth(&self) -> Result { - self.auth_request("auth/desktop/start", serde_json::json!({})) - .await + pub(crate) async fn start_auth( + &self, + all_methods: bool, + ) -> Result { + self.auth_request( + if all_methods { + "auth/desktop/start?methods=all" + } else { + "auth/desktop/start" + }, + serde_json::json!({}), + ) + .await } pub(crate) async fn poll_auth( @@ -167,7 +179,7 @@ impl IdentityVerifier { let identity: IdentityResponse = serde_json::from_slice(&bytes).map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; let user = identity.user; - if user.github_id <= 0 + if user.identity_id().is_none() || user.login.is_empty() || user.login.len() > 100 || !user @@ -221,6 +233,25 @@ mod tests { task.abort(); } + #[tokio::test] + async fn email_identity_cannot_collide_with_a_legacy_github_account() { + let (client, task) = verifier( + StatusCode::OK, + r#"{"user":{"accountId":"email-123","githubId":0,"login":"member"}}"#, + ) + .await; + let user = client.verify("account-token").await.unwrap(); + assert_eq!(user.identity_id().as_deref(), Some("email-123")); + task.abort(); + let (client, task) = verifier( + StatusCode::OK, + r#"{"user":{"accountId":"123","githubId":0,"login":"member"}}"#, + ) + .await; + assert!(client.verify("account-token").await.is_err()); + task.abort(); + } + #[tokio::test] async fn fails_closed_on_expired_tokens_unavailable_authority_and_invalid_profiles() { for (status, body, expected) in [ @@ -256,3 +287,19 @@ fn identity_request_permit() -> Result Option { + if self.github_id > 0 { + return Some(self.github_id.to_string()); + } + self.account_id + .as_ref() + .filter(|id| { + id.starts_with("email-") + && id.len() <= 64 + && id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-') + }) + .cloned() + } +} diff --git a/src/crates/services/relay-service/src/routes/auth.rs b/src/crates/services/relay-service/src/routes/auth.rs index ee654de811..be0398f0af 100644 --- a/src/crates/services/relay-service/src/routes/auth.rs +++ b/src/crates/services/relay-service/src/routes/auth.rs @@ -219,7 +219,13 @@ pub struct GithubPollRequest { transaction_secret: String, } +#[derive(Default, serde::Deserialize)] +pub(crate) struct LoginMethods { + methods: Option, +} + pub(crate) async fn github_start( + axum::extract::Query(query): axum::extract::Query, State(state): State, connect_info: Option>>, headers: HeaderMap, @@ -239,7 +245,7 @@ pub(crate) async fn github_start( )); } identity_verifier(verifier.as_ref().map(|v| &v.0))? - .start_auth() + .start_auth(query.methods.as_deref() == Some("all")) .await .map(Json) .map_err(|status| err("GitHub sign-in could not be started", status)) @@ -299,12 +305,18 @@ pub(crate) async fn verify_identity_credentials( status, ) })?; - UserRow::upsert_verified(db, &identity.github_id.to_string(), &identity.login) - .await - .map_err(|error| { - tracing::error!("Identity persistence failed: {error}"); - err("internal error", StatusCode::INTERNAL_SERVER_ERROR) - }) + UserRow::upsert_verified( + db, + &identity + .identity_id() + .ok_or_else(|| err("Unsupported account identity", StatusCode::UNAUTHORIZED))?, + &identity.login, + ) + .await + .map_err(|error| { + tracing::error!("Identity persistence failed: {error}"); + err("internal error", StatusCode::INTERNAL_SERVER_ERROR) + }) } /// Exchange a shared OpenBitFun GitHub session for a device-scoped relay token. diff --git a/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js b/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js index 474ee7491c..378cfe2696 100644 --- a/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js +++ b/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js @@ -1 +1 @@ -(()=>{var f=document.querySelector("[data-page-login-form]"),o=document.querySelector("[data-page-login-submit]"),u=document.querySelector("[data-page-login-error]"),p=f?.dataset.pageLoginState;function h(){let t=window.location.pathname.indexOf("/api/page-auth/");if(t>=0)return window.location.pathname.slice(0,t);let e=window.location.pathname.indexOf("/p/");return e>=0?window.location.pathname.slice(0,e):""}function l(t){return`${h()}${t}`}function y(){let t=window.location.pathname.indexOf("/p/");return`${t>=0?window.location.pathname.slice(t):window.location.pathname}${window.location.search}`}function b(t){return/^https?:\/\//i.test(t)?t:`${h()}${t}`}function T(){return navigator.language.toLowerCase().startsWith("zh")}function r(t,e){return T()?t:e}function g(t){u&&(u.textContent=t,u.hidden=t.length===0)}async function S(t){let e=r("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again.");try{let n=await t.json();return n.retry_after_secs&&n.retry_after_secs>0?r(`\u5C1D\u8BD5\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u5728 ${n.retry_after_secs} \u79D2\u540E\u91CD\u8BD5\u3002`,`Too many attempts. Try again in ${n.retry_after_secs} seconds.`):n.error==="account does not have access to this Page"?r("\u8BE5\u8D26\u53F7\u6CA1\u6709\u6B64\u9875\u9762\u7684\u8BBF\u95EE\u6743\u9650\u3002","This account cannot access the Page."):n.error||e}catch{return e}}async function d(t,e){let n=await fetch(t,{method:"POST",credentials:"same-origin",headers:{"content-type":"application/json"},body:JSON.stringify(e)});if(!n.ok)throw new Error(await S(n));return n.json()}f?.addEventListener("submit",async t=>{if(t.preventDefault(),!o||o.disabled)return;let e=window.open("about:blank","_blank");if(!e){g(r("\u8BF7\u5141\u8BB8\u767B\u5F55\u5F39\u7A97\u540E\u91CD\u8BD5\u3002","Allow the sign-in popup and try again."));return}e.opener=null,g(""),o.disabled=!0,o.textContent=r("\u7B49\u5F85 GitHub \u6388\u6743\u2026","Waiting for GitHub\u2026");try{let n=await d(l("/api/auth/github/start"),{}),i=new URL(n.authorizationUrl);if(i.protocol!=="https:"||i.hostname!=="github.com")throw new Error(r("\u767B\u5F55\u5730\u5740\u65E0\u6548\u3002","The sign-in URL is invalid."));e.location.replace(i.href);let s;for(;Date.now()setTimeout(m,Math.max(1,n.pollIntervalSeconds)*1e3));let c=await d(l("/api/auth/github/poll"),{transactionId:n.transactionId,transactionSecret:n.transactionSecret});if(c.tokens?.accessToken){s=c.tokens.accessToken;break}if(c.status!=="pending")throw new Error(r("\u6388\u6743\u672A\u5B8C\u6210\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002","Authorization did not complete. Sign in again."))}if(!s)throw new Error(r("\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in expired. Try again."));let a={access_token:s};p?a.state=p:(a.return_to=y(),a.path_prefix=h());let w=await d(l("/api/page-auth/login"),a);window.location.replace(b(w.redirect_to))}catch(n){g(n instanceof Error?n.message:r("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again."))}finally{e.close(),o.disabled=!1,o.textContent=r("\u4F7F\u7528 GitHub \u767B\u5F55","Sign in with GitHub")}});})(); +(()=>{var f=document.querySelector("[data-page-login-form]"),o=document.querySelector("[data-page-login-submit]"),u=document.querySelector("[data-page-login-error]"),p=f?.dataset.pageLoginState;function h(){let t=window.location.pathname.indexOf("/api/page-auth/");if(t>=0)return window.location.pathname.slice(0,t);let e=window.location.pathname.indexOf("/p/");return e>=0?window.location.pathname.slice(0,e):""}function l(t){return`${h()}${t}`}function y(){let t=window.location.pathname.indexOf("/p/");return`${t>=0?window.location.pathname.slice(t):window.location.pathname}${window.location.search}`}function b(t){return/^https?:\/\//i.test(t)?t:`${h()}${t}`}function T(){return navigator.language.toLowerCase().startsWith("zh")}function r(t,e){return T()?t:e}function g(t){u&&(u.textContent=t,u.hidden=t.length===0)}async function S(t){let e=r("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again.");try{let n=await t.json();return n.retry_after_secs&&n.retry_after_secs>0?r(`\u5C1D\u8BD5\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u5728 ${n.retry_after_secs} \u79D2\u540E\u91CD\u8BD5\u3002`,`Too many attempts. Try again in ${n.retry_after_secs} seconds.`):n.error==="account does not have access to this Page"?r("\u8BE5\u8D26\u53F7\u6CA1\u6709\u6B64\u9875\u9762\u7684\u8BBF\u95EE\u6743\u9650\u3002","This account cannot access the Page."):n.error||e}catch{return e}}async function d(t,e){let n=await fetch(t,{method:"POST",credentials:"same-origin",headers:{"content-type":"application/json"},body:JSON.stringify(e)});if(!n.ok)throw new Error(await S(n));return n.json()}f?.addEventListener("submit",async t=>{if(t.preventDefault(),!o||o.disabled)return;let e=window.open("about:blank","_blank");if(!e){g(r("\u8BF7\u5141\u8BB8\u767B\u5F55\u5F39\u7A97\u540E\u91CD\u8BD5\u3002","Allow the sign-in popup and try again."));return}e.opener=null,g(""),o.disabled=!0,o.textContent=r("\u7B49\u5F85\u767B\u5F55\u2026","Waiting for sign-in\u2026");try{let n=await d(l("/api/auth/github/start?methods=all"),{}),a=new URL(n.authorizationUrl);if(!(a.origin==="https://github.com"&&a.pathname==="/login/oauth/authorize"||a.origin==="https://auth.openbitfun.com"&&a.pathname==="/sign-in")||a.username||a.password)throw new Error(r("\u767B\u5F55\u5730\u5740\u65E0\u6548\u3002","The sign-in URL is invalid."));e.location.replace(a.href);let s;for(;Date.now()setTimeout(m,Math.max(1,n.pollIntervalSeconds)*1e3));let c=await d(l("/api/auth/github/poll"),{transactionId:n.transactionId,transactionSecret:n.transactionSecret});if(c.tokens?.accessToken){s=c.tokens.accessToken;break}if(c.status!=="pending")throw new Error(r("\u6388\u6743\u672A\u5B8C\u6210\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002","Authorization did not complete. Sign in again."))}if(!s)throw new Error(r("\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in expired. Try again."));let i={access_token:s};p?i.state=p:(i.return_to=y(),i.path_prefix=h());let w=await d(l("/api/page-auth/login"),i);window.location.replace(b(w.redirect_to))}catch(n){g(n instanceof Error?n.message:r("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again."))}finally{e.close(),o.disabled=!1,o.textContent=r("\u4F7F\u7528\u90AE\u7BB1\u6216 GitHub \u767B\u5F55","Sign in with email or GitHub")}});})(); diff --git a/src/crates/services/relay-service/src/routes/pages.rs b/src/crates/services/relay-service/src/routes/pages.rs index 0aeca1b233..f3847517fe 100644 --- a/src/crates/services/relay-service/src/routes/pages.rs +++ b/src/crates/services/relay-service/src/routes/pages.rs @@ -1320,14 +1320,14 @@ fn page_login_form_response(

OPENBITFUN PAGE

登录后访问

-

此页面受访问权限保护,请使用 GitHub 账号登录。

-

This Page is protected. Sign in with your GitHub account.

+

此页面受访问权限保护,请使用邮箱或 GitHub 登录。

+

This Page is protected. Sign in with your email or GitHub account.

{access_description}

- +
-

使用 OpenBitFun 统一 GitHub 账号登录。

+

使用 OpenBitFun 统一账号登录。

diff --git a/src/crates/services/relay-service/web/page-auth-client.test.mjs b/src/crates/services/relay-service/web/page-auth-client.test.mjs index a6406acfdb..338eca6de9 100644 --- a/src/crates/services/relay-service/web/page-auth-client.test.mjs +++ b/src/crates/services/relay-service/web/page-auth-client.test.mjs @@ -27,7 +27,7 @@ async function runLogin({ state, status = 'approved', authorizationUrl = 'https: fetch: async (url, options) => { const body = JSON.parse(options.body); calls.push({ url, body }); - if (url.endsWith('/github/start')) return { ok: true, json: async () => ({ transactionId: 'txn', transactionSecret: 'secret', authorizationUrl, expiresAt: Date.now() / 1000 + 60, pollIntervalSeconds: 3 }) }; + if (url.endsWith('/github/start?methods=all')) return { ok: true, json: async () => ({ transactionId: 'txn', transactionSecret: 'secret', authorizationUrl, expiresAt: Date.now() / 1000 + 60, pollIntervalSeconds: 3 }) }; if (url.endsWith('/github/poll')) return { ok: true, json: async () => ({ status, tokens: status === 'approved' ? { accessToken: 'verified-account-token' } : undefined }) }; if (url.endsWith('/page-auth/login')) return { ok: true, json: async () => ({ redirect_to: state ? 'https://pages.example/callback?code=one-time' : '/p/alice/demo?q=1' }) }; throw new Error(`Unexpected URL ${url}`); diff --git a/src/crates/services/relay-service/web/page-auth-client.ts b/src/crates/services/relay-service/web/page-auth-client.ts index a896925bf5..e709e7e853 100644 --- a/src/crates/services/relay-service/web/page-auth-client.ts +++ b/src/crates/services/relay-service/web/page-auth-client.ts @@ -103,11 +103,11 @@ form?.addEventListener('submit', async (event) => { popup.opener = null; showError(''); submitButton.disabled = true; - submitButton.textContent = message('等待 GitHub 授权…', 'Waiting for GitHub…'); + submitButton.textContent = message('等待登录…', 'Waiting for sign-in…'); try { - const start = await postJson(relayApiPath('/api/auth/github/start'), {}); + const start = await postJson(relayApiPath('/api/auth/github/start?methods=all'), {}); const authorization = new URL(start.authorizationUrl); - if (authorization.protocol !== 'https:' || authorization.hostname !== 'github.com') { + if (!((authorization.origin === 'https://github.com' && authorization.pathname === '/login/oauth/authorize') || (authorization.origin === 'https://auth.openbitfun.com' && authorization.pathname === '/sign-in')) || !!authorization.username || !!authorization.password) { throw new Error(message('登录地址无效。', 'The sign-in URL is invalid.')); } popup.location.replace(authorization.href); @@ -141,6 +141,6 @@ form?.addEventListener('submit', async (event) => { } finally { popup.close(); submitButton.disabled = false; - submitButton.textContent = message('使用 GitHub 登录', 'Sign in with GitHub'); + submitButton.textContent = message('使用邮箱或 GitHub 登录', 'Sign in with email or GitHub'); } }); diff --git a/src/crates/services/services-integrations/src/account_identity/mod.rs b/src/crates/services/services-integrations/src/account_identity/mod.rs index 9026f636ff..284aac13cd 100644 --- a/src/crates/services/services-integrations/src/account_identity/mod.rs +++ b/src/crates/services/services-integrations/src/account_identity/mod.rs @@ -126,7 +126,7 @@ impl AccountIdentityClient { } let me: MarketMe = serde_json::from_slice(&body) .map_err(|_| local_error("invalid_identity_response", "Invalid account response."))?; - if me.user.github_id <= 0 || me.user.login.trim().is_empty() { + if me.user.identity_id().is_none() || me.user.login.trim().is_empty() { return Err(local_error( "invalid_identity_response", "Invalid GitHub identity.", @@ -172,8 +172,11 @@ impl AccountIdentityClient { } pub async fn start_desktop_auth(&self) -> Result { - self.json(self.client.post(self.url("/auth/desktop/start"))) - .await + self.json( + self.client + .post(self.url("/auth/desktop/start?methods=all")), + ) + .await } pub async fn poll_desktop_auth( diff --git a/src/crates/services/services-integrations/src/remote_connect/account.rs b/src/crates/services/services-integrations/src/remote_connect/account.rs index d51d792d4c..97991ee6db 100644 --- a/src/crates/services/services-integrations/src/remote_connect/account.rs +++ b/src/crates/services/services-integrations/src/remote_connect/account.rs @@ -236,16 +236,17 @@ impl AccountClient { let profile = identity .me() .await? - .ok_or_else(|| anyhow!("Sign in with GitHub to continue"))?; + .ok_or_else(|| anyhow!("Sign in to continue"))?; let access_token = identity .access_token() .await? - .ok_or_else(|| anyhow!("Sign in with GitHub to continue"))?; - let device_secret = super::session_store::device_secret( - relay_url, - &profile.user.github_id.to_string(), - &device.device_id, - )?; + .ok_or_else(|| anyhow!("Sign in to continue"))?; + let account_id = profile + .user + .identity_id() + .ok_or_else(|| anyhow!("Unsupported account identity"))?; + let device_secret = + super::session_store::device_secret(relay_url, &account_id, &device.device_id)?; let body = serde_json::json!({ "access_token": access_token, "device_id": device.device_id, @@ -266,10 +267,8 @@ impl AccountClient { return Err(Self::into_buffered_error(response)); } let auth: AuthResponse = response.json().await?; - if auth.user_id != profile.user.github_id.to_string() { - return Err(anyhow!( - "relay returned a different GitHub account identity" - )); + if auth.user_id != account_id { + return Err(anyhow!("relay returned a different account identity")); } Ok(( AccountSession::new(auth.token, auth.user_id, device_secret), diff --git a/src/crates/services/skin-market-service/README.md b/src/crates/services/skin-market-service/README.md index 3bb165d309..c57536cccd 100644 --- a/src/crates/services/skin-market-service/README.md +++ b/src/crates/services/skin-market-service/README.md @@ -41,3 +41,11 @@ cargo check -p openbitfun-skin-market-server Production deployment, backup and rollback are documented in `deploy/skin-market/README.md`. + +Email identities use the authority's additive `accountId`; legacy GitHub profiles +still resolve by their numeric ID. Migration 0002 preserves all existing user IDs +and ownership references, and keeps email accounts in a separate namespace. +Neither an email match nor a shared display name links accounts. Old binaries do +not support email identities after this migration; use a forward fix rather than +rolling an old binary over new email-user data. Focused upgrade regression: +`cargo test -p openbitfun-skin-market-service --lib email_tests`. diff --git a/src/crates/services/skin-market-service/migrations/0002_email_identity.sql b/src/crates/services/skin-market-service/migrations/0002_email_identity.sql new file mode 100644 index 0000000000..af19ab59a1 --- /dev/null +++ b/src/crates/services/skin-market-service/migrations/0002_email_identity.sql @@ -0,0 +1,16 @@ +-- Preserve all internal IDs and foreign-key references. The runner disables +-- foreign keys on its exclusive migration connection, then checks them before commit. +CREATE TABLE users_next ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + github_id INTEGER UNIQUE, + login TEXT NOT NULL, + avatar_url TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +INSERT INTO users_next SELECT * FROM users; +DROP TABLE users; +ALTER TABLE users_next RENAME TO users; +ALTER TABLE users ADD COLUMN account_id TEXT; +UPDATE users SET account_id = CAST(github_id AS TEXT) WHERE github_id IS NOT NULL; +CREATE UNIQUE INDEX users_account_id ON users(account_id); diff --git a/src/crates/services/skin-market-service/src/auth.rs b/src/crates/services/skin-market-service/src/auth.rs index 607139b39f..0fd03bc11f 100644 --- a/src/crates/services/skin-market-service/src/auth.rs +++ b/src/crates/services/skin-market-service/src/auth.rs @@ -120,7 +120,7 @@ impl IdentityVerifier { let identity: IdentityResponse = response.json().await.map_err(|_| { SkinMarketError::unavailable("The identity service returned an invalid response.") })?; - if identity.user.github_id <= 0 + if identity.user.identity_id().is_none() || identity.user.login.trim().is_empty() || identity.user.login.len() > 100 || identity.user.avatar_url.len() > 2_048 diff --git a/src/crates/services/skin-market-service/src/db.rs b/src/crates/services/skin-market-service/src/db.rs index f7c60c9293..203f57f513 100644 --- a/src/crates/services/skin-market-service/src/db.rs +++ b/src/crates/services/skin-market-service/src/db.rs @@ -66,6 +66,35 @@ impl Database { .await?; transaction.commit().await?; } + let applied: Option<(i64,)> = + sqlx::query_as("SELECT version FROM schema_migrations WHERE version = 2") + .fetch_optional(&self.pool) + .await?; + if applied.is_none() { + use sqlx::Acquire; + let mut connection = self.pool.acquire().await?; + // This connection must never return to the pool with FK checks disabled. + connection.close_on_drop(); + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut *connection) + .await?; + let mut transaction = connection.begin().await?; + sqlx::raw_sql(include_str!("../migrations/0002_email_identity.sql")) + .execute(&mut *transaction) + .await?; + let violations = sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&mut *transaction) + .await?; + anyhow::ensure!( + violations.is_empty(), + "Identity migration violated foreign keys" + ); + sqlx::query("INSERT INTO schema_migrations(version, applied_at) VALUES(2, ?)") + .bind(Utc::now().timestamp()) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } Ok(()) } @@ -74,15 +103,19 @@ impl Database { profile: &AppearanceMarketUserSummary, ) -> SkinMarketResult { let now = Utc::now().timestamp(); + let account_id = profile + .identity_id() + .ok_or_else(|| SkinMarketError::unavailable("Unsupported account identity"))?; sqlx::query( - "INSERT INTO users(github_id, login, avatar_url, created_at, updated_at) - VALUES(?, ?, ?, ?, ?) - ON CONFLICT(github_id) DO UPDATE SET + "INSERT INTO users(account_id, github_id, login, avatar_url, created_at, updated_at) + VALUES(?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET login = excluded.login, avatar_url = excluded.avatar_url, updated_at = excluded.updated_at", ) - .bind(profile.github_id) + .bind(&account_id) + .bind((profile.github_id > 0).then_some(profile.github_id)) .bind(&profile.login) .bind(&profile.avatar_url) .bind(now) @@ -91,8 +124,8 @@ impl Database { .await .map_err(SkinMarketError::internal)?; let row = - sqlx::query("SELECT id, github_id, login, avatar_url FROM users WHERE github_id = ?") - .bind(profile.github_id) + sqlx::query("SELECT id, github_id, login, avatar_url FROM users WHERE account_id = ?") + .bind(&account_id) .fetch_one(&self.pool) .await .map_err(SkinMarketError::internal)?; @@ -101,3 +134,56 @@ impl Database { }) } } + +#[cfg(test)] +mod email_tests { + use super::*; + #[tokio::test] + async fn migration_preserves_legacy_owners_and_separates_email_accounts() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("legacy.sqlite"); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with( + SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true), + ) + .await + .unwrap(); + sqlx::raw_sql(INITIAL_MIGRATION) + .execute(&pool) + .await + .unwrap(); + sqlx::raw_sql("CREATE TABLE schema_migrations(version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL); INSERT INTO schema_migrations VALUES(1,0); INSERT INTO users VALUES(7,42,'alice','',1,1);").execute(&pool).await.unwrap(); + pool.close().await; + let github = AppearanceMarketUserSummary { + account_id: None, + github_id: 42, + login: "alice".into(), + avatar_url: "".into(), + }; + let email = AppearanceMarketUserSummary { + account_id: Some("email-42".into()), + github_id: 0, + login: "member".into(), + avatar_url: "".into(), + }; + for _ in 0..2 { + let db = Database::open(&path).await.unwrap(); + assert_eq!(db.upsert_user(&github).await.unwrap().internal_id, 7); + let other = db.upsert_user(&email).await.unwrap(); + assert_ne!(other.internal_id, 7); + assert_eq!( + db.upsert_user(&email).await.unwrap().internal_id, + other.internal_id + ); + assert!(sqlx::query("PRAGMA foreign_key_check") + .fetch_all(db.pool()) + .await + .unwrap() + .is_empty()); + db.pool.close().await; + } + } +} diff --git a/src/crates/services/skin-market-service/src/routes.rs b/src/crates/services/skin-market-service/src/routes.rs index 382ff1af71..81dcabad69 100644 --- a/src/crates/services/skin-market-service/src/routes.rs +++ b/src/crates/services/skin-market-service/src/routes.rs @@ -1314,7 +1314,8 @@ fn summary_from_row( min_openbitfun_version: draft.min_openbitfun_version, required_capabilities: meta.required_capabilities, owner: AppearanceMarketUserSummary { - github_id: row.get("github_id"), + account_id: None, + github_id: row.get::, _>("github_id").unwrap_or_default(), login: row.get("login"), avatar_url: row.get("avatar_url"), }, @@ -1564,12 +1565,16 @@ async fn admin_submission_detail( _ => None, }; let submitter = row - .try_get::, _>("github_id") + .try_get::, _>("login") .map_err(SkinMarketError::internal)? - .map(|github_id| { + .map(|login| { Ok::<_, SkinMarketError>(AppearanceMarketUserSummary { - github_id, - login: row.try_get("login").map_err(SkinMarketError::internal)?, + account_id: None, + github_id: row + .try_get::, _>("github_id") + .map_err(SkinMarketError::internal)? + .unwrap_or_default(), + login, avatar_url: row .try_get("avatar_url") .map_err(SkinMarketError::internal)?, diff --git a/src/miniapp-market-web/src/AccountSignIn.tsx b/src/miniapp-market-web/src/AccountSignIn.tsx new file mode 100644 index 0000000000..cb6d52bdea --- /dev/null +++ b/src/miniapp-market-web/src/AccountSignIn.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { marketApi } from './api'; +import { useLocale } from './i18n'; + +export function AccountSignIn() { + const { t, locale, setLocale } = useLocale(); + const [ticket, setTicket] = useState(''); + const [emailEnabled, setEmailEnabled] = useState(false); + const [githubEnabled, setGithubEnabled] = useState(false); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [challenge, setChallenge] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [retryAt, setRetryAt] = useState(0); + const [now, setNow] = useState(Date.now()); + const initial = useRef | null>(null); + useEffect(() => { + // Preserve a desktop ticket across reloads without putting its polling secret in the browser. + if (!initial.current) initial.current = (async () => { + const fragmentTicket = new URLSearchParams(window.location.hash.slice(1)).get('ticket'); + if (fragmentTicket) { + const config = await marketApi.config(); + setTicket(fragmentTicket); + setEmailEnabled(config.emailAuthConfigured === true); + setGithubEnabled(config.githubAuthConfigured); + } else { + const start = await marketApi.startLogin(new URLSearchParams(window.location.search).get('returnTo') || '/miniapp/'); + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#ticket=${encodeURIComponent(start.ticket)}`); + setTicket(start.ticket); setEmailEnabled(start.emailEnabled); setGithubEnabled(start.githubEnabled); + } + })().catch(() => setError(t('emailStartFailed'))); + }, [t]); + useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, []); + const showError = (cause: unknown) => { + const key = cause && typeof cause === 'object' && 'code' in cause ? cause.code : ''; + setError(t(key === 'email_rate_limit' ? 'emailRateLimited' : key === 'invalid_email_code' ? 'emailCodeInvalid' : key === 'invalid_email' ? 'emailInvalid' : 'emailStartFailed')); + }; + async function send(event?: FormEvent) { + event?.preventDefault(); setBusy(true); setError(''); + try { const sent = await marketApi.sendEmailCode(ticket, email); setChallenge(sent.challengeId); setCode(''); setRetryAt(Date.now() + sent.retryAfterSeconds * 1000); } + catch (cause) { showError(cause); } finally { setBusy(false); } + } + async function verify(event: FormEvent) { + event.preventDefault(); setBusy(true); setError(''); + try { + const result = await marketApi.verifyEmailCode(ticket, challenge, code); + const target = new URL(result.redirectUrl, window.location.origin); + if (![window.location.origin, 'https://auth.openbitfun.com', 'https://market.openbitfun.com'].includes(target.origin)) throw new Error('Untrusted return URL'); + window.location.assign(target.href); + } catch (cause) { showError(cause); setBusy(false); } + } + async function github() { + setBusy(true); setError(''); + try { const result = await marketApi.loginGithub(ticket); const url = new URL(result.authorizationUrl); + if (url.origin !== 'https://github.com' || url.pathname !== '/login/oauth/authorize' || url.username || url.password) throw new Error('Untrusted authorization URL'); + window.location.assign(url.href); + } catch (cause) { showError(cause); setBusy(false); } + } + return
+

{t('accountSignIn')}

{t('emailLoginIntro')}

+ + {emailEnabled &&
+ + {challenge && <>

{t('emailCodeSent')}

} + + {challenge && <>} +
} + {githubEnabled && } + {!!ticket && !emailEnabled && !githubEnabled &&

{t('emailUnavailable')}

} + {error &&

{error}

} +
; +} diff --git a/src/miniapp-market-web/src/App.tsx b/src/miniapp-market-web/src/App.tsx index 84ebd40400..674dad9fed 100644 --- a/src/miniapp-market-web/src/App.tsx +++ b/src/miniapp-market-web/src/App.tsx @@ -29,6 +29,7 @@ import { CircleAlert as WarningCircle, X, } from 'lucide-react'; +import { AccountSignIn } from './AccountSignIn'; import { downloadUrl, loginUrl, marketApi, MarketApiError } from './api'; import { formatCompactNumber, formatMarketDate, formatMarketDateTime } from './format'; import { GetOpenBitFunCta } from './GetOpenBitFunCta'; @@ -87,7 +88,7 @@ function navigate(path: string) { function App() { const path = window.location.pathname; - if (window.location.hostname === 'auth.openbitfun.com' || path === '/miniapp/auth/complete' || path === '/miniapp/auth/desktop-complete') { + if (window.location.hostname === 'auth.openbitfun.com' || path === '/miniapp/auth/sign-in' || path === '/miniapp/auth/complete' || path === '/miniapp/auth/desktop-complete') { return ; } return ; @@ -98,14 +99,7 @@ function GitHubIdentityPage({ complete }: { complete: boolean }) { useTheme(); useEffect(() => { document.title = `OpenBitFun · ${t('signIn')}`; }, [t]); return - {complete ? :
-
-
-
} + {complete ? : }
; } @@ -405,11 +399,11 @@ function Header({ ) : ( { - if (config?.githubAuthConfigured === false) event.preventDefault(); + if ((config?.githubAuthConfigured || config?.emailAuthConfigured) === false) event.preventDefault(); }} >
diff --git a/src/miniapp-market-web/src/api.ts b/src/miniapp-market-web/src/api.ts index bb18e0f38b..4293c4acc3 100644 --- a/src/miniapp-market-web/src/api.ts +++ b/src/miniapp-market-web/src/api.ts @@ -11,7 +11,7 @@ import type { SubmissionStatus, } from './types'; -const API = '/miniapp/api/v1'; +const API = typeof window !== 'undefined' && window.location.hostname === 'auth.openbitfun.com' ? '/api/v1' : '/miniapp/api/v1'; export class MarketApiError extends Error { readonly code: string; @@ -67,6 +67,10 @@ async function request(path: string, init: RequestInit = {}): Promise { } export const marketApi = { + startLogin: (returnTo: string) => request<{ ticket: string; emailEnabled: boolean; githubEnabled: boolean }>('/auth/login/start', { method: 'POST', body: JSON.stringify({ returnTo }) }), + loginGithub: (ticket: string) => request<{ authorizationUrl: string }>('/auth/login/github', { method: 'POST', body: JSON.stringify({ ticket }) }), + sendEmailCode: (ticket: string, email: string) => request<{ challengeId: string; retryAfterSeconds: number }>('/auth/email/send', { method: 'POST', body: JSON.stringify({ ticket, email }) }), + verifyEmailCode: (ticket: string, challengeId: string, code: string) => request<{ redirectUrl: string }>('/auth/email/verify', { method: 'POST', body: JSON.stringify({ ticket, challengeId, code }) }), config: () => request('/config'), me: () => request('/me'), logout: () => request('/auth/logout', { method: 'POST' }), diff --git a/src/miniapp-market-web/src/i18n.ts b/src/miniapp-market-web/src/i18n.ts index 0757ff2193..1b69c5dcc5 100644 --- a/src/miniapp-market-web/src/i18n.ts +++ b/src/miniapp-market-web/src/i18n.ts @@ -4,12 +4,29 @@ export type Locale = 'en-US' | 'zh-CN' | 'zh-TW'; const messages = { 'en-US': { + accountSignIn: "Sign in to OpenBitFun", + githubSignIn: "Continue with GitHub", + emailLoginIntro: "Use an email code or GitHub. No password needed.", + emailAddress: "Email address", + emailCode: "6-digit verification code", + emailSendCode: "Send verification code", + emailVerify: "Verify and sign in", + emailCodeSent: "Code sent. Check your inbox and spam folder.", + emailResend: "Resend code", + emailResendWait: "Please wait before resending", + emailChange: "Use another email", + emailInvalid: "Enter a valid email address.", + emailCodeInvalid: "Incorrect or expired code. Request a new code if needed.", + emailRateLimited: "Too many requests. Please try again later.", + emailStartFailed: "Sign-in could not be completed. Please try again or reopen the sign-in page.", + emailUnavailable: "Sign-in is unavailable on this server.", + market: 'MiniApp Market', discover: 'Discover', submit: 'Submit', submissions: 'My submissions', admin: 'Review', - signIn: 'Sign in with GitHub', + signIn: 'Sign in', signOut: 'Sign out', headline: 'Useful MiniApps, right inside OpenBitFun.', intro: @@ -33,7 +50,7 @@ const messages = { submitTitle: 'Submit a MiniApp', submitIntro: 'Upload a self-contained .bfminiapp package and 1–5 marketplace images. Node, npm, and remote executable dependencies are rejected.', - signInRequired: 'Sign in with GitHub to continue.', + signInRequired: 'Sign in to continue.', package: '.bfminiapp package', screenshots: 'Marketplace images', listingImageHint: @@ -61,9 +78,9 @@ const messages = { yank: 'Yank release', unpublish: 'Unpublish listing', back: 'Back to market', - authComplete: 'GitHub authorization complete', + authComplete: 'Sign-in complete', authCompleteBody: 'Return to the app or browser window where you started signing in. You can close this tab.', - authSharedIdentity: 'Use your GitHub identity for the marketplaces and remote device control.', + authSharedIdentity: 'Use the same OpenBitFun account for the marketplaces and remote device control.', footerNote: 'Reviewed releases / Hash-locked packages / Manual updates', openbitfunHome: 'OpenBitFun home', getOpenBitFunTitle: 'New to OpenBitFun?', @@ -149,7 +166,7 @@ const messages = { statusRejected: 'Rejected', statusWithdrawn: 'Withdrawn', marketNotPublic: 'The MiniApp marketplace is not open to the public yet.', - oauthNotConfigured: 'GitHub sign-in is being configured. Please try again shortly.', + oauthNotConfigured: 'Sign-in is being configured. Please try again shortly.', categoryDeveloper: 'Developer', categoryProductivity: 'Productivity', categoryData: 'Data', @@ -160,12 +177,29 @@ const messages = { categoryOther: 'Other', }, 'zh-CN': { + accountSignIn: "登录 OpenBitFun", + githubSignIn: "使用 GitHub 登录", + emailLoginIntro: "使用邮箱验证码或 GitHub 登录,无需设置密码。", + emailAddress: "邮箱地址", + emailCode: "6 位验证码", + emailSendCode: "发送验证码", + emailVerify: "验证并登录", + emailCodeSent: "验证码已发送,请检查收件箱和垃圾邮件。", + emailResend: "重新发送", + emailResendWait: "请稍后重新发送", + emailChange: "更换邮箱", + emailInvalid: "请输入有效的邮箱地址。", + emailCodeInvalid: "验证码错误或已过期,请重试或重新获取。", + emailRateLimited: "请求过于频繁,请稍后重试。", + emailStartFailed: "暂时无法完成登录,请重试或重新打开登录页面。", + emailUnavailable: "此服务器尚未配置登录方式。", + market: 'MiniApp 市场', discover: '发现', submit: '投稿', submissions: '我的投稿', admin: '审核', - signIn: '使用 GitHub 登录', + signIn: '登录', signOut: '退出登录', headline: '好用的 MiniApp,就在 OpenBitFun 里。', intro: '发现经过审核的小工具,在熟悉的工作流里安全安装、按需授权。', @@ -187,7 +221,7 @@ const messages = { releases: '版本历史', submitTitle: '投稿 MiniApp', submitIntro: '上传自包含的 .bfminiapp 和 1–5 张市场展示图。Node、npm 和远程可执行依赖会被拒绝。', - signInRequired: '请先使用 GitHub 登录。', + signInRequired: '请先登录。', package: '.bfminiapp 安装包', screenshots: '市场展示图', listingImageHint: @@ -214,9 +248,9 @@ const messages = { yank: '永久撤下版本', unpublish: '下架 Listing', back: '返回市场', - authComplete: 'GitHub 授权完成', + authComplete: '登录完成', authCompleteBody: '请返回发起登录的应用或浏览器窗口。你可以关闭此标签页。', - authSharedIdentity: '市场与远程设备控制共用你的 GitHub 身份。', + authSharedIdentity: '市场与远程设备控制共用你的 OpenBitFun 账号。', footerNote: '人工审核版本 / 哈希锁定安装包 / 手动更新', openbitfunHome: 'OpenBitFun 官网', getOpenBitFunTitle: '没有 OpenBitFun?', @@ -301,7 +335,7 @@ const messages = { statusRejected: '已驳回', statusWithdrawn: '已撤回', marketNotPublic: 'MiniApp 市场尚未对公众开放。', - oauthNotConfigured: 'GitHub 登录正在配置,请稍后再试。', + oauthNotConfigured: '登录正在配置,请稍后再试。', categoryDeveloper: '开发工具', categoryProductivity: '效率', categoryData: '数据', @@ -312,12 +346,29 @@ const messages = { categoryOther: '其他', }, 'zh-TW': { + accountSignIn: "登入 OpenBitFun", + githubSignIn: "使用 GitHub 登入", + emailLoginIntro: "使用信箱驗證碼或 GitHub 登入,無需設定密碼。", + emailAddress: "電子郵件地址", + emailCode: "6 位驗證碼", + emailSendCode: "傳送驗證碼", + emailVerify: "驗證並登入", + emailCodeSent: "驗證碼已傳送,請檢查收件匣和垃圾郵件。", + emailResend: "重新傳送", + emailResendWait: "請稍後重新傳送", + emailChange: "更換信箱", + emailInvalid: "請輸入有效的電子郵件地址。", + emailCodeInvalid: "驗證碼錯誤或已過期,請重試或重新取得。", + emailRateLimited: "請求過於頻繁,請稍後重試。", + emailStartFailed: "暫時無法完成登入,請重試或重新開啟登入頁面。", + emailUnavailable: "此伺服器尚未設定登入方式。", + market: 'MiniApp 市場', discover: '探索', submit: '投稿', submissions: '我的投稿', admin: '審核', - signIn: '使用 GitHub 登入', + signIn: '登入', signOut: '登出', headline: '好用的 MiniApp,就在 OpenBitFun 裡。', intro: '探索經過審核的小工具,在熟悉的工作流程裡安全安裝、按需授權。', @@ -339,7 +390,7 @@ const messages = { releases: '版本歷史', submitTitle: '投稿 MiniApp', submitIntro: '上傳自包含的 .bfminiapp 和 1–5 張市場展示圖。Node、npm 和遠端可執行依賴會被拒絕。', - signInRequired: '請先使用 GitHub 登入。', + signInRequired: '請先登入。', package: '.bfminiapp 安裝包', screenshots: '市場展示圖', listingImageHint: @@ -366,9 +417,9 @@ const messages = { yank: '永久撤下版本', unpublish: '下架 Listing', back: '返回市場', - authComplete: 'GitHub 授權完成', + authComplete: '登入完成', authCompleteBody: '請返回發起登入的應用程式或瀏覽器視窗。你可以關閉此分頁。', - authSharedIdentity: '市場與遠端裝置控制共用你的 GitHub 身分。', + authSharedIdentity: '市場與遠端裝置控制共用你的 OpenBitFun 帳號。', footerNote: '人工審核版本 / 雜湊鎖定安裝包 / 手動更新', openbitfunHome: 'OpenBitFun 官網', getOpenBitFunTitle: '還沒有 OpenBitFun?', @@ -453,7 +504,7 @@ const messages = { statusRejected: '已駁回', statusWithdrawn: '已撤回', marketNotPublic: 'MiniApp 市場尚未對公眾開放。', - oauthNotConfigured: 'GitHub 登入正在設定,請稍後再試。', + oauthNotConfigured: '登入正在設定,請稍後再試。', categoryDeveloper: '開發工具', categoryProductivity: '效率', categoryData: '資料', diff --git a/src/miniapp-market-web/src/styles.css b/src/miniapp-market-web/src/styles.css index 179423bf81..4f75898860 100644 --- a/src/miniapp-market-web/src/styles.css +++ b/src/miniapp-market-web/src/styles.css @@ -2552,3 +2552,9 @@ footer a:hover { animation-iteration-count: 1 !important; } } + +.account-sign-in { gap: 16px; padding-block: 32px; } +.account-sign-in form { display: grid; gap: 16px; width: min(100%, 360px); text-align: left; } +.account-sign-in label { display: grid; gap: 8px; } +.account-sign-in input { width: 100%; min-height: 44px; } +.account-sign-in button { min-height: 44px; } diff --git a/src/miniapp-market-web/src/types.ts b/src/miniapp-market-web/src/types.ts index 2fe14c08d1..86864baf99 100644 --- a/src/miniapp-market-web/src/types.ts +++ b/src/miniapp-market-web/src/types.ts @@ -1,5 +1,6 @@ export interface MarketUser { githubId: number; + accountId?: string; login: string; avatarUrl: string; } @@ -148,6 +149,7 @@ export interface Me { export interface MarketConfig { githubAuthConfigured: boolean; + emailAuthConfigured?: boolean; publicBrowse: boolean; webSubmissionsEnabled: boolean; categories: string[]; diff --git a/src/mobile-web/README.md b/src/mobile-web/README.md index ec88e1aa7f..004fbeb239 100644 --- a/src/mobile-web/README.md +++ b/src/mobile-web/README.md @@ -68,3 +68,7 @@ The workspace tools provide directory browsing, text-file reading/editing, file/ Pending questions and permissions use the runtime interaction mailbox independently of transcript history. Initial attachment, reconnect, foreground recovery, and permission control events refresh that mailbox. Requests are answered by `requestId`, including requests with no tool-call attachment; edited approval input is sent to the same runtime permission owner. File uploads stream 3 MiB chunks through the runtime transfer owner, use a whole-file digest, and resolve a lost append acknowledgment through the transfer cursor. Downloads stream chunks to the browser writable-file picker with backpressure; browsers without that API retain Blob parts until their download API accepts the file. Preview buffers remain separate from downloads. Terminal input is coalesced and serialized, resize keeps the latest dimensions, and ANSI output is rendered by xterm rather than interpreted by a custom parser. + +Account sign-in offers independent GitHub and email-code accounts through the +shared OpenBitFun authorization page. No password registration is needed. Use the +same login method and account on the desktop/CLI and phone to see its devices. diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index e900ccd62a..8bfbcb4355 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -35,18 +35,18 @@ export const messages: Record = { daysAgo: '{count}d ago', }, pairing: { - githubSignIn: "Sign in with GitHub", - githubDescription: "Sign in with the same GitHub account as your desktop or CLI. This phone controls the remote device; models and settings stay on that device.", + githubSignIn: "Sign in with email or GitHub", + githubDescription: "Sign in with the same OpenBitFun account as your desktop or CLI. This phone controls the remote device; models and settings stay on that device.", allowSignInPopup: "Allow the sign-in popup and try again.", - loginFailed: "GitHub sign-in failed. Try again.", - waitingForGitHub: "Complete GitHub sign-in in the opened page.", + loginFailed: "account sign-in failed. Try again.", + waitingForGitHub: "Complete account sign-in in the opened page.", restoringAccount: 'Restoring sign-in...', browserStorageUnavailable: 'Browser storage is unavailable. Allow site storage, then reload to sign in.', browserStorageInvalid: 'Saved browser sign-in data could not be read. It has been kept; try a newer version of this page.', signInChanged: 'Sign-in changed in another tab. Try again.', - sameGitHubAccount: "Use the same GitHub account as {account}.", + sameGitHubAccount: "Use the same OpenBitFun account as {account}.", - loginTitle: 'Sign in with GitHub', + loginTitle: 'Sign in with email or GitHub', connectTitle: 'Connect to OpenBitFun', loginDescription: 'Sign in to sync devices, sessions, and remote control state.', advancedOptions: 'Advanced options', @@ -57,7 +57,7 @@ export const messages: Record = { heroDescription: 'Continue sessions, follow progress, and respond to OpenBitFun from any screen.', encryptedConnection: 'End-to-end encrypted connection', enterUserIdToContinue: 'Enter your user ID to continue', - enterAccountToContinue: 'Enter your GitHub account to continue', + enterAccountToContinue: 'Enter your OpenBitFun account to continue', connectingAndPairing: 'Connecting and pairing...', pairedLoadingSessions: 'Paired! Loading sessions...', connectionError: 'Connection error', @@ -72,7 +72,7 @@ export const messages: Record = { qrExpired: 'This QR code has expired or the desktop stopped sharing. Scan a new code.', rateLimited: 'Too many pairing attempts. Wait one minute, then scan or try again.', relayUnavailable: 'The relay or desktop is temporarily unavailable. Try again shortly.', - accountSessionExpired: 'Your sign-in has expired. Sign in with GitHub again.', + accountSessionExpired: 'Your sign-in has expired. Sign in with email or GitHub again.', credentialsRejected: 'The username or password was not accepted.', fieldLabel: 'User ID', usernameLabel: 'Username', @@ -83,7 +83,7 @@ export const messages: Record = { showPassword: 'Show password', hidePassword: 'Hide password', note: 'The first successful connection binds this URL to your user ID for the current remote session.', - accountNote: 'Use the same GitHub account currently logged in on the scanned desktop. Your GitHub password is never entered in this app.', + accountNote: 'Use the same OpenBitFun account currently logged in on the scanned desktop. Use the same sign-in method on both devices.', connecting: 'Connecting...', retryIn: 'Retry in {seconds}s', loginAction: 'Sign in', @@ -162,8 +162,8 @@ export const messages: Record = { }, settings: { accountSection: 'Account', - githubAccount: 'GitHub account', - githubId: 'GitHub ID: {id}', + githubAccount: 'OpenBitFun account', + githubId: 'Account ID: {id}', currentAccount: 'Current account', signedIn: 'Verified', notSignedIn: 'Not signed in', @@ -293,7 +293,7 @@ export const messages: Record = { signOut: 'Sign out', title: 'Devices', accountReady: 'Signed in. Select an online device to access its workspaces and sessions.', - noDelegatedIdentity: 'The paired desktop is not logged into a GitHub account. Log in on the desktop to enable multi-device control, then retry.', + noDelegatedIdentity: 'The paired desktop is not logged into a OpenBitFun account. Log in on the desktop to enable multi-device control, then retry.', loading: 'Loading...', refresh: 'Refresh', retry: 'Retry', @@ -307,7 +307,7 @@ export const messages: Record = { switchFailed: 'Failed to switch device', loadFailed: 'Could not load devices. Check the connection and retry.', identityFailed: 'Could not request account access from the paired desktop.', - authorizationExpired: 'Your sign-in has expired. Sign in with GitHub again.', + authorizationExpired: 'Your sign-in has expired. Sign in with email or GitHub again.', deviceUnavailable: 'This device is offline or did not respond.', unknownDevice: 'Unnamed device', controllingDevice: 'Controlling {name}', @@ -349,14 +349,14 @@ export const messages: Record = { daysAgo: '{count} 天前', }, pairing: { - githubSignIn: "使用 GitHub 登录", - githubDescription: "使用与桌面或 CLI 相同的 GitHub 账号登录。手机用于远程控制,模型和设置由被控设备管理。", + githubSignIn: "使用邮箱或 GitHub 登录", + githubDescription: "使用与桌面或 CLI 相同的 OpenBitFun 账号登录。手机用于远程控制,模型和设置由被控设备管理。", allowSignInPopup: "请允许登录弹窗后重试。", - loginFailed: "GitHub 登录失败,请重试。", + loginFailed: "账号登录失败,请重试。", waitingForGitHub: "请在打开的页面完成 GitHub 登录。", - sameGitHubAccount: "请使用与 {account} 相同的 GitHub 账号。", + sameGitHubAccount: "请使用与 {account} 相同的 OpenBitFun 账号。", - loginTitle: '使用 GitHub 登录', + loginTitle: '使用邮箱或 GitHub 登录', connectTitle: '连接 OpenBitFun', loginDescription: '登录后即可同步设备、会话和远程控制状态。', advancedOptions: '高级选项', @@ -367,7 +367,7 @@ export const messages: Record = { heroDescription: '在任意屏幕上继续会话、查看进度,并及时回复 OpenBitFun。', encryptedConnection: '端到端加密连接', enterUserIdToContinue: '请输入你的用户 ID 继续', - enterAccountToContinue: '请输入你的 GitHub 账号继续', + enterAccountToContinue: '请输入你的 OpenBitFun 账号继续', connectingAndPairing: '正在连接并配对...', pairedLoadingSessions: '配对成功,正在加载会话...', connectionError: '连接异常', @@ -382,7 +382,7 @@ export const messages: Record = { qrExpired: '此二维码已过期,或桌面端已停止共享,请重新扫码。', rateLimited: '配对尝试过多,请等待一分钟后重新扫码或重试。', relayUnavailable: '中继服务或桌面端暂时不可用,请稍后重试。', - accountSessionExpired: '当前登录状态已过期,请重新使用 GitHub 登录。', + accountSessionExpired: '当前登录状态已过期,请重新使用邮箱或 GitHub 登录。', restoringAccount: '正在恢复登录状态…', browserStorageUnavailable: '浏览器存储不可用,请允许此网站保存数据后刷新页面重试。', browserStorageInvalid: '无法读取已保存的浏览器登录数据。数据已保留,请使用较新版本的页面重试。', @@ -397,7 +397,7 @@ export const messages: Record = { showPassword: '显示密码', hidePassword: '隐藏密码', note: '首次成功连接后,本次远程会话会把该 URL 绑定到你的用户 ID。', - accountNote: '请使用被扫码桌面端当前登录的 GitHub 账号。无需在此输入 GitHub 密码。', + accountNote: '请使用被扫码桌面端当前登录的 OpenBitFun 账号。请在两端使用相同的登录方式。', connecting: '连接中...', retryIn: '{seconds} 秒后重试', loginAction: '登录', @@ -476,8 +476,8 @@ export const messages: Record = { }, settings: { accountSection: '账号', - githubAccount: 'GitHub 账号', - githubId: 'GitHub ID:{id}', + githubAccount: 'OpenBitFun 账号', + githubId: 'Account ID:{id}', currentAccount: '当前账号', signedIn: '已认证', notSignedIn: '未登录', @@ -607,7 +607,7 @@ export const messages: Record = { signOut: '退出登录', title: '设备', accountReady: '已登录账号。选择在线设备后,即可查看它的工作区和会话。', - noDelegatedIdentity: '所连接的桌面端未使用 GitHub 登录。请在桌面端登录以启用多设备控制,然后重试。', + noDelegatedIdentity: '所连接的桌面端未使用邮箱或 GitHub 登录。请在桌面端登录以启用多设备控制,然后重试。', loading: '加载中...', refresh: '刷新', retry: '重试', @@ -621,7 +621,7 @@ export const messages: Record = { switchFailed: '切换设备失败', loadFailed: '无法加载设备,请检查连接后重试。', identityFailed: '无法从已配对桌面端获取账号访问权限。', - authorizationExpired: '当前登录状态已过期,请重新使用 GitHub 登录。', + authorizationExpired: '当前登录状态已过期,请重新使用邮箱或 GitHub 登录。', deviceUnavailable: '该设备离线或未响应。', unknownDevice: '未命名设备', controllingDevice: '正在控制 {name}', @@ -663,14 +663,14 @@ export const messages: Record = { daysAgo: '{count} 天前', }, pairing: { - githubSignIn: "使用 GitHub 登入", - githubDescription: "使用與桌面或 CLI 相同的 GitHub 帳號登入。手機用於遠端控制,模型與設定由被控裝置管理。", + githubSignIn: "使用信箱或 GitHub 登入", + githubDescription: "使用與桌面或 CLI 相同的 OpenBitFun 帳號登入。手機用於遠端控制,模型與設定由被控裝置管理。", allowSignInPopup: "請允許登入彈出視窗後重試。", - loginFailed: "GitHub 登入失敗,請重試。", + loginFailed: "帳號登入失敗,請重試。", waitingForGitHub: "請在開啟的頁面完成 GitHub 登入。", - sameGitHubAccount: "請使用與 {account} 相同的 GitHub 帳號。", + sameGitHubAccount: "請使用與 {account} 相同的 OpenBitFun 帳號。", - loginTitle: '使用 GitHub 登入', + loginTitle: '使用信箱或 GitHub 登入', connectTitle: '連接 OpenBitFun', loginDescription: '登入後即可同步裝置、會話和遠端控制狀態。', advancedOptions: '進階選項', @@ -681,7 +681,7 @@ export const messages: Record = { heroDescription: '在任意螢幕上繼續對話、查看進度,並及時回覆 OpenBitFun。', encryptedConnection: '端對端加密連線', enterUserIdToContinue: '請輸入你的用戶 ID 繼續', - enterAccountToContinue: '請輸入你的 GitHub 帳號繼續', + enterAccountToContinue: '請輸入你的 OpenBitFun 帳號繼續', connectingAndPairing: '正在連接並配對...', pairedLoadingSessions: '配對成功,正在加載會話...', connectionError: '連接異常', @@ -696,7 +696,7 @@ export const messages: Record = { qrExpired: '此 QR Code 已過期,或桌面端已停止分享,請重新掃描。', rateLimited: '配對嘗試過多,請等待一分鐘後重新掃描或重試。', relayUnavailable: '中繼服務或桌面端暫時無法使用,請稍後重試。', - accountSessionExpired: '目前登入狀態已過期,請重新使用 GitHub 登入。', + accountSessionExpired: '目前登入狀態已過期,請重新使用信箱或 GitHub 登入。', restoringAccount: '正在恢復登入狀態…', browserStorageUnavailable: '瀏覽器儲存空間無法使用,請允許此網站儲存資料後重新整理頁面再試。', browserStorageInvalid: '無法讀取已儲存的瀏覽器登入資料。資料已保留,請使用較新版本的頁面再試。', @@ -711,7 +711,7 @@ export const messages: Record = { showPassword: '顯示密碼', hidePassword: '隱藏密碼', note: '首次成功連接後,本次遠程會話會把該 URL 綁定到你的用戶 ID。', - accountNote: '請使用被掃碼桌面端目前登入的 GitHub 帳號。無需在此輸入 GitHub 密碼。', + accountNote: '請使用被掃碼桌面端目前登入的 OpenBitFun 帳號。請在兩端使用相同的登入方式。', connecting: '連接中...', retryIn: '{seconds} 秒後重試', loginAction: '登入', @@ -790,8 +790,8 @@ export const messages: Record = { }, settings: { accountSection: '帳號', - githubAccount: 'GitHub 帳號', - githubId: 'GitHub ID:{id}', + githubAccount: 'OpenBitFun 帳號', + githubId: 'Account ID:{id}', currentAccount: '目前帳號', signedIn: '已認證', notSignedIn: '未登入', @@ -921,7 +921,7 @@ export const messages: Record = { signOut: '登出', title: '設備', accountReady: '已登入帳號。選擇在線設備後,即可查看其工作區和會話。', - noDelegatedIdentity: '所連接的桌面端未使用 GitHub 登入。請在桌面端登入以啟用多設備控制,然後重試。', + noDelegatedIdentity: '所連接的桌面端未使用信箱或 GitHub 登入。請在桌面端登入以啟用多設備控制,然後重試。', loading: '加載中...', refresh: '重新整理', retry: '重試', @@ -935,7 +935,7 @@ export const messages: Record = { switchFailed: '切換設備失敗', loadFailed: '無法載入裝置,請檢查連線後重試。', identityFailed: '無法從已配對桌面端取得帳號存取權限。', - authorizationExpired: '目前登入狀態已過期,請重新使用 GitHub 登入。', + authorizationExpired: '目前登入狀態已過期,請重新使用信箱或 GitHub 登入。', deviceUnavailable: '該裝置離線或未回應。', unknownDevice: '未命名裝置', controllingDevice: '正在控制 {name}', diff --git a/src/mobile-web/src/services/CloudAccountClient.ts b/src/mobile-web/src/services/CloudAccountClient.ts index 4d0e3abe09..8436aa205a 100644 --- a/src/mobile-web/src/services/CloudAccountClient.ts +++ b/src/mobile-web/src/services/CloudAccountClient.ts @@ -86,9 +86,9 @@ export class CloudAccountClient { this.relayUrl = endpoint; } async authorize(popup: Window, signal: AbortSignal): Promise { - const start = await requestJson(this.relayUrl, '/api/auth/github/start', {}); + const start = await requestJson(this.relayUrl, '/api/auth/github/start?methods=all', {}); const url = new URL(start.authorizationUrl); - if (url.origin !== 'https://github.com' || url.pathname !== '/login/oauth/authorize' || url.username || url.password) { + if (!((url.origin === 'https://github.com' && url.pathname === '/login/oauth/authorize') || (url.origin === 'https://auth.openbitfun.com' && url.pathname === '/sign-in')) || url.username || url.password) { throw new Error('Untrusted account authorization URL.'); } if (signal.aborted) throw new Error('Sign-in cancelled.'); diff --git a/src/mobile-web/tests/account-login.test.mjs b/src/mobile-web/tests/account-login.test.mjs index af2940bf61..9a1f5ddedd 100644 --- a/src/mobile-web/tests/account-login.test.mjs +++ b/src/mobile-web/tests/account-login.test.mjs @@ -148,13 +148,17 @@ test('authorization follows the central GitHub OAuth URL and rejects lookalike d try { for (const authorizationUrl of [ 'https://github.com/login/oauth/authorize?state=test', + 'https://auth.openbitfun.com/sign-in#ticket=test', + 'https://auth.openbitfun.com.evil.example/sign-in', + 'https://user@auth.openbitfun.com/sign-in', + 'https://auth.openbitfun.com/other', 'https://github.com.attacker.example/login/oauth/authorize', 'https://github.com/login', 'https://user@github.com/login/oauth/authorize', 'http://github.com/login/oauth/authorize', ]) { let polls = 0; globalThis.fetch = async url => { - if (url.endsWith('/start')) return Response.json({ + if (url.endsWith('/start?methods=all')) return Response.json({ transactionId: 'txn', transactionSecret: 'secret', authorizationUrl, expiresAt: Date.now() / 1000 + 60, pollIntervalSeconds: 3, }); @@ -163,7 +167,7 @@ test('authorization follows the central GitHub OAuth URL and rejects lookalike d }; const popup = { location: { href: 'about:blank' } }; const result = new CloudAccountClient('https://remote.openbitfun.com/v/1.0.1').authorize(popup, new AbortController().signal); - if (authorizationUrl === 'https://github.com/login/oauth/authorize?state=test') { + if (authorizationUrl === 'https://github.com/login/oauth/authorize?state=test' || authorizationUrl === 'https://auth.openbitfun.com/sign-in#ticket=test') { assert.equal(await result, 'verified'); assert.equal(popup.location.href, authorizationUrl); assert.equal(polls, 1); diff --git a/src/skin-market-web/README.md b/src/skin-market-web/README.md index 98218762f0..a835848c83 100644 --- a/src/skin-market-web/README.md +++ b/src/skin-market-web/README.md @@ -13,7 +13,7 @@ Appearance packages. `src/links.ts`). The site is self-contained and does not import the main Web UI locale or theme catalogs. -GitHub identity is shared with the MiniApp market through its same-origin auth +GitHub and independent email-code identity are shared with the MiniApp market through its same-origin auth broker. Skin writes use the `/skin`-scoped CSRF alias issued by that broker. Local development proxies `/miniapp/api` to `127.0.0.1:9710`; set `MINIAPP_MARKET_DEV_API` when the broker runs elsewhere. diff --git a/src/skin-market-web/src/AdminPage.tsx b/src/skin-market-web/src/AdminPage.tsx index 27cf9a0618..0c056f93b8 100644 --- a/src/skin-market-web/src/AdminPage.tsx +++ b/src/skin-market-web/src/AdminPage.tsx @@ -177,7 +177,7 @@ export function AdminPage({ account, accountResolved, locale, t }: AdminPageProp
{t('reviewSubmitter')}
- {detail.submitter ? ( + {detail.submitter ? detail.submitter.githubId > 0 ? ( @{detail.submitter.login} - ) : t('reviewSubmitterUnknown')} + ) : {detail.submitter.login} : t('reviewSubmitterUnknown')}
{t('packageIdentity')}
{detail.submission.packageId || t('notDeclared')}
diff --git a/src/skin-market-web/src/App.tsx b/src/skin-market-web/src/App.tsx index b63a13c3f2..b3bfad5e60 100644 --- a/src/skin-market-web/src/App.tsx +++ b/src/skin-market-web/src/App.tsx @@ -1,5 +1,5 @@ -import { GithubLogo } from '@phosphor-icons/react'; import { + LogIn, RefreshCw as ArrowClockwise, ExternalLink as ArrowSquareOut, Globe as GlobeSimple, @@ -58,7 +58,7 @@ export default function App() { useEffect(() => { void sharedMarketAccountApi .config() - .then((config) => setGithubAuthConfigured(config.githubAuthConfigured)) + .then((config) => setGithubAuthConfigured(config.githubAuthConfigured || config.emailAuthConfigured === true)) .catch(() => undefined); void refreshAccount(); }, [refreshAccount]); @@ -191,7 +191,7 @@ export default function App() { if (githubAuthConfigured === false) event.preventDefault(); }} > -
; diff --git a/src/miniapp-market-web/src/App.tsx b/src/miniapp-market-web/src/App.tsx index 3012ec990d..95cb251dd3 100644 --- a/src/miniapp-market-web/src/App.tsx +++ b/src/miniapp-market-web/src/App.tsx @@ -98,7 +98,7 @@ function App() { function GitHubIdentityPage({ complete }: { complete: boolean }) { const { t } = useLocale(); useTheme(); - useEffect(() => { document.title = `OpenBitFun · ${t('signIn')}`; }, [t]); + useEffect(() => { document.title = `OpenBitFun · ${t(complete ? 'authComplete' : 'signIn')}`; }, [complete, t]); return {complete ? : } ; diff --git a/src/miniapp-market-web/src/api.test.ts b/src/miniapp-market-web/src/api.test.ts index b1ae5ba9b3..73d5b0b8af 100644 --- a/src/miniapp-market-web/src/api.test.ts +++ b/src/miniapp-market-web/src/api.test.ts @@ -19,7 +19,7 @@ describe('market API paths', () => { it('uses the broker camelCase return target contract', () => { expect(loginUrl('/miniapp/admin')).toBe( - 'https://auth.openbitfun.com/sign-in?returnTo=%2Fminiapp%2Fadmin', + 'https://auth.openbitfun.com/sign-in?locale=en-US&returnTo=%2Fminiapp%2Fadmin', ); }); }); diff --git a/src/miniapp-market-web/src/api.ts b/src/miniapp-market-web/src/api.ts index 4293c4acc3..1903f03f97 100644 --- a/src/miniapp-market-web/src/api.ts +++ b/src/miniapp-market-web/src/api.ts @@ -69,7 +69,7 @@ async function request(path: string, init: RequestInit = {}): Promise { export const marketApi = { startLogin: (returnTo: string) => request<{ ticket: string; emailEnabled: boolean; githubEnabled: boolean }>('/auth/login/start', { method: 'POST', body: JSON.stringify({ returnTo }) }), loginGithub: (ticket: string) => request<{ authorizationUrl: string }>('/auth/login/github', { method: 'POST', body: JSON.stringify({ ticket }) }), - sendEmailCode: (ticket: string, email: string) => request<{ challengeId: string; retryAfterSeconds: number }>('/auth/email/send', { method: 'POST', body: JSON.stringify({ ticket, email }) }), + sendEmailCode: (ticket: string, email: string, locale: string = 'en-US') => request<{ challengeId: string; retryAfterSeconds: number }>('/auth/email/send', { method: 'POST', body: JSON.stringify({ ticket, email, locale }) }), verifyEmailCode: (ticket: string, challengeId: string, code: string) => request<{ redirectUrl: string }>('/auth/email/verify', { method: 'POST', body: JSON.stringify({ ticket, challengeId, code }) }), config: () => request('/config'), me: () => request('/me'), @@ -141,7 +141,7 @@ export const marketApi = { }; export function loginUrl(returnTo = window.location.pathname): string { - return `https://auth.openbitfun.com/sign-in?returnTo=${encodeURIComponent(returnTo)}`; + return `https://auth.openbitfun.com/sign-in?locale=${encodeURIComponent((typeof document === 'undefined' ? 'en-US' : document.documentElement.lang || 'en-US'))}&returnTo=${encodeURIComponent(returnTo)}`; } export function downloadUrl(slug: string, release: number): string { diff --git a/src/miniapp-market-web/src/i18n.test.ts b/src/miniapp-market-web/src/i18n.test.ts new file mode 100644 index 0000000000..75bb05c7b9 --- /dev/null +++ b/src/miniapp-market-web/src/i18n.test.ts @@ -0,0 +1,17 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest'; +import { initialLocale, resolveMarketLocale } from './i18n'; +afterEach(() => { vi.unstubAllGlobals(); window.history.replaceState(null, '', '/'); }); +it('uses the caller language ahead of a previously saved auth preference', () => { + vi.stubGlobal('localStorage', { getItem: () => 'zh-CN' }); + window.history.replaceState(null, '', '/?locale=en-US'); + expect(initialLocale()).toBe('en-US'); +}); +it('uses browser English even when storage is unavailable', () => { + vi.stubGlobal('localStorage', { getItem: () => { throw new Error('blocked'); } }); + vi.stubGlobal('navigator', { language: 'en-GB' }); + expect(initialLocale()).toBe('en-US'); +}); +it.each([['zh-Hant-HK', 'zh-TW'], ['zh-Hans', 'zh-CN'], ['en', 'en-US'], ['fr', undefined]])('resolves %s from the shared locale contract', (input, expected) => { + expect(resolveMarketLocale(input)).toBe(expected); +}); diff --git a/src/miniapp-market-web/src/i18n.ts b/src/miniapp-market-web/src/i18n.ts index 3e39abeda7..27bd408b27 100644 --- a/src/miniapp-market-web/src/i18n.ts +++ b/src/miniapp-market-web/src/i18n.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import localeContract from '../../shared/i18n/contract/locales.json'; export type Locale = 'en-US' | 'zh-CN' | 'zh-TW'; @@ -16,6 +17,7 @@ const messages = { emailCodeSent: "Code sent. Check your inbox and spam folder.", emailResend: "Resend code", emailResendWait: "Please wait before resending", + authWorking: "Please wait…", emailChange: "Use another email", emailInvalid: "Enter a valid email address.", emailCodeInvalid: "Incorrect or expired code. Request a new code if needed.", @@ -190,6 +192,7 @@ const messages = { emailCodeSent: "验证码已发送,请检查收件箱和垃圾邮件。", emailResend: "重新发送", emailResendWait: "请稍后重新发送", + authWorking: "请稍候…", emailChange: "更换邮箱", emailInvalid: "请输入有效的邮箱地址。", emailCodeInvalid: "验证码错误或已过期,请重试或重新获取。", @@ -360,6 +363,7 @@ const messages = { emailCodeSent: "驗證碼已傳送,請檢查收件匣和垃圾郵件。", emailResend: "重新傳送", emailResendWait: "請稍後重新傳送", + authWorking: "請稍候…", emailChange: "更換信箱", emailInvalid: "請輸入有效的電子郵件地址。", emailCodeInvalid: "驗證碼錯誤或已過期,請重試或重新取得。", @@ -521,15 +525,21 @@ const messages = { export type MessageKey = keyof (typeof messages)['en-US']; -function initialLocale(): Locale { - const stored = localStorage.getItem('openbitfun-market-locale'); - if (stored === 'zh-CN' || stored === 'zh-TW' || stored === 'en-US') return stored; - const locale = navigator.language; - if (locale.toLowerCase().startsWith('zh-tw') || locale.toLowerCase().startsWith('zh-hk')) { - return 'zh-TW'; - } - if (locale.toLowerCase().startsWith('zh')) return 'zh-CN'; - return 'en-US'; +export function resolveMarketLocale(value: string | null): Locale | undefined { + const normalized = value?.trim().replace(/_/g, '-').toLowerCase(); + if (!normalized) return undefined; + const aliases = localeContract.locales.flatMap(locale => + locale.aliases.map(alias => ({ alias: alias.toLowerCase(), locale: locale.id as Locale })), + ).sort((left, right) => right.alias.length - left.alias.length); + return aliases.find(({ alias }) => normalized === alias || normalized.startsWith(`${alias}-`))?.locale; +} + +export function initialLocale(): Locale { + const requested = resolveMarketLocale(new URLSearchParams(window.location.search).get('locale')); + if (requested) return requested; + let stored: string | null = null; + try { stored = localStorage.getItem('openbitfun-market-locale'); } catch { /* Storage can be blocked in an auth popup. */ } + return resolveMarketLocale(stored) ?? resolveMarketLocale(navigator.language) ?? localeContract.fallbackLocale as Locale; } export function useLocale() { @@ -537,11 +547,16 @@ export function useLocale() { const t = useCallback((key: MessageKey) => messages[locale][key], [locale]); useEffect(() => { document.documentElement.lang = locale; - localStorage.setItem('openbitfun-market-locale', locale); + try { localStorage.setItem('openbitfun-market-locale', locale); } catch { /* Keep the selected language in memory. */ } }, [locale]); return { locale, - setLocale: setLocaleState, + setLocale: (next: Locale) => { + const url = new URL(window.location.href); + url.searchParams.set('locale', next); + window.history.replaceState(null, '', url); + setLocaleState(next); + }, t, }; } diff --git a/src/mobile-web/src/services/CloudAccountClient.ts b/src/mobile-web/src/services/CloudAccountClient.ts index 59e3fae2d3..2eb87e1697 100644 --- a/src/mobile-web/src/services/CloudAccountClient.ts +++ b/src/mobile-web/src/services/CloudAccountClient.ts @@ -91,6 +91,7 @@ export class CloudAccountClient { if (!((url.origin === 'https://github.com' && url.pathname === '/login/oauth/authorize') || (url.origin === 'https://auth.openbitfun.com' && url.pathname === '/sign-in')) || url.username || url.password) { throw new Error('Untrusted account authorization URL.'); } + if (url.origin === 'https://auth.openbitfun.com') url.searchParams.set('locale', (typeof document === 'undefined' ? 'en-US' : document.documentElement.lang || 'en-US')); if (signal.aborted) throw new Error('Sign-in cancelled.'); popup.location.href = url.href; while (!signal.aborted && Date.now() < start.expiresAt * 1000) { diff --git a/src/mobile-web/tests/account-login.test.mjs b/src/mobile-web/tests/account-login.test.mjs index 9a1f5ddedd..cac08a9044 100644 --- a/src/mobile-web/tests/account-login.test.mjs +++ b/src/mobile-web/tests/account-login.test.mjs @@ -169,7 +169,7 @@ test('authorization follows the central GitHub OAuth URL and rejects lookalike d const result = new CloudAccountClient('https://remote.openbitfun.com/v/1.0.1').authorize(popup, new AbortController().signal); if (authorizationUrl === 'https://github.com/login/oauth/authorize?state=test' || authorizationUrl === 'https://auth.openbitfun.com/sign-in#ticket=test') { assert.equal(await result, 'verified'); - assert.equal(popup.location.href, authorizationUrl); + assert.equal(popup.location.href, authorizationUrl.startsWith('https://auth.openbitfun.com') ? authorizationUrl.replace('/sign-in#', '/sign-in?locale=en-US#') : authorizationUrl); assert.equal(polls, 1); } else { await assert.rejects(result, /Untrusted/); diff --git a/src/skin-market-web/src/account.ts b/src/skin-market-web/src/account.ts index ece1126b4e..28fbb44f8b 100644 --- a/src/skin-market-web/src/account.ts +++ b/src/skin-market-web/src/account.ts @@ -66,5 +66,5 @@ export const sharedMarketAccountApi = { export function sharedMarketLoginUrl( returnTo = `${window.location.pathname}${window.location.search}`, ): string { - return `https://auth.openbitfun.com/sign-in?returnTo=${encodeURIComponent(returnTo)}`; + return `https://auth.openbitfun.com/sign-in?locale=${encodeURIComponent((typeof document === 'undefined' ? 'en-US' : document.documentElement.lang || 'en-US'))}&returnTo=${encodeURIComponent(returnTo)}`; } diff --git a/src/skin-market-web/src/api.test.ts b/src/skin-market-web/src/api.test.ts index 8de33b95f6..2ab3e095ca 100644 --- a/src/skin-market-web/src/api.test.ts +++ b/src/skin-market-web/src/api.test.ts @@ -48,7 +48,7 @@ describe('Skin Market API paths', () => { it('uses the MiniApp auth broker and returns to the current Skin route', () => { expect(sharedMarketLoginUrl('/skin/appearances/ocean-night?q=dark')).toBe( - 'https://auth.openbitfun.com/sign-in?returnTo=%2Fskin%2Fappearances%2Focean-night%3Fq%3Ddark', + 'https://auth.openbitfun.com/sign-in?locale=en-US&returnTo=%2Fskin%2Fappearances%2Focean-night%3Fq%3Ddark', ); }); diff --git a/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.test.ts b/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.test.ts index e761637108..579ac5d046 100644 --- a/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.test.ts +++ b/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.test.ts @@ -60,10 +60,25 @@ function setup() { const activeServices: AccountIdentityService[] = []; afterEach(() => { + document.documentElement.lang = ''; activeServices.splice(0).forEach(service => service.dispose()); }); describe('AccountIdentityService', () => { + it('carries the selected app language into auth without changing the ticket fragment', async () => { + const { api, dependencies, service } = setup(); + activeServices.push(service); + document.documentElement.lang = 'en-US'; + api.authStart.mockResolvedValue({ + transactionId: 'transaction-1', authorizationUrl: 'https://auth.openbitfun.com/sign-in#ticket=test-ticket', + expiresAt: 100, pollIntervalSeconds: 1, + }); + await service.initialize(); + api.me.mockResolvedValue(profile); + await service.signIn(); + expect(dependencies.openExternal).toHaveBeenCalledWith('https://auth.openbitfun.com/sign-in?locale=en-US#ticket=test-ticket'); + }); + it('uses the MiniApp desktop OAuth flow, keeps tokens out of the renderer, and shares identity', async () => { const { api, dependencies, service, syncPort } = setup(); activeServices.push(service); diff --git a/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.ts b/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.ts index 0cf105bd64..570c3955b0 100644 --- a/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.ts +++ b/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.ts @@ -271,7 +271,11 @@ export class AccountIdentityService { private async runSignIn(generation: number): Promise { const transaction = await this.dependencies.api.authStart(); this.ensureCurrentAuth(generation); - await this.dependencies.openExternal(transaction.authorizationUrl); + const authorizationUrl = new URL(transaction.authorizationUrl); + if (authorizationUrl.origin === 'https://auth.openbitfun.com' && typeof document !== 'undefined') { + authorizationUrl.searchParams.set('locale', document.documentElement.lang || 'en-US'); + } + await this.dependencies.openExternal(authorizationUrl.href); const deadline = transaction.expiresAt * 1000; while (this.dependencies.now() < deadline) { await this.dependencies.sleep(Math.max(1, transaction.pollIntervalSeconds) * 1000); From 908aa9dd451eb59ecf6ab17f89df1402af817b7c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 15 Sep 2026 14:45:20 +0800 Subject: [PATCH 16/23] feat(auth): support explicitly configured private SMTP relays --- deploy/miniapp-market/README.md | 26 ++++++ .../miniapp-market-service/src/email_auth.rs | 86 +++++++++++++++---- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/deploy/miniapp-market/README.md b/deploy/miniapp-market/README.md index 8d08fb2339..4f212833b0 100644 --- a/deploy/miniapp-market/README.md +++ b/deploy/miniapp-market/README.md @@ -524,3 +524,29 @@ migration 0002 保留原用户 ID 和外键,但旧 binary 不认识邮箱用 认证页优先使用该参数,其次使用已保存的语言和浏览器语言;别名遵循共享 i18n contract。 发送验证码请求可携带 `locale`(`en-US`、`zh-CN` 或 `zh-TW`),邮件主题、HTML 和纯文本使用同一语言。 旧客户端省略该字段或传入未知值时默认发送英文邮件,不影响验证码和令牌协议。 + +### Same-host outbound SMTP relay + +For a send-only Postfix relay on the auth server, set `SMTP_SECURITY=local`, +`SMTP_HOST` to its literal loopback/private IPv4 address, `SMTP_PORT` to the +private listener port, and `SMTP_USERNAME` to the sender address. Leave +`SMTP_PASSWORD` unset. This explicit mode does not send credentials or use TLS; +use it only over loopback or an isolated same-host Docker bridge. Public IPs, +DNS names, and supplied passwords are rejected. Existing `ssl`/`starttls` +configurations retain certificate validation and authenticated delivery. + +Bind Postfix only to loopback and the auth bridge gateway; restrict `mynetworks` +to loopback and the auth container's exact address, with `permit_mynetworks, +reject` relay/client restrictions. Recheck the container address after deployment. +Do not expose SMTP through a public Docker port or WAF. Restrict envelope senders +to the configured sender, sign with OpenDKIM, and keep `relayhost` empty for direct +MX delivery. Enable opportunistic TLS for outbound delivery, persistent queues, +and systemd startup ordering after Docker. Keep DKIM private keys and any SMTP +configuration under root-controlled server paths, outside the repository. + +Before switching production auth, publish a matching A/PTR identity, add the +outbound IP to the existing SPF record (never create a second SPF record), publish +DKIM, and establish DMARC. Keep existing MX records when inbound email remains +with another provider. Verify real delivery and recipient authentication headers; +SMTP queue acceptance alone does not prove delivery to the inbox. Preserve the +previous SMTP configuration for rollback until the new route is verified. diff --git a/src/crates/services/miniapp-market-service/src/email_auth.rs b/src/crates/services/miniapp-market-service/src/email_auth.rs index 8e7737cb7a..8575bebdc1 100644 --- a/src/crates/services/miniapp-market-service/src/email_auth.rs +++ b/src/crates/services/miniapp-market-service/src/email_auth.rs @@ -32,26 +32,50 @@ impl std::fmt::Debug for Mailer { } impl Mailer { pub(crate) fn from_env() -> MarketResult> { - let get = |key| std::env::var(key).ok().filter(|s| !s.is_empty()); - let Some(password) = get("SMTP_PASSWORD") else { + Self::from_settings(|key| std::env::var(key).ok().filter(|s| !s.is_empty())) + } + fn from_settings(get: impl Fn(&str) -> Option) -> MarketResult> { + let security = get("SMTP_SECURITY").unwrap_or_else(|| "ssl".into()); + let password = get("SMTP_PASSWORD"); + if security != "local" && password.is_none() { return Ok(None); - }; + } let username = get("SMTP_USERNAME") .ok_or_else(|| MarketError::internal("SMTP_USERNAME is required"))?; let host = get("SMTP_HOST").unwrap_or_else(|| "smtp.qiye.aliyun.com".into()); - let security = get("SMTP_SECURITY").unwrap_or_else(|| "ssl".into()); let builder = match security.as_str() { - "ssl" => AsyncSmtpTransport::::relay(&host), - "starttls" => AsyncSmtpTransport::::starttls_relay(&host), + "ssl" => AsyncSmtpTransport::::relay(&host) + .map_err(|_| MarketError::internal("Invalid SMTP TLS configuration"))?, + "starttls" => AsyncSmtpTransport::::starttls_relay(&host) + .map_err(|_| MarketError::internal("Invalid SMTP TLS configuration"))?, + "local" => { + // Only explicitly configured same-host/isolated private relays may omit TLS. + let private_address = host.parse::().is_ok_and(|ip| match ip { + std::net::IpAddr::V4(ip) => ip.is_loopback() || ip.is_private(), + std::net::IpAddr::V6(ip) => ip.is_loopback(), + }); + if !private_address || password.is_some() { + return Err(MarketError::internal( + "Local SMTP requires a private IP literal and must not send a password", + )); + } + AsyncSmtpTransport::::builder_dangerous(&host) + } _ => { return Err(MarketError::internal( - "SMTP_SECURITY must be ssl or starttls", + "SMTP_SECURITY must be ssl, starttls or local", )) } - } - .map_err(|_| MarketError::internal("Invalid SMTP TLS configuration"))?; + }; let port = get("SMTP_PORT") - .unwrap_or_else(|| if security == "ssl" { "465" } else { "587" }.into()) + .unwrap_or_else(|| { + match security.as_str() { + "ssl" => "465", + "local" => "25", + _ => "587", + } + .into() + }) .parse::() .map_err(|_| MarketError::internal("Invalid SMTP_PORT"))?; let from = Mailbox::new( @@ -60,15 +84,18 @@ impl Mailer { .parse() .map_err(|_| MarketError::internal("Invalid SMTP_USERNAME"))?, ); + let mut transport = builder + .port(port) + .timeout(Some(std::time::Duration::from_secs(15))); + if let Some(password) = password { + transport = transport.credentials(Credentials::new(username, password)); + } Ok(Some(Self { - transport: builder - .port(port) - .timeout(Some(std::time::Duration::from_secs(15))) - .credentials(Credentials::new(username, password)) - .build(), + transport: transport.build(), from, })) } + async fn send(&self, email: &str, code: &str, locale: &str) -> MarketResult<()> { let message = verification_message(self.from.clone(), email, code, locale)?; tokio::time::timeout( @@ -764,6 +791,35 @@ mod tests { assert!(!profile["user"]["login"].as_str().unwrap().contains('@')); } + #[test] + fn local_smtp_is_explicit_private_and_never_sends_credentials() { + assert!(Mailer::from_settings(|_| None).unwrap().is_none()); + for host in ["127.0.0.1", "172.19.0.1", "::1"] { + assert!(Mailer::from_settings(|key| match key { + "SMTP_SECURITY" => Some("local".into()), + "SMTP_HOST" => Some(host.into()), + "SMTP_USERNAME" => Some("hello@example.com".into()), + _ => None, + }) + .unwrap() + .is_some()); + } + for (host, password) in [ + ("8.8.8.8", None), + ("smtp.example.com", None), + ("172.19.0.1", Some("do-not-transmit")), + ] { + assert!(Mailer::from_settings(|key| match key { + "SMTP_SECURITY" => Some("local".into()), + "SMTP_HOST" => Some(host.into()), + "SMTP_USERNAME" => Some("hello@example.com".into()), + "SMTP_PASSWORD" => password.map(str::to_owned), + _ => None, + }) + .is_err()); + } + } + #[test] fn verification_email_has_html_and_plain_text_without_attachments() { let message = verification_message( From 49c80ed03b90c51ccacb39eb721c9d220787a473 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 15 Sep 2026 14:54:05 +0800 Subject: [PATCH 17/23] docs(mail): allow sender addresses across the owned domain --- deploy/miniapp-market/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/miniapp-market/README.md b/deploy/miniapp-market/README.md index 4f212833b0..fea8563b78 100644 --- a/deploy/miniapp-market/README.md +++ b/deploy/miniapp-market/README.md @@ -539,7 +539,8 @@ Bind Postfix only to loopback and the auth bridge gateway; restrict `mynetworks` to loopback and the auth container's exact address, with `permit_mynetworks, reject` relay/client restrictions. Recheck the container address after deployment. Do not expose SMTP through a public Docker port or WAF. Restrict envelope senders -to the configured sender, sign with OpenDKIM, and keep `relayhost` empty for direct +to the owned sender domain (for example, `*@openbitfun.com`), sign all its sender +addresses with OpenDKIM, and keep `relayhost` empty for direct MX delivery. Enable opportunistic TLS for outbound delivery, persistent queues, and systemd startup ordering after Docker. Keep DKIM private keys and any SMTP configuration under root-controlled server paths, outside the repository. From 074d680499c5c4b6e4237fe7cc90c92a1f6d79d4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 15 Sep 2026 15:09:07 +0800 Subject: [PATCH 18/23] docs(mail): add maintenance runbook and link email branding to website --- deploy/README.md | 1 + deploy/miniapp-market/README.md | 2 + deploy/outbound-mail/README.md | 183 ++++++++++++++++++ .../src/email/locales.json | 12 +- .../src/email/sign-in.html | 6 +- 5 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 deploy/outbound-mail/README.md diff --git a/deploy/README.md b/deploy/README.md index eb5be9ff77..192b707bd7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -8,6 +8,7 @@ Production origin restore and product deploys for the current OpenBitFun host | [openbitfun-host/](openbitfun-host/README.md) | New-host restore: website, release mirror, Relay Nginx, New API boundary | | [miniapp-market/](miniapp-market/README.md) | MiniApp market only | | [skin-market/](skin-market/README.md) | Skin market only | +| [outbound-mail/](outbound-mail/README.md) | Send-only Postfix/OpenDKIM: auth integration, DNS, maintenance and rollback | An Agent that is asked to move or rebuild the server starts at `openbitfun-host/AGENTS.md`. diff --git a/deploy/miniapp-market/README.md b/deploy/miniapp-market/README.md index fea8563b78..4c8ea7b45d 100644 --- a/deploy/miniapp-market/README.md +++ b/deploy/miniapp-market/README.md @@ -527,6 +527,8 @@ migration 0002 保留原用户 ID 和外键,但旧 binary 不认识邮箱用 ### Same-host outbound SMTP relay +完整维护流程见 [自建发信服务维护手册](../outbound-mail/README.md)。 + For a send-only Postfix relay on the auth server, set `SMTP_SECURITY=local`, `SMTP_HOST` to its literal loopback/private IPv4 address, `SMTP_PORT` to the private listener port, and `SMTP_USERNAME` to the sender address. Leave diff --git a/deploy/outbound-mail/README.md b/deploy/outbound-mail/README.md new file mode 100644 index 0000000000..1078ba4bc6 --- /dev/null +++ b/deploy/outbound-mail/README.md @@ -0,0 +1,183 @@ +# 自建发信服务维护手册 + +OpenBitFun 的验证码邮件可以通过同机 Postfix 直接投递给收件方 MX, +OpenDKIM 为邮件签名。不需要部署 IMAP、POP3、用户邮箱或公网收信入口。 +认证服务负责生成验证码及本地化邮件模板;Postfix 负责签名接入、队列和投递。 + +```text +auth → 私有 SMTP → Postfix → 收件方 MX:25 + ↕ + OpenDKIM +``` + +本手册描述可复用的维护规则。生产实例的具体 IP、路径、已部署版本和回滚文件见 +服务器 `/etc/openbitfun-mail/README.md`。不要将 SMTP 密码、DKIM 私钥、验证码、 +认证令牌或生产 env 文件复制到仓库。 + +## 网络与发信身份 + +- 同机进程可以通过 `127.0.0.1:25` 提交邮件,无需 SMTP 密码。 +- 认证容器使用同机专用 Docker 网桥的宿主机地址;只授权该容器的确切 IP。 +- Postfix 仅监听 loopback 和该网桥,不监听公网网卡,不发布公网 Docker SMTP 端口。 +- 出站 TCP 25 必须能连接收件方 MX。WAF 处理入站 HTTP(S),不代理出站 SMTP。 +- 允许整个自有域名的发件人,例如 `hello@openbitfun.com`、`support@openbitfun.com`。 + 新增同域发件地址无需创建本地邮箱。回复能否被接收,仍取决于已有收信服务。 +- `mynetworks` 限制调用方,发件地址规则限制可使用的域名;不能仅靠发件域名防止开放中继。 + +## 认证服务接入 + +在服务器的 SMTP env 文件中配置;私有模式示例: + +```dotenv +SMTP_SECURITY=local +SMTP_HOST=172.19.0.1 +SMTP_PORT=25 +SMTP_USERNAME=hello@openbitfun.com +SMTP_PASSWORD= +SMTP_FROM_NAME=OpenBitFun +``` + +示例 IP 必须替换成该实例的实际网桥地址。`SMTP_USERNAME` 在此模式中是发件地址, +不是登录凭据。`local` 只接受私有 IPv4/loopback 地址字面量,并拒绝非空密码; +不得用于跨主机的不可信网络。远端 SMTP 保持使用 `ssl` 或 `starttls`,验证证书并认证。 + +变更 env 后,需要使用已部署的提交版本重新创建认证容器,仅 `docker restart` +不会加载更新后的 Compose env_file。参考 [MiniApp 部署手册](../miniapp-market/README.md)。 +确认 `/miniapp/api/v1/health` 的 `emailAuthConfigured` 为 `true`。 + +`locale` 决定验证码邮件主题、HTML 与纯文本语言:`en-US`、`zh-CN`、`zh-TW`。 +旧客户端省略或传入未知值时使用英文。自建 SMTP 不会取消应用层验证码限流。 + +## 初次部署与恢复 + +Ubuntu 可安装 `postfix opendkim opendkim-tools`。安装时先选择仅本地监听, +完成调用方和发件域名限制后再启用网桥接入。以下是必须核对的配置关系, +不能将示例 IP 原样用于其他服务器。 + +`/etc/postfix/main.cf` 的关键项目: + +```ini +myhostname = outbound.openbitfun.com +myorigin = openbitfun.com +mydestination = +relayhost = +default_transport = smtp +relay_transport = relay +inet_protocols = ipv4 +inet_interfaces = 127.0.0.1, 172.19.0.1 +mynetworks = 127.0.0.0/8, 172.19.0.2/32 +smtpd_client_restrictions = permit_mynetworks, reject +smtpd_relay_restrictions = permit_mynetworks, reject +smtpd_sender_restrictions = check_sender_access regexp:/etc/postfix/allowed_sender_domains, reject +smtpd_milters = inet:127.0.0.1:8891 +non_smtpd_milters = inet:127.0.0.1:8891 +milter_protocol = 6 +milter_default_action = tempfail +smtp_tls_security_level = may +smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt +disable_vrfy_command = yes +``` + +`Local only` 安装配置可能留下 `default_transport = error`,必须改为上述出站 transport。 +`/etc/postfix/allowed_sender_domains` 使用精确的域名匹配: + +```text +/^[^@[:space:]]+@openbitfun[.]com$/ OK +``` + +这是 `regexp:` 映射,修改后不运行 `postmap` 生成 hash 数据库,检查配置并 reload 即可。 +其他域名不要加入此规则,除非已经完成该域名的所有权和发信验证。 + +`/etc/opendkim.conf` 的关键项目: + +```text +Syslog yes +SyslogSuccess yes +UserID opendkim +PidFile /run/opendkim/opendkim.pid +UMask 007 +Mode s +Canonicalization relaxed/relaxed +SignatureAlgorithm rsa-sha256 +Socket inet:8891@127.0.0.1 +KeyTable refile:/etc/opendkim/key.table +SigningTable refile:/etc/opendkim/signing.table +InternalHosts refile:/etc/opendkim/trusted.hosts +OversignHeaders From +``` + +`PidFile` 必须与发行版 systemd 单元一致,否则可能出现进程已运行但服务一直停留在 +`activating`。签名表用 `*@openbitfun.com` 匹配整个域名。KeyTable 指向相应 selector +及私钥;InternalHosts 只列 loopback 和确切的认证容器地址。 + +私钥由 `opendkim-genkey -b 2048` 在服务器上生成,目录仅允许维护者/签名服务访问, +私钥为 `opendkim:opendkim`、`0600`。只将生成的 `.txt` 公钥发布到 DNS。 +启用 `opendkim`、`postfix` 开机启动,并让 Postfix 的 systemd 启动顺序位于 Docker +和网络就绪之后。恢复服务器时先恢复网桥和签名文件,再启动邮件服务。 + +## DNS、换 IP 与密钥轮换 + +| 记录 | 作用 | +| --- | --- | +| `outbound` A | 指向实际出站公网 IP,不接入 WAF | +| 该公网 IP 的 PTR | 在云厂商反向解析中指向 `outbound.openbitfun.com` | +| 根域 TXT SPF | 在原有 SPF 中添加 `ip4:新IP`;保留仍在使用的发信商授权,不能新增第二条 SPF | +| `selector._domainkey` TXT | 对应 OpenDKIM selector 的完整公钥 | +| `_dmarc` TXT | 初始使用 `v=DMARC1; p=none`;验证全部合法发信源后再考虑更严格策略 | + +现有 MX 和企业邮箱入口保持不变。只有发信服务,不需要新增指向本机的 MX。 +修改 DNS 后同时检查权威服务器和公共递归解析。PTR、正向地址与 SMTP HELO 身份应匹配。 +QQ 官方说明其 SPF 缓存最长可达 12 小时,换 IP 必须提前发布授权; +不要因缓存拒收而放宽 SPF 为 `+all` 或关闭验证。 + +轮换 DKIM 时使用新 selector:生成新密钥、发布新公钥、确认解析与 +`opendkim-testkey` 成功,然后切换签名表并重启 OpenDKIM。旧公钥保留到旧邮件全部投递 +且缓存窗口结束,之后再撤除。私钥应有加密备份,不能放入普通 Git、工单或聊天记录。 + +## 日常检查与排障 + +```bash +systemctl is-active postfix opendkim +systemctl is-enabled postfix opendkim +postfix check +opendkim -n -x /etc/opendkim.conf +ss -ltn | grep -E ':25 |:8891 ' +postqueue -p +journalctl --since '30 minutes ago' -t postfix/smtp -t postfix/smtpd -t postfix/qmgr -u opendkim +``` + +| 现象 | 检查方向 | +| --- | --- | +| API 不提供邮箱登录 | SMTP env 是否加载、配置是否合法、健康接口是否报告已配置 | +| API 返回成功但收不到 | 按 Postfix queue ID 查 `status=sent/deferred/bounced`,不要把队列接受当收件成功 | +| `550 SPF check failed` | 出站 IP、SPF 唯一性、权威 DNS 与收件方缓存;记录回包后定向重试 | +| DKIM milter 连接失败 | OpenDKIM 状态、8891 监听、私钥权限和配置;保持 `tempfail`,不要静默去掉签名 | +| `Relay access denied` / `Access denied` | 实际容器 IP 与 mynetworks、发件域名映射、客户端限制 | +| `Recipient address rejected` 且 transport 为 error | `Local only` 安装遗留的 default_transport/relay_transport | +| 外部 MX 连接超时 | 出站 25、云厂商限制、DNS 和目标 MX 可达性 | +| 已 accepted 但进入垃圾箱 | 收件邮件的 SPF/DKIM/DMARC 结果、IP/域名信誉及内容;记录真实收件位置 | + +验收分三层:API 提交成功、收件方 MX 返回 `250`、指定收件人确认收到。 +测试只发给操作者明确授权的地址,不打印验证码或原始邮件正文。 +失败重试使用新验证码,不能让已过期验证码重新生效。 + +当前实例针对验证码将队列寿命设为 10 分钟、消息大小设为 1 MiB,并限制目标并发。 +这些属于业务参数;接入其他邮件类型前应评估队列寿命、退信处理和容量。 +不要直接清空整个队列来“修复”投递,按 queue ID 处理已确认可丢弃的测试邮件。 + +## 配置更新、备份与回滚 + +1. 将 Postfix/OpenDKIM 配置、签名密钥和 SMTP env 备份到服务器受限目录;离机备份加密。 +2. 修改前记录运行状态、当前镜像 revision 和容器网桥地址。镜像更新后再次核对调用方 IP。 +3. Postfix 普通配置运行 `postfix check` 后 reload;更改监听地址/协议后 restart。 + OpenDKIM 配置或签名表修改后先校验,再 restart。 +4. 发送授权测试并检查实际结果。回退发信路径时恢复之前的 SMTP env,使用同一个已部署 + 代码版本重新创建认证容器;不回退数据库、不删除用户和令牌。 +5. 回滚到外部 SMTP 只改变投递路径,必须明确记录仍使用外部提供商,不能报告为自建直投成功。 + +官方参考: +[Postfix 基础配置](https://www.postfix.org/BASIC_CONFIGURATION_README.html)、 +[Postfix 中继限制](https://www.postfix.org/SMTPD_ACCESS_README.html)、 +[Postfix Milter](https://www.postfix.org/MILTER_README.html)、 +[QQ SPF 缓存说明](https://service.mail.qq.com/detail/122/72)、 +[华为云 PTR](https://support.huaweicloud.com/usermanual-dns/zh-cn_topic_0077500015.html)。 diff --git a/src/crates/services/miniapp-market-service/src/email/locales.json b/src/crates/services/miniapp-market-service/src/email/locales.json index 038ee1cd01..7c228b0c61 100644 --- a/src/crates/services/miniapp-market-service/src/email/locales.json +++ b/src/crates/services/miniapp-market-service/src/email/locales.json @@ -6,8 +6,8 @@ "intro": "Enter the code below on the sign-in page to continue your work and creation.", "expiry": "Valid for 10 minutes · Use once", "security": "Keep this code private. If you did not request it, you can safely ignore this email.", - "footer": "OpenBitFun · Open source, more freedom to create", - "plain": "Your OpenBitFun verification code is: {{code}}\n\nIt expires in 10 minutes and can only be used once. Do not share this code.\nIf you did not request it, ignore this email.\n\nOpenBitFun" + "footer": "Open source, more freedom to create", + "plain": "Your OpenBitFun verification code is: {{code}}\n\nIt expires in 10 minutes and can only be used once. Do not share this code.\nIf you did not request it, ignore this email.\n\nOpenBitFun\nhttps://openbitfun.com" }, "zh-CN": { "subject": "OpenBitFun 登录验证码", @@ -16,8 +16,8 @@ "intro": "请在登录页面输入下方验证码,继续你的工作与创作。", "expiry": "10 分钟内有效 · 仅可使用一次", "security": "请勿向他人透露此验证码。如果你没有发起登录,无需操作,忽略此邮件即可。", - "footer": "OpenBitFun · 开源,让创造更自由", - "plain": "你的 OpenBitFun 登录验证码是:{{code}}\n\n验证码 10 分钟内有效,仅可使用一次。请勿向他人透露。\n如非本人操作,请忽略此邮件。\n\nOpenBitFun" + "footer": "开源,让创造更自由", + "plain": "你的 OpenBitFun 登录验证码是:{{code}}\n\n验证码 10 分钟内有效,仅可使用一次。请勿向他人透露。\n如非本人操作,请忽略此邮件。\n\nOpenBitFun\nhttps://openbitfun.com" }, "zh-TW": { "subject": "OpenBitFun 登入驗證碼", @@ -26,7 +26,7 @@ "intro": "請在登入頁面輸入下方驗證碼,繼續你的工作與創作。", "expiry": "10 分鐘內有效 · 僅可使用一次", "security": "請勿向他人透露此驗證碼。如果你沒有發起登入,無需操作,忽略此郵件即可。", - "footer": "OpenBitFun · 開源,讓創造更自由", - "plain": "你的 OpenBitFun 登入驗證碼是:{{code}}\n\n驗證碼 10 分鐘內有效,僅可使用一次。請勿向他人透露。\n如非本人操作,請忽略此郵件。\n\nOpenBitFun" + "footer": "開源,讓創造更自由", + "plain": "你的 OpenBitFun 登入驗證碼是:{{code}}\n\n驗證碼 10 分鐘內有效,僅可使用一次。請勿向他人透露。\n如非本人操作,請忽略此郵件。\n\nOpenBitFun\nhttps://openbitfun.com" } } diff --git a/src/crates/services/miniapp-market-service/src/email/sign-in.html b/src/crates/services/miniapp-market-service/src/email/sign-in.html index 9456f2a221..746fcdc3dc 100644 --- a/src/crates/services/miniapp-market-service/src/email/sign-in.html +++ b/src/crates/services/miniapp-market-service/src/email/sign-in.html @@ -32,9 +32,9 @@ - +
- + OpenBitFun OpenBitFunOpenBitFun

ACCOUNT · SIGN IN

@@ -49,7 +49,7 @@

{{security}}

-

{{footer}}

+

OpenBitFun · {{footer}}

From 6b0ca48194456d0d2dcba98d09bb42ec3b38fe91 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 15 Sep 2026 15:13:39 +0800 Subject: [PATCH 19/23] test(cli): keep mock model alive through cold terminal startup --- src/apps/cli/tests/support/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/apps/cli/tests/support/mod.rs b/src/apps/cli/tests/support/mod.rs index edf2d04361..f0c2eb97a8 100644 --- a/src/apps/cli/tests/support/mod.rs +++ b/src/apps/cli/tests/support/mod.rs @@ -410,7 +410,9 @@ impl MockOpenAiServer { let (release_tx, release_rx) = mpsc::channel(); let (disconnect_tx, disconnect_rx) = mpsc::channel(); let thread = thread::spawn(move || { - let deadline = Instant::now() + Duration::from_secs(30); + // The owning test bounds startup and turn waits. Keep the listener alive + // until Drop: a cold CLI startup can consume the old 30-second lifetime + // before its first model request, especially on Windows CI. let mut attempt = 0; loop { match listener.accept() { @@ -454,7 +456,7 @@ impl MockOpenAiServer { break; } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - if stop_for_thread.load(Ordering::Relaxed) || Instant::now() >= deadline { + if stop_for_thread.load(Ordering::Relaxed) { break; } thread::sleep(Duration::from_millis(10)); From cda758eafbdaf022bd9a5bcfef1df67d11c1ebd7 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 15 Sep 2026 15:29:01 +0800 Subject: [PATCH 20/23] test(auth): verify persistent brute-force and send limits under concurrency --- .../miniapp-market-service/src/email_auth.rs | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/crates/services/miniapp-market-service/src/email_auth.rs b/src/crates/services/miniapp-market-service/src/email_auth.rs index 8575bebdc1..9d3a5fc9b0 100644 --- a/src/crates/services/miniapp-market-service/src/email_auth.rs +++ b/src/crates/services/miniapp-market-service/src/email_auth.rs @@ -625,6 +625,101 @@ mod tests { .unwrap(); assert_eq!(count.0, 0); } + #[tokio::test] + async fn concurrent_wrong_codes_share_a_persistent_attempt_budget() { + let (dir, service) = setup().await; + let ticket = service.start_web_login("/miniapp/").await.unwrap(); + let (id, code) = code(&service, &ticket, "alice@example.com").await; + let wrong = if code == "000000" { "111111" } else { "000000" }; + for _ in 0..4 { + assert_eq!( + service + .verify_email_code(verify(&ticket, &id, wrong)) + .await + .unwrap_err() + .code, + invalid_code().code + ); + } + // Reopen the database as a restarted process, then race for the final attempt. + let restarted = AuthService::new( + service.config.clone(), + Database::open(&dir.path().join("db")).await.unwrap(), + ) + .unwrap(); + let (a, b, c) = tokio::join!( + service.verify_email_code(verify(&ticket, &id, wrong)), + restarted.verify_email_code(verify(&ticket, &id, wrong)), + restarted.verify_email_code(verify(&ticket, &id, wrong)), + ); + for result in [a, b, c] { + assert_eq!(result.unwrap_err().code, invalid_code().code); + } + let attempts: i64 = + sqlx::query_scalar("SELECT attempts FROM email_challenges WHERE id = ?") + .bind(&id) + .fetch_one(restarted.db.pool()) + .await + .unwrap(); + assert_eq!(attempts, 5); + assert!(restarted + .verify_email_code(verify(&ticket, &id, &code)) + .await + .is_err()); + let sessions: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM web_sessions") + .fetch_one(restarted.db.pool()) + .await + .unwrap(); + assert_eq!(sessions, 0); + } + + #[tokio::test] + async fn new_flows_and_concurrent_sends_cannot_reset_address_quota() { + let (_dir, service) = setup().await; + let a = service.start_web_login("/miniapp/").await.unwrap(); + let b = service.start_web_login("/skin/").await.unwrap(); + let send = |ticket: &str| EmailSendRequest { + ticket: ticket.into(), + email: "ALICE@example.com".into(), + locale: None, + }; + let (first, second) = tokio::join!( + service.prepare_email_code(send(&a)), + service.prepare_email_code(send(&b)), + ); + assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); + assert_eq!( + first.err().or(second.err()).unwrap().code, + "email_rate_limit" + ); + for _ in 1..10 { + sqlx::query("UPDATE email_challenges SET created_at = created_at - 61") + .execute(service.db.pool()) + .await + .unwrap(); + let ticket = service.start_web_login("/miniapp/").await.unwrap(); + service.prepare_email_code(send(&ticket)).await.unwrap(); + } + sqlx::query("UPDATE email_challenges SET created_at = created_at - 61") + .execute(service.db.pool()) + .await + .unwrap(); + let ticket = service.start_web_login("/miniapp/").await.unwrap(); + assert_eq!( + service + .prepare_email_code(send(&ticket)) + .await + .unwrap_err() + .code, + "email_rate_limit" + ); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_challenges") + .fetch_one(service.db.pool()) + .await + .unwrap(); + assert_eq!(count, 10); + } + #[tokio::test] async fn rate_limits_persist_and_expired_codes_fail() { let (dir, service) = setup().await; From 2c68566ff334fe2f522019149b4e1793bc5b86da Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 15 Sep 2026 15:32:12 +0800 Subject: [PATCH 21/23] fix(auth): raise email send limits for self-hosted delivery --- deploy/outbound-mail/README.md | 5 ++ .../services/miniapp-market-service/README.md | 4 +- .../src/auth_admission.rs | 44 +++++++++++---- .../miniapp-market-service/src/email_auth.rs | 53 +++++++++++++++++-- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/deploy/outbound-mail/README.md b/deploy/outbound-mail/README.md index 1078ba4bc6..52473fa0a9 100644 --- a/deploy/outbound-mail/README.md +++ b/deploy/outbound-mail/README.md @@ -26,6 +26,11 @@ auth → 私有 SMTP → Postfix → 收件方 MX:25 ## 认证服务接入 +验证码接口的防滥用限制与 SMTP 服务商额度无关:同一邮箱每 60 秒一次、滚动 +24 小时最多 20 封;全站每 60 秒最多 300 封,不设每日总量上限。发送失败也计数。 +每个验证码仍最多尝试 5 次,10 分钟有效。修改这些规则需更新认证服务并重新部署; +修改 Postfix 配置不会改变认证接口的限制。 + 在服务器的 SMTP env 文件中配置;私有模式示例: ```dotenv diff --git a/src/crates/services/miniapp-market-service/README.md b/src/crates/services/miniapp-market-service/README.md index 485e3b16a4..b24b54820a 100644 --- a/src/crates/services/miniapp-market-service/README.md +++ b/src/crates/services/miniapp-market-service/README.md @@ -151,8 +151,8 @@ URL。Relay 的旧命名 `/api/auth/github/start` 同样仅在 `methods=all` 时 `POST /auth/login/start` 创建浏览器授权票据;`POST /auth/login/github` 选择 GitHub; `POST /auth/email/send` 发码,`POST /auth/email/verify` 验码。邮箱地址按 ASCII 小写 规范化,不删除加号标签或点。六位随机验证码十分钟有效,单次最多五次尝试,只存 -基于 session secret 的 HMAC 摘要。每个邮箱一分钟一次、一天十次;服务器整体 -一分钟三十封、一天一千封,发送失败也计入配额。发送记录保留一天,配额跨重启有效。 +基于 session secret 的 HMAC 摘要。每个邮箱一分钟一次、一天二十次;服务器整体 +一分钟三百封、不设每日总量上限,发送失败也计入配额。发送记录保留一天,配额跨重启有效。 SMTP 错误不得包含地址、凭据或验证码。认证接口保留 body、并发、期限和全局限流。 浏览器授权票据与设备轮询 secret 分离。邮箱验证完成后使用六十秒、一次性 grant diff --git a/src/crates/services/miniapp-market-service/src/auth_admission.rs b/src/crates/services/miniapp-market-service/src/auth_admission.rs index c95233c184..0b577beb0d 100644 --- a/src/crates/services/miniapp-market-service/src/auth_admission.rs +++ b/src/crates/services/miniapp-market-service/src/auth_admission.rs @@ -20,6 +20,15 @@ struct AuthorizationRate { window: Instant, starts: u32, callbacks: u32, + email_sends: u32, + email_verifications: u32, +} + +enum AuthorizationKind { + Start, + Callback, + EmailSend, + EmailVerify, } impl AuthorizationRate { @@ -28,17 +37,20 @@ impl AuthorizationRate { window: Instant::now(), starts: 0, callbacks: 0, + email_sends: 0, + email_verifications: 0, } } - fn allow(&mut self, callback: bool) -> bool { + fn allow(&mut self, kind: AuthorizationKind) -> bool { if self.window.elapsed() >= Duration::from_secs(60) { *self = Self::new(); } - let count = if callback { - &mut self.callbacks - } else { - &mut self.starts + let count = match kind { + AuthorizationKind::Start => &mut self.starts, + AuthorizationKind::Callback => &mut self.callbacks, + AuthorizationKind::EmailSend => &mut self.email_sends, + AuthorizationKind::EmailVerify => &mut self.email_verifications, }; if *count >= STARTS_PER_MINUTE { return false; @@ -72,7 +84,12 @@ pub(crate) async fn admit(mut request: Request, next: Next) -> Response { .get_or_init(|| Mutex::new(AuthorizationRate::new())) .lock() .unwrap_or_else(|error| error.into_inner()) - .allow(path.ends_with("/callback")); + .allow(match path { + "/auth/github/callback" => AuthorizationKind::Callback, + "/auth/email/send" => AuthorizationKind::EmailSend, + "/auth/email/verify" => AuthorizationKind::EmailVerify, + _ => AuthorizationKind::Start, + }); if !allowed { let mut response = MarketError::new( StatusCode::TOO_MANY_REQUESTS, @@ -146,12 +163,19 @@ mod tests { fn sign_in_start_flood_does_not_consume_callback_capacity() { let mut rate = AuthorizationRate::new(); for _ in 0..STARTS_PER_MINUTE { - assert!(rate.allow(false)); + assert!(rate.allow(AuthorizationKind::Start)); + } + assert!(!rate.allow(AuthorizationKind::Start)); + assert!(rate.allow(AuthorizationKind::Callback)); + // Starting a flow must not spend the separate 300/min email allowance. + for _ in 0..STARTS_PER_MINUTE { + assert!(rate.allow(AuthorizationKind::EmailSend)); } - assert!(!rate.allow(false)); - assert!(rate.allow(true)); + assert!(!rate.allow(AuthorizationKind::EmailSend)); + assert!(rate.allow(AuthorizationKind::EmailVerify)); rate.window = Instant::now() - Duration::from_secs(61); - assert!(rate.allow(false)); + assert!(rate.allow(AuthorizationKind::Start)); + assert!(rate.allow(AuthorizationKind::EmailSend)); } #[tokio::test] diff --git a/src/crates/services/miniapp-market-service/src/email_auth.rs b/src/crates/services/miniapp-market-service/src/email_auth.rs index 9d3a5fc9b0..2e639e18ea 100644 --- a/src/crates/services/miniapp-market-service/src/email_auth.rs +++ b/src/crates/services/miniapp-market-service/src/email_auth.rs @@ -327,9 +327,9 @@ impl AuthService { .await .map_err(MarketError::internal)?; // INSERT is the first statement: serialize competing requests before quota evaluation. - let inserted = sqlx::query("INSERT INTO email_challenges(id, ticket_hash, email, code_hash, created_at, expires_at) SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM email_challenges WHERE email = ? AND created_at > ?) AND (SELECT COUNT(*) FROM email_challenges WHERE email = ? AND created_at > ?) < 10 AND (SELECT COUNT(*) FROM email_challenges WHERE created_at > ?) < 30 AND (SELECT COUNT(*) FROM email_challenges WHERE created_at > ?) < 1000") + let inserted = sqlx::query("INSERT INTO email_challenges(id, ticket_hash, email, code_hash, created_at, expires_at) SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM email_challenges WHERE email = ? AND created_at > ?) AND (SELECT COUNT(*) FROM email_challenges WHERE email = ? AND created_at > ?) < 20 AND (SELECT COUNT(*) FROM email_challenges WHERE created_at > ?) < 300") .bind(&id).bind(token_hash(&request.ticket)).bind(&email).bind(code_digest(&self.config.session_secret, &id, &code)).bind(now).bind(now + 600) - .bind(&email).bind(now - 60).bind(&email).bind(now - 86400).bind(now - 60).bind(now - 86400).execute(&mut *tx).await.map_err(MarketError::internal)?; + .bind(&email).bind(now - 60).bind(&email).bind(now - 86400).bind(now - 60).execute(&mut *tx).await.map_err(MarketError::internal)?; if inserted.rows_affected() != 1 { return Err(rate_limit()); } @@ -692,7 +692,7 @@ mod tests { first.err().or(second.err()).unwrap().code, "email_rate_limit" ); - for _ in 1..10 { + for _ in 1..20 { sqlx::query("UPDATE email_challenges SET created_at = created_at - 61") .execute(service.db.pool()) .await @@ -717,7 +717,52 @@ mod tests { .fetch_one(service.db.pool()) .await .unwrap(); - assert_eq!(count, 10); + assert_eq!(count, 20); + } + + #[tokio::test] + async fn global_send_quota_allows_300_per_minute_without_a_daily_cap() { + let (_dir, service) = setup().await; + let ticket = service.start_web_login("/miniapp/").await.unwrap(); + let now = Utc::now().timestamp(); + // Yesterday's old cap would reject all new sends after these 1,000 records. + sqlx::query("WITH RECURSIVE numbers(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 1000) INSERT INTO email_challenges(id, ticket_hash, email, code_hash, created_at, expires_at) SELECT 'history-' || n, ?, 'history-' || n || '@example.com', 'unused', ?, ? FROM numbers") + .bind(token_hash(&ticket)).bind(now - 120).bind(now - 1) + .execute(service.db.pool()).await.unwrap(); + for index in 0..300 { + service + .prepare_email_code(EmailSendRequest { + ticket: ticket.clone(), + email: format!("recipient-{index}@example.com"), + locale: None, + }) + .await + .unwrap(); + } + assert_eq!( + service + .prepare_email_code(EmailSendRequest { + ticket: ticket.clone(), + email: "over-minute-limit@example.com".into(), + locale: None, + }) + .await + .unwrap_err() + .code, + "email_rate_limit" + ); + sqlx::query("UPDATE email_challenges SET created_at = created_at - 61") + .execute(service.db.pool()) + .await + .unwrap(); + service + .prepare_email_code(EmailSendRequest { + ticket, + email: "next-minute@example.com".into(), + locale: None, + }) + .await + .unwrap(); } #[tokio::test] From 319e765dda648c92b6336e47e75a8553673ab0e1 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 15 Sep 2026 15:37:04 +0800 Subject: [PATCH 22/23] fix(auth): require eight-digit email verification codes --- deploy/outbound-mail/README.md | 2 +- .../services/miniapp-market-service/README.md | 3 +- .../src/email/sign-in.html | 2 +- .../miniapp-market-service/src/email_auth.rs | 58 +++++++++++++++++-- src/miniapp-market-web/src/AccountSignIn.tsx | 2 +- src/miniapp-market-web/src/i18n.ts | 6 +- 6 files changed, 62 insertions(+), 11 deletions(-) diff --git a/deploy/outbound-mail/README.md b/deploy/outbound-mail/README.md index 52473fa0a9..8c4651e1c4 100644 --- a/deploy/outbound-mail/README.md +++ b/deploy/outbound-mail/README.md @@ -28,7 +28,7 @@ auth → 私有 SMTP → Postfix → 收件方 MX:25 验证码接口的防滥用限制与 SMTP 服务商额度无关:同一邮箱每 60 秒一次、滚动 24 小时最多 20 封;全站每 60 秒最多 300 封,不设每日总量上限。发送失败也计数。 -每个验证码仍最多尝试 5 次,10 分钟有效。修改这些规则需更新认证服务并重新部署; +新验证码固定 8 位随机数字,每个验证码最多尝试 5 次,10 分钟有效。修改这些规则需更新认证服务并重新部署; 修改 Postfix 配置不会改变认证接口的限制。 在服务器的 SMTP env 文件中配置;私有模式示例: diff --git a/src/crates/services/miniapp-market-service/README.md b/src/crates/services/miniapp-market-service/README.md index b24b54820a..9d8ffaa29b 100644 --- a/src/crates/services/miniapp-market-service/README.md +++ b/src/crates/services/miniapp-market-service/README.md @@ -150,9 +150,10 @@ URL。Relay 的旧命名 `/api/auth/github/start` 同样仅在 `methods=all` 时 `POST /auth/login/start` 创建浏览器授权票据;`POST /auth/login/github` 选择 GitHub; `POST /auth/email/send` 发码,`POST /auth/email/verify` 验码。邮箱地址按 ASCII 小写 -规范化,不删除加号标签或点。六位随机验证码十分钟有效,单次最多五次尝试,只存 +规范化,不删除加号标签或点。固定八位随机数字验证码十分钟有效,单次最多五次尝试,只存 基于 session secret 的 HMAC 摘要。每个邮箱一分钟一次、一天二十次;服务器整体 一分钟三百封、不设每日总量上限,发送失败也计入配额。发送记录保留一天,配额跨重启有效。 +只接受八位数字,不兼容旧六位验证码;部署后旧验证码需重新获取。 SMTP 错误不得包含地址、凭据或验证码。认证接口保留 body、并发、期限和全局限流。 浏览器授权票据与设备轮询 secret 分离。邮箱验证完成后使用六十秒、一次性 grant diff --git a/src/crates/services/miniapp-market-service/src/email/sign-in.html b/src/crates/services/miniapp-market-service/src/email/sign-in.html index 746fcdc3dc..170efb9b45 100644 --- a/src/crates/services/miniapp-market-service/src/email/sign-in.html +++ b/src/crates/services/miniapp-market-service/src/email/sign-in.html @@ -10,7 +10,7 @@ @media only screen and (max-width: 480px) { .outer { padding: 24px 12px !important; } .content { padding: 28px 24px !important; } - .code { font-size: 34px !important; letter-spacing: 8px !important; } + .code { font-size: 28px !important; letter-spacing: 4px !important; } } @media (prefers-color-scheme: dark) { .canvas { background-color: #0e0e10 !important; } diff --git a/src/crates/services/miniapp-market-service/src/email_auth.rs b/src/crates/services/miniapp-market-service/src/email_auth.rs index 2e639e18ea..07e2ffd240 100644 --- a/src/crates/services/miniapp-market-service/src/email_auth.rs +++ b/src/crates/services/miniapp-market-service/src/email_auth.rs @@ -319,7 +319,7 @@ impl AuthService { self.login_flow(&request.ticket).await?; let now = Utc::now().timestamp(); let id = random_token(24); - let code = format!("{:06}", OsRng.gen_range(0..1_000_000u32)); + let code = format!("{:08}", OsRng.gen_range(0..100_000_000u32)); let mut tx = self .db .pool() @@ -350,7 +350,7 @@ impl AuthService { &self, request: EmailVerifyRequest, ) -> MarketResult { - if request.code.len() != 6 || !request.code.bytes().all(|b| b.is_ascii_digit()) { + if request.code.len() != 8 || !request.code.bytes().all(|b| b.is_ascii_digit()) { return Err(invalid_code()); } let flow = self.login_flow(&request.ticket).await?; @@ -608,7 +608,11 @@ mod tests { .verify_email_code(verify(&b, &id, &code)) .await .is_err()); - let wrong = if code == "000000" { "111111" } else { "000000" }; + let wrong = if code == "00000000" { + "11111111" + } else { + "00000000" + }; for _ in 0..5 { assert!(service .verify_email_code(verify(&a, &id, wrong)) @@ -625,12 +629,58 @@ mod tests { .unwrap(); assert_eq!(count.0, 0); } + #[tokio::test] + async fn only_eight_digits_are_issued_and_accepted_including_leading_zeroes() { + let (_dir, service) = setup().await; + for expected in ["00000000", "01234567", "99999999"] { + let ticket = service.start_web_login("/miniapp/").await.unwrap(); + let (id, generated) = code(&service, &ticket, &format!("{expected}@example.com")).await; + assert_eq!(generated.len(), 8); + assert!(generated.bytes().all(|byte| byte.is_ascii_digit())); + // Verify edge values without relying on random generation to produce them. + sqlx::query("UPDATE email_challenges SET code_hash = ? WHERE id = ?") + .bind(code_digest(&service.config.session_secret, &id, expected)) + .bind(&id) + .execute(service.db.pool()) + .await + .unwrap(); + for malformed in [ + "12345", + "123456", + "1234567", + "123456789", + "abcdefgh", + "12345678", + ] { + assert!(service + .verify_email_code(verify(&ticket, &id, malformed)) + .await + .is_err()); + } + // Shorter prefixes are never accepted as an equivalent code. + if expected.len() > 6 { + assert!(service + .verify_email_code(verify(&ticket, &id, &expected[..6])) + .await + .is_err()); + } + service + .verify_email_code(verify(&ticket, &id, expected)) + .await + .unwrap(); + } + } + #[tokio::test] async fn concurrent_wrong_codes_share_a_persistent_attempt_budget() { let (dir, service) = setup().await; let ticket = service.start_web_login("/miniapp/").await.unwrap(); let (id, code) = code(&service, &ticket, "alice@example.com").await; - let wrong = if code == "000000" { "111111" } else { "000000" }; + let wrong = if code == "00000000" { + "11111111" + } else { + "00000000" + }; for _ in 0..4 { assert_eq!( service diff --git a/src/miniapp-market-web/src/AccountSignIn.tsx b/src/miniapp-market-web/src/AccountSignIn.tsx index 575df19ffc..ff586900c7 100644 --- a/src/miniapp-market-web/src/AccountSignIn.tsx +++ b/src/miniapp-market-web/src/AccountSignIn.tsx @@ -76,7 +76,7 @@ export function AccountSignIn() { {emailEnabled &&
- {challenge && <>

{t('emailCodeSent')}

} + {challenge && <>

{t('emailCodeSent')}

} {challenge &&
}
} diff --git a/src/miniapp-market-web/src/i18n.ts b/src/miniapp-market-web/src/i18n.ts index 27bd408b27..93f7715996 100644 --- a/src/miniapp-market-web/src/i18n.ts +++ b/src/miniapp-market-web/src/i18n.ts @@ -11,7 +11,7 @@ const messages = { authOr: "or", authPrivate: "Password-free. Your code is only used to sign in.", emailAddress: "Email address", - emailCode: "6-digit verification code", + emailCode: "8-digit verification code", emailSendCode: "Send verification code", emailVerify: "Verify and sign in", emailCodeSent: "Code sent. Check your inbox and spam folder.", @@ -186,7 +186,7 @@ const messages = { authOr: "或", authPrivate: "无需密码,验证码仅用于本次登录。", emailAddress: "邮箱地址", - emailCode: "6 位验证码", + emailCode: "8 位验证码", emailSendCode: "发送验证码", emailVerify: "验证并登录", emailCodeSent: "验证码已发送,请检查收件箱和垃圾邮件。", @@ -357,7 +357,7 @@ const messages = { authOr: "或", authPrivate: "無需密碼,驗證碼僅用於本次登入。", emailAddress: "電子郵件地址", - emailCode: "6 位驗證碼", + emailCode: "8 位驗證碼", emailSendCode: "傳送驗證碼", emailVerify: "驗證並登入", emailCodeSent: "驗證碼已傳送,請檢查收件匣和垃圾郵件。", From 340dc33d0c88aa7b5c108c6288cc18805281755f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 15 Sep 2026 15:46:45 +0800 Subject: [PATCH 23/23] fix(auth): harden token transactions and sign-in failure handling --- .../miniapp-market-service/src/auth.rs | 212 +++++++++++++++--- .../src/auth_admission.rs | 31 ++- .../services/miniapp-market-service/src/db.rs | 1 + .../miniapp-market-service/src/email_auth.rs | 33 ++- .../services/relay-service/src/routes/auth.rs | 2 +- .../src/routes/page_auth_client.bundle.js | 2 +- .../web/page-auth-client.test.mjs | 8 + .../relay-service/web/page-auth-client.ts | 1 + .../src/account_identity/mod.rs | 2 +- .../src/AccountSignIn.test.tsx | 66 ++++++ src/miniapp-market-web/src/AccountSignIn.tsx | 14 +- src/miniapp-market-web/src/i18n.ts | 9 + .../src/services/RelayHttpClient.ts | 2 +- .../src/services/RemoteSessionManager.ts | 2 +- 14 files changed, 345 insertions(+), 40 deletions(-) create mode 100644 src/miniapp-market-web/src/AccountSignIn.test.tsx diff --git a/src/crates/services/miniapp-market-service/src/auth.rs b/src/crates/services/miniapp-market-service/src/auth.rs index 840d3d57f0..ef74c0ee5c 100644 --- a/src/crates/services/miniapp-market-service/src/auth.rs +++ b/src/crates/services/miniapp-market-service/src/auth.rs @@ -524,13 +524,20 @@ impl AuthService { let user_id: i64 = row .try_get("user_id") .map_err(|_| MarketError::internal("Authorized transaction has no user"))?; + let mut tx = self + .db + .pool() + .begin() + .await + .map_err(MarketError::internal)?; let updated = sqlx::query( "UPDATE desktop_auth_transactions SET status = 'consumed', updated_at = ? - WHERE id = ? AND status = 'authorized'", + WHERE id = ? AND status = 'authorized' AND expires_at > ?", ) .bind(Utc::now().timestamp()) .bind(&request.transaction_id) - .execute(self.db.pool()) + .bind(Utc::now().timestamp()) + .execute(&mut *tx) .await .map_err(MarketError::internal)?; if updated.rows_affected() != 1 { @@ -539,7 +546,10 @@ impl AuthService { "The desktop authorization was already consumed.", )); } - let tokens = self.issue_token_pair(user_id, None).await?; + let tokens = self + .issue_token_pair_in_transaction(&mut tx, user_id, None) + .await?; + tx.commit().await.map_err(MarketError::internal)?; Ok(DesktopAuthPollResponse { status: "authorized".to_string(), tokens: Some(tokens), @@ -550,45 +560,72 @@ impl AuthService { &self, refresh_token: &str, ) -> MarketResult { - let Some((user, family_id)) = self.db.api_token_user(refresh_token, "refresh").await? - else { - return Err(MarketError::unauthorized( - "The refresh token is invalid or expired.", - )); - }; - self.db.revoke_token_family(&family_id).await?; - self.issue_token_pair(user.internal_id, Some(family_id)) + let mut tx = self + .db + .pool() + .begin() .await + .map_err(MarketError::internal)?; + // Claim the old refresh token before reading identity: concurrent refreshes + // serialize here, and failures roll back both consumption and replacement. + let row = sqlx::query("UPDATE api_tokens SET revoked_at = ? WHERE token_hash = ? AND token_type = 'refresh' AND expires_at > ? AND revoked_at IS NULL RETURNING user_id, family_id") + .bind(Utc::now().timestamp()).bind(token_hash(refresh_token)).bind(Utc::now().timestamp()) + .fetch_optional(&mut *tx).await.map_err(MarketError::internal)? + .ok_or_else(|| MarketError::unauthorized("The refresh token is invalid or expired."))?; + let family_id: String = row.get("family_id"); + sqlx::query( + "UPDATE api_tokens SET revoked_at = ? WHERE family_id = ? AND revoked_at IS NULL", + ) + .bind(Utc::now().timestamp()) + .bind(&family_id) + .execute(&mut *tx) + .await + .map_err(MarketError::internal)?; + let pair = self + .issue_token_pair_in_transaction(&mut tx, row.get("user_id"), Some(family_id)) + .await?; + tx.commit().await.map_err(MarketError::internal)?; + Ok(pair) } + #[cfg(test)] async fn issue_token_pair( &self, user_id: i64, family_id: Option, + ) -> MarketResult { + let mut tx = self + .db + .pool() + .begin() + .await + .map_err(MarketError::internal)?; + let pair = self + .issue_token_pair_in_transaction(&mut tx, user_id, family_id) + .await?; + tx.commit().await.map_err(MarketError::internal)?; + Ok(pair) + } + + async fn issue_token_pair_in_transaction( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + user_id: i64, + family_id: Option, ) -> MarketResult { let access_token = random_token(32); let refresh_token = random_token(48); let family_id = family_id.unwrap_or_else(|| Uuid::new_v4().to_string()); let access_expires_at = (Utc::now() + Duration::minutes(ACCESS_TOKEN_MINUTES)).timestamp(); let refresh_expires_at = (Utc::now() + Duration::days(REFRESH_TOKEN_DAYS)).timestamp(); - self.db - .create_api_token( - user_id, - &access_token, - "access", - &family_id, - access_expires_at, - ) - .await?; - self.db - .create_api_token( - user_id, - &refresh_token, - "refresh", - &family_id, - refresh_expires_at, - ) - .await?; + for (token, kind, expires_at) in [ + (&access_token, "access", access_expires_at), + (&refresh_token, "refresh", refresh_expires_at), + ] { + sqlx::query("INSERT INTO api_tokens(token_hash, user_id, token_type, family_id, expires_at, created_at) VALUES(?, ?, ?, ?, ?, ?)") + .bind(token_hash(token)).bind(user_id).bind(kind).bind(&family_id).bind(expires_at).bind(Utc::now().timestamp()) + .execute(&mut **tx).await.map_err(MarketError::internal)?; + } Ok(MarketTokenPair { access_token, access_expires_at, @@ -1193,6 +1230,123 @@ mod tests { .all(|cookie| cookie.contains("Max-Age=0"))); } + #[tokio::test] + async fn concurrent_refresh_has_one_winner_and_preserves_the_winning_tokens() { + let temporary = tempfile::tempdir().unwrap(); + let database = Database::open(&temporary.path().join("market.sqlite")) + .await + .unwrap(); + let service = AuthService::new(test_config(temporary.path()), database.clone()).unwrap(); + let user = database.upsert_github_user(42, "alice", "").await.unwrap(); + for _ in 0..32 { + let pair = service + .issue_token_pair(user.internal_id, None) + .await + .unwrap(); + let (a, b) = tokio::join!( + service.refresh_tokens(&pair.refresh_token), + service.refresh_tokens(&pair.refresh_token), + ); + assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1); + let winner = a.ok().or(b.ok()).unwrap(); + assert!(database + .api_token_user(&winner.access_token, "access") + .await + .unwrap() + .is_some()); + assert!(database + .api_token_user(&winner.refresh_token, "refresh") + .await + .unwrap() + .is_some()); + assert!(service.refresh_tokens(&pair.refresh_token).await.is_err()); + } + } + + #[tokio::test] + async fn failed_refresh_rolls_back_revocation_and_partial_token_issuance() { + let temporary = tempfile::tempdir().unwrap(); + let database = Database::open(&temporary.path().join("market.sqlite")) + .await + .unwrap(); + let service = AuthService::new(test_config(temporary.path()), database.clone()).unwrap(); + let user = database.upsert_github_user(42, "alice", "").await.unwrap(); + let pair = service + .issue_token_pair(user.internal_id, None) + .await + .unwrap(); + sqlx::query("CREATE TRIGGER fail_refresh_insert BEFORE INSERT ON api_tokens WHEN NEW.token_type = 'refresh' BEGIN SELECT RAISE(ABORT, 'injected storage failure'); END") + .execute(database.pool()).await.unwrap(); + assert!(service.refresh_tokens(&pair.refresh_token).await.is_err()); + assert!(database + .api_token_user(&pair.access_token, "access") + .await + .unwrap() + .is_some()); + assert!(database + .api_token_user(&pair.refresh_token, "refresh") + .await + .unwrap() + .is_some()); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM api_tokens") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(count, 2); + sqlx::query("DROP TRIGGER fail_refresh_insert") + .execute(database.pool()) + .await + .unwrap(); + assert!(service.refresh_tokens(&pair.refresh_token).await.is_ok()); + } + + #[tokio::test] + async fn failed_device_token_issuance_keeps_authorization_claimable() { + let temporary = tempfile::tempdir().unwrap(); + let database = Database::open(&temporary.path().join("market.sqlite")) + .await + .unwrap(); + let service = AuthService::new(test_config(temporary.path()), database.clone()).unwrap(); + let user = database.upsert_github_user(42, "alice", "").await.unwrap(); + let start = service.start_desktop_login(true).await.unwrap(); + sqlx::query( + "UPDATE desktop_auth_transactions SET status = 'authorized', user_id = ? WHERE id = ?", + ) + .bind(user.internal_id) + .bind(&start.transaction_id) + .execute(database.pool()) + .await + .unwrap(); + sqlx::query("CREATE TRIGGER fail_device_token BEFORE INSERT ON api_tokens WHEN NEW.token_type = 'refresh' BEGIN SELECT RAISE(ABORT, 'injected storage failure'); END") + .execute(database.pool()).await.unwrap(); + let request = || DesktopAuthPollRequest { + transaction_id: start.transaction_id.clone(), + transaction_secret: start.transaction_secret.clone(), + }; + assert!(service.poll_desktop(request()).await.is_err()); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM api_tokens") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(count, 0); + sqlx::query("DROP TRIGGER fail_device_token") + .execute(database.pool()) + .await + .unwrap(); + assert!(service + .poll_desktop(request()) + .await + .unwrap() + .tokens + .is_some()); + assert!(service + .poll_desktop(request()) + .await + .unwrap() + .tokens + .is_none()); + } + #[tokio::test] async fn refresh_rotation_revokes_the_old_pair_and_keeps_admin_id_numeric() { let temporary = tempfile::tempdir().unwrap(); diff --git a/src/crates/services/miniapp-market-service/src/auth_admission.rs b/src/crates/services/miniapp-market-service/src/auth_admission.rs index 0b577beb0d..efb0f9cdf0 100644 --- a/src/crates/services/miniapp-market-service/src/auth_admission.rs +++ b/src/crates/services/miniapp-market-service/src/auth_admission.rs @@ -60,7 +60,30 @@ impl AuthorizationRate { } } -pub(crate) async fn admit(mut request: Request, next: Next) -> Response { +pub(crate) async fn admit(request: Request, next: Next) -> Response { + let path = request + .uri() + .path() + .strip_prefix("/miniapp/api/v1") + .unwrap_or(request.uri().path()); + if !path.starts_with("/auth/") { + return next.run(request).await; + } + let mut response = admit_auth(request, next).await; + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response + .headers_mut() + .insert(header::PRAGMA, HeaderValue::from_static("no-cache")); + response.headers_mut().insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + response +} + +async fn admit_auth(mut request: Request, next: Next) -> Response { let path = request .uri() .path() @@ -209,6 +232,12 @@ mod tests { .await .unwrap(); assert_eq!(response.status(), expected); + if path.starts_with("/auth/") { + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(response.headers()[header::REFERRER_POLICY], "no-referrer"); + } else { + assert!(!response.headers().contains_key(header::CACHE_CONTROL)); + } } } } diff --git a/src/crates/services/miniapp-market-service/src/db.rs b/src/crates/services/miniapp-market-service/src/db.rs index 161ce052e4..ec2653dca3 100644 --- a/src/crates/services/miniapp-market-service/src/db.rs +++ b/src/crates/services/miniapp-market-service/src/db.rs @@ -239,6 +239,7 @@ impl Database { Ok(()) } + #[cfg(test)] pub(crate) async fn create_api_token( &self, user_id: i64, diff --git a/src/crates/services/miniapp-market-service/src/email_auth.rs b/src/crates/services/miniapp-market-service/src/email_auth.rs index 07e2ffd240..751af780c4 100644 --- a/src/crates/services/miniapp-market-service/src/email_auth.rs +++ b/src/crates/services/miniapp-market-service/src/email_auth.rs @@ -264,7 +264,7 @@ impl AuthService { } async fn login_flow(&self, ticket: &str) -> MarketResult { let row = sqlx::query("SELECT transaction_id, return_to FROM login_flows WHERE ticket_hash = ? AND expires_at > ? AND consumed_at IS NULL") - .bind(token_hash(ticket)).bind(Utc::now().timestamp()).fetch_optional(self.db.pool()).await.map_err(MarketError::internal)?.ok_or_else(invalid_code)?; + .bind(token_hash(ticket)).bind(Utc::now().timestamp()).fetch_optional(self.db.pool()).await.map_err(MarketError::internal)?.ok_or_else(|| MarketError::bad_request("login_flow_expired", "This sign-in request is no longer active. Start sign-in again from the original app or website."))?; let transaction_id: Option = row.get("transaction_id"); Ok(OAuthFlowRecord { flow_kind: if transaction_id.is_some() { @@ -671,6 +671,37 @@ mod tests { } } + #[tokio::test] + async fn expired_login_requests_report_restart_without_spending_send_quota() { + let (_dir, service) = setup().await; + let ticket = service.start_web_login("/miniapp/").await.unwrap(); + let (id, code) = code(&service, &ticket, "alice@example.com").await; + sqlx::query("UPDATE login_flows SET expires_at = 0 WHERE ticket_hash = ?") + .bind(token_hash(&ticket)) + .execute(service.db.pool()) + .await + .unwrap(); + let error = service + .verify_email_code(verify(&ticket, &id, &code)) + .await + .unwrap_err(); + assert_eq!(error.code, "login_flow_expired"); + let error = service + .prepare_email_code(EmailSendRequest { + ticket, + email: "another@example.com".into(), + locale: None, + }) + .await + .unwrap_err(); + assert_eq!(error.code, "login_flow_expired"); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_challenges") + .fetch_one(service.db.pool()) + .await + .unwrap(); + assert_eq!(count, 1); + } + #[tokio::test] async fn concurrent_wrong_codes_share_a_persistent_attempt_budget() { let (dir, service) = setup().await; diff --git a/src/crates/services/relay-service/src/routes/auth.rs b/src/crates/services/relay-service/src/routes/auth.rs index be0398f0af..9d0b7128b7 100644 --- a/src/crates/services/relay-service/src/routes/auth.rs +++ b/src/crates/services/relay-service/src/routes/auth.rs @@ -298,7 +298,7 @@ pub(crate) async fn verify_identity_credentials( let identity = verifier.verify(access_token).await.map_err(|status| { err( if status == StatusCode::UNAUTHORIZED { - "Sign in with GitHub to continue" + "Sign in to continue" } else { "identity service unavailable" }, diff --git a/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js b/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js index 378cfe2696..0e53aeb1b6 100644 --- a/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js +++ b/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js @@ -1 +1 @@ -(()=>{var f=document.querySelector("[data-page-login-form]"),o=document.querySelector("[data-page-login-submit]"),u=document.querySelector("[data-page-login-error]"),p=f?.dataset.pageLoginState;function h(){let t=window.location.pathname.indexOf("/api/page-auth/");if(t>=0)return window.location.pathname.slice(0,t);let e=window.location.pathname.indexOf("/p/");return e>=0?window.location.pathname.slice(0,e):""}function l(t){return`${h()}${t}`}function y(){let t=window.location.pathname.indexOf("/p/");return`${t>=0?window.location.pathname.slice(t):window.location.pathname}${window.location.search}`}function b(t){return/^https?:\/\//i.test(t)?t:`${h()}${t}`}function T(){return navigator.language.toLowerCase().startsWith("zh")}function r(t,e){return T()?t:e}function g(t){u&&(u.textContent=t,u.hidden=t.length===0)}async function S(t){let e=r("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again.");try{let n=await t.json();return n.retry_after_secs&&n.retry_after_secs>0?r(`\u5C1D\u8BD5\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u5728 ${n.retry_after_secs} \u79D2\u540E\u91CD\u8BD5\u3002`,`Too many attempts. Try again in ${n.retry_after_secs} seconds.`):n.error==="account does not have access to this Page"?r("\u8BE5\u8D26\u53F7\u6CA1\u6709\u6B64\u9875\u9762\u7684\u8BBF\u95EE\u6743\u9650\u3002","This account cannot access the Page."):n.error||e}catch{return e}}async function d(t,e){let n=await fetch(t,{method:"POST",credentials:"same-origin",headers:{"content-type":"application/json"},body:JSON.stringify(e)});if(!n.ok)throw new Error(await S(n));return n.json()}f?.addEventListener("submit",async t=>{if(t.preventDefault(),!o||o.disabled)return;let e=window.open("about:blank","_blank");if(!e){g(r("\u8BF7\u5141\u8BB8\u767B\u5F55\u5F39\u7A97\u540E\u91CD\u8BD5\u3002","Allow the sign-in popup and try again."));return}e.opener=null,g(""),o.disabled=!0,o.textContent=r("\u7B49\u5F85\u767B\u5F55\u2026","Waiting for sign-in\u2026");try{let n=await d(l("/api/auth/github/start?methods=all"),{}),a=new URL(n.authorizationUrl);if(!(a.origin==="https://github.com"&&a.pathname==="/login/oauth/authorize"||a.origin==="https://auth.openbitfun.com"&&a.pathname==="/sign-in")||a.username||a.password)throw new Error(r("\u767B\u5F55\u5730\u5740\u65E0\u6548\u3002","The sign-in URL is invalid."));e.location.replace(a.href);let s;for(;Date.now()setTimeout(m,Math.max(1,n.pollIntervalSeconds)*1e3));let c=await d(l("/api/auth/github/poll"),{transactionId:n.transactionId,transactionSecret:n.transactionSecret});if(c.tokens?.accessToken){s=c.tokens.accessToken;break}if(c.status!=="pending")throw new Error(r("\u6388\u6743\u672A\u5B8C\u6210\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002","Authorization did not complete. Sign in again."))}if(!s)throw new Error(r("\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in expired. Try again."));let i={access_token:s};p?i.state=p:(i.return_to=y(),i.path_prefix=h());let w=await d(l("/api/page-auth/login"),i);window.location.replace(b(w.redirect_to))}catch(n){g(n instanceof Error?n.message:r("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again."))}finally{e.close(),o.disabled=!1,o.textContent=r("\u4F7F\u7528\u90AE\u7BB1\u6216 GitHub \u767B\u5F55","Sign in with email or GitHub")}});})(); +(()=>{var f=document.querySelector("[data-page-login-form]"),o=document.querySelector("[data-page-login-submit]"),u=document.querySelector("[data-page-login-error]"),p=f?.dataset.pageLoginState;function h(){let t=window.location.pathname.indexOf("/api/page-auth/");if(t>=0)return window.location.pathname.slice(0,t);let e=window.location.pathname.indexOf("/p/");return e>=0?window.location.pathname.slice(0,e):""}function l(t){return`${h()}${t}`}function y(){let t=window.location.pathname.indexOf("/p/");return`${t>=0?window.location.pathname.slice(t):window.location.pathname}${window.location.search}`}function b(t){return/^https?:\/\//i.test(t)?t:`${h()}${t}`}function T(){return navigator.language.toLowerCase().startsWith("zh")}function r(t,e){return T()?t:e}function g(t){u&&(u.textContent=t,u.hidden=t.length===0)}async function S(t){let e=r("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again.");try{let n=await t.json();return n.retry_after_secs&&n.retry_after_secs>0?r(`\u5C1D\u8BD5\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u5728 ${n.retry_after_secs} \u79D2\u540E\u91CD\u8BD5\u3002`,`Too many attempts. Try again in ${n.retry_after_secs} seconds.`):n.error==="account does not have access to this Page"?r("\u8BE5\u8D26\u53F7\u6CA1\u6709\u6B64\u9875\u9762\u7684\u8BBF\u95EE\u6743\u9650\u3002","This account cannot access the Page."):n.error||e}catch{return e}}async function d(t,e){let n=await fetch(t,{method:"POST",credentials:"same-origin",headers:{"content-type":"application/json"},body:JSON.stringify(e)});if(!n.ok)throw new Error(await S(n));return n.json()}f?.addEventListener("submit",async t=>{if(t.preventDefault(),!o||o.disabled)return;let e=window.open("about:blank","_blank");if(!e){g(r("\u8BF7\u5141\u8BB8\u767B\u5F55\u5F39\u7A97\u540E\u91CD\u8BD5\u3002","Allow the sign-in popup and try again."));return}e.opener=null,g(""),o.disabled=!0,o.textContent=r("\u7B49\u5F85\u767B\u5F55\u2026","Waiting for sign-in\u2026");try{let n=await d(l("/api/auth/github/start?methods=all"),{}),a=new URL(n.authorizationUrl);if(!(a.origin==="https://github.com"&&a.pathname==="/login/oauth/authorize"||a.origin==="https://auth.openbitfun.com"&&a.pathname==="/sign-in")||a.username||a.password)throw new Error(r("\u767B\u5F55\u5730\u5740\u65E0\u6548\u3002","The sign-in URL is invalid."));a.origin==="https://auth.openbitfun.com"&&a.searchParams.set("locale",navigator.language),e.location.replace(a.href);let s;for(;Date.now()setTimeout(m,Math.max(1,n.pollIntervalSeconds)*1e3));let c=await d(l("/api/auth/github/poll"),{transactionId:n.transactionId,transactionSecret:n.transactionSecret});if(c.tokens?.accessToken){s=c.tokens.accessToken;break}if(c.status!=="pending")throw new Error(r("\u6388\u6743\u672A\u5B8C\u6210\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002","Authorization did not complete. Sign in again."))}if(!s)throw new Error(r("\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in expired. Try again."));let i={access_token:s};p?i.state=p:(i.return_to=y(),i.path_prefix=h());let w=await d(l("/api/page-auth/login"),i);window.location.replace(b(w.redirect_to))}catch(n){g(n instanceof Error?n.message:r("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again."))}finally{e.close(),o.disabled=!1,o.textContent=r("\u4F7F\u7528\u90AE\u7BB1\u6216 GitHub \u767B\u5F55","Sign in with email or GitHub")}});})(); diff --git a/src/crates/services/relay-service/web/page-auth-client.test.mjs b/src/crates/services/relay-service/web/page-auth-client.test.mjs index 338eca6de9..d579205fc7 100644 --- a/src/crates/services/relay-service/web/page-auth-client.test.mjs +++ b/src/crates/services/relay-service/web/page-auth-client.test.mjs @@ -55,6 +55,14 @@ test('isolated Page sign-in retains the one-time login state and callback origin assert.equal(result.redirect, 'https://pages.example/callback?code=one-time'); }); +test('email sign-in carries the browser locale without changing the authorization ticket', async () => { + const result = await runLogin({ authorizationUrl: 'https://auth.openbitfun.com/sign-in#ticket=original-ticket' }); + const opened = new URL(result.opened); + assert.equal(opened.searchParams.get('locale'), 'en'); + assert.equal(opened.hash, '#ticket=original-ticket'); + assert.equal(result.calls[2].body.access_token, 'verified-account-token'); +}); + test('failed authorization or an untrusted OAuth URL never submits Page access', async () => { for (const options of [{ status: 'expired' }, { authorizationUrl: 'https://attacker.example/login' }]) { const result = await runLogin(options); diff --git a/src/crates/services/relay-service/web/page-auth-client.ts b/src/crates/services/relay-service/web/page-auth-client.ts index e709e7e853..459da8c9b1 100644 --- a/src/crates/services/relay-service/web/page-auth-client.ts +++ b/src/crates/services/relay-service/web/page-auth-client.ts @@ -110,6 +110,7 @@ form?.addEventListener('submit', async (event) => { if (!((authorization.origin === 'https://github.com' && authorization.pathname === '/login/oauth/authorize') || (authorization.origin === 'https://auth.openbitfun.com' && authorization.pathname === '/sign-in')) || !!authorization.username || !!authorization.password) { throw new Error(message('登录地址无效。', 'The sign-in URL is invalid.')); } + if (authorization.origin === 'https://auth.openbitfun.com') authorization.searchParams.set('locale', navigator.language); popup.location.replace(authorization.href); let accessToken: string | undefined; while (Date.now() < start.expiresAt * 1000) { diff --git a/src/crates/services/services-integrations/src/account_identity/mod.rs b/src/crates/services/services-integrations/src/account_identity/mod.rs index f7f853f947..694fc65b16 100644 --- a/src/crates/services/services-integrations/src/account_identity/mod.rs +++ b/src/crates/services/services-integrations/src/account_identity/mod.rs @@ -132,7 +132,7 @@ impl AccountIdentityClient { if me.user.identity_id().is_none() || me.user.login.trim().is_empty() { return Err(local_error( "invalid_identity_response", - "Invalid GitHub identity.", + "Invalid account identity.", )); } Ok(me) diff --git a/src/miniapp-market-web/src/AccountSignIn.test.tsx b/src/miniapp-market-web/src/AccountSignIn.test.tsx new file mode 100644 index 0000000000..c727f31e73 --- /dev/null +++ b/src/miniapp-market-web/src/AccountSignIn.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { AccountSignIn } from './AccountSignIn'; + +const api = vi.hoisted(() => ({ config: vi.fn(), sendEmailCode: vi.fn(), verifyEmailCode: vi.fn() })); +vi.mock('./api', () => ({ marketApi: api })); +let container: HTMLDivElement; +let root: ReturnType; +beforeEach(async () => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.resetAllMocks(); + window.history.replaceState(null, '', '/sign-in?locale=en-US#ticket=device-ticket'); + api.config.mockResolvedValue({ emailAuthConfigured: true, githubAuthConfigured: true }); + api.sendEmailCode.mockResolvedValue({ challengeId: 'challenge', retryAfterSeconds: 60 }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render()); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); +async function input(id: string, value: string) { + const element = container.querySelector(`#${id}`)!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + }); +} +async function submit() { + await act(async () => container.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))); +} +it('sends the selected locale and preserves all eight digits including leading zeroes', async () => { + await input('sign-in-email', 'alice@example.com'); + await submit(); + expect(api.sendEmailCode).toHaveBeenCalledWith('device-ticket', 'alice@example.com', 'en-US'); + const code = container.querySelector('#sign-in-code')!; + expect(code.minLength).toBe(8); + expect(code.maxLength).toBe(8); + code.value = '123456'; + expect(code.checkValidity()).toBe(false); + await input('sign-in-code', '00123456'); + expect(code.checkValidity()).toBe(true); + api.verifyEmailCode.mockRejectedValue({ code: 'invalid_email_code' }); + await submit(); + expect(api.verifyEmailCode).toHaveBeenCalledWith('device-ticket', 'challenge', '00123456'); + expect(container.querySelector('form')).not.toBeNull(); +}); +it('stops retrying an expired device ticket and tells the user to return to the originating app', async () => { + api.sendEmailCode.mockRejectedValue({ code: 'login_flow_expired' }); + await input('sign-in-email', 'alice@example.com'); + await submit(); + expect(container.querySelector('form')).toBeNull(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain('start sign-in again from the app or website'); + expect(window.location.hash).toBe('#ticket=device-ticket'); +}); +it('distinguishes mail delivery failures from invalid verification codes', async () => { + api.sendEmailCode.mockRejectedValue({ code: 'email_delivery_failed' }); + await input('sign-in-email', 'alice@example.com'); + await submit(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("couldn't send the email"); + expect(container.querySelector('form')).not.toBeNull(); +}); diff --git a/src/miniapp-market-web/src/AccountSignIn.tsx b/src/miniapp-market-web/src/AccountSignIn.tsx index ff586900c7..33b8fec3e0 100644 --- a/src/miniapp-market-web/src/AccountSignIn.tsx +++ b/src/miniapp-market-web/src/AccountSignIn.tsx @@ -13,6 +13,7 @@ export function AccountSignIn() { const [challenge, setChallenge] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + const [expired, setExpired] = useState(false); const [retryAt, setRetryAt] = useState(0); const [now, setNow] = useState(Date.now()); const initial = useRef | null>(null); @@ -36,7 +37,12 @@ export function AccountSignIn() { useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, []); const showError = (cause: unknown) => { const key = cause && typeof cause === 'object' && 'code' in cause ? cause.code : ''; - setError(key === 'email_rate_limit' ? 'emailRateLimited' : key === 'invalid_email_code' ? 'emailCodeInvalid' : key === 'invalid_email' ? 'emailInvalid' : 'emailStartFailed'); + if (key === 'login_flow_expired' || key === 'desktop_auth_expired') { + setExpired(true); + setError('emailFlowExpired'); + return; + } + setError(key === 'email_rate_limit' ? 'emailRateLimited' : key === 'auth_rate_limit' || key === 'auth_capacity' ? 'emailAuthBusy' : key === 'email_delivery_failed' ? 'emailDeliveryFailed' : key === 'invalid_email_code' ? 'emailCodeInvalid' : key === 'invalid_email' ? 'emailInvalid' : 'emailStartFailed'); }; async function send(event?: FormEvent) { event?.preventDefault(); setBusy(true); setError(''); @@ -73,15 +79,15 @@ export function AccountSignIn() {

{t('accountSignIn')}

{t('emailLoginIntro')}

{!ticket && !error &&

{t('authWorking')}

} - {emailEnabled &&
+ {emailEnabled && !expired &&
{challenge && <>

{t('emailCodeSent')}

} {challenge &&
}
} - {emailEnabled && githubEnabled &&
{t('authOr')}
} - {githubEnabled && } + {emailEnabled && githubEnabled && !expired &&
{t('authOr')}
} + {githubEnabled && !expired && } {!!ticket && !emailEnabled && !githubEnabled &&

{t('emailUnavailable')}

} {error &&

{t(error)}

} diff --git a/src/miniapp-market-web/src/i18n.ts b/src/miniapp-market-web/src/i18n.ts index 93f7715996..5260fd4e60 100644 --- a/src/miniapp-market-web/src/i18n.ts +++ b/src/miniapp-market-web/src/i18n.ts @@ -22,6 +22,9 @@ const messages = { emailInvalid: "Enter a valid email address.", emailCodeInvalid: "Incorrect or expired code. Request a new code if needed.", emailRateLimited: "Too many requests. Please try again later.", + emailFlowExpired: "This sign-in request has expired or already finished. Close this window and start sign-in again from the app or website you were using.", + emailDeliveryFailed: "We couldn't send the email. Wait a minute, then try again.", + emailAuthBusy: "Sign-in is busy. Wait a minute, then try again.", emailStartFailed: "Sign-in could not be completed. Please try again or reopen the sign-in page.", emailUnavailable: "Sign-in is unavailable on this server.", @@ -197,6 +200,9 @@ const messages = { emailInvalid: "请输入有效的邮箱地址。", emailCodeInvalid: "验证码错误或已过期,请重试或重新获取。", emailRateLimited: "请求过于频繁,请稍后重试。", + emailFlowExpired: "本次登录已过期或已完成。请关闭此窗口,返回原来的应用或网站重新发起登录。", + emailDeliveryFailed: "邮件发送失败,请等待一分钟后重试。", + emailAuthBusy: "登录服务繁忙,请等待一分钟后重试。", emailStartFailed: "暂时无法完成登录,请重试或重新打开登录页面。", emailUnavailable: "此服务器尚未配置登录方式。", @@ -368,6 +374,9 @@ const messages = { emailInvalid: "請輸入有效的電子郵件地址。", emailCodeInvalid: "驗證碼錯誤或已過期,請重試或重新取得。", emailRateLimited: "請求過於頻繁,請稍後重試。", + emailFlowExpired: "本次登入已過期或已完成。請關閉此視窗,返回原來的應用程式或網站重新發起登入。", + emailDeliveryFailed: "郵件傳送失敗,請等待一分鐘後重試。", + emailAuthBusy: "登入服務忙碌,請等待一分鐘後重試。", emailStartFailed: "暫時無法完成登入,請重試或重新開啟登入頁面。", emailUnavailable: "此伺服器尚未設定登入方式。", diff --git a/src/mobile-web/src/services/RelayHttpClient.ts b/src/mobile-web/src/services/RelayHttpClient.ts index 8261c1ac44..802d199130 100644 --- a/src/mobile-web/src/services/RelayHttpClient.ts +++ b/src/mobile-web/src/services/RelayHttpClient.ts @@ -302,7 +302,7 @@ export class RelayHttpClient { private async withAccount(operation: (identity: AccountIdentitySnapshot) => Promise): Promise { const identity = this.identity; - if (!identity) throw new Error('Sign in with GitHub to continue'); + if (!identity) throw new Error('Sign in to continue'); try { const result = await operation(identity); if (this.identity !== identity) throw new AccountIdentityChangedError(); diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index 71826c4456..616b10c472 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -608,7 +608,7 @@ export class RemoteSessionManager { async ping(): Promise { const controllerDeviceId = this.client.controllerDeviceId; - if (!controllerDeviceId) throw new Error('Sign in with GitHub to continue'); + if (!controllerDeviceId) throw new Error('Sign in to continue'); await this.request({ cmd: 'ping', client: getControlClientIdentity(controllerDeviceId) }); }