summaryrefslogtreecommitdiff
path: root/middleware.ts
blob: 2ff8408e507f0e153b612681225abc8538d76b58 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
export const runtime = 'nodejs';

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import acceptLanguage from 'accept-language';
import { getToken } from 'next-auth/jwt';

import { fallbackLng, languages, cookieName } from '@/i18n/settings';

acceptLanguage.languages(languages);

// 로그인이 필요 없는 공개 경로
const publicPaths = [
  '/evcp',
  '/procurement', 
  '/sales',
  '/engineering',
  '/partners',
  '/privacy',
  '/projects',
  '/partners/repository',
  '/partners/signup',
  '/partners/tech-signup',
  '/api/auth',
  '/spreadTest',
  '/auth/reset-password',
];

const landingPages = [
  '/', 
];

// 경로가 공개 경로인지 확인하는 함수
function isPublicPath(path: string, lng: string) {
  // 1. 언어별 루트 경로 체크 (예: /ko, /en)
  if (path === `/${lng}` || path === `/${lng}/`) {
    return true;
  }
  
  // 2. 랜딩 페이지들 체크
  if (landingPages.some(landingPage => {
    if (landingPage === '/') {
      return path === `/${lng}` || path === `/${lng}/`;
    }
    return path === `/${lng}${landingPage}` || path.startsWith(`/${lng}${landingPage}/`);
  })) {
    return true;
  }
  
  // 3. publicPaths 배열의 경로들과 매칭
  if (publicPaths.some(publicPath => {
    return path === `/${lng}${publicPath}`;
  })) {
    return true;
  }
  
  // 4. auth API는 별도 처리
  if (path.includes('/api/auth')) {
    return true;
  }
  
  return false;
}

// 도메인별 기본 대시보드 경로 정의
function getDashboardPath(domain: string, lng: string): string {
  switch (domain) {
    case 'pending':
      return `/${lng}/pending`;
    case 'evcp':
      return `/${lng}/evcp/report`;
    case 'procurement':
      return `/${lng}/procurement/dashboard`;
    case 'sales':
      return `/${lng}/sales/dashboard`;
    case 'engineering':
      return `/${lng}/engineering/dashboard`;
    case 'partners':
      return `/${lng}/partners/dashboard`;
    default:
      return `/${lng}/pending`; // 기본값
  }
}

// 도메인-URL 일치 여부 확인 및 올바른 리다이렉트 경로 반환
function getDomainRedirectPath(path: string, domain: string, lng: string) {
  // 도메인이 없는 경우 리다이렉트 없음
  if (!domain) return null;

  // 각 도메인 경로 패턴 확인 (trailing slash 문제 해결)
  const domainPatterns = {
    pending: `/pending`,
    evcp: `/evcp`,
    procurement: `/evcp`,
    sales: `/evcp`,
    engineering: `/evcp`,
    partners: `/partners`
  };

  // 현재 경로가 어떤 도메인 패턴에 속하는지 확인
  let currentPathDomain: string | null = null;
  for (const [domainName, pattern] of Object.entries(domainPatterns)) {
    // 정확한 매칭을 위해 언어 코드를 포함한 전체 패턴으로 확인
    const fullPattern = `/${lng}${pattern}`;
    if (path === fullPattern || path.startsWith(`${fullPattern}/`)) {
      currentPathDomain = domainName;
      break;
    }
  }

  // 도메인과 경로가 일치하지 않는 경우
  if (currentPathDomain && currentPathDomain !== domain) {
    // pending 사용자는 오직 pending 경로만 접근 가능
    if (domain === 'pending') {
      return getDashboardPath('pending', lng);
    }
    
    // 다른 도메인 사용자가 pending에 접근하려는 경우
    if (currentPathDomain === 'pending') {
      return getDashboardPath(domain, lng);
    }
    
    // 일반적인 도메인 불일치 처리
    const targetPattern = domainPatterns[domain as keyof typeof domainPatterns];
    if (targetPattern && currentPathDomain) {
      const sourcePattern = domainPatterns[currentPathDomain as keyof typeof domainPatterns];
      return path.replace(`/${lng}${sourcePattern}`, `/${lng}${targetPattern}`);
    }
  }

  // 일치하거나 처리할 수 없는 경우 null 반환
  return null;
}

// 세션 타임아웃 체크 함수
function checkSessionTimeout(token: any): { isExpired: boolean; isExpiringSoon: boolean } {
  if (!token?.sessionExpiredAt) {
    return { isExpired: false, isExpiringSoon: false };
  }

  const now = Date.now();
  const expiresAt = token.sessionExpiredAt;
  const timeUntilExpiry = expiresAt - now;
  const warningThreshold = 10 * 60 * 1000; // 10분

  return {
    isExpired: timeUntilExpiry <= 0,
    isExpiringSoon: timeUntilExpiry <= warningThreshold && timeUntilExpiry > 0
  };
}

// 실제 로그인 페이지인지 확인하는 함수 (pending 페이지 제외)
function isActualLoginPage(pathname: string, detectedLng: string): boolean {
  const actualLoginPages = [
    `/${detectedLng}/evcp`,
    `/${detectedLng}/procurement`, 
    `/${detectedLng}/sales`,
    `/${detectedLng}/engineering`,
    `/${detectedLng}/partners`,
    // pending은 로그인 페이지가 아니라 실제 대시보드이므로 제외
  ];
  
  return actualLoginPages.includes(pathname);
}

// 로그인 페이지 URL 생성 함수 (세션 만료 정보 포함)
function createLoginUrl(pathname: string, detectedLng: string, origin: string, request: NextRequest, reason?: string) {
  let loginPath;
  
  // 경로에 따라 적절한 로그인 페이지 선택
  if (pathname.includes('/partners') || pathname.startsWith(`/${detectedLng}/vendor`)) {
    loginPath = `/${detectedLng}/partners`;
  } else {
    // evcp, procurement, sales, engineering, pending 모두 evcp 로그인 사용
    // pending 페이지는 로그인 페이지가 아니라 실제 대시보드이므로 evcp 로그인으로 보냄
    loginPath = `/${detectedLng}/evcp`;
  }
  
  const redirectUrl = new URL(loginPath, origin);
  
  // 로그인 후 원래 페이지로 리다이렉트하기 위해 callbackUrl 추가
  redirectUrl.searchParams.set('callbackUrl', request.nextUrl.pathname + request.nextUrl.search);
  
  // 세션 만료 관련 정보 추가
  if (reason) {
    redirectUrl.searchParams.set('reason', reason);
    if (reason === 'expired') {
      redirectUrl.searchParams.set('message', '세션이 만료되었습니다. 다시 로그인해주세요.');
    }
  }
  
  return redirectUrl;
}

// 세션 쿠키 삭제 함수
function clearSessionCookies(response: NextResponse) {
  response.cookies.delete('next-auth.session-token');
  response.cookies.delete('__Secure-next-auth.session-token');
}

export async function middleware(request: NextRequest) {
  /**
   * 1. 쿠키에서 언어 가져오기
   */
  let lng = request.cookies.get(cookieName)?.value;

  /**
   * 2. 쿠키가 없다면 브라우저의 Accept-Language 헤더에서 언어를 추론
   */
  if (!lng) {
    const headerLang = request.headers.get('accept-language');
    lng = acceptLanguage.get(headerLang) || fallbackLng;
  }

  const { pathname, searchParams, origin } = request.nextUrl;

  /**
   * 3. "/" 경로로 들어온 경우 -> "/{lng}"로 리다이렉트
   */
  if (pathname === '/') {
    const redirectUrl = new URL(`/${lng}`, origin);
    redirectUrl.search = searchParams.toString();
    return NextResponse.redirect(redirectUrl);
  }

  /**
   * 4. 현재 pathname이 언어 경로를 포함하고 있는지 확인
   */
  const hasValidLngInPath = languages.some(
    (language) => pathname === `/${language}` || pathname.startsWith(`/${language}/`),
  );

  /**
   * 5. 언어 경로가 누락된 경우 -> "/{lng}" + 기존 pathname 으로 리다이렉트
   */
  if (!hasValidLngInPath) {
    const redirectUrl = new URL(`/${lng}${pathname}`, origin);
    redirectUrl.search = searchParams.toString();
    return NextResponse.redirect(redirectUrl);
  }

  // 언어 코드 추출
  const pathnameParts = pathname.split('/');
  const detectedLng = pathnameParts[1]; // 예: /ko/partners -> ko

  // 토큰 가져오기 (인증 상태 확인 및 도메인 검증에 사용)
  const token = await getToken({ req: request });

  /**
   * 6. 세션 타임아웃 체크 (인증된 사용자에 대해서만)
   */
  if (token && !isPublicPath(pathname, detectedLng)) {
    const { isExpired, isExpiringSoon } = checkSessionTimeout(token);
    
    if (isExpired) {
      console.log(`[Middleware.ts] Session expired in middleware for user ${token.email}`);
      const loginUrl = createLoginUrl(pathname, detectedLng, origin, request, 'expired');
      const response = NextResponse.redirect(loginUrl);
      clearSessionCookies(response);
      return response;
    }
  }

  /**
   * 7. 인증된 사용자의 도메인-URL 일치 확인 및 리다이렉션
   */
  if (token && token.domain && !isPublicPath(pathname, detectedLng)) {
    // 사용자의 domain과 URL 경로가 일치하는지 확인
    const redirectPath = getDomainRedirectPath(pathname, token.domain as string, detectedLng);

    // 도메인과 URL이 일치하지 않으면 리다이렉트
    if (redirectPath) {
      console.log("[Middleware.ts] redirectPath: ", redirectPath)
      const redirectUrl = new URL(redirectPath, origin);
      redirectUrl.search = searchParams.toString();
      return NextResponse.redirect(redirectUrl);
    }
  }

  /**
   * 8. 이미 로그인한 사용자가 실제 로그인 페이지에 접근할 경우 대시보드로 리다이렉트
   * (pending 페이지는 실제 대시보드이므로 제외)
   */
  if (token) {
    // 세션이 만료되지 않은 경우에만 대시보드로 리다이렉트
    const { isExpired } = checkSessionTimeout(token);
    
    if (!isExpired && isActualLoginPage(pathname, detectedLng)) {
      // 사용자의 도메인에 맞는 대시보드로 리다이렉트
      const dashboardPath = getDashboardPath(token.domain as string, detectedLng);
      const redirectUrl = new URL(dashboardPath, origin);
      redirectUrl.search = searchParams.toString();
      return NextResponse.redirect(redirectUrl);
    }
  }

  /**
   * 9. 인증 확인: 공개 경로가 아닌 경우 로그인 체크 및 리다이렉트
   */
  if (!isPublicPath(pathname, detectedLng)) {
    if (!token) {
      const loginUrl = createLoginUrl(pathname, detectedLng, origin, request);
      return NextResponse.redirect(loginUrl);
    }
    
    // 토큰은 있지만 세션이 만료된 경우 (이미 위에서 처리되었지만 추가 안전장치)
    const { isExpired } = checkSessionTimeout(token);
    if (isExpired) {
      const loginUrl = createLoginUrl(pathname, detectedLng, origin, request, 'expired');
      const response = NextResponse.redirect(loginUrl);
      clearSessionCookies(response);
      return response;
    }
  }

  /**
   * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다.
   */
  /**
   * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다.
   */
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-pathname', pathname);

  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });

  // 만료된 세션 쿠키 정리 (공개 경로 포함)
  if (token) {
    const { isExpired } = checkSessionTimeout(token);
    if (isExpired) {
      clearSessionCookies(response);
    }
  }

  /**
   * 11. 세션 만료 경고를 위한 헤더 추가
   */
  if (token && !isPublicPath(pathname, detectedLng)) {
    const { isExpiringSoon } = checkSessionTimeout(token);
    
    if (isExpiringSoon && token.sessionExpiredAt) {
      response.headers.set('X-Session-Warning', 'true');
      response.headers.set('X-Session-Expires-At', token.sessionExpiredAt.toString());
      response.headers.set('X-Session-Time-Left', (token.sessionExpiredAt - Date.now()).toString());
    }
  }

  /**
   * 12. 쿠키에 저장된 언어와 현재 lng가 다르면 업데이트
   */
  const currentCookie = request.cookies.get(cookieName)?.value;
  if (detectedLng && detectedLng !== currentCookie) {
    response.cookies.set(cookieName, detectedLng, { path: '/' });
  }

  return response;
}

/**
 * 13. 매칭할 경로 설정
 */
export const config = {
  matcher: [
    '/((?!_next|.*\\..*|api|viewer).*)', // API 경로 전체 제외
  ],
};