352 lines
10 KiB
TypeScript
352 lines
10 KiB
TypeScript
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<string>('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<string>('R2_BUCKET');
|
|
const accessKeyId = this.configService.get<string>('R2_ACCESS_KEY_ID');
|
|
const secretAccessKey = this.configService.get<string>('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<string>('R2_BUCKET');
|
|
const accessKeyId = this.configService.get<string>('R2_ACCESS_KEY_ID');
|
|
const secretAccessKey = this.configService.get<string>('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<string>('R2_ENDPOINT')
|
|
|| `https://${this.configService.get<string>('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`;
|
|
|
|
this.r2Client = new S3Client({
|
|
region: this.configService.get<string>('R2_REGION', 'auto'),
|
|
endpoint,
|
|
credentials: {
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
},
|
|
forcePathStyle: this.configService.get<string>('R2_FORCE_PATH_STYLE', 'true') !== 'false',
|
|
});
|
|
|
|
return this.r2Client;
|
|
}
|
|
|
|
private isR2Enabled() {
|
|
return Boolean(
|
|
this.configService.get<string>('R2_BUCKET')
|
|
&& this.configService.get<string>('R2_ACCESS_KEY_ID')
|
|
&& this.configService.get<string>('R2_SECRET_ACCESS_KEY'),
|
|
);
|
|
}
|
|
|
|
private createAvatarObjectKey(adminId: string) {
|
|
const prefix = this.configService.get<string>('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)}`;
|
|
}
|