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
|
/** middleware.ts */
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',
'/partners',
'/partners/repository',
'/partners/signup',
'/api/auth',
'/spreadTest',
'/auth/reset-password',
];
// 경로가 공개 경로인지 확인하는 함수
function isPublicPath(path: string, lng: string) {
// 1. 정확한 로그인 페이지 매칭 (/ko/evcp, /en/partners 등)
if (publicPaths.some(publicPath => path === `/${lng}${publicPath}`)) {
return true;
}
// 2. auth API는 별도 처리
if (path.includes('/api/auth')) {
return true;
}
return false;
}
// 도메인-URL 일치 여부 확인 및 올바른 리다이렉트 경로 반환
function getDomainRedirectPath(path: string, domain: string, lng: string) {
// 도메인이 없는 경우 리다이렉트 없음
if (!domain) return null;
// URL에 partners가 있는지 확인
const hasPartnersInPath = path.includes('/partners');
// URL에 evcp가 있는지 확인
const hasEvcpInPath = path.includes('/evcp');
// 1. 도메인이 'partners'인데 URL에 '/evcp/'가 있으면
if (domain === 'partners' && hasEvcpInPath) {
// URL에서 '/evcp/'를 '/partners/'로 교체
return path.replace('/evcp/', '/partners/');
}
// 2. 도메인이 'evcp'인데 URL에 '/partners/'가 있으면
if (domain === 'evcp' && hasPartnersInPath) {
// URL에서 '/partners/'를 '/evcp/'로 교체
return path.replace('/partners/', '/evcp/');
}
// 불일치가 없으면 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
};
}
// 로그인 페이지 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 {
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;
}
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(`Session expired in middleware for user ${token.email}`);
const loginUrl = createLoginUrl(pathname, detectedLng, origin, request, 'expired');
return NextResponse.redirect(loginUrl);
}
// 세션 만료 경고를 위한 응답 헤더 설정은 나중에 적용
}
/**
* 7. 인증된 사용자의 도메인-URL 일치 확인 및 리다이렉션
*/
if (token && token.domain && !isPublicPath(pathname, detectedLng)) {
// 사용자의 domain과 URL 경로가 일치하는지 확인
const redirectPath = getDomainRedirectPath(pathname, token.domain as string, detectedLng);
// 도메인과 URL이 일치하지 않으면 리다이렉트
if (redirectPath) {
const redirectUrl = new URL(redirectPath, origin);
redirectUrl.search = searchParams.toString();
return NextResponse.redirect(redirectUrl);
}
}
/**
* 8. 이미 로그인한 사용자가 로그인 페이지에 접근할 경우 대시보드로 리다이렉트
*/
if (token) {
// 세션이 만료되지 않은 경우에만 대시보드로 리다이렉트
const { isExpired } = checkSessionTimeout(token);
if (!isExpired) {
// 로그인 페이지 경로 확인 (정확한 /ko/evcp 또는 /en/partners 등)
const isEvcpLoginPage = pathname === `/${detectedLng}/evcp`;
const isPartnersLoginPage = pathname === `/${detectedLng}/partners`;
if (isEvcpLoginPage) {
// EVCP 로그인 페이지에 접근한 경우 report 페이지로 리다이렉트
const redirectUrl = new URL(`/${detectedLng}/evcp/report`, origin);
redirectUrl.search = searchParams.toString();
return NextResponse.redirect(redirectUrl);
} else if (isPartnersLoginPage) {
// Partners 로그인 페이지에 접근한 경우 dashboard 페이지로 리다이렉트
const redirectUrl = new URL(`/${detectedLng}/partners/dashboard`, 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');
return NextResponse.redirect(loginUrl);
}
}
/**
* 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다.
*/
const response = NextResponse.next();
/**
* 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 경로 전체 제외
],
};
|