420 lines
11 KiB
TypeScript
420 lines
11 KiB
TypeScript
import { createHash, randomBytes, scryptSync } from 'node:crypto';
|
|
|
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
import { GiteaService } from '../gitea/gitea.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { getAdminAvatarUrl } from './admin-avatar';
|
|
import { AdminRole } from './admin-role';
|
|
import {
|
|
CreateAdminSshKeyDto,
|
|
CreateAdminDto,
|
|
ListAdminsQuery,
|
|
ResetAdminPasswordDto,
|
|
UpdateAdminAppPermissionsDto,
|
|
UpdateAdminRoleDto,
|
|
UpdateAdminStatusDto,
|
|
} from './dto';
|
|
|
|
@Injectable()
|
|
export class AdminsService {
|
|
constructor(
|
|
private readonly configService: ConfigService,
|
|
private readonly giteaService: GiteaService,
|
|
private readonly prisma: PrismaService,
|
|
) {}
|
|
|
|
async listRoles() {
|
|
await this.ensureDefaultRoles();
|
|
|
|
return this.prisma.adminRoleOption.findMany({
|
|
where: { isActive: true },
|
|
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
|
|
select: {
|
|
id: true,
|
|
code: true,
|
|
name: true,
|
|
description: true,
|
|
sortOrder: true,
|
|
isActive: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
async list(query: ListAdminsQuery = {}) {
|
|
const page = toPositiveInt(query.page, 1);
|
|
const limit = Math.min(toPositiveInt(query.limit, 20), 100);
|
|
const keyword = query.keyword?.trim();
|
|
const where = {
|
|
...(query.role ? { role: query.role as AdminRole } : {}),
|
|
...(query.state === 'active' ? { isActive: true } : query.state === 'inactive' ? { isActive: false } : {}),
|
|
...(keyword
|
|
? {
|
|
OR: [
|
|
{ email: { contains: keyword, mode: 'insensitive' as const } },
|
|
{ name: { contains: keyword, mode: 'insensitive' as const } },
|
|
],
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
const [admins, total] = await this.prisma.$transaction([
|
|
this.prisma.admin.findMany({
|
|
where,
|
|
orderBy: [{ role: 'asc' }, { createdAt: 'desc' }],
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
role: true,
|
|
isActive: true,
|
|
lastLoginAt: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
sessions: {
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 1,
|
|
select: {
|
|
id: true,
|
|
expiresAt: true,
|
|
revokedAt: true,
|
|
createdAt: true,
|
|
},
|
|
},
|
|
sshKeys: {
|
|
orderBy: { createdAt: 'desc' },
|
|
select: adminSshKeySelect,
|
|
},
|
|
appPermissions: {
|
|
select: { appId: true },
|
|
},
|
|
},
|
|
}),
|
|
this.prisma.admin.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
admins: admins.map((admin, index) => {
|
|
return {
|
|
...admin,
|
|
no: total - ((page - 1) * limit + index),
|
|
avatarUrl: getAdminAvatarUrl(admin.id, this.configService),
|
|
};
|
|
}),
|
|
total,
|
|
page,
|
|
limit,
|
|
};
|
|
}
|
|
|
|
async create(body: CreateAdminDto) {
|
|
await this.ensureRoleEnabled(body.role);
|
|
const email = body.email.trim().toLowerCase();
|
|
const name = body.name.trim();
|
|
|
|
await this.giteaService.createUser({
|
|
username: createGiteaUsername(email),
|
|
email,
|
|
password: body.temporaryPassword,
|
|
fullName: name,
|
|
});
|
|
|
|
const admin = await this.prisma.admin.create({
|
|
data: {
|
|
email,
|
|
name,
|
|
role: body.role,
|
|
passwordHash: hashPassword(body.temporaryPassword),
|
|
},
|
|
select: adminSelect,
|
|
});
|
|
|
|
return { admin };
|
|
}
|
|
|
|
async updateStatus(id: string, body: UpdateAdminStatusDto) {
|
|
const existing = await this.ensureExists(id);
|
|
|
|
await this.giteaService.updateUserLoginDisabled(
|
|
createGiteaUsername(existing.email),
|
|
!body.isActive,
|
|
);
|
|
|
|
const admin = await this.prisma.admin.update({
|
|
where: { id },
|
|
data: { isActive: body.isActive },
|
|
select: adminSelect,
|
|
});
|
|
|
|
if (!body.isActive) {
|
|
await this.prisma.adminSession.updateMany({
|
|
where: { adminId: id, revokedAt: null },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
return { admin };
|
|
}
|
|
|
|
async updateRole(id: string, body: UpdateAdminRoleDto) {
|
|
await this.ensureExists(id);
|
|
await this.ensureRoleEnabled(body.role);
|
|
|
|
const admin = await this.prisma.admin.update({
|
|
where: { id },
|
|
data: { role: body.role },
|
|
select: adminSelect,
|
|
});
|
|
|
|
return { admin };
|
|
}
|
|
|
|
async resetPassword(id: string, body: ResetAdminPasswordDto) {
|
|
await this.ensureExists(id);
|
|
|
|
const admin = await this.prisma.admin.update({
|
|
where: { id },
|
|
data: { passwordHash: hashPassword(body.temporaryPassword) },
|
|
select: adminSelect,
|
|
});
|
|
|
|
await this.prisma.adminSession.updateMany({
|
|
where: { adminId: id, revokedAt: null },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
|
|
return { admin };
|
|
}
|
|
|
|
async listAppPermissions(id: string) {
|
|
await this.ensureExists(id);
|
|
|
|
const permissions = await this.prisma.adminAppPermission.findMany({
|
|
where: { adminId: id },
|
|
orderBy: { createdAt: 'asc' },
|
|
select: { appId: true },
|
|
});
|
|
|
|
return { appIds: permissions.map((permission) => permission.appId) };
|
|
}
|
|
|
|
async updateAppPermissions(id: string, body: UpdateAdminAppPermissionsDto) {
|
|
const admin = await this.prisma.admin.findUnique({
|
|
where: { id },
|
|
select: { id: true, role: true },
|
|
});
|
|
|
|
if (!admin) {
|
|
throw new NotFoundException('관리자를 찾을 수 없습니다.');
|
|
}
|
|
|
|
if (admin.role === AdminRole.OWNER) {
|
|
throw new BadRequestException('오너 권한은 앱 권한을 개별 설정하지 않습니다.');
|
|
}
|
|
|
|
const appIds = [...new Set(body.appIds.filter(Boolean))];
|
|
const existingApps = await this.prisma.app.findMany({
|
|
where: { id: { in: appIds } },
|
|
select: { id: true },
|
|
});
|
|
const existingAppIds = new Set(existingApps.map((app) => app.id));
|
|
const validAppIds = appIds.filter((appId) => existingAppIds.has(appId));
|
|
|
|
await this.prisma.$transaction([
|
|
this.prisma.adminAppPermission.deleteMany({ where: { adminId: id } }),
|
|
...(validAppIds.length
|
|
? [
|
|
this.prisma.adminAppPermission.createMany({
|
|
data: validAppIds.map((appId) => ({ adminId: id, appId })),
|
|
skipDuplicates: true,
|
|
}),
|
|
]
|
|
: []),
|
|
]);
|
|
|
|
return { appIds: validAppIds };
|
|
}
|
|
|
|
async createSshKey(adminId: string, body: CreateAdminSshKeyDto) {
|
|
const admin = await this.ensureExists(adminId);
|
|
|
|
const parsedKey = parseSshPublicKey(body.publicKey);
|
|
const label = body.label?.trim() || `${parsedKey.keyType} ${parsedKey.fingerprint}`;
|
|
const giteaUsername = createGiteaUsername(admin.email);
|
|
let giteaKeyId: number | null = null;
|
|
|
|
const giteaKey = await this.giteaService.createUserSshKey(giteaUsername, {
|
|
title: label,
|
|
key: parsedKey.normalizedKey,
|
|
});
|
|
giteaKeyId = giteaKey.id;
|
|
|
|
const sshKey = await this.prisma.adminSshKey.create({
|
|
data: {
|
|
adminId,
|
|
label,
|
|
keyType: parsedKey.keyType,
|
|
publicKey: parsedKey.normalizedKey,
|
|
publicKeyHash: parsedKey.publicKeyHash,
|
|
fingerprint: parsedKey.fingerprint,
|
|
giteaUsername,
|
|
giteaKeyId,
|
|
},
|
|
select: adminSshKeySelect,
|
|
});
|
|
|
|
return { sshKey };
|
|
}
|
|
|
|
async deleteSshKey(adminId: string, keyId: string) {
|
|
await this.ensureExists(adminId);
|
|
|
|
const sshKey = await this.prisma.adminSshKey.findFirst({
|
|
where: { id: keyId, adminId },
|
|
select: {
|
|
id: true,
|
|
giteaUsername: true,
|
|
giteaKeyId: true,
|
|
},
|
|
});
|
|
|
|
if (!sshKey) {
|
|
throw new NotFoundException('SSH 키를 찾을 수 없습니다.');
|
|
}
|
|
|
|
await this.prisma.adminSshKey.delete({ where: { id: keyId } });
|
|
|
|
if (sshKey.giteaUsername && sshKey.giteaKeyId) {
|
|
await this.giteaService.deleteUserSshKey(
|
|
sshKey.giteaUsername,
|
|
sshKey.giteaKeyId,
|
|
);
|
|
}
|
|
|
|
return { deleted: true };
|
|
}
|
|
|
|
private async ensureRoleEnabled(role: AdminRole) {
|
|
await this.ensureDefaultRoles();
|
|
|
|
const roleOption = await this.prisma.adminRoleOption.findUnique({
|
|
where: { code: role },
|
|
select: { isActive: true },
|
|
});
|
|
|
|
if (!roleOption?.isActive) {
|
|
throw new BadRequestException('사용할 수 없는 관리자 권한입니다.');
|
|
}
|
|
}
|
|
|
|
private async ensureDefaultRoles() {
|
|
await this.prisma.adminRoleOption.createMany({
|
|
data: [
|
|
{ code: AdminRole.OWNER, name: 'OWNER', description: '전체 관리자 권한', sortOrder: 1 },
|
|
{ code: AdminRole.ADMIN, name: 'ADMIN', description: '관리자 권한', sortOrder: 2 },
|
|
{ code: AdminRole.VIEWER, name: 'VIEWER', description: '조회 권한', sortOrder: 3 },
|
|
],
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
private async ensureExists(id: string) {
|
|
const admin = await this.prisma.admin.findUnique({
|
|
where: { id },
|
|
select: { id: true, email: true },
|
|
});
|
|
|
|
if (!admin) {
|
|
throw new NotFoundException('관리자를 찾을 수 없습니다.');
|
|
}
|
|
|
|
return admin;
|
|
}
|
|
}
|
|
|
|
const adminSelect = {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
role: true,
|
|
isActive: true,
|
|
lastLoginAt: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
} as const;
|
|
|
|
const adminSshKeySelect = {
|
|
id: true,
|
|
label: true,
|
|
keyType: true,
|
|
fingerprint: true,
|
|
giteaUsername: true,
|
|
giteaKeyId: true,
|
|
createdAt: true,
|
|
} as const;
|
|
|
|
function hashPassword(password: string) {
|
|
const salt = randomBytes(16).toString('hex');
|
|
const hash = scryptSync(password, salt, 64).toString('hex');
|
|
return `scrypt:${salt}:${hash}`;
|
|
}
|
|
|
|
function toPositiveInt(value: string | number | undefined, fallback: number) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
|
|
return Math.floor(parsed);
|
|
}
|
|
|
|
function createGiteaUsername(email: string) {
|
|
const account = email.split('@')[0] || email;
|
|
const username = account
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
.replace(/^[._-]+|[._-]+$/g, '')
|
|
.slice(0, 40);
|
|
|
|
return username || `admin-${createHash('sha1').update(email).digest('hex').slice(0, 8)}`;
|
|
}
|
|
|
|
function parseSshPublicKey(publicKey: string) {
|
|
const normalizedKey = publicKey.trim().replace(/\s+/g, ' ');
|
|
const [keyType, keyBody] = normalizedKey.split(' ');
|
|
|
|
if (!keyType || !keyBody || !isSupportedSshKeyType(keyType)) {
|
|
throw new BadRequestException('지원하지 않는 SSH 공개키 형식입니다.');
|
|
}
|
|
|
|
let keyBuffer: Buffer;
|
|
try {
|
|
keyBuffer = Buffer.from(keyBody, 'base64');
|
|
} catch {
|
|
throw new BadRequestException('SSH 공개키 base64 값을 확인하세요.');
|
|
}
|
|
|
|
if (!keyBuffer.length) {
|
|
throw new BadRequestException('SSH 공개키 값이 비어 있습니다.');
|
|
}
|
|
|
|
return {
|
|
keyType,
|
|
normalizedKey,
|
|
publicKeyHash: createHash('sha256').update(normalizedKey).digest('hex'),
|
|
fingerprint: `SHA256:${createHash('sha256')
|
|
.update(keyBuffer)
|
|
.digest('base64')
|
|
.replace(/=+$/, '')}`,
|
|
};
|
|
}
|
|
|
|
function isSupportedSshKeyType(keyType: string) {
|
|
return (
|
|
keyType === 'ssh-ed25519' ||
|
|
keyType === 'ssh-rsa' ||
|
|
keyType.startsWith('ecdsa-sha2-')
|
|
);
|
|
}
|