import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { randomBytes, scrypt as scryptCallback } from 'node:crypto'; import { promisify } from 'node:util'; import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; import { CreateManagedUserDto, ListUserSessionsQuery } from './dto'; const scrypt = promisify(scryptCallback); const passwordKeyLength = 64; type UserSessionRow = { id: string; sessionId: string | null; userId: string; user: string; email: string; avatarUrl: string | null; role: string; session: string; createdAt: Date | null; expiresAt: Date | null; lastSeen: Date | null; state: string; primaryAffiliation: { id: string; organizationId: string; organizationName: string; organizationType: string; department: string | null; position: string | null; } | null; }; @Injectable() export class UsersService { constructor( private readonly authPrisma: AuthPrismaService, private readonly configService: ConfigService, ) {} async listSessions(query: ListUserSessionsQuery) { const keyword = query.keyword?.trim(); const now = new Date(); const users = await this.authPrisma.user.findMany({ where: keyword ? { OR: [ { email: { contains: keyword, mode: 'insensitive' } }, { name: { contains: keyword, mode: 'insensitive' } }, ], } : undefined, orderBy: [{ lastLoginAt: 'desc' }, { createdAt: 'desc' }], include: { refreshTokens: { where: { revokedAt: null, expiresAt: { gt: now }, }, orderBy: { createdAt: 'desc' }, }, affiliations: { where: { OR: [{ endsAt: null }, { endsAt: { gt: now } }], }, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'desc' }], take: 1, include: { organization: { select: { id: true, name: true, type: true, }, }, }, }, }, }); const rows: UserSessionRow[] = users.flatMap((user): UserSessionRow[] => { const avatarUrl = getUserAvatarUrl( user.id, user.profileImageMime, this.configService.get('AUTH_PUBLIC_URL') ?? this.configService.get('ATNMY_AUTH_URL') ?? this.configService.get('AUTH_EXTERNAL_URL') ?? 'https://auth.autonomystudio.net', ); const primaryAffiliation = user.affiliations[0] ? { id: user.affiliations[0].id, organizationId: user.affiliations[0].organizationId, organizationName: user.affiliations[0].organization.name, organizationType: user.affiliations[0].organization.type, department: user.affiliations[0].department, position: user.affiliations[0].position, } : null; if (user.refreshTokens.length === 0) { return [ { id: `user-${user.id}`, sessionId: null, userId: user.id, user: user.name || user.email, email: user.email, avatarUrl, role: user.role, session: '세션 없음', createdAt: null, expiresAt: null, lastSeen: user.lastLoginAt, state: user.isActive ? '세션 없음' : '잠김', primaryAffiliation, }, ]; } return user.refreshTokens.map((token) => ({ id: token.id, sessionId: token.id, userId: user.id, user: user.name || user.email, email: user.email, avatarUrl, role: user.role, session: token.tokenHash.slice(0, 10), createdAt: token.createdAt, expiresAt: token.expiresAt, lastSeen: user.lastLoginAt ?? token.createdAt, state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now), primaryAffiliation, })); }); const filteredRows = query.state ? rows.filter((row) => row.state === query.state) : rows; return { sessions: filteredRows.map((row, index) => ({ ...row, no: filteredRows.length - index, })), }; } async createManagedUser(body: CreateManagedUserDto) { const email = body.email.trim().toLowerCase(); const existing = await this.authPrisma.user.findUnique({ where: { email }, select: { id: true }, }); if (existing) { throw new ConflictException('이미 등록된 이메일입니다.'); } const license = await this.authPrisma.license.findUnique({ where: { code: body.licenseCode ?? 'EDU' }, select: { id: true, defaultDurationDays: true }, }); if (!license) { throw new NotFoundException('라이센스를 찾을 수 없습니다.'); } const startsAt = new Date(); const expiresAt = calculateLicenseExpiresAt( startsAt, license.defaultDurationDays, ); const user = await this.authPrisma.user.create({ data: { email, passwordHash: await hashPassword(body.password), name: body.name.trim(), role: 'USER', affiliations: body.organizationId ? { create: { organizationId: body.organizationId, department: body.department?.trim() || null, position: body.position?.trim() || null, isPrimary: true, }, } : undefined, userLicenses: { create: { licenseId: license.id, startsAt, expiresAt, }, }, }, select: { id: true, email: true, name: true, }, }); return { user }; } async lockUser(userId: string) { const user = await this.authPrisma.user.findUnique({ where: { id: userId }, select: { id: true }, }); if (!user) { throw new NotFoundException('사용자를 찾을 수 없습니다.'); } await this.authPrisma.$transaction([ this.authPrisma.user.update({ where: { id: userId }, data: { isActive: false }, }), this.authPrisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() }, }), ]); return { ok: true }; } async unlockUser(userId: string) { const user = await this.authPrisma.user.findUnique({ where: { id: userId }, select: { id: true }, }); if (!user) { throw new NotFoundException('사용자를 찾을 수 없습니다.'); } await this.authPrisma.user.update({ where: { id: userId }, data: { isActive: true }, }); return { ok: true }; } async terminateSession(sessionId: string) { const session = await this.authPrisma.refreshToken.findUnique({ where: { id: sessionId }, select: { id: true, userId: true }, }); if (!session) { throw new NotFoundException('세션을 찾을 수 없습니다.'); } await this.authPrisma.refreshToken.updateMany({ where: { userId: session.userId, revokedAt: null, expiresAt: { gt: new Date() }, }, data: { revokedAt: new Date() }, }); return { ok: true }; } } async function hashPassword(password: string) { const salt = randomBytes(16).toString('hex'); const derivedKey = (await scrypt(password, salt, passwordKeyLength)) as Buffer; return `scrypt:${salt}:${derivedKey.toString('hex')}`; } function calculateLicenseExpiresAt( startsAt: Date, durationDays: number | null | undefined, ) { if (!durationDays) return null; const expiresAt = new Date(startsAt); expiresAt.setDate(expiresAt.getDate() + durationDays); return expiresAt; } export function getUserAvatarUrl( userId: string, mimeType?: string | null, authBaseUrl?: string | null, ) { if (!mimeType || !authBaseUrl) { return null; } const normalizedBaseUrl = authBaseUrl.replace(/\/+$/, ''); return `${normalizedBaseUrl}/auth/users/${encodeURIComponent(userId)}/avatar`; } function getSessionState( isUserActive: boolean, revokedAt: Date | null, expiresAt: Date, now: Date, ) { if (!isUserActive) { return '잠김'; } if (revokedAt) { return '종료'; } if (expiresAt < now) { return '만료'; } return '유효토큰'; }