diff --git a/Cargo.lock b/Cargo.lock index 54b014e354..e39fb01725 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" @@ -6303,7 +6352,9 @@ dependencies = [ "base64 0.22.1", "chrono", "hex", + "hmac", "image 0.25.10", + "lettre", "openbitfun-product-domains", "openbitfun-services-core", "rand 0.8.7", @@ -8000,6 +8051,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/assets/brand/README.md b/assets/brand/README.md index 746347b6c4..d7472f2af8 100644 --- a/assets/brand/README.md +++ b/assets/brand/README.md @@ -42,3 +42,11 @@ icon containers with `node --test scripts/generate-brand-assets.test.mjs`. by the release letter's connected animation rig. The gradients belong to this illustration, not to the application theme. Vite bundles this source directly; it is independent of the application icon generator and needs no PNG export. + +Verification emails use the silver application mark on its black rounded-square +background, generated at +`src/miniapp-market-web/public/assets/openbitfun-email-app-icon.png`. The market build +publishes this stable anonymous image path for email clients; it contains no +recipient or verification data and does not require authentication. + +The previous email mark URL remains available for already-sent messages. 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 3833b94be1..4c8ea7b45d 100644 --- a/deploy/miniapp-market/README.md +++ b/deploy/miniapp-market/README.md @@ -497,3 +497,59 @@ 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 不认识邮箱用户;发布后不能把 +回退镜像当作数据库回滚。先完成两个市场的一致性备份和恢复演练,保留旧镜像, +出现问题优先向前修复;不得未经确认恢复备份或丢弃新增用户。 + +### 登录语言与验证码邮件 + +各端打开 `https://auth.openbitfun.com/sign-in` 时通过 `locale` 查询参数传递当前界面语言。 +认证页优先使用该参数,其次使用已保存的语言和浏览器语言;别名遵循共享 i18n contract。 +发送验证码请求可携带 `locale`(`en-US`、`zh-CN` 或 `zh-TW`),邮件主题、HTML 和纯文本使用同一语言。 +旧客户端省略该字段或传入未知值时默认发送英文邮件,不影响验证码和令牌协议。 + +### 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 +`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 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. + +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/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/deploy/outbound-mail/README.md b/deploy/outbound-mail/README.md new file mode 100644 index 0000000000..8c4651e1c4 --- /dev/null +++ b/deploy/outbound-mail/README.md @@ -0,0 +1,188 @@ +# 自建发信服务维护手册 + +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 服务商额度无关:同一邮箱每 60 秒一次、滚动 +24 小时最多 20 封;全站每 60 秒最多 300 封,不设每日总量上限。发送失败也计数。 +新验证码固定 8 位随机数字,每个验证码最多尝试 5 次,10 分钟有效。修改这些规则需更新认证服务并重新部署; +修改 Postfix 配置不会改变认证接口的限制。 + +在服务器的 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/scripts/generate-brand-assets.mjs b/scripts/generate-brand-assets.mjs index 116dafc95f..326b7a1a92 100644 --- a/scripts/generate-brand-assets.mjs +++ b/scripts/generate-brand-assets.mjs @@ -323,6 +323,12 @@ async function generateBrandAssets() { await writeFile(path.join(exportDir, 'openbitfun-app-icon.ico'), tauriContainers.ico); await writeFile(path.join(exportDir, 'openbitfun-app-icon.icns'), tauriContainers.icns); + await writePng(outputPath('src', 'miniapp-market-web', 'public', 'assets', 'openbitfun-email-mark.png'), darkMarkSmall); + + await writePng(outputPath('src', 'miniapp-market-web', 'public', 'assets', 'openbitfun-email-app-icon.png'), applicationIcon); + + await writePng(outputPath('src', 'crates', 'services', 'miniapp-market-service', 'src', 'email', 'app-icon.png'), applicationIcon); + const webBrandDir = outputPath('src', 'web-ui', 'public', 'brand'); await writePng(path.join(webBrandDir, 'openbitfun-mark-dark.png'), darkMark); await writePng(path.join(webBrandDir, 'openbitfun-mark-light.png'), lightMark); diff --git a/scripts/generate-brand-assets.test.mjs b/scripts/generate-brand-assets.test.mjs index 2097870e3c..55eb449673 100644 --- a/scripts/generate-brand-assets.test.mjs +++ b/scripts/generate-brand-assets.test.mjs @@ -204,3 +204,16 @@ test('ICNS canonicalization rejects malformed containers', () => { const truncated = createIcns([createChunk('ic07', Buffer.from('small'))]).subarray(0, -1); assert.throws(() => canonicalizeIcns(truncated), /Invalid ICNS length/); }); + +test('verification email reuses the current mark and the OpenBitFun reference palette', () => { + const emailMark = readFileSync('src/miniapp-market-web/public/assets/openbitfun-email-app-icon.png'); + assert.deepEqual(emailMark, readFileSync('src/web-ui/public/brand/openbitfun-app-icon.png')); + assert.deepEqual(emailMark, readFileSync('src/crates/services/miniapp-market-service/src/email/app-icon.png')); + const html = readFileSync('src/crates/services/miniapp-market-service/src/email/sign-in.html', 'utf8'); + const tokens = JSON.parse(readFileSync('design-system/packages/theme-openbitfun/src/reference.tokens.json', 'utf8')); + const palette = new Set([...Object.values(tokens.ref.color.neutral), ...Object.values(tokens.ref.color.cyan)] + .map(token => token?.$value).filter(Boolean)); + for (const [color] of html.matchAll(/#[0-9a-f]{6}\b/g)) assert.ok(palette.has(color), `Email color ${color} must come from the existing neutral/cyan palette`); + assert.match(html, /src="cid:openbitfun-app-icon"/); + assert.doesNotMatch(html, / 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/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)); diff --git a/src/apps/miniapp-market-server/Dockerfile b/src/apps/miniapp-market-server/Dockerfile index 22e6625132..4c10c84906 100644 --- a/src/apps/miniapp-market-server/Dockerfile +++ b/src/apps/miniapp-market-server/Dockerfile @@ -22,6 +22,7 @@ COPY design-system/tooling/token-engine/ design-system/tooling/token-engine/ COPY design-system/packages/design-tokens/ design-system/packages/design-tokens/ COPY design-system/packages/theme-openbitfun/ design-system/packages/theme-openbitfun/ COPY src/miniapp-market-web/ src/miniapp-market-web/ +COPY src/shared/i18n/contract/locales.json src/shared/i18n/contract/locales.json RUN pnpm --dir design-system/packages/design-tokens build \ && pnpm --dir design-system/packages/theme-openbitfun build \ && pnpm --dir design-system/packages/ui build \ @@ -44,6 +45,7 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY Cargo.toml Cargo.lock ./ +COPY third_party/eioc/ third_party/eioc/ COPY src/ src/ RUN --mount=type=cache,id=openbitfun-market-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ diff --git a/src/apps/miniapp-market-server/Dockerfile.dockerignore b/src/apps/miniapp-market-server/Dockerfile.dockerignore index bfe9f881de..e132ea72cb 100644 --- a/src/apps/miniapp-market-server/Dockerfile.dockerignore +++ b/src/apps/miniapp-market-server/Dockerfile.dockerignore @@ -2,6 +2,11 @@ !Cargo.toml !Cargo.lock +!third_party/ +!third_party/eioc/ +!third_party/eioc/Cargo.toml +!third_party/eioc/src/ +!third_party/eioc/src/** !package.json !pnpm-lock.yaml !pnpm-workspace.yaml @@ -40,6 +45,7 @@ !src/**/src/** !src/crates/services/miniapp-market-service/migrations/** !src/miniapp-market-web/** +!src/shared/i18n/contract/locales.json **/node_modules **/dist 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/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt index 8b78619906..e4799108a0 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt @@ -105,7 +105,13 @@ internal fun AccountLoginPage( openAuthorization: ((String) -> Unit)? = null, ) { val busy = state is AccountUiState.SigningIn || state is AccountUiState.Authorizing - val authorizationUrl = (state as? AccountUiState.Authorizing)?.authorizationUrl + val locale = androidx.compose.ui.platform.LocalConfiguration.current.locales[0].toLanguageTag() + val authorizationUrl = (state as? AccountUiState.Authorizing)?.authorizationUrl?.let { value -> + val uri = android.net.Uri.parse(value) + if (uri.scheme == "https" && uri.host == "auth.openbitfun.com") { + uri.buildUpon().appendQueryParameter("locale", locale).build().toString() + } else value + } val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current var launchedAuthorizationUrl by rememberSaveable { mutableStateOf(null) } var launchFailed by rememberSaveable(authorizationUrl) { mutableStateOf(false) } 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..41bd7a2f9f 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,23 +24,23 @@ 浅色 深色 账号 - 使用 GitHub 登录 - 使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。 + 使用邮箱或 GitHub 登录 + 使用邮箱或 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。 登录服务器 OpenBitFun 用户名 OpenBitFun 密码 Relay 地址,例如 https://relay.example.com 正在登录… 个人资料 - GitHub 账号 - 当前连接已通过账号 %1$s 验证。密码不会保存到手机。 + OpenBitFun 账号 + 当前连接已通过账号 %1$s 验证。 连接 当前控制 资料 中继地址 用户名 密码 - 通过 GitHub 登录 + 使用邮箱或 GitHub 登录 退出登录 离线 在线 @@ -55,8 +55,8 @@ 账号设备 扫描二维码连接 当前控制 · %1$s - 请输入有效的用户名和密码。 - 用户名或密码被拒绝。 + 登录信息无效,请重新登录。 + 登录状态已失效,请重新使用邮箱或 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..1c577b39c4 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,23 +25,23 @@ 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 - This connection is verified by account %1$s. Your password is not stored on this phone. + OpenBitFun account + This connection is verified by account %1$s. Connect Controlling Details Relay URL Username Password - Sign in with GitHub + Sign in with email or GitHub Sign out Offline Online @@ -57,8 +57,8 @@ Scan QR code to connect Controlling · %1$s - Enter a valid username and password. - The username or password was rejected. + Sign-in details are invalid. Please sign in again. + Your sign-in has expired. Sign in again with email or GitHub. Too many sign-in attempts. Try again later. Sign-in is temporarily unavailable. Try again later. Unable to connect. Check your connection and try again. @@ -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/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index b1a17bd3da..118b69eda2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -653,7 +653,12 @@ export abstract class AppRootRuntimeComposition { remoteState: this.remotePageState, hooks: { deviceId: (): string => this.remoteConnectionController.getDeviceId(), - openAuthorization: async (url: string): Promise => this.host.openExternalLink ? await this.host.openExternalLink(url) : false, + openAuthorization: async (url: string): Promise => { + const localizedUrl = url.startsWith('https://auth.openbitfun.com/sign-in#') + ? url.replace('/sign-in#', `/sign-in?locale=${encodeURIComponent(RemoteI18n.language())}#`) + : url; + return this.host.openExternalLink ? await this.host.openExternalLink(localizedUrl) : false; + }, remoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), invalidatePreview: (): void => this.filePreviewController.invalidate(), invalidateRemoteActivity: (): void => this.remoteActivityViewModel.invalidate(), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/WorkspaceToolsViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/WorkspaceToolsViewModel.ets index f5b205d2f0..d7eea8c22a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/WorkspaceToolsViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/WorkspaceToolsViewModel.ets @@ -4,7 +4,7 @@ import { Encoding } from '../../services/Encoding'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; import { DurableSessionStream } from '../../services/DurableSessionStream'; import { WorkspaceToolsState, WorkspaceToolsIntent, WorkspaceFileEntry, WorkspaceTerminalEntry } from '../state/WorkspaceToolsState'; -interface RuntimeLocation { path: string; remoteConnectionId?: string; } +import { RuntimeFileLocation as RuntimeLocation } from '../../model/RemoteModels'; interface RuntimeSystemInfo { homeDir?: string; } interface DirectoryPage { children: WorkspaceFileEntry[]; hasMore: boolean; } interface TerminalPage { data: string; nextOffset: number; cursor: number; truncated: boolean; } 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/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index eb8cc33cd6..af70b3ab05 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -446,7 +446,10 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile async getFileInfo(path: string, sessionId?: string): Promise { const command = RemoteCommandFactory.getFileInfo(path, sessionId); - this.applyFileWorkspace(command, sessionId, this.workspace); + const workspace: RuntimeFileLocation | undefined = this.workspace ? { + path: this.workspace.path, remoteConnectionId: this.workspace.remoteConnectionId + } : undefined; + this.applyFileWorkspace(command, sessionId, workspace); const response = await this.send(command); return { name: response.name || RemoteResponseMapper.basename(path), 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+Account.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift index 464ebdf6d9..bc4ef2cee4 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift @@ -198,7 +198,15 @@ extension MobileAppModel { accountGeneration = generation accountBusy = state is AccountUiStateSigningIn || state is AccountUiStateAuthorizing let previousAuthorizationURL = accountAuthorizationURL - accountAuthorizationURL = (state as? AccountUiStateAuthorizing).flatMap { URL(string: $0.authorizationUrl) } + accountAuthorizationURL = (state as? AccountUiStateAuthorizing).flatMap { authorization in + guard var components = URLComponents(string: authorization.authorizationUrl) else { return nil } + if components.scheme == "https", components.host == "auth.openbitfun.com" { + var items = (components.queryItems ?? []).filter { $0.name != "locale" } + items.append(URLQueryItem(name: "locale", value: appLanguage.rawValue)) + components.queryItems = items + } + return components.url + } if accountSheetOpen, let url = accountAuthorizationURL, url != previousAuthorizationURL { openAccountAuthorization() } 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..f29dcb3d6b 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -1886,7 +1886,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Your sign-in is no longer valid. Enter the account and password again." + "value": "Your sign-in has expired. Sign in again with email or GitHub." } } } @@ -2246,7 +2246,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Incorrect account or password" + "value": "Sign-in details are invalid. Please sign in again." } } } @@ -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/apps/skin-market-server/Dockerfile b/src/apps/skin-market-server/Dockerfile index 50f91b23e3..2e27588506 100644 --- a/src/apps/skin-market-server/Dockerfile +++ b/src/apps/skin-market-server/Dockerfile @@ -45,6 +45,7 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY Cargo.toml Cargo.lock ./ +COPY third_party/eioc/ third_party/eioc/ COPY src/ src/ RUN --mount=type=cache,id=openbitfun-skin-market-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ diff --git a/src/apps/skin-market-server/Dockerfile.dockerignore b/src/apps/skin-market-server/Dockerfile.dockerignore index 97b27b0b3b..12552aaf05 100644 --- a/src/apps/skin-market-server/Dockerfile.dockerignore +++ b/src/apps/skin-market-server/Dockerfile.dockerignore @@ -2,6 +2,11 @@ !Cargo.toml !Cargo.lock +!third_party/ +!third_party/eioc/ +!third_party/eioc/Cargo.toml +!third_party/eioc/src/ +!third_party/eioc/src/** !package.json !pnpm-lock.yaml !pnpm-workspace.yaml 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..9d8ffaa29b 100644 --- a/src/crates/services/miniapp-market-service/README.md +++ b/src/crates/services/miniapp-market-service/README.md @@ -136,3 +136,58 @@ 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_`。 + +The verification email uses `src/email/sign-in.html` as a multipart/alternative +HTML body with a complete UTF-8 plain-text fallback. Layout uses presentation +tables, inline styles and system fonts; the six digits stay selectable text. +Media queries add compact spacing and dark-mode colors without being required +for readability. There are no scripts, forms, tracking pixels or recipient data +in image URLs. The HTML branch is multipart/related with the original application +PNG embedded as an inline CID resource (`src/email/app-icon.png`). It does not +require external image downloads. The brand generator owns this byte-identical +copy of the application icon; do not recolor it or add a CSS placeholder background. +The market web build publishes the same icon separately for the sign-in page. + +Email clients cannot consume CSS variables or theme packages. The template's +small inline palette is an email-specific snapshot of the existing OpenBitFun +reference scales: neutral 0/70/75/200/350/650/800/850/900/950/1000 and cyan 500 from +`design-system/packages/theme-openbitfun/src/reference.tokens.json`. Preserve +those source mappings when updating the template; do not add a separate brand +palette. Check the HTML at 390px and desktop widths in light and dark mode, +then run `cargo test -p openbitfun-miniapp-market-service --lib email_auth::tests` +and a real message render check after SMTP/template changes. 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..ef74c0ee5c 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, @@ -500,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 { @@ -515,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), @@ -526,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, @@ -747,13 +808,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('/') @@ -1169,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 74b9036069..efb0f9cdf0 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; @@ -48,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() @@ -59,14 +94,25 @@ 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 .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, @@ -140,12 +186,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(false)); - assert!(rate.allow(true)); + 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(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] @@ -179,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 b5d6b9cf67..ec2653dca3 100644 --- a/src/crates/services/miniapp-market-service/src/db.rs +++ b/src/crates/services/miniapp-market-service/src/db.rs @@ -17,6 +17,7 @@ pub(crate) struct Database { pub(crate) struct AuthenticatedUser { pub internal_id: i64, pub profile: MarketUserSummary, + pub email: Option, } impl Database { @@ -69,11 +70,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 +133,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?; @@ -135,12 +175,13 @@ impl Database { &self, github_id: i64, ) -> MarketResult> { - let row = - sqlx::query("SELECT id, github_id, login, avatar_url FROM users WHERE github_id = ?") - .bind(github_id) - .fetch_optional(&self.pool) - .await - .map_err(MarketError::internal)?; + let row = sqlx::query( + "SELECT id, github_id, login, avatar_url, NULL AS email FROM users WHERE github_id = ?", + ) + .bind(github_id) + .fetch_optional(&self.pool) + .await + .map_err(MarketError::internal)?; Ok(row.map(user_from_row)) } @@ -171,9 +212,10 @@ impl Database { token: &str, ) -> MarketResult> { let row = sqlx::query( - "SELECT u.id, u.github_id, u.login, u.avatar_url, s.csrf_hash, s.expires_at + "SELECT u.id, u.github_id, u.login, u.avatar_url, e.email, s.csrf_hash, s.expires_at FROM web_sessions s JOIN users u ON u.id = s.user_id + LEFT JOIN email_identities e ON e.user_id = u.id AND u.github_id IS NULL WHERE s.token_hash = ? AND s.expires_at > ?", ) .bind(token_hash(token)) @@ -197,6 +239,7 @@ impl Database { Ok(()) } + #[cfg(test)] pub(crate) async fn create_api_token( &self, user_id: i64, @@ -227,9 +270,10 @@ impl Database { token_type: &str, ) -> MarketResult> { let row = sqlx::query( - "SELECT u.id, u.github_id, u.login, u.avatar_url, t.family_id + "SELECT u.id, u.github_id, u.login, u.avatar_url, e.email, t.family_id FROM api_tokens t JOIN users u ON u.id = t.user_id + LEFT JOIN email_identities e ON e.user_id = u.id AND u.github_id IS NULL WHERE t.token_hash = ? AND t.token_type = ? AND t.expires_at > ? AND t.revoked_at IS NULL", ) @@ -261,8 +305,13 @@ impl Database { fn user_from_row(row: sqlx::sqlite::SqliteRow) -> AuthenticatedUser { AuthenticatedUser { internal_id: row.get("id"), + email: row.get("email"), 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 +321,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/app-icon.png b/src/crates/services/miniapp-market-service/src/email/app-icon.png new file mode 100644 index 0000000000..32aee8246c Binary files /dev/null and b/src/crates/services/miniapp-market-service/src/email/app-icon.png differ diff --git a/src/crates/services/miniapp-market-service/src/email/locales.json b/src/crates/services/miniapp-market-service/src/email/locales.json new file mode 100644 index 0000000000..7c228b0c61 --- /dev/null +++ b/src/crates/services/miniapp-market-service/src/email/locales.json @@ -0,0 +1,32 @@ +{ + "en-US": { + "subject": "OpenBitFun sign-in code", + "preview": "Your OpenBitFun sign-in code expires in 10 minutes.", + "heading": "Sign in to OpenBitFun", + "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": "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 登录验证码", + "preview": "你的 OpenBitFun 登录验证码,10 分钟内有效。", + "heading": "登录你的 OpenBitFun", + "intro": "请在登录页面输入下方验证码,继续你的工作与创作。", + "expiry": "10 分钟内有效 · 仅可使用一次", + "security": "请勿向他人透露此验证码。如果你没有发起登录,无需操作,忽略此邮件即可。", + "footer": "开源,让创造更自由", + "plain": "你的 OpenBitFun 登录验证码是:{{code}}\n\n验证码 10 分钟内有效,仅可使用一次。请勿向他人透露。\n如非本人操作,请忽略此邮件。\n\nOpenBitFun\nhttps://openbitfun.com" + }, + "zh-TW": { + "subject": "OpenBitFun 登入驗證碼", + "preview": "你的 OpenBitFun 登入驗證碼,10 分鐘內有效。", + "heading": "登入你的 OpenBitFun", + "intro": "請在登入頁面輸入下方驗證碼,繼續你的工作與創作。", + "expiry": "10 分鐘內有效 · 僅可使用一次", + "security": "請勿向他人透露此驗證碼。如果你沒有發起登入,無需操作,忽略此郵件即可。", + "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 new file mode 100644 index 0000000000..170efb9b45 --- /dev/null +++ b/src/crates/services/miniapp-market-service/src/email/sign-in.html @@ -0,0 +1,57 @@ + + + + + + + + {{subject}} + + + +
{{preview}}
+ + +
+ + + + +

OpenBitFun · {{footer}}

+ +
+ + 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..751af780c4 --- /dev/null +++ b/src/crates/services/miniapp-market-service/src/email_auth.rs @@ -0,0 +1,1184 @@ +//! 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::{ + header::{ContentTransferEncoding, ContentType}, + Attachment, Mailbox, MultiPart, SinglePart, + }, + 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> { + 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 builder = match security.as_str() { + "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, starttls or local", + )) + } + }; + let port = get("SMTP_PORT") + .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( + Some(get("SMTP_FROM_NAME").unwrap_or_else(|| "OpenBitFun".into())), + username + .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: 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( + std::time::Duration::from_secs(20), + self.transport.send(message), + ) + .await + .map_err(|_| delivery_error())? + .map_err(|_| delivery_error())?; + Ok(()) + } +} +fn verification_message( + from: Mailbox, + email: &str, + code: &str, + requested_locale: &str, +) -> MarketResult { + static COPY: std::sync::LazyLock = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("email/locales.json")).expect("valid email translations") + }); + let locale = if COPY.get(requested_locale).is_some() { + requested_locale + } else { + "en-US" + }; + let copy = COPY[locale].as_object().expect("email locale object"); + let mut html = include_str!("email/sign-in.html").replace("{{locale}}", locale); + for (key, value) in copy { + html = html.replace( + &format!("{{{{{key}}}}}"), + value.as_str().expect("email translation"), + ); + } + let html = html.replace("{{code}}", code); + let plain = copy["plain"] + .as_str() + .expect("plain email translation") + .replace("{{code}}", code); + Message::builder() + .from(from) + .to(email.parse().map_err(|_| invalid_email())?) + .subject(copy["subject"].as_str().expect("email subject")) + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .header(ContentTransferEncoding::Base64) + .body(plain), + ) + .multipart( + MultiPart::related() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .header(ContentTransferEncoding::Base64) + .body(html), + ) + .singlepart(Attachment::new_inline("openbitfun-app-icon".into()).body( + include_bytes!("email/app-icon.png").to_vec(), + ContentType::parse("image/png").expect("valid PNG MIME type"), + )), + ), + ) + .map_err(|_| MarketError::internal("Could not compose verification email")) +} + +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, + #[serde(default)] + pub locale: Option, +} +#[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(|| 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() { + "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 locale = request.locale.clone().unwrap_or_else(|| "en-US".into()); + 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, &locale).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!("{:08}", OsRng.gen_range(0..100_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 > ?) < 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).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() != 8 || !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(), + locale: None, + }) + .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_eq!(user.email.as_deref(), Some("alice@example.com")); + assert_eq!(github.email, None); + assert!(!user.profile.login.contains('@')); + 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_eq!(token_user.email, user.email); + 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 == "00000000" { + "11111111" + } else { + "00000000" + }; + 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 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 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; + let ticket = service.start_web_login("/miniapp/").await.unwrap(); + let (id, code) = code(&service, &ticket, "alice@example.com").await; + let wrong = if code == "00000000" { + "11111111" + } else { + "00000000" + }; + 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..20 { + 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, 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] + 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(), + locale: None + }) + .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 + .clone() + .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"))); + let cookie_header = cookies + .iter() + .map(|cookie| cookie.split(';').next().unwrap()) + .collect::>() + .join("; "); + let response = app + .oneshot( + Request::get("/me") + .header(header::COOKIE, cookie_header) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let profile: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), 16384).await.unwrap()).unwrap(); + assert_eq!(profile["email"], "alice@example.com"); + assert!(profile["user"].get("email").is_none()); + 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( + "OpenBitFun ".parse().unwrap(), + "alice@example.com", + "123456", + "en-US", + ) + .unwrap(); + let raw = String::from_utf8(message.formatted()).unwrap(); + assert!(raw.contains("MIME-Version: 1.0\r\n")); + assert!(raw.contains("Content-Type: text/plain; charset=utf-8\r\n")); + assert!(!raw.contains("application/octet-stream")); + assert!(!raw.contains("Content-Disposition: attachment")); + use base64::Engine; + assert!(raw.contains("Content-Transfer-Encoding: base64")); + assert!(raw.contains("Content-Type: multipart/alternative;")); + assert!(raw.contains("Content-Type: text/html; charset=utf-8")); + assert!(raw.contains("Content-Type: multipart/related;")); + assert!(raw.contains("Content-ID: ")); + assert!(raw.contains("Content-Disposition: inline")); + let decode_part = |content_type: &str| { + let part = raw + .split("Content-Type: ") + .find(|part| part.starts_with(content_type)) + .unwrap(); + let (_, body) = part.split_once("\r\n\r\n").unwrap(); + let encoded = body.split("\r\n--").next().unwrap(); + base64::engine::general_purpose::STANDARD + .decode(encoded.split_whitespace().collect::()) + .unwrap() + }; + let plain = String::from_utf8(decode_part("text/plain;")).unwrap(); + assert!(plain.contains("Your OpenBitFun verification code is: 123456")); + assert!(plain.is_ascii()); + let html = String::from_utf8(decode_part("text/html;")).unwrap(); + assert!(html.contains("src=\"cid:openbitfun-app-icon\"")); + assert!(!html.contains("src=\"https://")); + assert_eq!( + decode_part("image/png"), + include_bytes!("email/app-icon.png") + ); + assert!(html.contains(">123456")); + assert!(!html.contains("{{code}}")); + assert!(!html.contains(" = translations["en-US"].as_object().unwrap().keys().collect(); + for locale in ["en-US", "zh-CN", "zh-TW"] { + assert_eq!( + translations[locale] + .as_object() + .unwrap() + .keys() + .collect::>(), + expected + ); + let message = verification_message( + "OpenBitFun ".parse().unwrap(), + "a@example.com", + "654321", + locale, + ) + .unwrap(); + let raw = String::from_utf8(message.formatted()).unwrap(); + use base64::Engine; + let part = raw + .split("Content-Type: ") + .find(|p| p.starts_with("text/html;")) + .unwrap(); + let encoded = part + .split_once("\r\n\r\n") + .unwrap() + .1 + .split("\r\n--") + .next() + .unwrap(); + let html = String::from_utf8( + base64::engine::general_purpose::STANDARD + .decode(encoded.split_whitespace().collect::()) + .unwrap(), + ) + .unwrap(); + assert!(html.contains(&format!("lang=\"{locale}\""))); + assert!(html.contains(translations[locale]["heading"].as_str().unwrap())); + assert!(!html.contains("{{")); + if locale == "en-US" { + assert!(!html.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c))); + } + } + let unknown = verification_message( + "OpenBitFun ".parse().unwrap(), + "a@example.com", + "654321", + "unknown", + ) + .unwrap(); + assert!(String::from_utf8(unknown.formatted()) + .unwrap() + .contains("Subject: OpenBitFun sign-in code")); + } + + #[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..efe0e9ac91 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], @@ -121,6 +122,8 @@ struct MarketConfigResponse { #[serde(rename_all = "camelCase")] struct MeResponse { user: MarketUserSummary, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, is_admin: bool, } @@ -186,6 +189,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 +293,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 +301,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 +334,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 +550,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 +587,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 +604,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( @@ -617,6 +659,7 @@ fn identity_response(state: &MarketState, auth: &RequestAuth) -> Response { let mut response = Json(MeResponse { is_admin: state.auth.is_admin(&auth.user), user: auth.user.profile.clone(), + email: auth.user.email.clone(), }) .into_response(); response @@ -1284,7 +1327,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 +1363,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 +1484,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 +1644,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 +2836,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..9d0b7128b7 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)) @@ -292,19 +298,25 @@ 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" }, 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..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 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."));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/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..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 @@ -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}`); @@ -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 a896925bf5..459da8c9b1 100644 --- a/src/crates/services/relay-service/web/page-auth-client.ts +++ b/src/crates/services/relay-service/web/page-auth-client.ts @@ -103,13 +103,14 @@ 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.')); } + 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) { @@ -141,6 +142,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..694fc65b16 100644 --- a/src/crates/services/services-integrations/src/account_identity/mod.rs +++ b/src/crates/services/services-integrations/src/account_identity/mod.rs @@ -59,6 +59,9 @@ impl From for StoredMarketCredentials { #[serde(rename_all = "camelCase")] pub struct MarketMe { pub user: GitHubUser, + /// Verified email is private account metadata, never a public owner name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, pub is_admin: bool, } @@ -126,10 +129,10 @@ 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.", + "Invalid account identity.", )); } Ok(me) @@ -172,8 +175,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( @@ -348,3 +354,22 @@ impl std::fmt::Debug for MarketTokenPair { f.debug_struct("AccountTokenPair").finish_non_exhaustive() } } + +#[cfg(test)] +mod profile_tests { + use super::MarketMe; + + #[test] + fn verified_email_is_optional_and_survives_host_projection() { + let legacy = r#"{"user":{"githubId":42,"login":"alice","avatarUrl":""},"isAdmin":false}"#; + let github: MarketMe = serde_json::from_str(legacy).unwrap(); + assert!(github.email.is_none()); + assert_eq!(github.user.identity_id().as_deref(), Some("42")); + let email = r#"{"user":{"githubId":0,"accountId":"email-7","login":"user-internal","avatarUrl":""},"email":"alice@example.com","isAdmin":false}"#; + let account: MarketMe = serde_json::from_str(email).unwrap(); + let projected = serde_json::to_value(&account).unwrap(); + assert_eq!(projected["email"], "alice@example.com"); + assert_eq!(account.user.identity_id().as_deref(), Some("email-7")); + assert_eq!(account.user.login, "user-internal"); + } +} 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/index.html b/src/miniapp-market-web/index.html index 7d784c2134..6046231bb3 100644 --- a/src/miniapp-market-web/index.html +++ b/src/miniapp-market-web/index.html @@ -7,8 +7,8 @@ name="description" content="Single-purpose tools that open inside OpenBitFun, where the assistant can already see them. Every release is read by a human and locked to a hash." /> - - + + diff --git a/src/miniapp-market-web/public/assets/openbitfun-email-app-icon.png b/src/miniapp-market-web/public/assets/openbitfun-email-app-icon.png new file mode 100644 index 0000000000..32aee8246c Binary files /dev/null and b/src/miniapp-market-web/public/assets/openbitfun-email-app-icon.png differ diff --git a/src/miniapp-market-web/public/assets/openbitfun-email-mark.png b/src/miniapp-market-web/public/assets/openbitfun-email-mark.png new file mode 100644 index 0000000000..cd9395ab5a Binary files /dev/null and b/src/miniapp-market-web/public/assets/openbitfun-email-mark.png differ diff --git a/src/miniapp-market-web/public/assets/openbitfun-favicon-16.png b/src/miniapp-market-web/public/assets/openbitfun-favicon-16.png new file mode 100644 index 0000000000..c75e68b637 Binary files /dev/null and b/src/miniapp-market-web/public/assets/openbitfun-favicon-16.png differ diff --git a/src/miniapp-market-web/public/assets/openbitfun-favicon-32.png b/src/miniapp-market-web/public/assets/openbitfun-favicon-32.png new file mode 100644 index 0000000000..b59b02acea Binary files /dev/null and b/src/miniapp-market-web/public/assets/openbitfun-favicon-32.png differ 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 new file mode 100644 index 0000000000..33b8fec3e0 --- /dev/null +++ b/src/miniapp-market-web/src/AccountSignIn.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { ArrowRight, ChevronDown, Globe, Github, KeyRound, Mail, ShieldCheck } from 'lucide-react'; +import { marketApi } from './api'; +import { useLocale, type MessageKey } 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 [expired, setExpired] = useState(false); + const [retryAt, setRetryAt] = useState(0); + const [now, setNow] = useState(Date.now()); + const initial = useRef | null>(null); + useEffect(() => { document.title = `OpenBitFun · ${t('signIn')}`; }, [t]); + 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('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 : ''; + 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(''); + try { const sent = await marketApi.sendEmailCode(ticket, email, locale); 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'); + if (target.origin === 'https://auth.openbitfun.com' && target.pathname === '/complete') target.searchParams.set('locale', locale); + 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
+
+
OpenBitFun
+
+
+
+
+

{t('accountSignIn')}

{t('emailLoginIntro')}

+ {!ticket && !error &&

{t('authWorking')}

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

{t('emailCodeSent')}

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

{t('emailUnavailable')}

} + {error &&

{t(error)}

} +
+

+
; +} diff --git a/src/miniapp-market-web/src/App.tsx b/src/miniapp-market-web/src/App.tsx index a2189c630e..95cb251dd3 100644 --- a/src/miniapp-market-web/src/App.tsx +++ b/src/miniapp-market-web/src/App.tsx @@ -30,6 +30,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'; @@ -88,7 +89,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 ; @@ -97,16 +98,9 @@ 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 ? :
-
-
-
} + {complete ? : }
; } @@ -216,7 +210,6 @@ function MarketApp() {