fix: 보안 점검 보완 — 소셜 로그인·세션·업로드 강화 (HIGH+MEDIUM)
- OAuth 제공자 이메일 검증 미확인 차단(email_verified/is_email_verified 미검증 시 거부) - OAuth state CSRF 방지: 세션 없는 CookieStateStore(httpOnly 1회용 state 쿠키) 두 전략에 주입 - refresh 토큰 폐기: User.tokenVersion + refresh payload ver 검증, 로그아웃 시 일괄 무효화 - 파일 업로드 MIME 화이트리스트(html/svg 차단) + 다운로드 attachment·nosniff(저장형 XSS 차단) - 인증 라우트 throttle(login 10·signup 5·resend 3 /분) - 첨부 삭제 업로더/admin 한정 - 인증 토큰 로그 운영 환경 게이트(원문 토큰 미기록) - docs/security-review.md(감사 보고서) 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { AuthService, type SignupResult } from './auth.service';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
@@ -93,6 +94,8 @@ export class AuthController {
|
||||
|
||||
@Post('signup')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
// 가입 남용/메일 폭탄 방지 — IP당 분당 5회
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '회원가입 (인증 메일 발송, 자동 로그인 안 함)' })
|
||||
@ApiResponse({ status: 201, description: '가입 접수 — 인증 메일 발송됨' })
|
||||
@ApiResponse({ status: 409, description: '이미 가입된 이메일 (BIZ_001)' })
|
||||
@@ -103,6 +106,8 @@ export class AuthController {
|
||||
|
||||
@Post('resend-verification')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// 메일 폭탄 방지 — IP당 분당 3회
|
||||
@Throttle({ default: { limit: 3, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '인증 메일 재발송' })
|
||||
@ApiResponse({ status: 200, description: '재발송 접수(존재 여부 비노출)' })
|
||||
async resend(@Body() dto: ResendVerificationDto): Promise<null> {
|
||||
@@ -128,6 +133,8 @@ export class AuthController {
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// 무차별 대입 방지 — IP당 분당 10회
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '로그인' })
|
||||
@ApiResponse({ status: 200, description: '로그인 성공, 인증 쿠키 발급' })
|
||||
@ApiResponse({
|
||||
@@ -187,9 +194,15 @@ export class AuthController {
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '로그아웃 (인증 쿠키 제거)' })
|
||||
@ApiOperation({ summary: '로그아웃 (세션 폐기 + 인증 쿠키 제거)' })
|
||||
@ApiResponse({ status: 200, description: '로그아웃 성공' })
|
||||
logout(@Res({ passthrough: true }) res: Response): null {
|
||||
async logout(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<null> {
|
||||
// refresh 토큰 버전을 올려 발급된 refresh 토큰을 서버측에서 무효화(탈취 토큰 재사용 차단)
|
||||
const cookies = req.cookies as Record<string, string> | undefined;
|
||||
await this.authService.revokeSession(cookies?.refresh_token);
|
||||
this.clearAuthCookies(res);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MailService } from '../mail/mail.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import type { JwtPayload } from './types/jwt-payload';
|
||||
|
||||
// bcrypt 해시 라운드
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
@@ -31,6 +32,8 @@ export interface SignupResult {
|
||||
export interface OAuthProfile {
|
||||
provider: string;
|
||||
email: string | null;
|
||||
// 제공자가 이메일 소유를 검증했는지 — 미검증이면 계정 매칭/생성을 거부(탈취/선점 방지)
|
||||
emailVerified: boolean;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
@@ -137,6 +140,14 @@ export class AuthService {
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
// 제공자가 이메일을 검증하지 않았으면 거부 — 미검증 이메일로 타인 계정 탈취/선점 방지
|
||||
if (!profile.emailVerified) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'소셜 계정의 이메일이 인증되지 않았습니다. 제공자에서 이메일 인증을 완료한 뒤 다시 시도해 주세요.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
let user = await this.userService.findByEmail(profile.email);
|
||||
if (!user) {
|
||||
user = await this.userService.createOAuthUser({
|
||||
@@ -153,8 +164,10 @@ export class AuthService {
|
||||
return UserService.toPublic(user);
|
||||
}
|
||||
|
||||
// access/refresh 토큰 발급
|
||||
// access/refresh 토큰 발급 — refresh 에는 현재 tokenVersion 을 함께 서명(폐기 검증용)
|
||||
async issueTokens(user: PublicUser): Promise<TokenPair> {
|
||||
const dbUser = await this.userService.findById(user.id);
|
||||
const ver = dbUser?.tokenVersion ?? 0;
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ sub: user.id, email: user.email },
|
||||
{
|
||||
@@ -164,7 +177,7 @@ export class AuthService {
|
||||
},
|
||||
);
|
||||
const refreshToken = await this.jwtService.signAsync(
|
||||
{ sub: user.id },
|
||||
{ sub: user.id, ver },
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn: (this.config.get<string>('JWT_REFRESH_TTL') ??
|
||||
@@ -174,6 +187,23 @@ export class AuthService {
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
// 세션 폐기 — refresh 토큰을 검증해 사용자의 tokenVersion 을 증가(발급된 모든 refresh 무효화).
|
||||
// 로그아웃 시 호출. 무효/만료 토큰이면 폐기할 세션이 없으므로 조용히 무시한다.
|
||||
async revokeSession(refreshToken: string | undefined): Promise<void> {
|
||||
if (!refreshToken) return;
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(
|
||||
refreshToken,
|
||||
{ secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET') },
|
||||
);
|
||||
if (payload?.sub) {
|
||||
await this.userService.incrementTokenVersion(payload.sub);
|
||||
}
|
||||
} catch {
|
||||
// 무효/만료 refresh 토큰 — 무시
|
||||
}
|
||||
}
|
||||
|
||||
// 인증 토큰 생성·저장 후 인증 메일(현재 로그) 발송
|
||||
private async sendVerification(user: User): Promise<void> {
|
||||
const rawToken = randomBytes(32).toString('hex');
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { Request } from 'express';
|
||||
|
||||
// OAuth CSRF 방지용 state 저장소 — 세션을 쓰지 않는 무상태(JWT) 구조라
|
||||
// passport-oauth2 의 기본 SessionStore 대신 httpOnly 쿠키에 1회용 난수 state 를 보관한다.
|
||||
// 인가 시작 시 store() 로 쿠키 발급 + state 반환(인가 URL 에 부착), 콜백에서 verify() 로
|
||||
// 쿠키와 제공자 반환 state 일치를 확인한다(불일치 시 인증 거부).
|
||||
const STATE_COOKIE = 'oauth_state';
|
||||
const STATE_TTL_MS = 10 * 60 * 1000; // 10분
|
||||
|
||||
type StoreCallback = (err: Error | null, state?: string) => void;
|
||||
type VerifyCallback = (
|
||||
err: Error | null,
|
||||
ok?: boolean,
|
||||
info?: { message: string },
|
||||
) => void;
|
||||
|
||||
function cookieOptions() {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProd,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/api/auth',
|
||||
maxAge: STATE_TTL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export class CookieStateStore {
|
||||
// passport 버전에 따라 (req, cb) 또는 (req, meta, cb) 로 호출되므로 마지막 인자를 콜백으로 사용
|
||||
store(req: Request, ...rest: unknown[]): void {
|
||||
const cb = rest[rest.length - 1] as StoreCallback;
|
||||
const state = randomBytes(16).toString('hex');
|
||||
req.res?.cookie(STATE_COOKIE, state, cookieOptions());
|
||||
cb(null, state);
|
||||
}
|
||||
|
||||
verify(req: Request, providedState: string, ...rest: unknown[]): void {
|
||||
const cb = rest[rest.length - 1] as VerifyCallback;
|
||||
const cookies = req.cookies as Record<string, string> | undefined;
|
||||
const expected = cookies?.[STATE_COOKIE];
|
||||
// 1회용 — 검증 직후 쿠키 제거
|
||||
req.res?.clearCookie(STATE_COOKIE, { path: '/api/auth' });
|
||||
if (!expected || expected !== providedState) {
|
||||
cb(null, false, { message: 'OAuth state 불일치' });
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Strategy, type Profile } from 'passport-google-oauth20';
|
||||
import {
|
||||
Strategy,
|
||||
type Profile,
|
||||
type StrategyOptions,
|
||||
} from 'passport-google-oauth20';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { CookieStateStore } from './cookie-state.store';
|
||||
import type { PublicUser } from '../../user/user.service';
|
||||
|
||||
// 구글 OAuth 전략 — 콜백에서 프로필을 정규화해 AuthService 로 위임.
|
||||
@@ -13,12 +18,15 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
config: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
super({
|
||||
// store: 쿠키 기반 state 저장소(OAuth CSRF 방지)
|
||||
const options: StrategyOptions & { store: CookieStateStore } = {
|
||||
clientID: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
|
||||
clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
|
||||
callbackURL: config.getOrThrow<string>('GOOGLE_CALLBACK_URL'),
|
||||
scope: ['email', 'profile'],
|
||||
});
|
||||
store: new CookieStateStore(),
|
||||
};
|
||||
super(options);
|
||||
}
|
||||
|
||||
// 성공 시 PublicUser 반환 → @nestjs/passport 가 req.user 에 주입
|
||||
@@ -30,9 +38,13 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
const email = profile.emails?.[0]?.value ?? null;
|
||||
const name = profile.displayName || email?.split('@')[0] || '구글 사용자';
|
||||
const avatarUrl = profile.photos?.[0]?.value ?? null;
|
||||
// 구글이 이메일 소유를 검증했는지(email_verified)
|
||||
const json = profile._json as { email_verified?: boolean } | undefined;
|
||||
const emailVerified = json?.email_verified === true;
|
||||
return this.authService.validateOrCreateOAuthUser({
|
||||
provider: 'google',
|
||||
email,
|
||||
emailVerified,
|
||||
name,
|
||||
avatarUrl,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,11 @@ export class JwtRefreshStrategy extends PassportStrategy(
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
// 토큰 버전 불일치 = 로그아웃/비밀번호 변경으로 폐기된 토큰 → 거부.
|
||||
// (버전 미포함 구토큰은 0 으로 간주해 기존 세션 호환)
|
||||
if ((payload.ver ?? 0) !== user.tokenVersion) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return UserService.toPublic(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Strategy, type KakaoProfile } from 'passport-kakao';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { CookieStateStore } from './cookie-state.store';
|
||||
import type { PublicUser } from '../../user/user.service';
|
||||
|
||||
// 카카오 OAuth 전략 — 콜백 프로필을 정규화해 AuthService 로 위임.
|
||||
@@ -17,6 +18,8 @@ export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
|
||||
clientID: config.getOrThrow<string>('KAKAO_CLIENT_ID'),
|
||||
clientSecret: config.get<string>('KAKAO_CLIENT_SECRET') ?? '',
|
||||
callbackURL: config.getOrThrow<string>('KAKAO_CALLBACK_URL'),
|
||||
// store: 쿠키 기반 state 저장소(OAuth CSRF 방지)
|
||||
store: new CookieStateStore(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +31,8 @@ export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
|
||||
): Promise<PublicUser> {
|
||||
const account = profile._json?.kakao_account;
|
||||
const email = account?.email ?? null;
|
||||
// 카카오가 이메일 보유+검증을 확인했는지(is_email_verified)
|
||||
const emailVerified = account?.is_email_verified === true;
|
||||
const name =
|
||||
account?.profile?.nickname ||
|
||||
profile._json?.properties?.nickname ||
|
||||
@@ -41,6 +46,7 @@ export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
|
||||
return this.authService.validateOrCreateOAuthUser({
|
||||
provider: 'kakao',
|
||||
email,
|
||||
emailVerified,
|
||||
name,
|
||||
avatarUrl,
|
||||
});
|
||||
|
||||
@@ -4,4 +4,6 @@ export interface JwtPayload {
|
||||
sub: string;
|
||||
// 이메일 (access 토큰에만 포함)
|
||||
email?: string;
|
||||
// refresh 토큰 버전 (refresh 토큰에만 포함) — User.tokenVersion 과 일치해야 유효
|
||||
ver?: number;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ declare module 'passport-kakao' {
|
||||
id: number;
|
||||
kakao_account?: {
|
||||
email?: string;
|
||||
// 카카오 이메일 보유/검증 플래그 — 미검증 이메일은 신뢰하지 않는다
|
||||
has_email?: boolean;
|
||||
is_email_verified?: boolean;
|
||||
profile?: {
|
||||
nickname?: string;
|
||||
profile_image_url?: string;
|
||||
@@ -26,6 +29,8 @@ declare module 'passport-kakao' {
|
||||
clientID: string;
|
||||
clientSecret?: string;
|
||||
callbackURL: string;
|
||||
// passport-oauth2 호환 state 저장소(OAuth CSRF 방지)
|
||||
store?: unknown;
|
||||
}
|
||||
|
||||
export type VerifyCallback = (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// 메일 발송 서비스 (플레이스홀더) —
|
||||
// 현재는 실제 SMTP 전송 대신 백엔드 로그로 대체한다.
|
||||
@@ -7,8 +8,18 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
|
||||
// 가입 인증 메일 — 사용자가 클릭할 인증 링크를 로그로 출력한다.
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
// 가입 인증 메일 — 개발 환경에서만 인증 링크(원문 토큰 포함)를 로그로 출력한다.
|
||||
// 운영 환경에서는 토큰이 로그에 남지 않도록 수신자만 기록한다(실 메일러로 교체 전제).
|
||||
sendVerificationEmail(to: string, name: string, verifyUrl: string): void {
|
||||
const isProd = this.config.get<string>('NODE_ENV') === 'production';
|
||||
if (isProd) {
|
||||
this.logger.log(
|
||||
`[메일 발송 - 가입 인증] 수신자=${to} (인증 링크는 보안상 로그에 남기지 않음 — 실 메일러 필요)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.log(
|
||||
[
|
||||
'',
|
||||
|
||||
@@ -44,8 +44,10 @@ import { ApprovalChangesDto, ApprovalRequestDto } from './dto/approval.dto';
|
||||
import {
|
||||
MAX_FILE_SIZE,
|
||||
UPLOAD_DIR,
|
||||
fileMimeFilter,
|
||||
type UploadedDiskFile,
|
||||
} from './upload.config';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
|
||||
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번)
|
||||
@ApiTags('Task')
|
||||
@@ -193,6 +195,7 @@ export class TaskController {
|
||||
FileInterceptor('file', {
|
||||
dest: UPLOAD_DIR,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: fileMimeFilter,
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@@ -204,6 +207,14 @@ export class TaskController {
|
||||
@UploadedFile() file: UploadedDiskFile,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<AttachmentResponse> {
|
||||
// 파일 누락 또는 허용되지 않는 형식(fileFilter 가 걸러 file 미존재)
|
||||
if (!file) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'업로드할 수 없는 파일 형식이거나 파일이 없습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return this.taskService.addFile(repoId, user.id, taskId, file);
|
||||
}
|
||||
|
||||
@@ -238,11 +249,13 @@ export class TaskController {
|
||||
taskId,
|
||||
fileId,
|
||||
);
|
||||
// 표준 응답 래퍼를 거치지 않고 바이너리를 직접 스트리밍(@Res)
|
||||
// 표준 응답 래퍼를 거치지 않고 바이너리를 직접 스트리밍(@Res).
|
||||
// attachment(다운로드 강제) + nosniff 로 브라우저 인라인 렌더링/스니핑 차단(저장형 XSS 방지)
|
||||
res.setHeader('Content-Type', attachment.mime);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`inline; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
|
||||
);
|
||||
res.sendFile(absolutePath);
|
||||
}
|
||||
|
||||
@@ -782,9 +782,16 @@ export class TaskService {
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
await this.assertMember(repo.id, actingUserId);
|
||||
const membership = await this.assertMember(repo.id, actingUserId);
|
||||
const task = await this.getTaskOrThrow(repo.id, seq);
|
||||
const attachment = await this.getAttachmentOrThrow(task.id, fileId);
|
||||
// 업로더 본인 또는 관리자만 삭제 가능 — 멤버라도 타인의 첨부는 지울 수 없음
|
||||
if (
|
||||
attachment.uploader?.id !== actingUserId &&
|
||||
membership.roleType !== 'admin'
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
const fileName = attachment.name; // 삭제 전 캡처
|
||||
await this.attachmentRepo.remove(attachment);
|
||||
await this.deleteFileFromDisk(attachment.storedName);
|
||||
@@ -1109,6 +1116,7 @@ export class TaskService {
|
||||
): Promise<Attachment> {
|
||||
const attachment = await this.attachmentRepo.findOne({
|
||||
where: { id: fileId, task: { id: taskId } },
|
||||
relations: ['uploader'],
|
||||
});
|
||||
if (!attachment) {
|
||||
throw new NotFoundException();
|
||||
|
||||
@@ -13,6 +13,40 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
// 첨부 1건당 최대 크기(25MB)
|
||||
export const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
// 허용 MIME 화이트리스트 — 실행 가능/스크립트(예: text/html, image/svg+xml) 차단.
|
||||
// 다운로드는 attachment + nosniff 로 제공하지만, 업로드 단계에서도 1차로 막는다(심층 방어).
|
||||
export const ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
// 이미지
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
// 문서
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
// 압축
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-rar-compressed',
|
||||
]);
|
||||
|
||||
// multer fileFilter — 허용 MIME 만 통과. 거부 시 파일이 저장되지 않아 컨트롤러가 400 처리.
|
||||
export function fileMimeFilter(
|
||||
_req: unknown,
|
||||
file: { mimetype: string },
|
||||
cb: (error: Error | null, acceptFile: boolean) => void,
|
||||
): void {
|
||||
cb(null, ALLOWED_MIME_TYPES.has(file.mimetype));
|
||||
}
|
||||
|
||||
// multer(diskStorage) 가 채워주는 업로드 파일 메타 — @types/multer 의존 없이 필요한 필드만 정의
|
||||
export interface UploadedDiskFile {
|
||||
originalname: string; // 원본 파일명(비ASCII 는 latin1 인코딩으로 들어옴)
|
||||
|
||||
@@ -66,6 +66,11 @@ export class User {
|
||||
@Column({ name: 'avatar_url', type: 'varchar', nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
|
||||
// refresh 토큰 버전 — refresh JWT payload 에 함께 서명되며, 로그아웃·비밀번호 변경 시 증가시켜
|
||||
// 발급된 모든 refresh 토큰을 일괄 무효화한다(서버측 세션 폐기).
|
||||
@Column({ name: 'token_version', type: 'int', default: 0 })
|
||||
tokenVersion!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -146,6 +146,11 @@ export class UserService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
// refresh 토큰 버전 증가 — 발급된 모든 refresh 토큰 무효화(로그아웃·비밀번호 변경 시)
|
||||
async incrementTokenVersion(userId: string): Promise<void> {
|
||||
await this.userRepository.increment({ id: userId }, 'tokenVersion', 1);
|
||||
}
|
||||
|
||||
// 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거)
|
||||
static toPublic(user: User): PublicUser {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user