track requesters for builds releases deployments and migrations
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-10 10:43:02 +09:00
parent dd09471267
commit 3873ce3169
17 changed files with 690 additions and 61 deletions
+307 -3
View File
@@ -1,13 +1,25 @@
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
import { GiteaService } from '../gitea/gitea.service';
import { PrismaService } from '../prisma/prisma.service';
import { AdminLoginDto } from './dto';
import { getUserAvatarUrl } from '../users/users.service';
import { AdminLoginDto, ChangeAdminPasswordDto } from './dto';
@Injectable()
export class AdminAuthService {
constructor(private readonly prisma: PrismaService) {}
private r2Client?: S3Client;
constructor(
private readonly authPrisma: AuthPrismaService,
private readonly configService: ConfigService,
private readonly giteaService: GiteaService,
private readonly prisma: PrismaService,
) {}
async login(
body: AdminLoginDto,
@@ -25,6 +37,16 @@ export class AdminAuthService {
appPermissions: {
select: { appId: true },
},
sshKeys: {
orderBy: { createdAt: 'desc' },
select: {
id: true,
label: true,
fingerprint: true,
giteaUsername: true,
createdAt: true,
},
},
},
});
@@ -52,6 +74,8 @@ export class AdminAuthService {
}),
]);
const avatarUrl = await this.getAdminAvatarUrl(admin.email);
return {
token,
expiresAt,
@@ -60,10 +84,272 @@ export class AdminAuthService {
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: await this.getAdminAvatarUrl(admin.email),
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, user } = await this.getLinkedAuthUser(adminId);
const giteaUsername = await this.ensureGiteaUser(admin);
await this.uploadAvatarToR2(user.id, imageData, mimeType);
await this.giteaService.updateUserAvatar(giteaUsername, imageData, mimeType);
await this.authPrisma.user.update({
where: { id: user.id },
data: {
profileImageData: null,
profileImageMime: mimeType,
profileImageUpdatedAt: new Date(),
},
});
return {
avatarUrl: await this.getAdminAvatarUrl(admin.email),
};
}
async deleteAvatar(adminId: string) {
const { admin, user } = await this.getLinkedAuthUser(adminId);
const giteaUsername = this.resolveGiteaUsername(admin);
if (this.isR2Enabled()) {
await this.deleteAvatarFromR2(user.id);
}
await this.giteaService.deleteUserAvatar(giteaUsername);
await this.authPrisma.user.update({
where: { id: user.id },
data: {
profileImageData: null,
profileImageMime: null,
profileImageUpdatedAt: new Date(),
},
});
return {
avatarUrl: await this.getAdminAvatarUrl(admin.email),
};
}
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 async getAdminAvatarUrl(email: string) {
const user = await this.authPrisma.user.findUnique({
where: { email },
select: {
id: true,
profileImageMime: true,
},
});
const authBaseUrl = 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';
return getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl);
}
private async getLinkedAuthUser(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('관리자 세션이 유효하지 않습니다.');
}
const user = await this.authPrisma.user.findUnique({
where: { email: admin.email },
select: { id: true },
});
if (!user) {
throw new NotFoundException('프로필 이미지와 연결할 사용자 계정을 찾을 수 없습니다.');
}
return { admin, user };
}
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(userId: 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(userId),
Body: imageData,
ContentType: mimeType,
CacheControl: 'public, max-age=60',
}));
}
private async deleteAvatarFromR2(userId: 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(userId),
}));
}
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(userId: string) {
const prefix = this.configService.get<string>('R2_PUBLIC_FILE_PREFIX', 'studio-files/public');
return `${prefix.replace(/^\/+|\/+$/g, '')}/users/${userId}/avatar`;
}
}
function verifyPassword(password: string, passwordHash: string) {
@@ -85,3 +371,21 @@ function verifyPassword(password: string, passwordHash: string) {
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)}`;
}