diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 4aec2de..a4932f4 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -40,6 +40,9 @@ jobs: -e REDIS_PASSWORD='Rds@nTsgKrcqAkbuf6PwJIFMZQzF' \ -e JWT_SECRET=98b1e7e377a40021ad7c46c55e467d2a218a89db7afc7c912780152ad64bdc45 \ -e AI_PROVIDER=mock \ + -e APPLE_BUNDLE_ID=cloud.longde.AIStudyApp \ + -e APPLE_ISSUER=https://appleid.apple.com \ + -e APPLE_JWKS_URL=https://appleid.apple.com/auth/keys \ -e ENABLE_SWAGGER=true \ -e SWAGGER_USER=admin \ -e SWAGGER_PASSWORD='Swgr@fmDentAYVXQUpG6oZDpJ' \ diff --git a/docs/ios登录流程.md b/docs/ios登录流程.md new file mode 100644 index 0000000..27a7a71 --- /dev/null +++ b/docs/ios登录流程.md @@ -0,0 +1,1003 @@ +对,现在后端登录要开始接了。你要先理解一个核心点: + +```text +Apple 登录不是你的 App 登录系统本身。 +Apple 只是帮你证明“这个人是谁”。 +真正的登录态,要由你的后端发 accessToken / refreshToken。 +``` + +也就是说最终流程是: + +```text +iOS 调 Apple 登录 +→ 拿到 Apple identityToken +→ 发给你的 NestJS 后端 +→ 后端校验 Apple token +→ 后端创建 / 查找用户 +→ 后端生成自己的 accessToken + refreshToken +→ iOS 存 Keychain +→ 以后所有接口带 Authorization: Bearer accessToken +``` + +不过你现在后端开发阶段,我建议先做: + +```text +dev-login +→ /users/me +→ Keychain +→ 知识库接口 +``` + +Apple 登录随后再接,不要让 Apple 流程卡住后端开发。 + +--- + +# 一、后端登录模块要做哪些接口 + +第一版只需要这 5 个: + +```http +POST /api/auth/dev-login +POST /api/auth/apple +POST /api/auth/refresh +POST /api/auth/logout +GET /api/users/me +``` + +其中现在最先实现: + +```http +POST /api/auth/dev-login +POST /api/auth/refresh +GET /api/users/me +``` + +等这些通了,再接: + +```http +POST /api/auth/apple +``` + +--- + +# 二、数据库先建这 3 张表 + +## 1. users + +```prisma +model User { + id String @id @default(cuid()) + email String? + nickname String? + avatarUrl String? + role UserRole @default(USER) + status UserStatus @default(ACTIVE) + onboardingCompleted Boolean @default(false) + + authAccounts AuthAccount[] + refreshTokens RefreshToken[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum UserRole { + USER + ADMIN + SUPER_ADMIN +} + +enum UserStatus { + ACTIVE + DISABLED + DELETED +} +``` + +## 2. auth_accounts + +这个表用来记录用户是通过什么方式登录的。 + +```prisma +model AuthAccount { + id String @id @default(cuid()) + userId String + provider AuthProvider + providerUserId String + email String? + + user User @relation(fields: [userId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([provider, providerUserId]) + @@index([userId]) +} + +enum AuthProvider { + DEV + APPLE +} +``` + +## 3. refresh_tokens + +```prisma +model RefreshToken { + id String @id @default(cuid()) + userId String + tokenHash String + expiresAt DateTime + revokedAt DateTime? + + user User @relation(fields: [userId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} +``` + +注意:`refreshToken` 不要明文存数据库,只存 hash。 + +--- + +# 三、接口返回格式 + +登录成功后,后端统一返回: + +```json +{ + "accessToken": "eyJ...", + "refreshToken": "eyJ...", + "user": { + "id": "user_xxx", + "email": "test@zhixi.app", + "nickname": "测试用户", + "avatarUrl": null, + "role": "USER", + "status": "ACTIVE", + "onboardingCompleted": false + } +} +``` + +iOS 拿到以后: + +```text +accessToken:内存里用,接口请求带上 +refreshToken:存 Keychain,用来恢复登录 +user:存 AppSession / UserStore +``` + +--- + +# 四、dev-login 怎么做 + +dev-login 是开发阶段用的。 + +请求: + +```http +POST /api/auth/dev-login +``` + +```json +{ + "email": "test@zhixi.app", + "nickname": "测试用户", + "devSecret": "你的开发密钥" +} +``` + +后端逻辑: + +```text +1. 判断 NODE_ENV 不是 production +2. 校验 devSecret +3. 根据 email 查 AuthAccount +4. 如果没有,创建 User + AuthAccount +5. 生成 accessToken +6. 生成 refreshToken +7. refreshToken hash 入库 +8. 返回 token + user +``` + +这个接口必须禁止生产环境使用: + +```ts +if (process.env.NODE_ENV === 'production') { + throw new ForbiddenException('dev-login is disabled in production') +} +``` + +--- + +# 五、Apple 登录怎么接 + +iOS 通过 Sign in with Apple 拿到: + +```text +identityToken +authorizationCode +userIdentifier +email +fullName +``` + +然后发给后端: + +```http +POST /api/auth/apple +``` + +```json +{ + "identityToken": "eyJ...", + "authorizationCode": "...", + "userIdentifier": "000123.xxx", + "email": "xxx@privaterelay.appleid.com", + "fullName": { + "givenName": "Long", + "familyName": "De" + } +} +``` + +后端逻辑: + +```text +1. 校验 identityToken +2. 确认 token 的 aud 是你的 Bundle ID +3. 确认 token 的 issuer 是 Apple +4. 拿 sub 作为 Apple providerUserId +5. 查 auth_accounts(provider=APPLE, providerUserId=sub) +6. 没有就创建 user + auth_account +7. 有就找到 user +8. 生成自己的 accessToken / refreshToken +9. 返回 token + user +``` + +你的 Bundle ID 现在是: + +```text +cloud.longde.AIStudyApp +``` + +所以后端环境变量可以先配: + +```env +APPLE_BUNDLE_ID=cloud.longde.AIStudyApp +``` + +Apple token 里的 `aud` 必须等于这个。 + +--- + +# 六、refresh 怎么做 + +请求: + +```http +POST /api/auth/refresh +``` + +```json +{ + "refreshToken": "eyJ..." +} +``` + +后端逻辑: + +```text +1. 校验 refreshToken 签名 +2. 解析 userId / tokenId +3. 查 refresh_tokens 表 +4. 对比 hash +5. 确认未过期、未 revoked +6. 生成新的 accessToken +7. 可选:轮换新的 refreshToken +8. 返回新 token +``` + +第一版可以先简单: + +```json +{ + "accessToken": "new_access_token", + "refreshToken": "new_refresh_token" +} +``` + +建议做 refreshToken 轮换,这样更安全。 + +--- + +# 七、logout 怎么做 + +请求: + +```http +POST /api/auth/logout +``` + +Header: + +```http +Authorization: Bearer accessToken +``` + +Body: + +```json +{ + "refreshToken": "eyJ..." +} +``` + +后端逻辑: + +```text +1. 找到 refresh_tokens 记录 +2. 设置 revokedAt +3. 返回成功 +``` + +iOS 侧: + +```text +清除 Keychain +清除 AppSession +回到登录页 +``` + +--- + +# 八、/users/me 怎么做 + +请求: + +```http +GET /api/users/me +``` + +Header: + +```http +Authorization: Bearer accessToken +``` + +返回: + +```json +{ + "id": "user_xxx", + "email": "test@zhixi.app", + "nickname": "测试用户", + "avatarUrl": null, + "role": "USER", + "status": "ACTIVE", + "onboardingCompleted": false +} +``` + +这个接口是 App 启动后判断登录态的核心接口。 + +--- + +# 九、iOS 侧怎么接 + +iOS 需要这几个东西: + +```text +AuthService +UserService +TokenStore / KeychainTokenStore +AppSession +``` + +启动流程: + +```text +App 启动 +→ AppSession.checkSession() +→ 从 Keychain 读 refreshToken +→ 如果没有,进入登录页 +→ 如果有,调用 /auth/refresh +→ 成功后调用 /users/me +→ 进入主界面 +→ 失败则清空 token,进入登录页 +``` + +登录流程: + +```text +点击开发登录 / Apple 登录 +→ 调后端登录接口 +→ 后端返回 accessToken + refreshToken + user +→ refreshToken 存 Keychain +→ accessToken 放内存 +→ user 放 AppSession +→ 进入主界面 +``` + +以后所有接口都带: + +```http +Authorization: Bearer accessToken +``` + +如果接口返回 401: + +```text +调用 /auth/refresh +→ 成功后重试原请求 +→ 失败则退出登录 +``` + +--- + +# 十、后端模块结构建议 + +NestJS 可以这样拆: + +```text +src/modules/auth/ +├── auth.controller.ts +├── auth.service.ts +├── apple-auth.service.ts +├── token.service.ts +├── dto/ +│ ├── dev-login.dto.ts +│ ├── apple-login.dto.ts +│ └── refresh-token.dto.ts +├── guards/ +│ └── jwt-auth.guard.ts +├── decorators/ +│ └── current-user.decorator.ts +└── strategies/ + └── jwt.strategy.ts + +src/modules/users/ +├── users.controller.ts +├── users.service.ts +└── dto/ +``` + +--- + +# 十一、后面所有业务接口都依赖这个 + +登录做完后,你的知识库接口应该这样写: + +```http +GET /api/knowledge-bases +Authorization: Bearer accessToken +``` + +后端不要让前端传 `userId`。 + +后端应该从 token 里拿: + +```ts +currentUser.id +``` + +然后查: + +```ts +where: { + userId: currentUser.id, + deletedAt: null +} +``` + +这个规则非常重要: + +```text +用户资源接口,不要相信前端传 userId。 +只相信 JWT 里的 currentUser.id。 +``` + +--- + +# 十二、你现在的开发顺序 + +建议你现在按这个顺序写: + +```text +1. Prisma 建 users / auth_accounts / refresh_tokens +2. 做 TokenService:生成 accessToken / refreshToken +3. 做 dev-login +4. 做 JwtAuthGuard +5. 做 CurrentUser 装饰器 +6. 做 /users/me +7. iOS 接 dev-login + Keychain + AppSession +8. 知识库接口全部加 JwtAuthGuard +9. 再做 Apple 登录 +``` + +--- + +# 最终一句话 + +你后端登录对接的核心不是“接 Apple 登录按钮”,而是先建立自己的认证系统: + +```text +users ++ auth_accounts ++ refresh_tokens ++ accessToken ++ refreshToken ++ JwtAuthGuard ++ /users/me +``` + +然后 Apple 登录只是其中一个 provider。 +现在先把 `dev-login → token → /users/me → iOS Keychain` 跑通,后面知识库和学习闭环就不会卡住。 +不需要你刚才在 Xcode 里弄的那种 **iOS 开发证书 / Provisioning Profile**。 + +你后端接 Apple 登录,主要分两种情况: + +## 1. 只做 iOS App 的 Sign in with Apple 登录 + +**后端不需要 Apple 开发证书。** + +你需要的是: + +```text +Bundle ID +Apple 公钥地址 +后端自己的 JWT Secret +``` + +你的 Bundle ID 现在是: + +```text +cloud.longde.AIStudyApp +``` + +iOS 登录成功后,会给你一个: + +```text +identityToken +``` + +你的后端要做的是: + +```text +1. 拿 identityToken +2. 用 Apple 公钥验证这个 JWT 签名 +3. 校验 iss 是 Apple +4. 校验 aud 等于 cloud.longde.AIStudyApp +5. 校验 exp 没过期 +6. 取 sub 作为 Apple 用户唯一 ID +7. 创建 / 查找你的 users +8. 发你自己的 accessToken / refreshToken +``` + +Apple 官方文档也是这个流程:App 完成 Sign in with Apple 后,会返回 identity token,服务端应验证该 token,确认它没有过期、没有被篡改或重放。([Apple Developer][1]) + +所以这里**不需要 p12 证书、开发证书、发布证书、Provisioning Profile**。 + +## 2. 什么时候后端才需要 Apple 的 Key? + +如果你后端要主动调用 Apple 的一些服务,才可能需要 Apple 后台生成的私钥 `.p8`。 + +比如: + +```text +App Store Server API +App Store Connect API +订阅状态查询 +订阅通知验证 +Apple IAP 交易相关接口 +APNs 推送 +``` + +这些通常需要在 Apple Developer / App Store Connect 里创建 API Key,然后后端用私钥生成 JWT。Apple 官方 App Store Connect API 和 App Store Server API 都是通过 JWT 授权请求。([Apple Developer][2]) + +但这是后面的事情。 + +**你现在做登录,不需要。** + +## 你现在后端登录最小需要配置 + +`.env` 里先放这些就够: + +```env +JWT_ACCESS_SECRET=你自己的随机强密钥 +JWT_REFRESH_SECRET=你自己的随机强密钥 +APPLE_BUNDLE_ID=cloud.longde.AIStudyApp +APPLE_ISSUER=https://appleid.apple.com +``` + +如果你要验证 Apple identityToken,后端会去 Apple 的 JWKS 公钥地址取公钥。你不需要自己申请证书。 + +## 一句话 + +```text +iOS 打包运行需要证书和 Provisioning Profile; +后端验证 Apple 登录不需要这些证书,只需要验证 identityToken,并校验 Bundle ID。 +``` + +所以你后端现在可以直接继续做: + +```text +POST /api/auth/dev-login +POST /api/auth/apple +POST /api/auth/refresh +GET /api/users/me +``` + +Apple 证书这块不会卡你的后端登录接口。 + +[1]: https://developer.apple.com/documentation/signinwithapple/authenticating-users-with-sign-in-with-apple?utm_source=chatgpt.com "Authenticating users with Sign in with Apple" +[2]: https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api?utm_source=chatgpt.com "Creating API Keys for App Store Connect API" +公钥不在 Xcode,也不在 App Store Connect 里面。 + +**Apple 登录用的公钥是 Apple 公开提供的 JWKS 地址,后端运行时去 Apple 获取。**你不用自己申请,也不用下载证书。Apple 官方文档里这个接口就是用来获取验证 identity token 签名的公钥。([Apple Developer][1]) + +后端用这个地址: + +```text +https://appleid.apple.com/auth/keys +``` + +## 你后端要做的事 + +你的后端收到 iOS 发来的: + +```text +identityToken +``` + +然后后端做: + +```text +1. 解析 identityToken 的 header,拿到 kid +2. 请求 Apple 公钥地址 +3. 从 keys 里找到 kid 对应的公钥 +4. 验证 identityToken 签名 +5. 校验 iss、aud、exp +6. aud 必须等于你的 Bundle ID +``` + +你的 Bundle ID 是: + +```text +cloud.longde.AIStudyApp +``` + +所以后端 `.env` 里可以配: + +```env +APPLE_BUNDLE_ID=cloud.longde.AIStudyApp +APPLE_ISSUER=https://appleid.apple.com +APPLE_JWKS_URL=https://appleid.apple.com/auth/keys +``` + +## 推荐用 `jose` 库,不要自己手写公钥解析 + +NestJS / Node 后端建议装: + +```bash +npm install jose +``` + +然后写一个 Apple token 校验服务: + +```ts +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +@Injectable() +export class AppleAuthService { + private readonly appleIssuer = 'https://appleid.apple.com'; + private readonly appleBundleId = process.env.APPLE_BUNDLE_ID!; + private readonly jwks = createRemoteJWKSet( + new URL('https://appleid.apple.com/auth/keys'), + ); + + async verifyIdentityToken(identityToken: string) { + try { + const { payload } = await jwtVerify(identityToken, this.jwks, { + issuer: this.appleIssuer, + audience: this.appleBundleId, + }); + + return { + appleUserId: payload.sub, + email: typeof payload.email === 'string' ? payload.email : undefined, + emailVerified: payload.email_verified, + }; + } catch (error) { + throw new UnauthorizedException('Invalid Apple identity token'); + } + } +} +``` + +这里 `jose` 会自动做: + +```text +读取 token header 里的 kid +去 Apple JWKS 里找对应 key +验证 JWT 签名 +校验 issuer +校验 audience +校验过期时间 +``` + +你不用手动把 `n`、`e` 转成 RSA 公钥。 + +## 后端 Apple 登录接口大概这样 + +```ts +@Post('apple') +async loginWithApple(@Body() dto: AppleLoginDto) { + const appleUser = await this.appleAuthService.verifyIdentityToken( + dto.identityToken, + ); + + return this.authService.loginWithProvider({ + provider: 'APPLE', + providerUserId: appleUser.appleUserId, + email: appleUser.email, + nickname: dto.nickname, + }); +} +``` + +DTO: + +```ts +export class AppleLoginDto { + identityToken: string; + authorizationCode?: string; + userIdentifier?: string; + email?: string; + nickname?: string; +} +``` + +## 你要记住的区别 + +```text +iOS 真机运行: +需要 Apple Development 证书、Provisioning Profile。 + +后端验证 Apple 登录: +不需要证书。 +只需要 Apple JWKS 公钥地址 + Bundle ID。 +``` + +所以现在你已经有: + +```text +Bundle ID:cloud.longde.AIStudyApp +Apple 公钥地址:https://appleid.apple.com/auth/keys +``` + +后端就可以开始写 `/api/auth/apple` 了。 + +[1]: https://developer.apple.com/documentation/signinwithapplerestapi/fetch-apple%27s-public-key-for-verifying-token-signature?utm_source=chatgpt.com "Fetch Apple's public key to verify token signatures" +对,**这里就应该先设计后端接口契约**。 + +你理解得基本对:Apple 登录的核心参数确实就是: + +```text +identityToken +``` + +但我不建议接口只设计成一个裸 token。更合理的是: + +```text +identityToken 必填 +其他 Apple 返回的信息作为可选字段 +``` + +因为 Apple 登录有几个坑: + +```text +email / fullName 只会在用户第一次授权时返回 +后续登录可能拿不到 +authorizationCode 以后可能用于更完整的 Apple 账号校验 / 撤销 +userIdentifier 可以辅助客户端识别,但后端不要完全信它 +``` + +## 推荐接口 + +```http +POST /api/auth/apple +``` + +请求体: + +```json +{ + "identityToken": "eyJ...", + "authorizationCode": "c123...", + "userIdentifier": "000123.xxxxx", + "email": "xxx@privaterelay.appleid.com", + "fullName": { + "givenName": "Long", + "familyName": "Wang" + }, + "nonce": "optional_nonce" +} +``` + +## 最小必填字段 + +真正必须的只有: + +```json +{ + "identityToken": "eyJ..." +} +``` + +所以 DTO 可以这样设计: + +```ts +export class AppleLoginDto { + @IsString() + @IsNotEmpty() + identityToken: string; + + @IsOptional() + @IsString() + authorizationCode?: string; + + @IsOptional() + @IsString() + userIdentifier?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + fullName?: { + givenName?: string; + familyName?: string; + }; + + @IsOptional() + @IsString() + nonce?: string; +} +``` + +## 后端真正信什么? + +后端真正应该信的是: + +```text +identityToken 里校验出来的 sub +``` + +流程是: + +```text +1. iOS 把 identityToken 发给后端 +2. 后端用 Apple 公钥验证 token +3. 校验 aud === cloud.longde.AIStudyApp +4. 校验 iss === https://appleid.apple.com +5. 校验 exp 没过期 +6. 从 payload.sub 拿 Apple 用户唯一 ID +7. 用 sub 去 auth_accounts 里查找或创建用户 +8. 后端生成自己的 accessToken / refreshToken +``` + +不要让前端传: + +```text +userId +``` + +也不要以后端完全信任: + +```text +userIdentifier +email +nickname +``` + +这些都只能作为辅助信息。 + +## 返回值设计 + +登录成功后,后端返回你自己的登录态: + +```json +{ + "accessToken": "your_access_token", + "refreshToken": "your_refresh_token", + "user": { + "id": "user_xxx", + "email": "xxx@privaterelay.appleid.com", + "nickname": "Wang", + "avatarUrl": null, + "role": "USER", + "status": "ACTIVE", + "onboardingCompleted": false + } +} +``` + +iOS 拿到以后: + +```text +accessToken:后续请求放 Authorization Header +refreshToken:存 Keychain +user:存 AppSession +``` + +## 同时保留 dev-login + +你现在开发阶段还应该保留: + +```http +POST /api/auth/dev-login +``` + +用于本地联调: + +```json +{ + "email": "test@zhixi.app", + "nickname": "测试用户", + "devSecret": "xxx" +} +``` + +这样你后端接口可以先跑通: + +```text +dev-login +→ /users/me +→ knowledge-bases +→ knowledge-items +``` + +Apple 登录后面接上来,只是多一个 provider。 + +## 最终登录接口清单 + +第一版设计成这样: + +```text +POST /api/auth/dev-login +POST /api/auth/apple +POST /api/auth/refresh +POST /api/auth/logout +GET /api/users/me +``` + +其中: + +```text +/auth/apple:正式 Apple 登录 +/auth/dev-login:开发调试 +/auth/refresh:刷新登录态 +/auth/logout:退出登录 +/users/me:获取当前用户 +``` + +## 一句话 + +是的,**后端 Apple 登录接口的核心参数就是 identityToken**。 + +但接口最好设计成: + +```text +identityToken 必填 +authorizationCode / userIdentifier / email / fullName / nonce 可选 +``` + +这样现在简单能跑,后面也不用返工。