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 status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
let message = '服务器内部错误';
|
let message = '服务器内部错误';
|
||||||
|
let errorCode: string | undefined;
|
||||||
|
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
status = exception.getStatus();
|
status = exception.getStatus();
|
||||||
const exceptionResponse = exception.getResponse();
|
const exceptionResponse = exception.getResponse();
|
||||||
|
|
||||||
|
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||||
|
const resp = exceptionResponse as Record<string, unknown>;
|
||||||
|
errorCode = resp['errorCode'] as string | undefined;
|
||||||
message =
|
message =
|
||||||
typeof exceptionResponse === 'string'
|
(resp['message'] as string) || exception.message;
|
||||||
? exceptionResponse
|
} else {
|
||||||
: (exceptionResponse as any).message || exception.message;
|
message = String(exceptionResponse);
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(message)) message = message.join('; ');
|
if (Array.isArray(message)) message = message.join('; ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,6 +53,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
success: false,
|
success: false,
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
message,
|
message,
|
||||||
|
...(errorCode ? { errorCode } : {}),
|
||||||
...(isProduction ? {} : { path: request.url }),
|
...(isProduction ? {} : { path: request.url }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,9 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
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()
|
@Injectable()
|
||||||
export class JwtAuthGuard implements CanActivate {
|
export class JwtAuthGuard implements CanActivate {
|
||||||
@ -37,7 +40,7 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
const token = this.extractToken(request);
|
const token = this.extractToken(request);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException('请先登录');
|
throw new CapiException(CAPIErrorCode.AUTH_UNAUTHORIZED, '请先登录', HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -47,7 +50,7 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
|
|
||||||
// Reject admin tokens on user endpoints
|
// Reject admin tokens on user endpoints
|
||||||
if (payload.type === 'admin') {
|
if (payload.type === 'admin') {
|
||||||
throw new UnauthorizedException('无效的访问令牌');
|
throw new CapiException(CAPIErrorCode.AUTH_WRONG_TOKEN_TYPE, '无效的访问令牌', HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
@ -56,18 +59,18 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user || user.deletedAt) {
|
if (!user || user.deletedAt) {
|
||||||
throw new UnauthorizedException('账号不存在或已注销');
|
throw new CapiException(CAPIErrorCode.AUTH_USER_DELETED, '账号不存在或已注销', HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.status !== 'active') {
|
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 };
|
request.user = { id: user.id, email: user.email, role: user.role };
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UnauthorizedException) throw err;
|
if (err instanceof CapiException || err instanceof UnauthorizedException) throw err;
|
||||||
throw new UnauthorizedException('登录已过期,请重新登录');
|
throw new CapiException(CAPIErrorCode.AUTH_UNAUTHORIZED, '登录已过期,请重新登录', HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import * as crypto from 'crypto';
|
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 { ConfigService } from '@nestjs/config';
|
||||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
|
import { CapiException } from '../../common/errors/capi.exception';
|
||||||
|
import { CAPIErrorCode } from '../../common/errors/capi-error-codes';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppleAuthService implements OnModuleInit {
|
export class AppleAuthService implements OnModuleInit {
|
||||||
@ -54,7 +57,16 @@ export class AppleAuthService implements OnModuleInit {
|
|||||||
email?: string;
|
email?: string;
|
||||||
emailVerified?: boolean;
|
emailVerified?: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
const nodeEnv = process.env.NODE_ENV;
|
||||||
|
|
||||||
if (!this.appleBundleId) {
|
if (!this.appleBundleId) {
|
||||||
|
if (nodeEnv === 'production') {
|
||||||
|
throw new CapiException(
|
||||||
|
CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN,
|
||||||
|
'Apple 登录未配置,请联系管理员',
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.verifyMock(identityToken);
|
return this.verifyMock(identityToken);
|
||||||
}
|
}
|
||||||
return this.verifyReal(identityToken, rawNonce);
|
return this.verifyReal(identityToken, rawNonce);
|
||||||
@ -66,7 +78,7 @@ export class AppleAuthService implements OnModuleInit {
|
|||||||
emailVerified?: boolean;
|
emailVerified?: boolean;
|
||||||
} {
|
} {
|
||||||
if (!identityToken || identityToken.trim().length < 4) {
|
if (!identityToken || identityToken.trim().length < 4) {
|
||||||
throw new UnauthorizedException('identityToken 无效');
|
throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, 'identityToken 无效', HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockUserId = crypto
|
const mockUserId = crypto
|
||||||
@ -111,17 +123,15 @@ export class AppleAuthService implements OnModuleInit {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg: string = err?.message ?? '';
|
const msg: string = err?.message ?? '';
|
||||||
if (msg.includes('audience')) {
|
if (msg.includes('audience')) {
|
||||||
throw new UnauthorizedException(
|
throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, `identityToken audience 不匹配,期望 ${this.appleBundleId}`, HttpStatus.UNAUTHORIZED);
|
||||||
`identityToken audience 不匹配,期望 ${this.appleBundleId}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (msg.includes('issuer')) {
|
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')) {
|
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 { Controller, Post, Body, HttpCode, HttpStatus, Req } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto';
|
import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto';
|
||||||
|
import { LoginResponseDto, RefreshResponseDto, LogoutResponseDto } from './dto/auth-response.dto';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
import { LoginRateLimit } from '../../common/decorators/rate-limit.decorator';
|
import { LoginRateLimit } from '../../common/decorators/rate-limit.decorator';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
@ -16,7 +17,7 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@LoginRateLimit()
|
@LoginRateLimit()
|
||||||
@ApiOperation({ summary: '开发登录(仅非生产环境)' })
|
@ApiOperation({ summary: '开发登录(仅非生产环境)' })
|
||||||
@ApiResponse({ status: 200, description: '登录成功' })
|
@ApiResponse({ status: 200, description: '登录成功', type: LoginResponseDto })
|
||||||
@ApiResponse({ status: 403, description: '生产环境禁用' })
|
@ApiResponse({ status: 403, description: '生产环境禁用' })
|
||||||
async devLogin(@Body() dto: DevLoginDto) {
|
async devLogin(@Body() dto: DevLoginDto) {
|
||||||
return this.authService.devLogin(dto);
|
return this.authService.devLogin(dto);
|
||||||
@ -27,7 +28,7 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@LoginRateLimit()
|
@LoginRateLimit()
|
||||||
@ApiOperation({ summary: 'Apple 登录' })
|
@ApiOperation({ summary: 'Apple 登录' })
|
||||||
@ApiResponse({ status: 200, description: '登录成功' })
|
@ApiResponse({ status: 200, description: '登录成功', type: LoginResponseDto })
|
||||||
@ApiResponse({ status: 401, description: '身份验证失败' })
|
@ApiResponse({ status: 401, description: '身份验证失败' })
|
||||||
async appleLogin(@Body() dto: AppleLoginDto) {
|
async appleLogin(@Body() dto: AppleLoginDto) {
|
||||||
return this.authService.appleLogin(dto);
|
return this.authService.appleLogin(dto);
|
||||||
@ -37,7 +38,7 @@ export class AuthController {
|
|||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '刷新令牌' })
|
@ApiOperation({ summary: '刷新令牌' })
|
||||||
@ApiResponse({ status: 200, description: '刷新成功' })
|
@ApiResponse({ status: 200, description: '刷新成功', type: RefreshResponseDto })
|
||||||
@ApiResponse({ status: 401, description: '刷新令牌无效' })
|
@ApiResponse({ status: 401, description: '刷新令牌无效' })
|
||||||
async refresh(@Body() dto: RefreshDto) {
|
async refresh(@Body() dto: RefreshDto) {
|
||||||
return this.authService.refresh(dto.refreshToken);
|
return this.authService.refresh(dto.refreshToken);
|
||||||
@ -46,7 +47,7 @@ export class AuthController {
|
|||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '退出登录' })
|
@ApiOperation({ summary: '退出登录' })
|
||||||
@ApiResponse({ status: 200, description: '退出成功' })
|
@ApiResponse({ status: 200, description: '退出成功', type: LogoutResponseDto })
|
||||||
@ApiResponse({ status: 401, description: '未登录' })
|
@ApiResponse({ status: 401, description: '未登录' })
|
||||||
async logout(@Req() req: Request, @Body() dto: RefreshDto) {
|
async logout(@Req() req: Request, @Body() dto: RefreshDto) {
|
||||||
const user = (req as any).user;
|
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 { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { AppleAuthService } from './apple-auth.service';
|
import { AppleAuthService } from './apple-auth.service';
|
||||||
import { TokenService } from './token.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';
|
import type { AppleLoginDto, DevLoginDto } from './dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -14,12 +17,12 @@ export class AuthService {
|
|||||||
|
|
||||||
async devLogin(dto: DevLoginDto) {
|
async devLogin(dto: DevLoginDto) {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
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;
|
const devSecret = process.env.DEV_SECRET;
|
||||||
if (!devSecret || dto.devSecret !== devSecret) {
|
if (!devSecret || dto.devSecret !== devSecret) {
|
||||||
throw new UnauthorizedException('devSecret 无效');
|
throw new CapiException(CAPIErrorCode.AUTH_UNAUTHORIZED, 'devSecret 无效', HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerUserId = dto.email;
|
const providerUserId = dto.email;
|
||||||
@ -128,18 +131,22 @@ export class AuthService {
|
|||||||
include: { user: true },
|
include: { user: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!stored || stored.expiresAt < new Date()) {
|
if (!stored) {
|
||||||
throw new UnauthorizedException('刷新令牌无效或已过期');
|
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
|
// Check user status before issuing new tokens
|
||||||
if (stored.user.deletedAt) {
|
if (stored.user.deletedAt) {
|
||||||
await this.revokeAllUserTokens(stored.userId);
|
await this.revokeAllUserTokens(stored.userId);
|
||||||
throw new UnauthorizedException('账号已注销');
|
throw new CapiException(CAPIErrorCode.AUTH_USER_DELETED, '账号已注销', HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stored.user.status !== 'active') {
|
if (stored.user.status !== 'active') {
|
||||||
throw new UnauthorizedException('账号已被禁用');
|
throw new CapiException(CAPIErrorCode.AUTH_USER_DISABLED, '账号已被禁用', HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.refreshToken.update({
|
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 { AppleLoginDto } from './apple-login.dto';
|
||||||
export { DevLoginDto } from './dev-login.dto';
|
export { DevLoginDto } from './dev-login.dto';
|
||||||
export { RefreshDto } from './refresh-token.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