fix: H0-01 彻底阻断生产环境 mock + 结构化错误码 + iOS Auth 合同文档
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 41s

- apple-auth.service.ts: verifyIdentityToken 增加 NODE_ENV 检查,
  生产环境缺 APPLE_BUNDLE_ID 时运行时返回 401,不再走 mock
- 新增 CAPIErrorCode 语义错误码体系 (src/common/errors/)
- 新增 CapiException 携带 errorCode 的 HttpException 子类
- GlobalExceptionFilter 响应自动包含 errorCode 字段
- AuthService/JwtAuthGuard/AppleAuthService 全部改用 CapiException
- 新增 LoginResponseDto/RefreshResponseDto/LogoutResponseDto/UserDto
- Auth controller Swagger 添加 type 参数
- 新增 docs/ios-auth-api-contract.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-27 21:03:15 +08:00
parent c6fd1731d5
commit b9e6055400
10 changed files with 322 additions and 29 deletions

View File

@ -0,0 +1,154 @@
# iOS Auth API Contract
> 冻结日期2026-05-27 | 版本1.0 | 未经评审不得修改请求/响应字段
## 1. 基础约定
| 项目 | 值 |
|------|-----|
| Base URL生产 | `https://api.longde.cloud` |
| Content-Type | `application/json` |
| 认证方式 | `Authorization: Bearer <accessToken>` |
| 成功响应格式 | `{ success: true, data: <T>, timestamp: "ISO8601" }` |
| 错误响应格式 | `{ success: false, statusCode: <int>, message: "<中文>", errorCode: "<语义码>" }` |
## 2. Token 生命周期
| Token | 有效期 | 存储位置 |
|-------|--------|----------|
| accessToken (JWT) | 1 小时 | Keychain |
| refreshToken (opaque) | 7 天 | Keychain |
- refreshToken 是一次性的:每次 `/auth/refresh` 成功后旧的立即吊销,返回新的
- accessToken 过期 → iOS 用 refreshToken 换新,不要重新走 Apple 登录
- refreshToken 过期 → 回到登录页
## 3. 接口清单
### 3.1 Apple 登录
```
POST /auth/apple
```
**请求体:**
| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| identityToken | string | 是 | Apple 返回的 JWT identityToken |
| authorizationCode | string | 否 | Apple 返回的授权码(建议传) |
| nonce | string | 否 | iOS 生成的原始 nonce未哈希 |
| fullName.givenName | string | 否 | 用户的名 |
| fullName.familyName | string | 否 | 用户的姓 |
| email | string | 否 | Apple 返回的邮箱 |
**成功响应 `data`**
| 字段 | 类型 | 说明 |
|------|------|------|
| accessToken | string | JWT含 type: "user" |
| refreshToken | string | 96 位十六进制字符串 |
| user.id | string | 用户 ID |
| user.email | string\|null | 邮箱 |
| user.nickname | string\|null | 昵称 |
| user.avatarUrl | string\|null | 头像 URL |
| user.role | string | 角色 |
| user.status | string | 状态 |
| user.onboardingCompleted | boolean | 是否完成引导 |
**错误:**
| errorCode | HTTP | 说明 |
|-----------|------|------|
| AUTH_INVALID_APPLE_TOKEN | 401 | identityToken 无效、过期或验证失败 |
### 3.2 刷新 Token
```
POST /auth/refresh
```
**请求体:**
| 字段 | 类型 | 必需 |
|------|------|------|
| refreshToken | string | 是 |
**成功响应 `data`** 同登录响应(新 accessToken + 新 refreshToken + user
**错误:**
| errorCode | HTTP | 说明 |
|-----------|------|------|
| AUTH_REFRESH_TOKEN_EXPIRED | 401 | 超过 7 天未使用 |
| AUTH_REFRESH_TOKEN_REVOKED | 401 | 已被登出/安全事件撤销 |
| AUTH_USER_DISABLED | 401 | 账号被管理员禁用 |
| AUTH_USER_DELETED | 401 | 账号已注销 |
### 3.3 获取当前用户
```
GET /users/me
```
需要 Bearer token。
**成功响应 `data`** 用户对象(同登录响应中的 user
**错误:**
| errorCode | HTTP | 说明 |
|-----------|------|------|
| AUTH_UNAUTHORIZED | 401 | 未登录或 token 过期 |
| AUTH_USER_DISABLED | 401 | 账号被禁用 |
| AUTH_USER_DELETED | 401 | 账号已注销 |
| AUTH_WRONG_TOKEN_TYPE | 401 | 使用了 admin token |
### 3.4 登出
```
POST /auth/logout
```
需要 Bearer token。
**请求体:**
| 字段 | 类型 | 必需 |
|------|------|------|
| refreshToken | string | 是 |
**成功响应:** `{ success: true, message: "已退出登录" }`
**错误:**
| errorCode | HTTP | 说明 |
|-----------|------|------|
| AUTH_UNAUTHORIZED | 401 | token 已过期(不影响客户端清本地状态) |
## 4. 完整错误码表
| errorCode | 含义 | iOS 处理策略 |
|-----------|------|-------------|
| AUTH_INVALID_APPLE_TOKEN | Apple token 验证失败 | 提示用户重试 Apple 登录 |
| AUTH_USER_DISABLED | 账号被禁用 | 清空本地 session显示禁用提示 |
| AUTH_USER_DELETED | 账号已注销 | 清空本地 session回到欢迎页 |
| AUTH_REFRESH_TOKEN_EXPIRED | Refresh token 过期 | 清空本地 session回到登录页 |
| AUTH_REFRESH_TOKEN_REVOKED | Refresh token 被撤销 | 清空本地 session回到登录页 |
| AUTH_UNAUTHORIZED | 未认证 | 尝试 refresh失败则回登录页 |
| AUTH_WRONG_TOKEN_TYPE | Token 类型错误 | 清空本地 session重新登录 |
| AUTH_DEV_LOGIN_FORBIDDEN | 生产环境禁用 dev 登录 | 不触发(仅 iOS 不关心) |
| VALIDATION_ERROR | 请求参数校验失败 | 检查发送的字段 |
| NOT_FOUND | 资源未找到 | 提示用户 |
| FORBIDDEN | 权限不足 | 提示用户 |
| RATE_LIMITED | 请求过快 | 稍后重试 |
| INTERNAL_ERROR | 服务器错误 | 提示用户稍后重试 |
## 5. iOS 实现要点
1. **Token 存储** — 用 Keychain不是 UserDefaults
2. **401 自动刷新** — APIClient 拦截 401用 refreshToken 换新 accessToken失败则清 session
3. **并发刷新** — 多个请求同时 401 时只发一次 refresh
4. **AppSession 状态** — 维护状态机:`unauthenticated → authenticating → authenticated → refreshing/expired/disabled/deleted`
5. **Apple 登录 nonce** — 用 `SecRandomCopyBytes` 生成SHA256 后传给 Apple原始值传给后端
6. **authorizationCode** — 提取并传给后端

View File

@ -0,0 +1,40 @@
/**
* CAPI
*
* CAPI `errorCode` iOS
* message
*/
export const CAPIErrorCode = {
// ── Auth 认证 ──
/** Apple identityToken 无效或验证失败 */
AUTH_INVALID_APPLE_TOKEN: 'AUTH_INVALID_APPLE_TOKEN',
/** 用户已被禁用 */
AUTH_USER_DISABLED: 'AUTH_USER_DISABLED',
/** 用户已注销/删除 */
AUTH_USER_DELETED: 'AUTH_USER_DELETED',
/** Refresh token 已过期7 天未使用) */
AUTH_REFRESH_TOKEN_EXPIRED: 'AUTH_REFRESH_TOKEN_EXPIRED',
/** Refresh token 已被撤销(登出或安全事件) */
AUTH_REFRESH_TOKEN_REVOKED: 'AUTH_REFRESH_TOKEN_REVOKED',
/** 未登录或 access token 过期 */
AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED',
/** 使用了 admin token 访问用户端点(或反之) */
AUTH_WRONG_TOKEN_TYPE: 'AUTH_WRONG_TOKEN_TYPE',
/** 开发登录在生产环境被禁用 */
AUTH_DEV_LOGIN_FORBIDDEN: 'AUTH_DEV_LOGIN_FORBIDDEN',
// ── 通用 ──
/** 请求参数校验失败 */
VALIDATION_ERROR: 'VALIDATION_ERROR',
/** 资源未找到 */
NOT_FOUND: 'NOT_FOUND',
/** 权限不足 */
FORBIDDEN: 'FORBIDDEN',
/** 请求过于频繁 */
RATE_LIMITED: 'RATE_LIMITED',
/** 服务器内部错误 */
INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const;
export type CAPIErrorCode = (typeof CAPIErrorCode)[keyof typeof CAPIErrorCode];

View File

@ -0,0 +1,19 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { CAPIErrorCode } from './capi-error-codes';
/**
* HttpException
* CAPI iOS errorCode
*/
export class CapiException extends HttpException {
readonly errorCode: CAPIErrorCode;
constructor(
errorCode: CAPIErrorCode,
message: string,
status: HttpStatus = HttpStatus.BAD_REQUEST,
) {
super({ message, errorCode }, status);
this.errorCode = errorCode;
}
}

View File

@ -24,14 +24,21 @@ export class GlobalExceptionFilter implements ExceptionFilter {
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
let errorCode: string | undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
const resp = exceptionResponse as Record<string, unknown>;
errorCode = resp['errorCode'] as string | undefined;
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || exception.message;
(resp['message'] as string) || exception.message;
} else {
message = String(exceptionResponse);
}
if (Array.isArray(message)) message = message.join('; ');
}
@ -46,6 +53,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
success: false,
statusCode: status,
message,
...(errorCode ? { errorCode } : {}),
...(isProduction ? {} : { path: request.url }),
});
}

View File

@ -10,6 +10,9 @@ import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { CapiException } from '../errors/capi.exception';
import { CAPIErrorCode } from '../errors/capi-error-codes';
import { HttpStatus } from '@nestjs/common';
@Injectable()
export class JwtAuthGuard implements CanActivate {
@ -37,7 +40,7 @@ export class JwtAuthGuard implements CanActivate {
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('请先登录');
throw new CapiException(CAPIErrorCode.AUTH_UNAUTHORIZED, '请先登录', HttpStatus.UNAUTHORIZED);
}
try {
@ -47,7 +50,7 @@ export class JwtAuthGuard implements CanActivate {
// Reject admin tokens on user endpoints
if (payload.type === 'admin') {
throw new UnauthorizedException('无效的访问令牌');
throw new CapiException(CAPIErrorCode.AUTH_WRONG_TOKEN_TYPE, '无效的访问令牌', HttpStatus.UNAUTHORIZED);
}
const user = await this.prisma.user.findUnique({
@ -56,18 +59,18 @@ export class JwtAuthGuard implements CanActivate {
});
if (!user || user.deletedAt) {
throw new UnauthorizedException('账号不存在或已注销');
throw new CapiException(CAPIErrorCode.AUTH_USER_DELETED, '账号不存在或已注销', HttpStatus.UNAUTHORIZED);
}
if (user.status !== 'active') {
throw new UnauthorizedException('账号已被禁用');
throw new CapiException(CAPIErrorCode.AUTH_USER_DISABLED, '账号已被禁用', HttpStatus.UNAUTHORIZED);
}
request.user = { id: user.id, email: user.email, role: user.role };
return true;
} catch (err) {
if (err instanceof UnauthorizedException) throw err;
throw new UnauthorizedException('登录已过期,请重新登录');
if (err instanceof CapiException || err instanceof UnauthorizedException) throw err;
throw new CapiException(CAPIErrorCode.AUTH_UNAUTHORIZED, '登录已过期,请重新登录', HttpStatus.UNAUTHORIZED);
}
}

View File

@ -1,7 +1,10 @@
import * as crypto from 'crypto';
import { Injectable, UnauthorizedException, Logger, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { CapiException } from '../../common/errors/capi.exception';
import { CAPIErrorCode } from '../../common/errors/capi-error-codes';
@Injectable()
export class AppleAuthService implements OnModuleInit {
@ -54,7 +57,16 @@ export class AppleAuthService implements OnModuleInit {
email?: string;
emailVerified?: boolean;
}> {
const nodeEnv = process.env.NODE_ENV;
if (!this.appleBundleId) {
if (nodeEnv === 'production') {
throw new CapiException(
CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN,
'Apple 登录未配置,请联系管理员',
HttpStatus.UNAUTHORIZED,
);
}
return this.verifyMock(identityToken);
}
return this.verifyReal(identityToken, rawNonce);
@ -66,7 +78,7 @@ export class AppleAuthService implements OnModuleInit {
emailVerified?: boolean;
} {
if (!identityToken || identityToken.trim().length < 4) {
throw new UnauthorizedException('identityToken 无效');
throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, 'identityToken 无效', HttpStatus.UNAUTHORIZED);
}
const mockUserId = crypto
@ -111,17 +123,15 @@ export class AppleAuthService implements OnModuleInit {
} catch (err: any) {
const msg: string = err?.message ?? '';
if (msg.includes('audience')) {
throw new UnauthorizedException(
`identityToken audience 不匹配,期望 ${this.appleBundleId}`,
);
throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, `identityToken audience 不匹配,期望 ${this.appleBundleId}`, HttpStatus.UNAUTHORIZED);
}
if (msg.includes('issuer')) {
throw new UnauthorizedException('identityToken issuer 无效');
throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, 'identityToken issuer 无效', HttpStatus.UNAUTHORIZED);
}
if (msg.includes('nonce')) {
throw new UnauthorizedException('identityToken nonce 验证失败');
throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, 'identityToken nonce 验证失败', HttpStatus.UNAUTHORIZED);
}
throw new UnauthorizedException('identityToken 验证失败');
throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, 'identityToken 验证失败', HttpStatus.UNAUTHORIZED);
}
}

View File

@ -2,6 +2,7 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Controller, Post, Body, HttpCode, HttpStatus, Req } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto';
import { LoginResponseDto, RefreshResponseDto, LogoutResponseDto } from './dto/auth-response.dto';
import { Public } from '../../common/decorators/public.decorator';
import { LoginRateLimit } from '../../common/decorators/rate-limit.decorator';
import type { Request } from 'express';
@ -16,7 +17,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@LoginRateLimit()
@ApiOperation({ summary: '开发登录(仅非生产环境)' })
@ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 200, description: '登录成功', type: LoginResponseDto })
@ApiResponse({ status: 403, description: '生产环境禁用' })
async devLogin(@Body() dto: DevLoginDto) {
return this.authService.devLogin(dto);
@ -27,7 +28,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@LoginRateLimit()
@ApiOperation({ summary: 'Apple 登录' })
@ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 200, description: '登录成功', type: LoginResponseDto })
@ApiResponse({ status: 401, description: '身份验证失败' })
async appleLogin(@Body() dto: AppleLoginDto) {
return this.authService.appleLogin(dto);
@ -37,7 +38,7 @@ export class AuthController {
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新令牌' })
@ApiResponse({ status: 200, description: '刷新成功' })
@ApiResponse({ status: 200, description: '刷新成功', type: RefreshResponseDto })
@ApiResponse({ status: 401, description: '刷新令牌无效' })
async refresh(@Body() dto: RefreshDto) {
return this.authService.refresh(dto.refreshToken);
@ -46,7 +47,7 @@ export class AuthController {
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '退出登录' })
@ApiResponse({ status: 200, description: '退出成功' })
@ApiResponse({ status: 200, description: '退出成功', type: LogoutResponseDto })
@ApiResponse({ status: 401, description: '未登录' })
async logout(@Req() req: Request, @Body() dto: RefreshDto) {
const user = (req as any).user;

View File

@ -1,7 +1,10 @@
import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { HttpStatus } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AppleAuthService } from './apple-auth.service';
import { TokenService } from './token.service';
import { CapiException } from '../../common/errors/capi.exception';
import { CAPIErrorCode } from '../../common/errors/capi-error-codes';
import type { AppleLoginDto, DevLoginDto } from './dto';
@Injectable()
@ -14,12 +17,12 @@ export class AuthService {
async devLogin(dto: DevLoginDto) {
if (process.env.NODE_ENV === 'production') {
throw new ForbiddenException('dev-login is disabled in production');
throw new CapiException(CAPIErrorCode.AUTH_DEV_LOGIN_FORBIDDEN, 'dev-login is disabled in production', HttpStatus.FORBIDDEN);
}
const devSecret = process.env.DEV_SECRET;
if (!devSecret || dto.devSecret !== devSecret) {
throw new UnauthorizedException('devSecret 无效');
throw new CapiException(CAPIErrorCode.AUTH_UNAUTHORIZED, 'devSecret 无效', HttpStatus.UNAUTHORIZED);
}
const providerUserId = dto.email;
@ -128,18 +131,22 @@ export class AuthService {
include: { user: true },
});
if (!stored || stored.expiresAt < new Date()) {
throw new UnauthorizedException('刷新令牌无效或已过期');
if (!stored) {
throw new CapiException(CAPIErrorCode.AUTH_REFRESH_TOKEN_REVOKED, '刷新令牌已失效', HttpStatus.UNAUTHORIZED);
}
if (stored.expiresAt < new Date()) {
throw new CapiException(CAPIErrorCode.AUTH_REFRESH_TOKEN_EXPIRED, '刷新令牌已过期,请重新登录', HttpStatus.UNAUTHORIZED);
}
// Check user status before issuing new tokens
if (stored.user.deletedAt) {
await this.revokeAllUserTokens(stored.userId);
throw new UnauthorizedException('账号已注销');
throw new CapiException(CAPIErrorCode.AUTH_USER_DELETED, '账号已注销', HttpStatus.UNAUTHORIZED);
}
if (stored.user.status !== 'active') {
throw new UnauthorizedException('账号已被禁用');
throw new CapiException(CAPIErrorCode.AUTH_USER_DISABLED, '账号已被禁用', HttpStatus.UNAUTHORIZED);
}
await this.prisma.refreshToken.update({

View File

@ -0,0 +1,50 @@
import { ApiProperty } from '@nestjs/swagger';
export class UserDto {
@ApiProperty({ example: 'clx...' })
id: string;
@ApiProperty({ example: 'user@example.com', nullable: true })
email: string | null;
@ApiProperty({ example: '张三', nullable: true })
nickname: string | null;
@ApiProperty({ example: null, nullable: true })
avatarUrl: string | null;
@ApiProperty({ example: 'user' })
role: string;
@ApiProperty({ example: 'active' })
status: string;
@ApiProperty({ example: false })
onboardingCompleted: boolean;
}
export class AuthTokensDto {
@ApiProperty({ description: 'JWT access token有效期 1h', example: 'eyJhbG...' })
accessToken: string;
@ApiProperty({ description: '96 位十六进制 refresh token一次性轮换', example: 'a1b2c3...' })
refreshToken: string;
}
export class LoginResponseDto extends AuthTokensDto {
@ApiProperty({ type: UserDto })
user: UserDto;
}
export class RefreshResponseDto extends AuthTokensDto {
@ApiProperty({ type: UserDto })
user: UserDto;
}
export class LogoutResponseDto {
@ApiProperty({ example: true })
success: boolean;
@ApiProperty({ example: '已退出登录' })
message: string;
}

View File

@ -1,3 +1,4 @@
export { AppleLoginDto } from './apple-login.dto';
export { DevLoginDto } from './dev-login.dto';
export { RefreshDto } from './refresh-token.dto';
export { LoginResponseDto, RefreshResponseDto, LogoutResponseDto, UserDto } from './auth-response.dto';