/** * # 身份认证服务(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; /** # 登录 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; /** # 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 { if (!userId) return null; const m = await this.prisma.workspaceMember.findFirst({ where: { userId }, orderBy: { createdAt: "asc" }, select: { workspaceId: true }, }); return m?.workspaceId ?? null; } }