DevOS v1 + Phase 2: unified resource model, infra ops, website deployment, AI chat ops
- 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)
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* # 身份认证服务(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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user