Skip to content

Commit 64cefef

Browse files
SOIVclaude
andcommitted
feat(core,api): Phase 1.9.3 인증 백엔드 구현 완료
packages/core: - auth/services: UserAuthService, JwtSessionManagerImpl, WhitelistServiceImpl, AdminPinServiceImpl, TotpServiceImpl, hashPassword/verifyPassword 구현 - 의존성 추가: bcryptjs, jsonwebtoken, otplib apps/api: - POST /auth/login (whitelist 검사 → 비밀번호 검증 → TOTP 분기) - POST /auth/totp/verify|enroll|confirm (TOTP 챌린지 완료 / 등록) - POST /auth/pin/verify (관리자 PIN 검증) - POST /auth/password/change (임시 비밀번호 강제 변경) - POST /auth/password/recovery/issue|confirm (관리자 토큰 발급 / 복구) - POST /auth/refresh (리프레시 토큰 회전) - POST /auth/logout (세션 폐기) - requireAuth 미들웨어 (Bearer JWT 검증) - DB 마이그레이션: 001_auth_schema.sql (users, sessions, totp, whitelist, admin_pin, recovery_tokens) - env: JWT_SECRET, JWT_REFRESH_SECRET, TOTP_ISSUER 추가 - CJS/ESM 혼용 해결: import type with { "resolution-mode": "import" } Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ee4d183 commit 64cefef

18 files changed

Lines changed: 995 additions & 20 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ DATABASE_URL=postgresql://fieldstack:fieldstack@localhost:5432/fieldstack
1010
# SQLite (경량 단독 인스턴스용)
1111
# DB_PROVIDER=sqlite
1212
# SQLITE_PATH=./data/database.db
13+
14+
# ── Auth ─────────────────────────────────────────────────────
15+
# 프로덕션에서 반드시 32자 이상의 랜덤 문자열로 교체
16+
# JWT_SECRET=change-me-to-a-random-32-char-secret
17+
# JWT_REFRESH_SECRET=change-me-to-another-random-secret
18+
TOTP_ISSUER=Fieldstack

apps/api/src/app.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,36 @@
1-
import express from 'express';
1+
import path from 'node:path';
2+
23
import cors from 'cors';
4+
import express from 'express';
35

4-
import { healthRouter } from './routes/health';
6+
import type {
7+
AdminPinServiceImpl,
8+
DbProvider,
9+
JwtSessionManagerImpl,
10+
TotpServiceImpl,
11+
UserAuthService,
12+
WhitelistServiceImpl,
13+
} from '@fieldstack/core' with { "resolution-mode": "import" };
14+
15+
import { validateEnv } from './config/env';
516
import { errorHandler } from './middleware/error';
17+
import { createAuthRouter } from './routes/auth';
18+
import { healthRouter } from './routes/health';
619

7-
export function createApp() {
20+
// ── App 팩토리 ────────────────────────────────────────────────
21+
22+
export interface AppServices {
23+
jwtManager: JwtSessionManagerImpl;
24+
whitelist: WhitelistServiceImpl;
25+
adminPin: AdminPinServiceImpl;
26+
totpService: TotpServiceImpl;
27+
userAuth: UserAuthService;
28+
}
29+
30+
export function createApp(services?: AppServices) {
831
const app = express();
932

1033
// ── Middleware ────────────────────────────────────────────────
11-
// 개발: 모든 origin 허용 / 프로덕션: 단일 포트 통합 서빙이므로 CORS 불필요
1234
if (process.env['NODE_ENV'] !== 'production') {
1335
app.use(cors());
1436
}
@@ -19,18 +41,57 @@ export function createApp() {
1941
// ── Core routes ───────────────────────────────────────────────
2042
app.use('/health', healthRouter);
2143

22-
// TODO(Phase 1.9.3): 인증 라우트 마운트 (POST /auth/login 등)
23-
// TODO(Phase 1.9.2): 모듈 라우트 마운트 (DB 초기화 완료 후)
44+
if (services) {
45+
app.use('/auth', createAuthRouter(services));
46+
}
2447

2548
// ── Error handler (반드시 마지막) ─────────────────────────────
2649
app.use(errorHandler);
2750

2851
return app;
2952
}
3053

31-
// ── DB 초기화 (서버 시작 시 호출) ────────────────────────────
54+
// ── DB 초기화 ────────────────────────────────────────────────
3255

33-
export async function initDb(): Promise<void> {
56+
export async function initDb(): Promise<DbProvider> {
3457
const { getDb } = await import('@fieldstack/core');
35-
await getDb();
58+
return getDb();
59+
}
60+
61+
// ── 마이그레이션 실행 ─────────────────────────────────────────
62+
63+
export async function runMigrations(db: DbProvider): Promise<void> {
64+
const { FileMigrationRunner } = await import('@fieldstack/core');
65+
const coreDir = path.join(__dirname, 'db', 'migrations', 'core');
66+
const runner = new FileMigrationRunner(db, 'core', coreDir);
67+
await runner.run();
68+
}
69+
70+
// ── 서비스 초기화 ─────────────────────────────────────────────
71+
72+
export async function initServices(db: DbProvider): Promise<AppServices> {
73+
const env = validateEnv(process.env);
74+
75+
const {
76+
JwtSessionManagerImpl,
77+
WhitelistServiceImpl,
78+
AdminPinServiceImpl,
79+
TotpServiceImpl,
80+
UserAuthService,
81+
} = await import('@fieldstack/core');
82+
83+
const accessSecret = env.JWT_SECRET ?? 'dev-access-secret-change-in-production';
84+
const refreshSecret = env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret-change-in-production';
85+
86+
if (env.NODE_ENV === 'production' && !env.JWT_SECRET) {
87+
throw new Error('[fieldstack][api] JWT_SECRET must be set in production');
88+
}
89+
90+
const jwtManager = new JwtSessionManagerImpl(db, accessSecret, refreshSecret);
91+
const whitelist = new WhitelistServiceImpl(db);
92+
const adminPin = new AdminPinServiceImpl(db);
93+
const totpService = new TotpServiceImpl(db, env.TOTP_ISSUER);
94+
const userAuth = new UserAuthService(db, jwtManager, whitelist, totpService);
95+
96+
return { jwtManager, whitelist, adminPin, totpService, userAuth };
3697
}

apps/api/src/config/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ const EnvSchema = z.object({
88
DB_PROVIDER: z.enum(['postgres', 'sqlite']).default('postgres'),
99
DATABASE_URL: z.string().url().optional(),
1010
SQLITE_PATH: z.string().optional(),
11+
// Auth
12+
JWT_SECRET: z.string().min(32).optional(),
13+
JWT_REFRESH_SECRET: z.string().min(32).optional(),
14+
TOTP_ISSUER: z.string().default('Fieldstack'),
1115
}).refine(
1216
(env) => env.DB_PROVIDER !== 'postgres' || Boolean(env.DATABASE_URL),
1317
{ message: 'DATABASE_URL is required when DB_PROVIDER=postgres', path: ['DATABASE_URL'] },
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
-- ── 001_auth_schema.sql ─────────────────────────────────────────
2+
-- Core 인증 스키마: users, sessions, totp, whitelist, admin_pin, recovery
3+
4+
CREATE TABLE IF NOT EXISTS users (
5+
id {{UUID_PRIMARY_KEY}},
6+
email TEXT NOT NULL UNIQUE,
7+
password_hash TEXT NOT NULL,
8+
is_temp_password BOOLEAN NOT NULL DEFAULT {{BOOLEAN_FALSE}},
9+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}},
10+
updated_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
11+
);
12+
13+
CREATE TABLE IF NOT EXISTS sessions (
14+
id UUID PRIMARY KEY,
15+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
16+
refresh_token_hash TEXT NOT NULL UNIQUE,
17+
expires_at TIMESTAMPTZ NOT NULL,
18+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
19+
);
20+
21+
CREATE TABLE IF NOT EXISTS totp_credentials (
22+
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
23+
secret TEXT NOT NULL,
24+
verified BOOLEAN NOT NULL DEFAULT {{BOOLEAN_FALSE}},
25+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
26+
);
27+
28+
CREATE TABLE IF NOT EXISTS totp_challenges (
29+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
30+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
31+
expires_at TIMESTAMPTZ NOT NULL,
32+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
33+
);
34+
35+
CREATE TABLE IF NOT EXISTS whitelist_rules (
36+
id {{UUID_PRIMARY_KEY}},
37+
type TEXT NOT NULL CHECK (type IN ('email', 'domain')),
38+
value TEXT NOT NULL,
39+
enabled BOOLEAN NOT NULL DEFAULT {{BOOLEAN_TRUE}},
40+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
41+
);
42+
43+
-- 관리자 PIN (단일 행 보장)
44+
CREATE TABLE IF NOT EXISTS admin_pin (
45+
id INT PRIMARY KEY DEFAULT 1,
46+
pin_hash TEXT NOT NULL,
47+
updated_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}},
48+
CHECK (id = 1)
49+
);
50+
51+
CREATE TABLE IF NOT EXISTS password_recovery_tokens (
52+
id {{UUID_PRIMARY_KEY}},
53+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
54+
token_hash TEXT NOT NULL UNIQUE,
55+
expires_at TIMESTAMPTZ NOT NULL,
56+
used BOOLEAN NOT NULL DEFAULT {{BOOLEAN_FALSE}},
57+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
58+
);

apps/api/src/index.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'dotenv/config';
22

33
import { validateEnv } from './config/env';
4-
import { createApp, initDb } from './app';
4+
import { createApp, initDb, initServices, runMigrations } from './app';
55

66
// ── 환경변수 검증 (누락·오류 시 즉시 종료) ────────────────────
77
const env = validateEnv(process.env);
@@ -17,13 +17,18 @@ if (env.INSTALL_MODE === 'bypass') {
1717
console.warn('[fieldstack][api] DEV INSTALL BYPASS ACTIVE');
1818
}
1919

20-
// ── DB 초기화 → 서버 시작 ─────────────────────────────────────
20+
// ── DB 초기화 → 마이그레이션 → 서비스 초기화 → 서버 시작 ─────
2121
async function start() {
22+
let services;
23+
2224
if (env.DB_PROVIDER === 'postgres' && env.DATABASE_URL) {
23-
await initDb();
25+
const db = await initDb();
26+
await runMigrations(db);
27+
services = await initServices(db);
28+
console.log('[fieldstack][api] DB initialized and migrations applied');
2429
}
2530

26-
const app = createApp();
31+
const app = createApp(services);
2732
app.listen(env.PORT, () => {
2833
console.log(`[fieldstack][api] server listening on http://localhost:${env.PORT}`);
2934
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { NextFunction, Request, Response } from 'express';
2+
3+
import type { JwtSessionManagerImpl } from '@fieldstack/core' with { "resolution-mode": "import" };
4+
5+
declare global {
6+
// eslint-disable-next-line @typescript-eslint/no-namespace
7+
namespace Express {
8+
interface Request {
9+
auth?: { userId: string; sessionId: string };
10+
}
11+
}
12+
}
13+
14+
export function requireAuth(jwtManager: JwtSessionManagerImpl) {
15+
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
16+
const header = req.headers.authorization;
17+
if (!header?.startsWith('Bearer ')) {
18+
res.status(401).json({ success: false, error: 'Unauthorized' });
19+
return;
20+
}
21+
22+
try {
23+
const token = header.slice(7);
24+
req.auth = await jwtManager.verifyAccessToken(token);
25+
next();
26+
} catch {
27+
res.status(401).json({ success: false, error: 'Invalid or expired token' });
28+
}
29+
};
30+
}

0 commit comments

Comments
 (0)