- Monorepo: NestJS API (Fastify) + React web (antd/ProComponents) + shared packages - PostgreSQL/Prisma (unified resources, relations graph, audit, events, jobs) - Auth/RBAC/workspace isolation, credential vault (AES-256-GCM) - Servers/SSH, Docker discovery & lifecycle, Job Center - Websites & deployments (git provider credential clone, env, health verification) - Domains/DNS (Cloudflare adapter), certificates (TLS probe), monitoring - Automation (cron/event/webhook HMAC + notifications), AI Gateway, MCP (29 tools) - AI chat window (LLM function-calling + rules fallback, SSE streaming, approvals) - Notes: Vditor WYSIWYG markdown editor, non-blocking autosave - Tests: 35 unit/security tests; UI audit clean (desktop+mobile, deep links)
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
/**
|
||
* # 身份认证服务(Task 005)
|
||
* 注册/登录/当前用户。令牌策略:HS256 JWT(12h),载荷携带激活工作区。
|
||
* 登出为无状态操作(客户端丢弃令牌);服务端记录审计。
|
||
*/
|
||
import { Injectable } from "@nestjs/common";
|
||
import { z } from "zod";
|
||
import { signJwt, hashPassword, verifyPassword } from "@devos/auth";
|
||
import { PrismaService } from "../../prisma/prisma.service";
|
||
import { ConfigService } from "../../common/config/config.service";
|
||
import { AuditService } from "../audit/audit.service";
|
||
import { ApiError } from "../../common/errors/api-error";
|
||
import { getRequestContext } from "../../common/request-context";
|
||
import { SecurityPolicyService } from "../../common/security/security-policy.service";
|
||
|
||
/** # 注册 DTO **/
|
||
export const RegisterSchema = z.object({
|
||
email: z.string().email().max(200),
|
||
password: z.string().min(8).max(128),
|
||
name: z.string().min(1).max(80).optional(),
|
||
});
|
||
export type RegisterDto = z.infer<typeof RegisterSchema>;
|
||
|
||
/** # 登录 DTO(账号支持用户名或邮箱) **/
|
||
export const LoginSchema = z.object({
|
||
email: z.string().min(1).max(200),
|
||
password: z.string().min(1).max(128),
|
||
});
|
||
export type LoginDto = z.infer<typeof LoginSchema>;
|
||
|
||
/** # JWT 有效期(秒) **/
|
||
const TOKEN_TTL_SEC = 12 * 3600;
|
||
|
||
@Injectable()
|
||
export class IdentityService {
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly config: ConfigService,
|
||
private readonly audit: AuditService,
|
||
private readonly security: SecurityPolicyService,
|
||
) {}
|
||
|
||
/** # 注册:创建用户 + 个人默认工作区(OWNER)+ 自动登录(受注册开关与限流约束) */
|
||
async register(dto: RegisterDto) {
|
||
const policy = this.security.checkRegistration();
|
||
if (!policy.allowed) {
|
||
throw ApiError.forbidden(policy.reason!);
|
||
}
|
||
const ip = getRequestContext()?.ip ?? "unknown";
|
||
if (!this.security.checkRate(`register:${ip}`)) {
|
||
throw new ApiError(429, "RATE_LIMITED", "Too many attempts, slow down");
|
||
}
|
||
const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||
if (existing) throw ApiError.conflict("Email already registered");
|
||
|
||
const user = await this.prisma.$transaction(async (tx) => {
|
||
const created = await tx.user.create({
|
||
data: {
|
||
email: dto.email,
|
||
name: dto.name ?? dto.email.split("@")[0],
|
||
passwordHash: await hashPassword(dto.password),
|
||
},
|
||
});
|
||
const count = await tx.workspace.count();
|
||
const workspace = await tx.workspace.create({
|
||
data: {
|
||
name: `${created.name ?? created.email}'s Workspace`,
|
||
slug: count === 0 ? "main" : `ws-${created.id.slice(0, 8)}`,
|
||
},
|
||
});
|
||
await tx.workspaceMember.create({
|
||
data: { workspaceId: workspace.id, userId: created.id, role: "OWNER" },
|
||
});
|
||
return created;
|
||
});
|
||
|
||
await this.audit.record({
|
||
workspaceId: (await this.firstWorkspaceId(user.id)) ?? "",
|
||
actorType: "USER",
|
||
actorId: user.id,
|
||
action: "auth.register",
|
||
resourceType: "User",
|
||
resourceId: user.id,
|
||
});
|
||
return this.issueToken(user.id, user.email);
|
||
}
|
||
|
||
/** # 登录(按 IP 限流,防暴力破解) */
|
||
async login(dto: LoginDto) {
|
||
const ip = getRequestContext()?.ip ?? "unknown";
|
||
if (!this.security.checkRate(`login:${ip}`)) {
|
||
throw new ApiError(429, "RATE_LIMITED", "Too many attempts, slow down");
|
||
}
|
||
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||
// 统一错误信息,避免枚举探测
|
||
if (!user || !(await verifyPassword(dto.password, user.passwordHash))) {
|
||
await this.audit.record({
|
||
workspaceId: (await this.firstWorkspaceId(user?.id ?? "")) ?? "",
|
||
actorType: "USER",
|
||
actorId: user?.id,
|
||
action: "auth.login",
|
||
status: "FAILURE",
|
||
metadata: { reason: "invalid credentials" },
|
||
});
|
||
throw ApiError.unauthorized("Invalid email or password");
|
||
}
|
||
const wid = await this.firstWorkspaceId(user.id);
|
||
await this.audit.record({
|
||
workspaceId: wid ?? "",
|
||
actorType: "USER",
|
||
actorId: user.id,
|
||
action: "auth.login",
|
||
resourceType: "User",
|
||
resourceId: user.id,
|
||
});
|
||
return this.issueToken(user.id, user.email, wid);
|
||
}
|
||
|
||
/** # 当前用户:身份 + 工作区列表 */
|
||
async me(userId: string) {
|
||
const user = await this.prisma.user.findUnique({
|
||
where: { id: userId },
|
||
include: {
|
||
memberships: {
|
||
include: { workspace: { select: { id: true, name: true, slug: true } } },
|
||
orderBy: { createdAt: "asc" },
|
||
},
|
||
},
|
||
});
|
||
if (!user) throw ApiError.unauthorized();
|
||
return {
|
||
id: user.id,
|
||
email: user.email,
|
||
name: user.name,
|
||
activeWorkspaceId: user.memberships[0]?.workspace.id ?? null,
|
||
workspaces: user.memberships.map((m) => ({
|
||
id: m.workspace.id,
|
||
name: m.workspace.name,
|
||
slug: m.workspace.slug,
|
||
role: m.role,
|
||
})),
|
||
};
|
||
}
|
||
|
||
/** # 登出(无状态;记录审计便于追溯) */
|
||
async logout(userId: string, workspaceId: string | null) {
|
||
await this.audit.record({
|
||
workspaceId: workspaceId ?? "",
|
||
actorType: "USER",
|
||
actorId: userId,
|
||
action: "auth.logout",
|
||
resourceType: "User",
|
||
resourceId: userId,
|
||
});
|
||
return { ok: true };
|
||
}
|
||
|
||
/** # 签发令牌并返回用户视图 */
|
||
private async issueToken(userId: string, email: string, wid?: string | null) {
|
||
const activeWorkspaceId = wid ?? (await this.firstWorkspaceId(userId));
|
||
const token = signJwt(
|
||
{ sub: userId, email, wid: activeWorkspaceId ?? null },
|
||
this.config.appSecret,
|
||
{ expiresInSec: TOKEN_TTL_SEC },
|
||
);
|
||
const me = await this.me(userId);
|
||
return { token, expiresIn: TOKEN_TTL_SEC, user: me, requestId: getRequestContext()?.requestId };
|
||
}
|
||
|
||
/** # 查询用户第一个工作区 ID */
|
||
private async firstWorkspaceId(userId: string): Promise<string | null> {
|
||
if (!userId) return null;
|
||
const m = await this.prisma.workspaceMember.findFirst({
|
||
where: { userId },
|
||
orderBy: { createdAt: "asc" },
|
||
select: { workspaceId: true },
|
||
});
|
||
return m?.workspaceId ?? null;
|
||
}
|
||
}
|