init studio admin backend
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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() || '';
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
|
||||
export class AdminLoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
password!: string;
|
||||
}
|
||||
Reference in New Issue
Block a user