generated from ttipo/project-base
feat(backend): 소셜 로그인·JWT 인증과 회원·파일 API 추가
앱의 기록을 서버로 옮기려면 회원과 인증이 먼저 있어야 한다. - 소셜 로그인: 앱이 SDK 로 받은 토큰을 서버가 제공자에게 직접 확인한다 (카카오·네이버는 사용자 정보 API, 구글·Apple 은 id_token JWKS 검증) - 소셜 앱 키가 생기기 전까지 쓰는 개발용 로그인(기기 키) — 운영에서는 닫힌다 - 액세스 JWT(1시간) + 리프레시 토큰(해시 저장·회전·재사용 탐지) - 전역 JwtAuthGuard — 기본으로 모든 라우트가 로그인을 요구하고 @Public() 으로만 연다 - 내 정보 조회·수정·탈퇴(모든 기록과 파일 정리), 파일 소유자 기록과 내려받기, 업로드 시 매직 바이트로 내용 형식 검사 - 컬럼은 snake_case 이름 규칙, 테스트 환경은 기동 시 마이그레이션 실행 (CI e2e 는 빈 DB) - 템플릿 예제 사용자 API(in-memory)를 실제 회원 API 로 교체하고 e2e 계약 테스트를 옮겼다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { join } from 'path';
|
||||
import { DataSourceOptions } from 'typeorm';
|
||||
|
||||
import { SnakeNamingStrategy } from '@/shared/config/snake-naming.strategy';
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,15 @@ export function isProductionEnv(): boolean {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
* 기동할 때 미적용 마이그레이션을 실행할지.
|
||||
* 운영과 테스트(e2e — 빈 DB 에서 시작한다)는 자동으로 실행하고,
|
||||
* 개발은 `migration:run` 으로 직접 적용한다 (생성한 SQL 을 확인한 뒤 적용하도록).
|
||||
*/
|
||||
function shouldRunMigrations(): boolean {
|
||||
return isProductionEnv() || process.env.NODE_ENV === 'test';
|
||||
}
|
||||
|
||||
/**
|
||||
* 스키마 자동 동기화 사용 여부.
|
||||
*
|
||||
@@ -69,9 +79,10 @@ export function buildDatabaseOptions(): DataSourceOptions {
|
||||
password: process.env.DB_PASSWORD ?? DEV_DB_DEFAULTS.password,
|
||||
database: process.env.DB_NAME ?? DEV_DB_DEFAULTS.database,
|
||||
synchronize: shouldSynchronize(),
|
||||
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
|
||||
migrationsRun: isProduction,
|
||||
migrationsRun: shouldRunMigrations(),
|
||||
logging: !isProduction,
|
||||
// 컬럼은 snake_case, 엔티티 속성은 camelCase
|
||||
namingStrategy: new SnakeNamingStrategy(),
|
||||
extra: { ...POOL_OPTIONS },
|
||||
entities: paths.entities,
|
||||
migrations: paths.migrations,
|
||||
|
||||
@@ -12,6 +12,7 @@ const prod = (extra: Record<string, string> = {}) => ({
|
||||
DB_USER: 'app_user',
|
||||
DB_PASSWORD: 'a-real-secret',
|
||||
DB_NAME: 'project_db',
|
||||
JWT_SECRET: 'a-production-secret-that-is-long-enough-123',
|
||||
...extra,
|
||||
});
|
||||
|
||||
@@ -27,6 +28,18 @@ describe('validateEnv', () => {
|
||||
).toThrow(/PORT 는 양의 정수/);
|
||||
});
|
||||
|
||||
it('토큰 만료 기간 형식이 틀리면 실패한다', () => {
|
||||
expect(() =>
|
||||
validateEnv({
|
||||
NODE_ENV: 'development',
|
||||
JWT_ACCESS_EXPIRES_IN: '1시간',
|
||||
}),
|
||||
).toThrow(/JWT_ACCESS_EXPIRES_IN 는 15m · 1h · 14d/);
|
||||
expect(() =>
|
||||
validateEnv({ NODE_ENV: 'development', JWT_REFRESH_EXPIRES_IN: '14d' }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('빈 문자열은 통과한다 (compose 가 미정의 변수를 빈 값으로 주입한다)', () => {
|
||||
expect(() =>
|
||||
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
|
||||
@@ -39,6 +52,15 @@ describe('validateEnv', () => {
|
||||
expect(() => validateEnv(prod())).not.toThrow();
|
||||
});
|
||||
|
||||
it('JWT_SECRET 이 없거나 짧으면 실패한다', () => {
|
||||
expect(() => validateEnv(omit(prod(), 'JWT_SECRET'))).toThrow(
|
||||
/JWT_SECRET 가 설정되지 않았습니다/,
|
||||
);
|
||||
expect(() => validateEnv(prod({ JWT_SECRET: 'short-secret' }))).toThrow(
|
||||
/JWT_SECRET 는 32자 이상/,
|
||||
);
|
||||
});
|
||||
|
||||
it('필수 DB 설정이 없으면 실패한다', () => {
|
||||
expect(() => validateEnv(omit(prod(), 'DB_PASSWORD'))).toThrow(
|
||||
/DB_PASSWORD 가 설정되지 않았습니다/,
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
|
||||
*/
|
||||
|
||||
import { parseDurationMs } from '@/shared/utils/duration';
|
||||
|
||||
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
|
||||
const PLACEHOLDER_PATTERNS = [/change[-_]?me/i, /^password$/i, /^secret$/i];
|
||||
|
||||
@@ -16,6 +18,17 @@ const REQUIRED_IN_PRODUCTION = [
|
||||
'DB_USER',
|
||||
'DB_PASSWORD',
|
||||
'DB_NAME',
|
||||
// 비워 두면 개발용 고정 키로 서명하게 된다 — 운영에서는 기동을 막는다 (modules/auth/auth.config.ts)
|
||||
'JWT_SECRET',
|
||||
] as const;
|
||||
|
||||
/** 운영 JWT 서명 키 최소 길이 — 짧은 키는 무차별 대입으로 풀린다 */
|
||||
const JWT_SECRET_MIN_LENGTH = 32;
|
||||
|
||||
/** 값이 있다면 `15m`·`1h`·`14d` 형식이어야 하는 키 */
|
||||
const DURATION_KEYS = [
|
||||
'JWT_ACCESS_EXPIRES_IN',
|
||||
'JWT_REFRESH_EXPIRES_IN',
|
||||
] as const;
|
||||
|
||||
/** 값이 있다면 양의 정수여야 하는 키 */
|
||||
@@ -95,6 +108,16 @@ export function validateEnv(
|
||||
}
|
||||
}
|
||||
|
||||
// 기간 형식 — 틀리면 토큰 만료가 조용히 기본값이 되므로 기동 시점에 막는다
|
||||
for (const key of DURATION_KEYS) {
|
||||
const value = readString(config, key);
|
||||
if (value !== undefined && parseDurationMs(value) === null) {
|
||||
errors.push(
|
||||
`${key} 는 15m · 1h · 14d 같은 기간 형식이어야 합니다. (현재 값: "${value}")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isProduction) {
|
||||
// 2. 운영 필수값 — 없으면 기동 실패
|
||||
for (const key of REQUIRED_IN_PRODUCTION) {
|
||||
@@ -113,6 +136,13 @@ export function validateEnv(
|
||||
}
|
||||
}
|
||||
|
||||
const jwtSecret = readString(config, 'JWT_SECRET');
|
||||
if (jwtSecret !== undefined && jwtSecret.length < JWT_SECRET_MIN_LENGTH) {
|
||||
errors.push(
|
||||
`JWT_SECRET 는 ${JWT_SECRET_MIN_LENGTH}자 이상이어야 합니다. (운영 환경)`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
|
||||
const allowLocalhostUrl =
|
||||
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
|
||||
|
||||
import { snakeCase } from '@/shared/utils/case';
|
||||
|
||||
/**
|
||||
* 엔티티 속성(camelCase)을 DB 컬럼(snake_case)으로 바꾸는 이름 규칙.
|
||||
* Postgres 는 대문자가 섞인 이름을 따옴표로 감싸야 해서, 컬럼은 snake_case 로 통일한다.
|
||||
* 응답(JSON)은 엔티티 속성 이름 그대로 camelCase 다 (.claude/rules/api-design.md).
|
||||
*/
|
||||
export class SnakeNamingStrategy
|
||||
extends DefaultNamingStrategy
|
||||
implements NamingStrategyInterface
|
||||
{
|
||||
override tableName(className: string, customName?: string): string {
|
||||
return customName ?? snakeCase(className);
|
||||
}
|
||||
|
||||
override columnName(
|
||||
propertyName: string,
|
||||
customName: string | undefined,
|
||||
embeddedPrefixes: string[],
|
||||
): string {
|
||||
const prefix = embeddedPrefixes.map((p) => `${snakeCase(p)}_`).join('');
|
||||
return prefix + (customName ?? snakeCase(propertyName));
|
||||
}
|
||||
|
||||
override relationName(propertyName: string): string {
|
||||
return snakeCase(propertyName);
|
||||
}
|
||||
|
||||
override joinColumnName(
|
||||
relationName: string,
|
||||
referencedColumnName: string,
|
||||
): string {
|
||||
return snakeCase(`${relationName}_${referencedColumnName}`);
|
||||
}
|
||||
|
||||
override joinTableName(
|
||||
firstTableName: string,
|
||||
secondTableName: string,
|
||||
firstPropertyName: string,
|
||||
): string {
|
||||
return snakeCase(
|
||||
`${firstTableName}_${firstPropertyName.replace(/\./g, '_')}_${secondTableName}`,
|
||||
);
|
||||
}
|
||||
|
||||
override joinTableColumnName(
|
||||
tableName: string,
|
||||
propertyName: string,
|
||||
columnName?: string,
|
||||
): string {
|
||||
return snakeCase(`${tableName}_${columnName ?? propertyName}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { snakeCase } from './case';
|
||||
|
||||
describe('snakeCase', () => {
|
||||
it('camelCase 를 snake_case 로 바꾼다', () => {
|
||||
expect(snakeCase('photoFileId')).toBe('photo_file_id');
|
||||
expect(snakeCase('createdAt')).toBe('created_at');
|
||||
});
|
||||
|
||||
it('PascalCase 와 연속 대문자도 단어 단위로 나눈다', () => {
|
||||
expect(snakeCase('RefreshToken')).toBe('refresh_token');
|
||||
expect(snakeCase('HTTPServer')).toBe('http_server');
|
||||
});
|
||||
|
||||
it('이미 snake_case 면 그대로 둔다', () => {
|
||||
expect(snakeCase('user_id')).toBe('user_id');
|
||||
});
|
||||
|
||||
it('점·하이픈은 밑줄로 바꾼다 (임베디드 접두어)', () => {
|
||||
expect(snakeCase('archive.from')).toBe('archive_from');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// 문자열 표기 변환 — 부수효과 없는 순수 함수
|
||||
|
||||
/**
|
||||
* camelCase · PascalCase 를 snake_case 로 바꾼다.
|
||||
* DB 컬럼·테이블 이름에 쓴다 (예: `photoFileId` → `photo_file_id`, `HTTPServer` → `http_server`).
|
||||
*/
|
||||
export function snakeCase(value: string): string {
|
||||
return value
|
||||
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z\d]+)/g, '$1_$2')
|
||||
.replace(/[\s.-]+/g, '_')
|
||||
.toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { decodeCursor, encodeCursor, keysetPage } from './cursor';
|
||||
|
||||
describe('커서 인코딩', () => {
|
||||
it('인코딩한 값을 그대로 되돌린다', () => {
|
||||
const cursor = encodeCursor(['2026-10-11T00:00:00.000Z', 3, 'a1b2']);
|
||||
|
||||
expect(decodeCursor(cursor, 3)).toEqual([
|
||||
'2026-10-11T00:00:00.000Z',
|
||||
3,
|
||||
'a1b2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('커서는 URL 에 그대로 넣을 수 있는 문자만 쓴다', () => {
|
||||
expect(encodeCursor(['한글 값', '?&='])).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('키 개수가 다르면 null 이다', () => {
|
||||
expect(decodeCursor(encodeCursor(['a']), 2)).toBeNull();
|
||||
});
|
||||
|
||||
it('해석할 수 없는 값이면 null 이다', () => {
|
||||
expect(decodeCursor('not-a-cursor', 1)).toBeNull();
|
||||
expect(decodeCursor(encodeCursor([]), 0)).toEqual([]);
|
||||
expect(
|
||||
decodeCursor(Buffer.from('{"a":1}').toString('base64url'), 1),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeCursor(Buffer.from('[{"a":1}]').toString('base64url'), 1),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keysetPage', () => {
|
||||
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
|
||||
it('limit 보다 하나 더 읽혔으면 다음 페이지가 있다', () => {
|
||||
const page = keysetPage(rows, 2, (row) => [row.id]);
|
||||
|
||||
expect(page.items).toEqual([{ id: 'a' }, { id: 'b' }]);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(decodeCursor(page.nextCursor ?? '', 1)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('마지막 페이지면 nextCursor 가 null 이다', () => {
|
||||
const page = keysetPage(rows, 3, (row) => [row.id]);
|
||||
|
||||
expect(page.items).toHaveLength(3);
|
||||
expect(page.hasNext).toBe(false);
|
||||
expect(page.nextCursor).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// 커서 페이지네이션 — 부수효과 없는 순수 함수
|
||||
|
||||
/** 커서에 담는 정렬 키 하나 (예: 생성 시각 ISO 문자열, 정렬 순서, id) */
|
||||
export type CursorValue = string | number;
|
||||
|
||||
/**
|
||||
* 정렬 키 목록을 불투명한 커서 문자열로 만든다.
|
||||
* 클라이언트는 값을 해석하지 않고 다음 요청에 그대로 돌려준다.
|
||||
*/
|
||||
export function encodeCursor(values: readonly CursorValue[]): string {
|
||||
return Buffer.from(JSON.stringify(values), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서를 정렬 키 목록으로 되돌린다.
|
||||
* 형식이 틀렸거나 키 개수가 [arity] 와 다르면 null — 호출한 쪽이 VAL_001 로 응답한다.
|
||||
*/
|
||||
export function decodeCursor(
|
||||
cursor: string,
|
||||
arity: number,
|
||||
): CursorValue[] | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||
);
|
||||
if (!Array.isArray(parsed) || parsed.length !== arity) return null;
|
||||
|
||||
const values: CursorValue[] = [];
|
||||
for (const value of parsed as unknown[]) {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
||||
values.push(value);
|
||||
}
|
||||
return values;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** [limit] + 1 개를 읽어 온 결과를 한 페이지와 다음 커서로 나눈다 */
|
||||
export function keysetPage<T>(
|
||||
rows: readonly T[],
|
||||
limit: number,
|
||||
keyOf: (row: T) => readonly CursorValue[],
|
||||
): { items: T[]; hasNext: boolean; nextCursor: string | null } {
|
||||
const hasNext = rows.length > limit;
|
||||
const items = rows.slice(0, limit);
|
||||
const last = items.at(-1);
|
||||
|
||||
return {
|
||||
items,
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last !== undefined ? encodeCursor(keyOf(last)) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
|
||||
/** Postgres 오류 코드 — https://www.postgresql.org/docs/current/errcodes-appendix.html */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
const PG_FOREIGN_KEY_VIOLATION = '23503';
|
||||
|
||||
function pgCodeOf(error: unknown): string | null {
|
||||
if (!(error instanceof QueryFailedError)) return null;
|
||||
const driverError: unknown = error.driverError;
|
||||
if (
|
||||
typeof driverError === 'object' &&
|
||||
driverError !== null &&
|
||||
'code' in driverError &&
|
||||
typeof driverError.code === 'string'
|
||||
) {
|
||||
return driverError.code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 유니크 제약 위반 — 같은 값이 동시에 들어온 경우 */
|
||||
export function isUniqueViolation(error: unknown): boolean {
|
||||
return pgCodeOf(error) === PG_UNIQUE_VIOLATION;
|
||||
}
|
||||
|
||||
/** 외래 키 위반 — 아직 다른 행이 참조하고 있는 행을 지우려 한 경우 등 */
|
||||
export function isForeignKeyViolation(error: unknown): boolean {
|
||||
return pgCodeOf(error) === PG_FOREIGN_KEY_VIOLATION;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { parseDurationMs } from './duration';
|
||||
|
||||
describe('parseDurationMs', () => {
|
||||
it('단위별로 밀리초를 계산한다', () => {
|
||||
expect(parseDurationMs('500ms')).toBe(500);
|
||||
expect(parseDurationMs('30s')).toBe(30_000);
|
||||
expect(parseDurationMs('15m')).toBe(900_000);
|
||||
expect(parseDurationMs('1h')).toBe(3_600_000);
|
||||
expect(parseDurationMs('14d')).toBe(1_209_600_000);
|
||||
});
|
||||
|
||||
it('앞뒤 공백은 무시한다', () => {
|
||||
expect(parseDurationMs(' 1h ')).toBe(3_600_000);
|
||||
});
|
||||
|
||||
it('형식이 틀리거나 0 이면 null 이다', () => {
|
||||
expect(parseDurationMs(undefined)).toBeNull();
|
||||
expect(parseDurationMs('')).toBeNull();
|
||||
expect(parseDurationMs('1 hour')).toBeNull();
|
||||
expect(parseDurationMs('h')).toBeNull();
|
||||
expect(parseDurationMs('0d')).toBeNull();
|
||||
expect(parseDurationMs('-1h')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// 기간 문자열 파싱 — 부수효과 없는 순수 함수
|
||||
|
||||
const UNIT_TO_MS: Readonly<Record<string, number>> = {
|
||||
ms: 1,
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
};
|
||||
|
||||
const DURATION_PATTERN = /^(\d+)(ms|s|m|h|d)$/;
|
||||
|
||||
/**
|
||||
* `15m` · `1h` · `14d` 같은 기간 문자열을 밀리초로 바꾼다.
|
||||
* 형식이 틀리거나 0 이하면 null 을 돌려준다 (호출한 쪽이 기본값·검증 실패를 정한다).
|
||||
*/
|
||||
export function parseDurationMs(value: string | undefined): number | null {
|
||||
if (value === undefined) return null;
|
||||
const match = DURATION_PATTERN.exec(value.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const amount = Number(match[1]);
|
||||
if (amount <= 0) return null;
|
||||
return amount * UNIT_TO_MS[match[2]];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
|
||||
/** SHA-256 해시 (hex) — 토큰처럼 원문을 저장하면 안 되는 값을 찾을 때 쓴다 */
|
||||
export function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/** 추측할 수 없는 무작위 문자열 (base64url) */
|
||||
export function randomToken(bytes: number): string {
|
||||
return randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
Reference in New Issue
Block a user