fix: H0-01 彻底阻断生产环境 mock + 结构化错误码 + iOS Auth 合同文档
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 41s
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:
parent
c6fd1731d5
commit
b9e6055400
154
docs/ios-auth-api-contract.md
Normal file
154
docs/ios-auth-api-contract.md
Normal 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** — 提取并传给后端
|
||||
40
src/common/errors/capi-error-codes.ts
Normal file
40
src/common/errors/capi-error-codes.ts
Normal 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];
|
||||
19
src/common/errors/capi.exception.ts
Normal file
19
src/common/errors/capi.exception.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as any).message || exception.message;
|
||||
|
||||
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||
const resp = exceptionResponse as Record<string, unknown>;
|
||||
errorCode = resp['errorCode'] as string | undefined;
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
|
||||
50
src/modules/auth/dto/auth-response.dto.ts
Normal file
50
src/modules/auth/dto/auth-response.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user