init studio admin backend

This commit is contained in:
2026-07-01 17:30:04 +09:00
commit 4437d9980d
144 changed files with 44964 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
import { Body, Controller, Post, Req } from '@nestjs/common';
import { AdminAuthService } from './admin-auth.service';
import { Public } from './decorators/admin-auth.decorators';
import { AdminLoginDto } from './dto';
@Controller('auth/admin')
export class AdminAuthController {
constructor(private readonly adminAuthService: AdminAuthService) {}
@Public()
@Post('login')
login(
@Body() body: AdminLoginDto,
@Req() request: { ip?: string; headers: Record<string, string | string[] | undefined> },
) {
const userAgent = request.headers['user-agent'];
return this.adminAuthService.login(body, {
ipAddress: request.ip,
userAgent: Array.isArray(userAgent) ? userAgent.join(', ') : userAgent,
});
}
}
+93
View File
@@ -0,0 +1,93 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { createHash } from 'node:crypto';
import { AdminRole } from '../admins/admin-role';
import { PrismaService } from '../prisma/prisma.service';
import { ADMIN_PUBLIC_KEY, ADMIN_ROLES_KEY } from './decorators/admin-auth.decorators';
type RequestWithAdmin = {
method?: string;
headers?: Record<string, string | string[] | undefined>;
admin?: {
id: string;
email: string;
name: string;
role: AdminRole;
};
};
@Injectable()
export class AdminAuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(ADMIN_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<RequestWithAdmin>();
if (request.method?.toUpperCase() === 'OPTIONS') return true;
const token = getBearerToken(request.headers?.authorization);
if (!token) {
throw new UnauthorizedException('관리자 로그인이 필요합니다.');
}
const tokenHash = createHash('sha256').update(token).digest('hex');
const session = await this.prisma.adminSession.findUnique({
where: { tokenHash },
include: {
admin: {
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
},
},
},
});
const now = new Date();
if (!session || session.revokedAt || session.expiresAt <= now || !session.admin.isActive) {
throw new UnauthorizedException('관리자 세션이 유효하지 않습니다.');
}
request.admin = {
id: session.admin.id,
email: session.admin.email,
name: session.admin.name,
role: session.admin.role,
};
const roles = this.reflector.getAllAndOverride<AdminRole[]>(ADMIN_ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (roles?.length && !roles.includes(session.admin.role)) {
throw new ForbiddenException('권한이 없습니다.');
}
const method = request.method?.toUpperCase();
if (session.admin.role === AdminRole.VIEWER && method && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
throw new ForbiddenException('조회 권한만 허용됩니다.');
}
return true;
}
}
function getBearerToken(value: string | string[] | undefined) {
const header = Array.isArray(value) ? value[0] : value;
const match = header?.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || '';
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { AdminAuthController } from './admin-auth.controller';
import { AdminAuthService } from './admin-auth.service';
@Module({
imports: [PrismaModule],
controllers: [AdminAuthController],
providers: [AdminAuthService],
})
export class AdminAuthModule {}
+83
View File
@@ -0,0 +1,83 @@
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AdminLoginDto } from './dto';
@Injectable()
export class AdminAuthService {
constructor(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,
},
});
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,
},
}),
]);
return {
token,
expiresAt,
admin: {
id: admin.id,
email: admin.email,
name: admin.name,
role: admin.role,
},
};
}
}
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');
}
@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
import { AdminRole } from '../../admins/admin-role';
export const ADMIN_PUBLIC_KEY = 'adminPublic';
export const ADMIN_ROLES_KEY = 'adminRoles';
export const Public = () => SetMetadata(ADMIN_PUBLIC_KEY, true);
export const AdminRoles = (...roles: AdminRole[]) => SetMetadata(ADMIN_ROLES_KEY, roles);
+9
View File
@@ -0,0 +1,9 @@
import { IsEmail, IsString } from 'class-validator';
export class AdminLoginDto {
@IsEmail()
email!: string;
@IsString()
password!: string;
}