Files
studio-admin-be/src/users/users.service.ts
T
chyoo 3873ce3169
ci/woodpecker/push/woodpecker Pipeline was successful
track requesters for builds releases deployments and migrations
2026-07-10 10:43:02 +09:00

328 lines
8.4 KiB
TypeScript

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<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('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 '유효토큰';
}