Skip to content

Commit 2ae5ba4

Browse files
feat: Security hardening, mobile responsiveness, and deployment improvements
- Account lockout (5 failed logins → 30min lock) with Prisma migration - Rate limiting on auth endpoints (register, login, forgot/reset/change password) - POST /auth/logout endpoint to invalidate refresh tokens - Password strength validation (uppercase + lowercase + digit) on all DTOs and frontend forms - Auth audit logging (login success/failure, password change, account deletion, logout) - DOMPurify XSS sanitization for HTML imports, markdown rendering, and template preview - Input length validation (@maxlength) on document titles, space names, search queries, etc. - Mobile responsive fixes: AI panels, version history, permission dialog, notification dropdown, editor toolbar - Table horizontal scroll for members and AI subscriptions pages - Graceful shutdown with app.enableShutdownHooks() - Production Nginx config (nginx/nginx.conf + proxy.conf) with API/WebSocket/static proxy - Updated deployment docs in DEVELOPMENT.md and .env.example Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 980131f commit 2ae5ba4

38 files changed

Lines changed: 496 additions & 56 deletions

DEVELOPMENT.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,52 @@ pnpm exec prisma migrate dev
723723

724724
---
725725

726+
## 🌐 生产部署(Nginx 反向代理)
727+
728+
项目包含 `nginx/` 目录下的生产 Nginx 配置:
729+
730+
```bash
731+
# 目录结构
732+
nginx/
733+
├── nginx.conf # 主配置(HTTP/HTTPS server 块)
734+
└── proxy.conf # 代理规则(API/WebSocket/前端/静态资源)
735+
```
736+
737+
### 使用方式
738+
739+
1.`nginx/` 目录挂载到 Nginx 容器:
740+
741+
```yaml
742+
# docker-compose.prod.yml 中添加 nginx 服务
743+
nginx:
744+
image: nginx:alpine
745+
ports:
746+
- "80:80"
747+
- "443:443"
748+
volumes:
749+
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
750+
- ./nginx/proxy.conf:/etc/nginx/conf.d/proxy.conf:ro
751+
# SSL 证书(生产环境)
752+
# - ./ssl:/etc/nginx/ssl:ro
753+
depends_on:
754+
- api
755+
- web
756+
```
757+
758+
2. HTTPS 配置:编辑 `nginx/nginx.conf`,取消注释 HTTPS server 块,填写域名和证书路径。
759+
760+
3. 数据库备份:
761+
762+
```bash
763+
# 备份
764+
docker exec docStudio-postgres pg_dump -U postgres docStudio_dev > backup.sql
765+
766+
# 恢复
767+
docker exec -i docStudio-postgres psql -U postgres docStudio_dev < backup.sql
768+
```
769+
770+
---
771+
726772
## 🆘 获取帮助
727773

728774
- **Prisma 文档**:https://www.prisma.io/docs

apps/api/.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ API_URL=http://localhost:3001
1111
FRONTEND_URL=http://localhost:3000
1212

1313
# ── JWT ───────────────────────────────────────────────────────
14+
# 生产环境务必使用随机长密钥:openssl rand -base64 64
1415
JWT_SECRET=your-super-secret-jwt-key-change-in-production
1516
JWT_EXPIRES_IN=7d
1617

1718
# ── 超级管理员(首次启动自动创建)────────────────────────────
19+
# 生产环境务必修改默认密码(需含大写+小写+数字)
1820
SUPER_ADMIN_EMAIL=admin@doc-studio.com
19-
SUPER_ADMIN_PASSWORD=admin
21+
SUPER_ADMIN_PASSWORD=Admin123
2022

2123
# ── Redis ─────────────────────────────────────────────────────
2224
REDIS_HOST=localhost
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- AlterEnum
2+
-- This migration adds more than one value to an enum.
3+
-- With PostgreSQL versions 11 and earlier, this is not possible
4+
-- in a single migration. This can be worked around by creating
5+
-- multiple migrations, each migration adding only one value to
6+
-- the enum.
7+
8+
9+
ALTER TYPE "ActivityAction" ADD VALUE 'LOGIN_SUCCESS';
10+
ALTER TYPE "ActivityAction" ADD VALUE 'LOGIN_FAILED';
11+
ALTER TYPE "ActivityAction" ADD VALUE 'PASSWORD_CHANGED';
12+
ALTER TYPE "ActivityAction" ADD VALUE 'ACCOUNT_DELETED';
13+
ALTER TYPE "ActivityAction" ADD VALUE 'LOGOUT';
14+
15+
-- AlterEnum
16+
ALTER TYPE "EntityType" ADD VALUE 'USER';
17+
18+
-- AlterTable
19+
ALTER TABLE "users" ADD COLUMN "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
20+
ADD COLUMN "lockedUntil" TIMESTAMP(3);

apps/api/prisma/schema.prisma

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ model User {
3535
// 新用户引导
3636
onboardingCompleted Boolean @default(false)
3737
38+
// 登录安全
39+
failedLoginAttempts Int @default(0)
40+
lockedUntil DateTime?
41+
3842
// JWT Refresh Token
3943
refreshToken String?
4044
@@ -241,6 +245,11 @@ enum ActivityAction {
241245
LEAVE
242246
INVITE
243247
ROLE_CHANGE
248+
LOGIN_SUCCESS
249+
LOGIN_FAILED
250+
PASSWORD_CHANGED
251+
ACCOUNT_DELETED
252+
LOGOUT
244253
}
245254

246255
// 实体类型枚举
@@ -250,6 +259,7 @@ enum EntityType {
250259
SNAPSHOT
251260
SHARE_LINK
252261
MEMBER
262+
USER
253263
}
254264

255265
// ==================== 最近访问模型(独立优化表) ====================

apps/api/src/auth/auth.controller.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
ApiBearerAuth,
1818
ApiExcludeEndpoint,
1919
} from '@nestjs/swagger';
20+
import { Throttle } from '@nestjs/throttler';
2021
// Fastify types imported as values (not 'import type') for decorator metadata
2122
import { AuthService } from './auth.service';
2223
import { RegisterDto } from './dto/register.dto';
@@ -37,6 +38,7 @@ export class AuthController {
3738
constructor(private authService: AuthService) {}
3839

3940
@Post('register')
41+
@Throttle({ default: { limit: 5, ttl: 3600000 } }) // 5 次/小时
4042
@ApiOperation({ summary: '用户注册', description: '创建新用户账号' })
4143
@ApiResponse({
4244
status: 201,
@@ -50,6 +52,7 @@ export class AuthController {
5052
}
5153

5254
@Post('login')
55+
@Throttle({ default: { limit: 10, ttl: 900000 } }) // 10 次/15分钟
5356
@HttpCode(HttpStatus.OK)
5457
@ApiOperation({ summary: '用户登录', description: '使用邮箱和密码登录' })
5558
@ApiResponse({
@@ -77,6 +80,7 @@ export class AuthController {
7780
}
7881

7982
@Post('change-password')
83+
@Throttle({ default: { limit: 5, ttl: 3600000 } }) // 5 次/小时
8084
@UseGuards(JwtAuthGuard)
8185
@ApiBearerAuth('JWT-auth')
8286
@ApiOperation({ summary: '修改密码', description: '需要 JWT 认证' })
@@ -113,6 +117,7 @@ export class AuthController {
113117
// ==================== 密码重置 ====================
114118

115119
@Post('forgot-password')
120+
@Throttle({ default: { limit: 3, ttl: 3600000 } }) // 3 次/小时
116121
@HttpCode(HttpStatus.OK)
117122
@ApiOperation({ summary: '忘记密码', description: '发送密码重置邮件' })
118123
@ApiResponse({ status: 200, description: '如果邮箱存在将发送重置邮件' })
@@ -121,6 +126,7 @@ export class AuthController {
121126
}
122127

123128
@Post('reset-password')
129+
@Throttle({ default: { limit: 5, ttl: 3600000 } }) // 5 次/小时
124130
@HttpCode(HttpStatus.OK)
125131
@ApiOperation({ summary: '重置密码', description: '使用重置令牌设置新密码' })
126132
@ApiResponse({ status: 200, description: '密码重置成功' })
@@ -145,6 +151,18 @@ export class AuthController {
145151
return this.authService.deleteAccount(user.id, dto.password ?? '');
146152
}
147153

154+
// ==================== 登出 ====================
155+
156+
@Post('logout')
157+
@UseGuards(JwtAuthGuard)
158+
@ApiBearerAuth('JWT-auth')
159+
@HttpCode(HttpStatus.OK)
160+
@ApiOperation({ summary: '退出登录', description: '清除 refresh token 使其失效' })
161+
@ApiResponse({ status: 200, description: '已退出登录' })
162+
async logout(@CurrentUser() user: UserResponseDto) {
163+
return this.authService.logout(user.id);
164+
}
165+
148166
// ==================== Refresh Token ====================
149167

150168
@Post('refresh')

apps/api/src/auth/auth.service.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Injectable,
33
UnauthorizedException,
44
BadRequestException,
5+
Logger,
56
} from '@nestjs/common';
67
import { JwtService } from '@nestjs/jwt';
78
import { UsersService } from '../users/users.service';
@@ -11,17 +12,26 @@ import { ChangePasswordDto } from './dto/change-password.dto';
1112
import { PrismaService } from '../prisma/prisma.service';
1213
import { EmailService } from '../email/email.service';
1314
import { OnboardingService } from '../onboarding/onboarding.service';
15+
import { ActivityService } from '../activity/activity.service';
1416
import { randomBytes } from 'crypto';
1517
import * as bcrypt from 'bcryptjs';
1618

19+
/** 登录失败锁定阈值 */
20+
const MAX_FAILED_ATTEMPTS = 5;
21+
/** 锁定时长(毫秒)— 30 分钟 */
22+
const LOCK_DURATION_MS = 30 * 60 * 1000;
23+
1724
@Injectable()
1825
export class AuthService {
26+
private readonly logger = new Logger(AuthService.name);
27+
1928
constructor(
2029
private usersService: UsersService,
2130
private jwtService: JwtService,
2231
private prisma: PrismaService,
2332
private emailService: EmailService,
2433
private onboardingService: OnboardingService,
34+
private activityService: ActivityService,
2535
) {}
2636

2737
private async generateTokens(userId: string, email: string) {
@@ -76,19 +86,72 @@ export class AuthService {
7686
throw new UnauthorizedException('邮箱或密码错误');
7787
}
7888

89+
// 检查账号是否被锁定
90+
if (user.lockedUntil && user.lockedUntil > new Date()) {
91+
const remainingMin = Math.ceil(
92+
(user.lockedUntil.getTime() - Date.now()) / 60000,
93+
);
94+
this.activityService.log({
95+
userId: user.id,
96+
action: 'LOGIN_FAILED',
97+
entityType: 'USER',
98+
entityId: user.id,
99+
metadata: { reason: 'account_locked' },
100+
});
101+
throw new UnauthorizedException(
102+
`账号已锁定,请 ${remainingMin} 分钟后重试`,
103+
);
104+
}
105+
79106
// 验证密码
80107
const isPasswordValid = await this.usersService.validatePassword(
81108
password,
82109
user.password,
83110
);
84111

85112
if (!isPasswordValid) {
113+
// 递增失败次数,达到阈值则锁定
114+
const attempts = (user.failedLoginAttempts ?? 0) + 1;
115+
const lockData: { failedLoginAttempts: number; lockedUntil?: Date } = {
116+
failedLoginAttempts: attempts,
117+
};
118+
if (attempts >= MAX_FAILED_ATTEMPTS) {
119+
lockData.lockedUntil = new Date(Date.now() + LOCK_DURATION_MS);
120+
}
121+
await this.prisma.user.update({
122+
where: { id: user.id },
123+
data: lockData,
124+
});
125+
126+
this.activityService.log({
127+
userId: user.id,
128+
action: 'LOGIN_FAILED',
129+
entityType: 'USER',
130+
entityId: user.id,
131+
metadata: { attempts },
132+
});
133+
86134
throw new UnauthorizedException('邮箱或密码错误');
87135
}
88136

137+
// 登录成功 — 重置失败计数
138+
if (user.failedLoginAttempts > 0 || user.lockedUntil) {
139+
await this.prisma.user.update({
140+
where: { id: user.id },
141+
data: { failedLoginAttempts: 0, lockedUntil: null },
142+
});
143+
}
144+
89145
// 生成 token 对
90146
const tokens = await this.generateTokens(user.id, user.email);
91147

148+
this.activityService.log({
149+
userId: user.id,
150+
action: 'LOGIN_SUCCESS',
151+
entityType: 'USER',
152+
entityId: user.id,
153+
});
154+
92155
// 返回用户信息(不包含密码)
93156
const { password: _, ...userWithoutPassword } = user;
94157

@@ -127,6 +190,13 @@ export class AuthService {
127190
// 更新密码
128191
await this.usersService.updatePassword(userId, newPassword);
129192

193+
this.activityService.log({
194+
userId,
195+
action: 'PASSWORD_CHANGED',
196+
entityType: 'USER',
197+
entityId: userId,
198+
});
199+
130200
return { message: '密码修改成功' };
131201
}
132202

@@ -315,12 +385,37 @@ export class AuthService {
315385
}
316386
}
317387

388+
this.activityService.log({
389+
userId,
390+
action: 'ACCOUNT_DELETED',
391+
entityType: 'USER',
392+
entityId: userId,
393+
});
394+
318395
// 级联删除所有用户数据(Prisma onDelete: Cascade 会处理大部分关联)
319396
await this.prisma.user.delete({ where: { id: userId } });
320397

321398
return { message: '账号已永久删除' };
322399
}
323400

401+
// ==================== 登出 ====================
402+
403+
async logout(userId: string) {
404+
await this.prisma.user.update({
405+
where: { id: userId },
406+
data: { refreshToken: null },
407+
});
408+
409+
this.activityService.log({
410+
userId,
411+
action: 'LOGOUT',
412+
entityType: 'USER',
413+
entityId: userId,
414+
});
415+
416+
return { message: '已退出登录' };
417+
}
418+
324419
async resetPassword(token: string, newPassword: string) {
325420
const user = await this.prisma.user.findUnique({
326421
where: { resetToken: token },

apps/api/src/auth/dto/change-password.dto.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
1+
import { IsNotEmpty, IsString, Matches, MinLength } from 'class-validator';
22
import { ApiProperty } from '@nestjs/swagger';
33

44
export class ChangePasswordDto {
@@ -11,12 +11,15 @@ export class ChangePasswordDto {
1111
currentPassword: string;
1212

1313
@ApiProperty({
14-
description: '新密码(至少8个字符)',
15-
example: 'newPassword123',
14+
description: '新密码(至少8位,需包含大写字母、小写字母和数字)',
15+
example: 'NewPass123',
1616
minLength: 8,
1717
})
1818
@IsString({ message: '密码必须是字符串' })
1919
@MinLength(8, { message: '新密码长度至少为 8 个字符' })
20+
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, {
21+
message: '密码需包含至少一个大写字母、一个小写字母和一个数字',
22+
})
2023
@IsNotEmpty({ message: '新密码不能为空' })
2124
newPassword: string;
2225
}

apps/api/src/auth/dto/register.dto.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
1+
import { IsEmail, IsNotEmpty, IsString, Matches, MaxLength, MinLength } from 'class-validator';
22
import { ApiProperty } from '@nestjs/swagger';
33

44
export class RegisterDto {
@@ -16,15 +16,19 @@ export class RegisterDto {
1616
})
1717
@IsString({ message: '名称必须是字符串' })
1818
@IsNotEmpty({ message: '名称不能为空' })
19+
@MaxLength(50, { message: '名称不能超过 50 个字符' })
1920
name: string;
2021

2122
@ApiProperty({
22-
description: '用户密码(至少8个字符)',
23-
example: 'password123',
23+
description: '密码(至少8位,需包含大写字母、小写字母和数字)',
24+
example: 'MyPass123',
2425
minLength: 8,
2526
})
2627
@IsString({ message: '密码必须是字符串' })
2728
@MinLength(8, { message: '密码长度至少为 8 个字符' })
29+
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, {
30+
message: '密码需包含至少一个大写字母、一个小写字母和一个数字',
31+
})
2832
@IsNotEmpty({ message: '密码不能为空' })
2933
password: string;
3034
}

0 commit comments

Comments
 (0)