Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# PLAN — 邮箱验证码登录 + Google 登录 / Email-OTP & Google Sign-In (v1.0)

**Status: DRAFT,待评审。** 承接 [PLAN_ACCOUNTS_BILLING_v1.0.md](PLAN_ACCOUNTS_BILLING_v1.0.md)
(B1 邮箱+密码 / SIWA、B8a 验证链接、B8b SIWA-web)。目标:① 邮箱**验证码(OTP)
免密登录**成为邮箱路径的默认形态;② 新增 **Google 登录**(推翻原 §4 "不加 Google"
的裁决——当时的顾虑是 4.8 连带义务,但 SIWA 已上线且处主位,义务已满足)。

## 1. 现状与缺口

| 组件 | 位置 | 现状 |
| --- | --- | --- |
| 邮箱+密码账号(scrypt) | [src/web/accounts.ts](../src/web/accounts.ts) | ✅ 保留不动(ASC 审核账号必须 user/pass) |
| 验证**链接**(提额 $1→$5) | accounts.ts `mintVerifyToken` + [mailer.ts](../src/web/mailer.ts) | ⚠️ OTP 上线后此流程被吸收(见 §2.3) |
| SIWA(iOS 原生 + web Services ID) | [cloudAuth.ts](../src/web/cloudAuth.ts) / [login.ts](../src/web/login.ts) | ✅ 线上原生已启用;web 端 `appleWeb:null` 待配 |
| Google 登录 | — | ✗ 全库为零 |
| OTP | — | ✗ 全库为零 |
| 发信 | mailer.ts(Resend) | ⚠️ OTP **硬依赖**线上 RESEND_API_KEY(现在缺失只降级打日志) |
| 登录限流/锁定 | accounts.ts 锁定 + [billing/limits.ts](../src/billing/limits.ts) | ✅ OTP 复用同一套 |

## 2. 设计

### 2.1 OTP 免密登录(与密码并存,OTP 为默认)

* **端点**:
* `POST /api/auth/otp/request {email}` → 生成 6 位数字码;存 `SHA-256(code+salt)`,
TTL 10 分钟;同邮箱 60s 冷却、每日 ≤10 次;per-IP 限流复用 limits.ts;
响应恒定(不泄露邮箱是否已注册)。
* `POST /api/auth/otp/verify {email, code}` → 常量时间比对;单条 OTP 最多试 5 次,
超限作废;成功 → 签发既有 HMAC session(cookie/JSON token 双通道不变)。
* **注册=登录合一**:验证码成功即证明邮箱所有权 —— 未注册邮箱直接建号且
`verified=true`(直接 $5 窗口);已注册未验证账号顺带转正。**独立的
"注册+验证链接"两步流被吸收**,`/api/auth/verify/*` 保留兼容存量邮件。
* **存储**:pending-OTP 独立表(未注册邮箱尚无 account record):file 模式
`/data/otp.json`(复用 fs-utils 原子写+锁,读时惰性清理过期);Firestore 模式
`otps` 集合 CAS(与 [firestore.ts](../src/cloud/firestore.ts) 现有模式一致)。
* **密码路径保留**:`/api/auth/login` 原样;UI 上折叠为 "Use password instead"。
审核账号 `reviewer@meetlisa.ai` 继续密码登录,ASC 表单不变。

### 2.2 Google 登录

* **服务端** `src/web/googleAuth.ts`,复刻 cloudAuth.ts 风格(纯 Node crypto,
零依赖):Google JWKS(`googleapis.com/oauth2/v3/certs`)缓存 + RS256 验签;
校验 `iss ∈ {accounts.google.com, https://accounts.google.com}`、
`aud ∈ {LISA_GOOGLE_WEB_CLIENT_ID, LISA_GOOGLE_IOS_CLIENT_ID}`、`exp`、
`email_verified === true`。端点 `POST /api/auth/google {idToken}`。
* **账号映射(决策点,推荐如下)**:Google 邮箱命中现有 email-kind 账号 →
登入该账号并记 `googleSub`(一个邮箱一个 uid,余额不分裂;Google 已验证
所有权,风险可控);否则建 `g-<sub>` 新号,`verified=true`。SIWA 不参与
匹配(private relay 邮箱)。
* **客户端**:
* **web**:GIS(Google Identity Services)按钮,动态注入(与 SIWA-web 同模式);
`/api/auth/config` 增发 `google.webClientId`。
* **iOS**:**不引 GoogleSignIn SDK**(沿袭零依赖惯例,先例:拒 RevenueCat、
cloudAuth 纯 crypto)——用 `ASWebAuthenticationSession` + OAuth **PKCE**
(authorization code → token 端点换 `id_token`,iOS 类 client 无 secret)→
POST /api/auth/google。project.yml 加反转 client ID 的 URL scheme。
* **Mac 菜单栏 / CLI**:v1 不做原生 Google(OTP 已覆盖"免密"诉求);后续可
复用 ASWebAuthenticationSession(Mac)/ 设备码(CLI)。
* **按钮次序(4.8/HIG)**:Apple 首位、Google 次之、邮箱第三 —— SIWA 等同
显著性义务持续满足。App Privacy 无新增类别(email/uid 已申报)。

### 2.3 防滥用

OTP 发信是新攻击面:① 请求响应恒定,防枚举;② 冷却+日限+IP 限流(费率:
Resend 免费档 100 封/天,超限告警接既有通道);③ 验证码尝试上限与密码锁定
共用计数语义;④ OTP 建号获 $5 窗口 —— 一次性邮箱女巫风险与现有"邮箱验证后
$5"完全同级,不新增暴露(仍受 per-uid 并发=1 + RPM + 全局日上限约束)。

## 3. Phasing(堆叠 PR,两线可并行)

| 阶段 | 内容 | 依赖 |
| --- | --- | --- |
| **A0** | 本方案入库 | — |
| **A1** | OTP 服务端:otp store(file+Firestore)+ 两端点 + 邮件模板 + 限流 + 测试 | — |
| **A2** | OTP 四端:web 登录页 code-first;iOS 表单;Mac AccountWindow;CLI `lisa login` 默认 OTP(`--password` 保留) | A1 |
| **A3** | Google 服务端:googleAuth 验签 + `/api/auth/google` + email 绑定 + config 下发 + 测试 | — |
| **A4** | Google 客户端:web GIS 按钮 + iOS PKCE 流(project.yml URL scheme) | A3 |
| **A5** | 文档:runbook 增补(GCP OAuth、Resend 核查、部署 env);RELEASE 注记;iOS 1.2 tag 发 TestFlight(CI 已配好,`pocket-v1.2.0` 即可) | A2+A4 |

## 4. 运营前置(人工,代码做不了)

1. **Resend 发信必须真的在线上生效**(OTP 硬依赖):确认 `RESEND_API_KEY` 已配
且 `meetlisa.ai` 发信域在 Resend 验证通过(SPF/DKIM);未配则 OTP 无法送达。
2. **GCP Console**:OAuth consent screen(External、发布)+ 两个 OAuth Client ID
(Web 应用、iOS 应用 bundle `ai.meetlisa.main`)。
3. **重部署**:带 `LISA_GOOGLE_WEB_CLIENT_ID` / `LISA_GOOGLE_IOS_CLIENT_ID`
(顺带补 SIWA-web 的 Services ID,把 `appleWeb:null` 一起解决)。
4. ASC:1.2 送审时 Review Notes 不变(demo 账号仍密码制);4.8 合规自查通过。

## 5. 决策点(默认已选推荐项,评审时可推翻)

1. Google 邮箱与既有邮箱账号**自动绑定**(推荐)vs 各自建号。
2. iOS 用 **PKCE 零依赖**(推荐)vs 引 GoogleSignIn SPM。
3. Mac/CLI 的 Google **缓到 v1.1**(推荐)vs 一并做。
4. OTP 默认位(推荐 OTP-first,密码折叠)vs 密码默认、OTP 备选。
62 changes: 62 additions & 0 deletions src/web/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
sessionAccountValid,
resetLoginThrottles,
appleUid,
ensureOtpAccount,
AccountError,
} = await import("./accounts.js");

Expand Down Expand Up @@ -70,6 +71,67 @@ describe("email accounts", () => {
});
});

describe("code-only (OTP) accounts", () => {
test("first code creates a verified, passwordless account", async () => {
const { acct, created } = await ensureOtpAccount("New@Example.com", 1000);
assert.equal(created, true);
assert.equal(acct.kind, "email");
assert.equal(acct.email, "new@example.com");
assert.equal(acct.verified, true, "reading the mail proved ownership");
assert.equal(acct.scrypt, undefined, "no password material");
});

test("a later code signs into the same account rather than making a second", async () => {
const first = await ensureOtpAccount("a@b.co", 1000);
const second = await ensureOtpAccount("a@b.co", 2000);
assert.equal(second.created, false);
assert.equal(second.acct.uid, first.acct.uid);
assert.equal(second.acct.lastLoginAt, 2000);
});

test("a passwordless account cannot be logged into with any password", async () => {
const { acct } = await ensureOtpAccount("a@b.co", 1000);
assert.equal(await verifyEmailLogin("a@b.co", "password-123", 2000), null);
assert.equal(await verifyEmailLogin("a@b.co", "", 2000), null);
assert.equal((await getAccount(acct.uid))?.uid, acct.uid, "the account still exists");
});

test("a code verifies an existing unverified password account, dropping the pre-verification password", async () => {
const made = await createEmailAccount("a@b.co", "password-123");
assert.equal(made.verified, false);
assert.equal(made.sessionVersion, 0);
const { acct, created } = await ensureOtpAccount("a@b.co", 3000);
assert.equal(created, false);
assert.equal(acct.uid, made.uid);
assert.equal(acct.verified, true);
// /api/auth/register is open, so a password set before ownership was proven
// can't be trusted. Signing in by code drops it and rotates sessionVersion, so
// the pre-set password no longer authenticates (anti account-pre-hijacking).
assert.equal(acct.scrypt, undefined, "pre-verification password dropped");
assert.equal(acct.sessionVersion, 1, "sessionVersion rotated to kill prior sessions");
assert.equal(await verifyEmailLogin("a@b.co", "password-123", 4000), null, "the pre-set password no longer works");
});

test("account pre-hijacking is closed: an attacker's pre-set password can't survive the victim's code sign-in", async () => {
// Attacker pre-registers the victim's address with a password they know.
const attacker = await createEmailAccount("victim@x.co", "attacker-knows-this");
assert.equal(attacker.verified, false);
// Victim signs in by mailed code (proving they, not the attacker, own the inbox).
const { acct } = await ensureOtpAccount("victim@x.co", 5000);
assert.equal(acct.uid, attacker.uid, "same record — the code adopts the existing account");
// The attacker can no longer authenticate with the password they set.
assert.equal(
await verifyEmailLogin("victim@x.co", "attacker-knows-this", 6000),
null,
"attacker's pre-set password is invalidated on the victim's ownership proof",
);
});

test("a malformed address is refused", async () => {
await assert.rejects(ensureOtpAccount("not-an-email", 1000), isCode("invalid_email"));
});
});

describe("apple accounts", () => {
test("upsert creates once, then updates lastLoginAt; uid is fs-safe", async () => {
const a = await upsertAppleAccount("001234.abcdef.5678", "user@example.com", 1000);
Expand Down
81 changes: 77 additions & 4 deletions src/web/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
* Two account kinds share one store (`$lisaHome()/accounts.json`, 0600):
* - **Apple** (`apple-<sub>`): created/updated on every verified Sign in with
* Apple. No password material — Apple is the authority.
* - **Email** (`em-<random>`): self-serve email+password, scrypt-hashed. This
* is what App Review's demo account uses (ASC insists on user/pass), and the
* path for desktop/web users without an Apple ID.
* - **Email** (`em-<random>`): self-serve, keyed by the address. Password
* material is OPTIONAL: accounts born from a mailed one-time code
* (src/web/otp.ts) carry no scrypt params at all, and the password path
* rejects them constant-time like any other bad credential. App Review's
* demo account is the password kind (ASC insists on user/pass).
*
* Every account carries a `sessionVersion`; session tokens embed it and the
* gate rejects a mismatch — so deleting an account (App Store 5.1.1(v)) or a
Expand Down Expand Up @@ -38,7 +40,11 @@ export interface AccountRecord {
kind: AccountKind;
/** Normalized (trimmed, lowercased). Optional for Apple (user may hide it). */
email?: string;
/** Password material — email accounts only. */
/**
* Password material — email accounts that chose one. Absent for code-only
* (OTP) accounts; `verifyEmailLogin` then fails against the decoy params, so
* a passwordless account can never be password-authenticated.
*/
scrypt?: ScryptParams;
createdAt: number;
lastLoginAt: number;
Expand Down Expand Up @@ -260,6 +266,73 @@ export async function createEmailAccount(
});
}

/**
* Apply proof-of-ownership (a mailed OTP or a verified OIDC email) to an account.
* The FIRST time an account becomes verified this way, anything set on it before
* is untrusted — `/api/auth/register` is open and unauthenticated, so an attacker
* can pre-create any address with a password of their choosing. So on the
* unverified→verified transition we drop any pre-set password and rotate
* `sessionVersion` (invalidating any session minted from that credential),
* closing the account pre-hijacking path. A password the real owner sets AFTER
* verifying is unaffected. Returns whether this was the first verification.
*/
function markVerifiedByOwnershipProof(acct: AccountRecord): boolean {
const firstVerification = !acct.verified;
acct.verified = true;
delete acct.verifyTokenHash;
delete acct.verifyExpiresAt;
if (firstVerification && acct.scrypt) {
delete acct.scrypt;
acct.sessionVersion += 1;
}
return firstVerification;
}

/**
* Sign-in by mailed code (A1). The code already proved this person reads the
* address, so one call both registers and authenticates: an existing account
* comes back marked verified (ownership was just demonstrated, which levels the
* free window $1 → $5), a new one is created with no password material.
*
* The lookup and the insert share a single mutation, so two codes redeemed at
* once can't create the address twice under Firestore CAS.
*
* Password lockouts are deliberately NOT cleared here: they guard the password
* credential only, and leaving them in place means someone else's failed
* guessing can never be undone by the victim signing in normally.
*/
export async function ensureOtpAccount(
emailRaw: string,
now: number = Date.now(),
): Promise<{ acct: AccountRecord; created: boolean }> {
const email = normalizeEmail(emailRaw);
if (!validEmail(email)) throw new AccountError("invalid_email");
const uid = `em-${crypto.randomBytes(9).toString("hex")}`;
return mutateAccounts((list) => {
const existing = list.find((a) => a.kind === "email" && a.email === email);
if (existing) {
existing.lastLoginAt = now;
// OTP proves inbox control → apply ownership proof. This drops any password
// set before verification (an attacker can pre-register any address via the
// open /api/auth/register) and rotates sessionVersion, closing the account
// pre-hijacking path where the attacker's password survived the adoption.
markVerifiedByOwnershipProof(existing);
return { acct: existing, created: false };
}
const rec: AccountRecord = {
uid,
kind: "email",
email,
createdAt: now,
lastLoginAt: now,
verified: true,
sessionVersion: 0,
};
list.push(rec);
return { acct: rec, created: true };
});
}

/**
* Email+password login. Returns the account or null; throws AccountError
* ("throttled") while the email is locked out. A wrong password on an unknown
Expand Down
58 changes: 49 additions & 9 deletions src/web/mailer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/**
* Outbound mail — email-ownership verification for LISA accounts
* (docs/PLAN_ACCOUNTS_BILLING_v1.0.md B7 follow-up, milestone B8a).
* Outbound mail — account verification and sign-in codes
* (docs/PLAN_ACCOUNTS_BILLING_v1.0.md B8a; docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md A1).
*
* One provider, one purpose: Resend's REST API (zero deps) sends the
* verification link that levels an email account's free window from $1 to $5.
* Without RESEND_API_KEY the mailer degrades loudly-but-safely: the link is
* printed to the server log so an operator can forward it by hand (and dev
* setups keep working offline).
* One provider, two messages, both over Resend's REST API (zero deps): the
* verification link that levels an email account's free window from $1 to $5,
* and the one-time code that signs a person in without a password.
* Without RESEND_API_KEY the mailer degrades loudly-but-safely: the link or code
* is printed to the server log so an operator can forward it by hand (and dev
* setups keep working offline). **Sign-in by code therefore needs a real key in
* production** — a code nobody receives is a sign-in nobody completes.
*
* Env: RESEND_API_KEY, LISA_MAIL_FROM (default "LISA <no-reply@meetlisa.ai>";
* the domain must be verified in Resend before real sends succeed).
Expand Down Expand Up @@ -42,15 +44,53 @@ export function verificationEmail(link: string): { subject: string; text: string
};
}

/** Compose the sign-in code mail (pure — unit-testable). */
export function signInCodeEmail(code: string, ttlMinutes: number): { subject: string; text: string } {
return {
subject: `${code} is your LISA sign-in code`,
text:
`Your LISA sign-in code is:\n\n` +
` ${code}\n\n` +
`Enter it in the app or on the sign-in page. It expires in ${ttlMinutes} minutes ` +
`and can be used once.\n\n` +
`If you didn't try to sign in, ignore this mail — nobody can use the code ` +
`without reading this inbox.`,
};
}

/**
* Mail the one-time sign-in code. Same degradation as the verification link:
* with no API key the code goes to the server log (dev/offline), which is why
* a production deployment must have RESEND_API_KEY set.
*/
export async function sendSignInCodeEmail(
to: string,
code: string,
ttlMinutes: number,
cfg: MailerConfig = mailerConfig(),
fetchFn: typeof fetch = fetch,
): Promise<MailResult> {
return deliver(to, signInCodeEmail(code, ttlMinutes), `sign-in code for ${to}: ${code}`, cfg, fetchFn);
}

export async function sendVerificationEmail(
to: string,
link: string,
cfg: MailerConfig = mailerConfig(),
fetchFn: typeof fetch = fetch,
): Promise<MailResult> {
const mail = verificationEmail(link);
return deliver(to, verificationEmail(link), `verification link for ${to}: ${link}`, cfg, fetchFn);
}

async function deliver(
to: string,
mail: { subject: string; text: string },
fallbackLog: string,
cfg: MailerConfig,
fetchFn: typeof fetch,
): Promise<MailResult> {
if (!cfg.apiKey) {
console.error(`[mail] RESEND_API_KEY unset — verification link for ${to}: ${link}`);
console.error(`[mail] RESEND_API_KEY unset — ${fallbackLog}`);
return { sent: false, detail: "no_api_key" };
}
try {
Expand Down
Loading