import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'; import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { GiteaService } from '../gitea/gitea.service'; import { PrismaService } from '../prisma/prisma.service'; import { AdminLoginDto, ChangeAdminPasswordDto } from './dto'; @Injectable() export class AdminAuthService { private r2Client?: S3Client; constructor( private readonly configService: ConfigService, private readonly giteaService: GiteaService, private readonly prisma: PrismaService, ) {} async login( body: AdminLoginDto, context: { ipAddress?: string; userAgent?: string }, ) { const admin = await this.prisma.admin.findUnique({ where: { email: body.email.trim().toLowerCase() }, select: { id: true, email: true, name: true, role: true, isActive: true, passwordHash: true, appPermissions: { select: { appId: true }, }, sshKeys: { orderBy: { createdAt: 'desc' }, select: { id: true, label: true, fingerprint: true, giteaUsername: true, createdAt: true, }, }, }, }); if (!admin || !admin.isActive || !verifyPassword(body.password, admin.passwordHash)) { throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다.'); } const token = randomBytes(32).toString('hex'); const tokenHash = hashToken(token); const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 12); await this.prisma.$transaction([ this.prisma.admin.update({ where: { id: admin.id }, data: { lastLoginAt: new Date() }, }), this.prisma.adminSession.create({ data: { adminId: admin.id, tokenHash, ipAddress: context.ipAddress, userAgent: context.userAgent, expiresAt, }, }), ]); const avatarUrl = this.getAdminAvatarUrl(admin.id); return { token, expiresAt, admin: { id: admin.id, email: admin.email, name: admin.name, role: admin.role, avatarUrl, appPermissions: admin.appPermissions.map((permission) => permission.appId), sshKeys: admin.sshKeys, }, }; } async me(adminId: string) { const admin = await this.prisma.admin.findUnique({ where: { id: adminId }, select: { id: true, email: true, name: true, role: true, appPermissions: { select: { appId: true }, }, sshKeys: { orderBy: { createdAt: 'desc' }, select: { id: true, label: true, fingerprint: true, giteaUsername: true, createdAt: true, }, }, }, }); if (!admin) { throw new UnauthorizedException('관리자 세션이 유효하지 않습니다.'); } return { admin: { id: admin.id, email: admin.email, name: admin.name, role: admin.role, avatarUrl: this.getAdminAvatarUrl(admin.id), appPermissions: admin.appPermissions.map((permission) => permission.appId), sshKeys: admin.sshKeys, }, }; } async updateAvatar(adminId: string, imageData: Buffer, mimeType: string) { if (!['image/jpeg', 'image/png', 'image/webp'].includes(mimeType)) { throw new BadRequestException('지원하지 않는 이미지 형식입니다.'); } if (!imageData.length) { throw new BadRequestException('이미지 파일이 비어 있습니다.'); } if (imageData.length > 80 * 1024) { throw new BadRequestException('프로필 이미지는 80KB 이하로 업로드해 주세요.'); } const admin = await this.getAdminForAvatar(adminId); const giteaUsername = await this.ensureGiteaUser(admin); await this.uploadAvatarToR2(admin.id, imageData, mimeType); await this.giteaService.updateUserAvatar(giteaUsername, imageData, mimeType); return { avatarUrl: this.getAdminAvatarUrl(admin.id), }; } async deleteAvatar(adminId: string) { const admin = await this.getAdminForAvatar(adminId); const giteaUsername = this.resolveGiteaUsername(admin); if (this.isR2Enabled()) { await this.deleteAvatarFromR2(admin.id); } await this.giteaService.deleteUserAvatar(giteaUsername); return { avatarUrl: this.getAdminAvatarUrl(admin.id), }; } async changePassword(adminId: string, body: ChangeAdminPasswordDto) { if (body.currentPassword === body.newPassword) { throw new BadRequestException('새 비밀번호는 현재 비밀번호와 달라야 합니다.'); } const admin = await this.prisma.admin.findUnique({ where: { id: adminId }, select: { id: true, passwordHash: true, isActive: true, }, }); if (!admin || !admin.isActive) { throw new UnauthorizedException('관리자 세션이 유효하지 않습니다.'); } if (!verifyPassword(body.currentPassword, admin.passwordHash)) { throw new BadRequestException('현재 비밀번호가 올바르지 않습니다.'); } await this.prisma.admin.update({ where: { id: admin.id }, data: { passwordHash: hashPassword(body.newPassword) }, }); return { ok: true }; } private getAdminAvatarUrl(adminId: string) { const publicBaseUrl = this.configService.get('R2_PUBLIC_BASE_URL')?.replace(/\/+$/, ''); if (!publicBaseUrl) return null; return `${publicBaseUrl}/${this.createAvatarObjectKey(adminId)}`; } private async getAdminForAvatar(adminId: string) { const admin = await this.prisma.admin.findUnique({ where: { id: adminId }, select: { id: true, email: true, name: true, sshKeys: { where: { giteaUsername: { not: null } }, orderBy: { createdAt: 'desc' }, take: 1, select: { giteaUsername: true }, }, }, }); if (!admin) { throw new UnauthorizedException('관리자 세션이 유효하지 않습니다.'); } return admin; } private async ensureGiteaUser(admin: { email: string; name: string; sshKeys?: Array<{ giteaUsername: string | null }> }) { const storedUsername = this.resolveStoredGiteaUsername(admin); if (storedUsername) return storedUsername; const username = createGiteaUsername(admin.email); await this.giteaService.createUser({ username, email: admin.email, password: randomBytes(24).toString('hex'), fullName: admin.name, }); return username; } private resolveGiteaUsername(admin: { email: string; sshKeys?: Array<{ giteaUsername: string | null }> }) { return this.resolveStoredGiteaUsername(admin) ?? createGiteaUsername(admin.email); } private resolveStoredGiteaUsername(admin: { sshKeys?: Array<{ giteaUsername: string | null }> }) { return admin.sshKeys?.find((key) => key.giteaUsername?.trim())?.giteaUsername?.trim() || null; } private async uploadAvatarToR2(adminId: string, imageData: Buffer, mimeType: string) { const bucket = this.configService.get('R2_BUCKET'); const accessKeyId = this.configService.get('R2_ACCESS_KEY_ID'); const secretAccessKey = this.configService.get('R2_SECRET_ACCESS_KEY'); if (!bucket || !accessKeyId || !secretAccessKey) { throw new BadRequestException('R2 설정이 필요합니다.'); } await this.getR2Client(accessKeyId, secretAccessKey).send(new PutObjectCommand({ Bucket: bucket, Key: this.createAvatarObjectKey(adminId), Body: imageData, ContentType: mimeType, CacheControl: 'public, max-age=60', })); } private async deleteAvatarFromR2(adminId: string) { const bucket = this.configService.get('R2_BUCKET'); const accessKeyId = this.configService.get('R2_ACCESS_KEY_ID'); const secretAccessKey = this.configService.get('R2_SECRET_ACCESS_KEY'); if (!bucket || !accessKeyId || !secretAccessKey) return; await this.getR2Client(accessKeyId, secretAccessKey).send(new DeleteObjectCommand({ Bucket: bucket, Key: this.createAvatarObjectKey(adminId), })); } private getR2Client(accessKeyId: string, secretAccessKey: string) { if (this.r2Client) return this.r2Client; const endpoint = this.configService.get('R2_ENDPOINT') || `https://${this.configService.get('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`; this.r2Client = new S3Client({ region: this.configService.get('R2_REGION', 'auto'), endpoint, credentials: { accessKeyId, secretAccessKey, }, forcePathStyle: this.configService.get('R2_FORCE_PATH_STYLE', 'true') !== 'false', }); return this.r2Client; } private isR2Enabled() { return Boolean( this.configService.get('R2_BUCKET') && this.configService.get('R2_ACCESS_KEY_ID') && this.configService.get('R2_SECRET_ACCESS_KEY'), ); } private createAvatarObjectKey(adminId: string) { const prefix = this.configService.get('R2_PUBLIC_FILE_PREFIX', 'studio-files/public'); return `${prefix.replace(/^\/+|\/+$/g, '')}/admins/${adminId}/avatar`; } } function verifyPassword(password: string, passwordHash: string) { const [scheme, salt, storedHash] = passwordHash.split(':'); if (scheme !== 'scrypt' || !salt || !storedHash) { return false; } const currentHash = scryptSync(password, salt, 64); const storedBuffer = Buffer.from(storedHash, 'hex'); if (storedBuffer.length !== currentHash.length) { return false; } return timingSafeEqual(storedBuffer, currentHash); } function hashToken(token: string) { return createHash('sha256').update(token).digest('hex'); } function hashPassword(password: string) { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(password, salt, 64).toString('hex'); return `scrypt:${salt}:${hash}`; } 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)}`; }