Files
devos/apps/api/src/modules/identity/identity.service.ts
T
phptits 0d6cd37579 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)
2026-09-10 06:35:55 +00:00

181 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 身份认证服务(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;
}
}