@thinkingcat/auth-utils 1.0.6 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +631 -286
- package/dist/index.d.ts +87 -51
- package/dist/index.js +384 -127
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.verifyToken = verifyToken;
|
|
4
37
|
exports.extractRoleFromPayload = extractRoleFromPayload;
|
|
@@ -16,6 +49,7 @@ exports.redirectToError = redirectToError;
|
|
|
16
49
|
exports.clearAuthCookies = clearAuthCookies;
|
|
17
50
|
exports.getEffectiveRole = getEffectiveRole;
|
|
18
51
|
exports.requiresSubscription = requiresSubscription;
|
|
52
|
+
exports.checkLicenseKey = checkLicenseKey;
|
|
19
53
|
exports.checkRoleAccess = checkRoleAccess;
|
|
20
54
|
exports.redirectToSSOLogin = redirectToSSOLogin;
|
|
21
55
|
exports.redirectToRoleDashboard = redirectToRoleDashboard;
|
|
@@ -29,9 +63,11 @@ exports.isProtectedApiPath = isProtectedApiPath;
|
|
|
29
63
|
exports.checkAuthentication = checkAuthentication;
|
|
30
64
|
exports.verifyAndRefreshTokenWithNextAuth = verifyAndRefreshTokenWithNextAuth;
|
|
31
65
|
exports.createMiddlewareConfig = createMiddlewareConfig;
|
|
66
|
+
exports.handleMiddleware = handleMiddleware;
|
|
32
67
|
const jwt_1 = require("next-auth/jwt");
|
|
33
68
|
const jose_1 = require("jose");
|
|
34
69
|
const server_1 = require("next/server");
|
|
70
|
+
const crypto_1 = require("crypto");
|
|
35
71
|
/**
|
|
36
72
|
* 토큰 검증 및 디코딩
|
|
37
73
|
* @param accessToken JWT access token
|
|
@@ -54,11 +90,11 @@ async function verifyToken(accessToken, secret) {
|
|
|
54
90
|
/**
|
|
55
91
|
* payload에서 역할 추출 (서비스별)
|
|
56
92
|
* @param payload JWT payload
|
|
57
|
-
* @param serviceId 서비스 ID (
|
|
93
|
+
* @param serviceId 서비스 ID (필수)
|
|
58
94
|
* @param defaultRole 기본 역할 (기본값: 'ADMIN')
|
|
59
95
|
* @returns 추출된 역할
|
|
60
96
|
*/
|
|
61
|
-
function extractRoleFromPayload(payload, serviceId
|
|
97
|
+
function extractRoleFromPayload(payload, serviceId, defaultRole = 'ADMIN') {
|
|
62
98
|
const services = payload.services || [];
|
|
63
99
|
const service = services.find((s) => s.serviceId === serviceId);
|
|
64
100
|
return service?.role || payload.role || defaultRole;
|
|
@@ -66,18 +102,19 @@ function extractRoleFromPayload(payload, serviceId = 'checkon', defaultRole = 'A
|
|
|
66
102
|
/**
|
|
67
103
|
* payload에서 NextAuth JWT 객체 생성
|
|
68
104
|
* @param payload JWT payload
|
|
105
|
+
* @param serviceId 서비스 ID (필수)
|
|
69
106
|
* @param includeAcademies academies 정보 포함 여부
|
|
70
107
|
* @returns NextAuth JWT 객체
|
|
71
108
|
*/
|
|
72
|
-
function createNextAuthJWT(payload, includeAcademies = false) {
|
|
109
|
+
function createNextAuthJWT(payload, serviceId, includeAcademies = false) {
|
|
73
110
|
const services = payload.services || [];
|
|
74
|
-
const
|
|
75
|
-
const effectiveRole =
|
|
111
|
+
const service = services.find((s) => s.serviceId === serviceId);
|
|
112
|
+
const effectiveRole = service?.role || payload.role || 'ADMIN';
|
|
76
113
|
const jwt = {
|
|
77
114
|
id: (payload.id || payload.sub),
|
|
78
115
|
email: payload.email,
|
|
79
116
|
name: payload.name,
|
|
80
|
-
role: effectiveRole, // Role enum 타입
|
|
117
|
+
role: effectiveRole, // Role enum 타입 (string으로 캐스팅)
|
|
81
118
|
services: payload.services,
|
|
82
119
|
academies: includeAcademies
|
|
83
120
|
? payload.academies || []
|
|
@@ -141,7 +178,10 @@ function setCustomTokens(response, accessToken, optionsOrRefreshToken, options)
|
|
|
141
178
|
if (typeof optionsOrRefreshToken === 'string') {
|
|
142
179
|
// 기존 방식: refreshToken이 문자열로 전달된 경우
|
|
143
180
|
refreshTokenValue = optionsOrRefreshToken;
|
|
144
|
-
const { cookiePrefix: prefix
|
|
181
|
+
const { cookiePrefix: prefix, isProduction: prod = false, } = options || {};
|
|
182
|
+
if (!prefix) {
|
|
183
|
+
throw new Error('cookiePrefix is required');
|
|
184
|
+
}
|
|
145
185
|
cookiePrefix = prefix;
|
|
146
186
|
isProduction = prod;
|
|
147
187
|
}
|
|
@@ -149,7 +189,10 @@ function setCustomTokens(response, accessToken, optionsOrRefreshToken, options)
|
|
|
149
189
|
// 새로운 방식: options 객체로 전달된 경우
|
|
150
190
|
const opts = optionsOrRefreshToken || {};
|
|
151
191
|
refreshTokenValue = opts.refreshToken;
|
|
152
|
-
|
|
192
|
+
if (!opts.cookiePrefix) {
|
|
193
|
+
throw new Error('cookiePrefix is required');
|
|
194
|
+
}
|
|
195
|
+
cookiePrefix = opts.cookiePrefix;
|
|
153
196
|
isProduction = opts.isProduction || false;
|
|
154
197
|
}
|
|
155
198
|
// access_token 설정
|
|
@@ -203,10 +246,10 @@ function setNextAuthToken(response, sessionToken, options = {}) {
|
|
|
203
246
|
/**
|
|
204
247
|
* 리다이렉트용 HTML 생성
|
|
205
248
|
* @param redirectPath 리다이렉트할 경로
|
|
206
|
-
* @param text 표시할 텍스트 (
|
|
249
|
+
* @param text 표시할 텍스트 (필수)
|
|
207
250
|
* @returns HTML 문자열
|
|
208
251
|
*/
|
|
209
|
-
function createRedirectHTML(redirectPath, text
|
|
252
|
+
function createRedirectHTML(redirectPath, text) {
|
|
210
253
|
return `
|
|
211
254
|
<!DOCTYPE html>
|
|
212
255
|
<html>
|
|
@@ -281,14 +324,15 @@ function createRedirectHTML(redirectPath, text = 'checkon') {
|
|
|
281
324
|
* @param options 추가 옵션
|
|
282
325
|
* @param options.refreshToken refresh token (선택)
|
|
283
326
|
* @param options.redirectPath 리다이렉트할 경로 (기본값: 페이지 리로드)
|
|
284
|
-
* @param options.text 리다이렉트 HTML에 표시할 텍스트 (
|
|
285
|
-
* @param options.cookiePrefix 쿠키 이름 접두사 (
|
|
327
|
+
* @param options.text 리다이렉트 HTML에 표시할 텍스트 (선택사항)
|
|
328
|
+
* @param options.cookiePrefix 쿠키 이름 접두사 (필수)
|
|
286
329
|
* @param options.isProduction 프로덕션 환경 여부 (기본값: false)
|
|
287
330
|
* @param options.cookieDomain 쿠키 도메인 (선택)
|
|
288
331
|
* @returns NextResponse 객체
|
|
289
332
|
*/
|
|
290
|
-
async function createAuthResponse(accessToken, secret, options
|
|
291
|
-
|
|
333
|
+
async function createAuthResponse(accessToken, secret, options) {
|
|
334
|
+
checkLicenseKey(options.licenseKey);
|
|
335
|
+
const { refreshToken, redirectPath, text, cookiePrefix, isProduction = false, cookieDomain, serviceId, } = options;
|
|
292
336
|
// 1. 토큰 검증
|
|
293
337
|
const tokenResult = await verifyToken(accessToken, secret);
|
|
294
338
|
if (!tokenResult) {
|
|
@@ -296,14 +340,15 @@ async function createAuthResponse(accessToken, secret, options = {}) {
|
|
|
296
340
|
}
|
|
297
341
|
const { payload } = tokenResult;
|
|
298
342
|
// 2. 역할 추출
|
|
299
|
-
const role = extractRoleFromPayload(payload);
|
|
343
|
+
const role = extractRoleFromPayload(payload, serviceId);
|
|
300
344
|
// 3. NextAuth JWT 생성 및 인코딩
|
|
301
|
-
const jwt = createNextAuthJWT(payload);
|
|
345
|
+
const jwt = createNextAuthJWT(payload, serviceId);
|
|
302
346
|
const sessionToken = await encodeNextAuthToken(jwt, secret);
|
|
303
347
|
// 4. HTML 생성
|
|
348
|
+
const displayText = text || serviceId;
|
|
304
349
|
const html = redirectPath
|
|
305
|
-
? createRedirectHTML(redirectPath,
|
|
306
|
-
: createRedirectHTML('',
|
|
350
|
+
? createRedirectHTML(redirectPath, displayText)
|
|
351
|
+
: createRedirectHTML('', displayText).replace("window.location.href = ''", "window.location.reload()");
|
|
307
352
|
// 5. Response 생성
|
|
308
353
|
const response = new server_1.NextResponse(html, {
|
|
309
354
|
status: 200,
|
|
@@ -338,21 +383,22 @@ async function createAuthResponse(accessToken, secret, options = {}) {
|
|
|
338
383
|
* 서비스 구독 유효성 확인 함수
|
|
339
384
|
* @param services 서비스 정보 배열
|
|
340
385
|
* @param serviceId 확인할 서비스 ID
|
|
386
|
+
* @param ssoBaseURL SSO 서버 기본 URL (필수)
|
|
341
387
|
* @returns 구독 유효성 결과
|
|
342
388
|
*/
|
|
343
|
-
function validateServiceSubscription(services, serviceId) {
|
|
389
|
+
function validateServiceSubscription(services, serviceId, ssoBaseURL) {
|
|
344
390
|
const filteredServices = services.filter(service => service.serviceId === serviceId);
|
|
345
391
|
if (filteredServices.length === 0) {
|
|
346
392
|
return {
|
|
347
393
|
isValid: false,
|
|
348
|
-
redirectUrl: `${
|
|
394
|
+
redirectUrl: `${ssoBaseURL}/services/${serviceId}?type=subscription_required`
|
|
349
395
|
};
|
|
350
396
|
}
|
|
351
397
|
const service = filteredServices[0];
|
|
352
398
|
if (service.status !== "ACTIVE") {
|
|
353
399
|
return {
|
|
354
400
|
isValid: false,
|
|
355
|
-
redirectUrl: `${
|
|
401
|
+
redirectUrl: `${ssoBaseURL}/services/${service.serviceId}?type=subscription_required`,
|
|
356
402
|
service
|
|
357
403
|
};
|
|
358
404
|
}
|
|
@@ -365,13 +411,12 @@ function validateServiceSubscription(services, serviceId) {
|
|
|
365
411
|
* SSO 서버에서 refresh token을 사용하여 새로운 access token을 발급받는 함수
|
|
366
412
|
* @param refreshToken refresh token
|
|
367
413
|
* @param options 옵션
|
|
368
|
-
* @param options.ssoBaseURL SSO 서버 기본 URL (
|
|
414
|
+
* @param options.ssoBaseURL SSO 서버 기본 URL (필수)
|
|
369
415
|
* @param options.authServiceKey 인증 서비스 키 (기본값: 환경 변수)
|
|
370
416
|
* @returns SSO refresh token 응답
|
|
371
417
|
*/
|
|
372
418
|
async function refreshSSOToken(refreshToken, options) {
|
|
373
|
-
const
|
|
374
|
-
const authServiceKey = options?.authServiceKey || process.env.AUTH_SERVICE_SECRET_KEY;
|
|
419
|
+
const { ssoBaseURL, authServiceKey } = options;
|
|
375
420
|
if (!authServiceKey) {
|
|
376
421
|
throw new Error('AUTH_SERVICE_SECRET_KEY not configured');
|
|
377
422
|
}
|
|
@@ -390,13 +435,12 @@ async function refreshSSOToken(refreshToken, options) {
|
|
|
390
435
|
* @param userId 사용자 ID
|
|
391
436
|
* @param accessToken access token
|
|
392
437
|
* @param options 옵션
|
|
393
|
-
* @param options.ssoBaseURL SSO 서버 기본 URL (
|
|
438
|
+
* @param options.ssoBaseURL SSO 서버 기본 URL (필수)
|
|
394
439
|
* @param options.authServiceKey 인증 서비스 키 (기본값: 환경 변수)
|
|
395
440
|
* @returns refresh token 또는 null
|
|
396
441
|
*/
|
|
397
442
|
async function getRefreshTokenFromSSO(userId, accessToken, options) {
|
|
398
|
-
const
|
|
399
|
-
const authServiceKey = options?.authServiceKey || process.env.AUTH_SERVICE_SECRET_KEY;
|
|
443
|
+
const { ssoBaseURL, authServiceKey } = options;
|
|
400
444
|
if (!authServiceKey) {
|
|
401
445
|
return null;
|
|
402
446
|
}
|
|
@@ -422,56 +466,59 @@ async function getRefreshTokenFromSSO(userId, accessToken, options) {
|
|
|
422
466
|
}
|
|
423
467
|
return null;
|
|
424
468
|
}
|
|
425
|
-
/**
|
|
426
|
-
* 토큰 검증 및 리프레시 처리 공통 함수
|
|
427
|
-
*
|
|
428
|
-
* @param req - NextRequest 객체
|
|
429
|
-
* @param secret - JWT 서명에 사용할 secret key
|
|
430
|
-
* @param options - 옵션
|
|
431
|
-
* @param options.cookiePrefix - 쿠키 이름 접두사 (기본값: 'checkon')
|
|
432
|
-
* @param options.isProduction - 프로덕션 환경 여부 (기본값: false)
|
|
433
|
-
* @param options.cookieDomain - 쿠키 도메인 (선택)
|
|
434
|
-
* @param options.text - 리다이렉트 HTML에 표시할 텍스트 (기본값: 'checkon')
|
|
435
|
-
* @param options.ssoBaseURL - SSO 서버 기본 URL (기본값: 환경 변수 또는 기본값)
|
|
436
|
-
* @param options.authServiceKey - 인증 서비스 키 (기본값: 환경 변수)
|
|
437
|
-
* @returns 검증 결과 및 필요시 리프레시된 응답
|
|
438
|
-
*/
|
|
439
469
|
async function verifyAndRefreshToken(req, secret, options) {
|
|
440
|
-
const { cookiePrefix
|
|
470
|
+
const { cookiePrefix, serviceId, isProduction, cookieDomain, text, ssoBaseURL, authServiceKey, } = options;
|
|
441
471
|
// 1. access_token 쿠키 확인
|
|
442
472
|
const accessTokenName = `${cookiePrefix}_access_token`;
|
|
443
473
|
const accessToken = req.cookies.get(accessTokenName)?.value;
|
|
444
474
|
if (accessToken) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
475
|
+
try {
|
|
476
|
+
const secretBytes = new TextEncoder().encode(secret);
|
|
477
|
+
const { payload } = await (0, jose_1.jwtVerify)(accessToken, secretBytes);
|
|
478
|
+
if (payload && typeof payload === 'object' && payload.email) {
|
|
479
|
+
return { isValid: true, payload: payload };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// 토큰 검증 실패
|
|
448
484
|
}
|
|
449
|
-
// 토큰이 만료되었거나 유효하지 않음 (리프레시 시도)
|
|
450
485
|
}
|
|
451
|
-
//
|
|
486
|
+
// 리프레시 토큰으로 갱신 시도
|
|
452
487
|
const refreshTokenName = `${cookiePrefix}_refresh_token`;
|
|
453
488
|
const refreshToken = req.cookies.get(refreshTokenName)?.value;
|
|
454
489
|
if (refreshToken) {
|
|
455
490
|
try {
|
|
491
|
+
if (!ssoBaseURL || !authServiceKey) {
|
|
492
|
+
return { isValid: false, error: 'SSO_CONFIG_MISSING' };
|
|
493
|
+
}
|
|
456
494
|
const refreshResult = await refreshSSOToken(refreshToken, {
|
|
457
|
-
ssoBaseURL
|
|
458
|
-
authServiceKey
|
|
495
|
+
ssoBaseURL,
|
|
496
|
+
authServiceKey,
|
|
459
497
|
});
|
|
460
498
|
if (refreshResult.success && refreshResult.accessToken) {
|
|
461
|
-
// 토큰 갱신 성공 - createAuthResponse 사용
|
|
462
499
|
const newRefreshToken = refreshResult.refreshToken || refreshToken;
|
|
463
500
|
try {
|
|
501
|
+
let payload;
|
|
502
|
+
try {
|
|
503
|
+
const secretBytes = new TextEncoder().encode(secret);
|
|
504
|
+
const { payload: tokenPayload } = await (0, jose_1.jwtVerify)(refreshResult.accessToken, secretBytes);
|
|
505
|
+
if (tokenPayload && typeof tokenPayload === 'object' && tokenPayload.email) {
|
|
506
|
+
payload = tokenPayload;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// 토큰 검증 실패
|
|
511
|
+
}
|
|
464
512
|
const response = await createAuthResponse(refreshResult.accessToken, secret, {
|
|
465
513
|
refreshToken: newRefreshToken,
|
|
466
|
-
redirectPath: '',
|
|
467
|
-
text,
|
|
514
|
+
redirectPath: '',
|
|
515
|
+
text: text || serviceId,
|
|
468
516
|
cookiePrefix,
|
|
469
517
|
isProduction,
|
|
470
518
|
cookieDomain,
|
|
519
|
+
serviceId,
|
|
520
|
+
licenseKey: options.licenseKey,
|
|
471
521
|
});
|
|
472
|
-
// payload 추출을 위해 토큰 검증
|
|
473
|
-
const tokenResult = await verifyToken(refreshResult.accessToken, secret);
|
|
474
|
-
const payload = tokenResult?.payload;
|
|
475
522
|
return { isValid: true, response, payload };
|
|
476
523
|
}
|
|
477
524
|
catch (error) {
|
|
@@ -507,10 +554,10 @@ function redirectToError(req, code, message, errorPath = '/error') {
|
|
|
507
554
|
/**
|
|
508
555
|
* 인증 쿠키를 삭제하는 헬퍼 함수
|
|
509
556
|
* @param response NextResponse 객체
|
|
510
|
-
* @param cookiePrefix 쿠키 이름 접두사 (
|
|
557
|
+
* @param cookiePrefix 쿠키 이름 접두사 (필수)
|
|
511
558
|
* @returns NextResponse 객체
|
|
512
559
|
*/
|
|
513
|
-
function clearAuthCookies(response, cookiePrefix
|
|
560
|
+
function clearAuthCookies(response, cookiePrefix) {
|
|
514
561
|
response.cookies.delete(`${cookiePrefix}_access_token`);
|
|
515
562
|
response.cookies.delete(`${cookiePrefix}_refresh_token`);
|
|
516
563
|
return response;
|
|
@@ -518,10 +565,10 @@ function clearAuthCookies(response, cookiePrefix = 'checkon') {
|
|
|
518
565
|
/**
|
|
519
566
|
* JWT에서 서비스별 역할을 추출하는 헬퍼 함수
|
|
520
567
|
* @param token NextAuth JWT 객체 또는 null
|
|
521
|
-
* @param serviceId 서비스 ID (
|
|
568
|
+
* @param serviceId 서비스 ID (필수)
|
|
522
569
|
* @returns 추출된 역할 또는 undefined
|
|
523
570
|
*/
|
|
524
|
-
function getEffectiveRole(token, serviceId
|
|
571
|
+
function getEffectiveRole(token, serviceId) {
|
|
525
572
|
if (!token)
|
|
526
573
|
return undefined;
|
|
527
574
|
// token이 이미 JWT 객체인 경우 role을 직접 사용
|
|
@@ -549,6 +596,19 @@ function requiresSubscription(pathname, role, subscriptionRequiredPaths, systemA
|
|
|
549
596
|
// 구독이 필요한 경로인지 확인
|
|
550
597
|
return subscriptionRequiredPaths.some(path => pathname.startsWith(path));
|
|
551
598
|
}
|
|
599
|
+
// 유효한 라이센스 키 해시 목록
|
|
600
|
+
const VALID_LICENSE_KEY_HASHES = new Set([
|
|
601
|
+
'73bce4f3b64804c255cdab450d759a8b53038f9edb59ae42d9988b08dfd007e2',
|
|
602
|
+
]);
|
|
603
|
+
function checkLicenseKey(licenseKey) {
|
|
604
|
+
if (!licenseKey || licenseKey.length < 10) {
|
|
605
|
+
throw new Error('License key is required');
|
|
606
|
+
}
|
|
607
|
+
const keyHash = (0, crypto_1.createHash)('sha256').update(licenseKey).digest('hex');
|
|
608
|
+
if (!VALID_LICENSE_KEY_HASHES.has(keyHash)) {
|
|
609
|
+
throw new Error('Invalid license key');
|
|
610
|
+
}
|
|
611
|
+
}
|
|
552
612
|
function checkRoleAccess(pathname, role, roleConfig) {
|
|
553
613
|
// 각 역할 설정을 확인
|
|
554
614
|
for (const [configRole, config] of Object.entries(roleConfig)) {
|
|
@@ -569,12 +629,11 @@ function checkRoleAccess(pathname, role, roleConfig) {
|
|
|
569
629
|
* SSO 로그인 페이지로 리다이렉트하는 헬퍼 함수
|
|
570
630
|
* @param req NextRequest 객체
|
|
571
631
|
* @param serviceId 서비스 ID
|
|
572
|
-
* @param ssoBaseURL SSO 서버 기본 URL (
|
|
632
|
+
* @param ssoBaseURL SSO 서버 기본 URL (필수)
|
|
573
633
|
* @returns NextResponse 리다이렉트 응답
|
|
574
634
|
*/
|
|
575
635
|
function redirectToSSOLogin(req, serviceId, ssoBaseURL) {
|
|
576
|
-
|
|
577
|
-
return server_1.NextResponse.redirect(new URL(`${baseURL}/auth/login?serviceId=${serviceId}`, req.url));
|
|
636
|
+
return server_1.NextResponse.redirect(new URL(`${ssoBaseURL}/auth/login?serviceId=${serviceId}`, req.url));
|
|
578
637
|
}
|
|
579
638
|
/**
|
|
580
639
|
* 역할별 대시보드 경로로 리다이렉트하는 헬퍼 함수
|
|
@@ -619,10 +678,10 @@ function isValidToken(token) {
|
|
|
619
678
|
* 특정 역할을 가지고 있는지 확인하는 함수
|
|
620
679
|
* @param token NextAuth JWT 객체
|
|
621
680
|
* @param role 확인할 역할
|
|
622
|
-
* @param serviceId 서비스 ID (
|
|
681
|
+
* @param serviceId 서비스 ID (필수)
|
|
623
682
|
* @returns 역할 보유 여부
|
|
624
683
|
*/
|
|
625
|
-
function hasRole(token, role, serviceId
|
|
684
|
+
function hasRole(token, role, serviceId) {
|
|
626
685
|
if (!token)
|
|
627
686
|
return false;
|
|
628
687
|
const effectiveRole = getEffectiveRole(token, serviceId);
|
|
@@ -632,10 +691,10 @@ function hasRole(token, role, serviceId = 'checkon') {
|
|
|
632
691
|
* 여러 역할 중 하나라도 가지고 있는지 확인하는 함수
|
|
633
692
|
* @param token NextAuth JWT 객체
|
|
634
693
|
* @param roles 확인할 역할 배열
|
|
635
|
-
* @param serviceId 서비스 ID (
|
|
694
|
+
* @param serviceId 서비스 ID (필수)
|
|
636
695
|
* @returns 역할 보유 여부
|
|
637
696
|
*/
|
|
638
|
-
function hasAnyRole(token, roles, serviceId
|
|
697
|
+
function hasAnyRole(token, roles, serviceId) {
|
|
639
698
|
if (!token)
|
|
640
699
|
return false;
|
|
641
700
|
const effectiveRole = getEffectiveRole(token, serviceId);
|
|
@@ -677,8 +736,7 @@ function isProtectedApiPath(pathname, exemptPaths = []) {
|
|
|
677
736
|
* @returns 인증 결과
|
|
678
737
|
*/
|
|
679
738
|
async function checkAuthentication(req, secret, options) {
|
|
680
|
-
const { cookiePrefix
|
|
681
|
-
// 1. NextAuth 토큰 확인
|
|
739
|
+
const { cookiePrefix, serviceId, getNextAuthToken, } = options;
|
|
682
740
|
let nextAuthToken = null;
|
|
683
741
|
if (getNextAuthToken) {
|
|
684
742
|
nextAuthToken = await getNextAuthToken(req);
|
|
@@ -689,7 +747,6 @@ async function checkAuthentication(req, secret, options) {
|
|
|
689
747
|
token: nextAuthToken,
|
|
690
748
|
};
|
|
691
749
|
}
|
|
692
|
-
// 2. 자체 토큰 확인
|
|
693
750
|
const authCheck = await verifyAndRefreshToken(req, secret, options);
|
|
694
751
|
if (authCheck.response) {
|
|
695
752
|
return {
|
|
@@ -719,76 +776,276 @@ async function checkAuthentication(req, secret, options) {
|
|
|
719
776
|
* @returns 인증 결과
|
|
720
777
|
*/
|
|
721
778
|
async function verifyAndRefreshTokenWithNextAuth(req, nextAuthToken, secret, options) {
|
|
722
|
-
// 1. NextAuth 토큰이 있고 유효하면 통과
|
|
723
779
|
if (nextAuthToken && isValidToken(nextAuthToken)) {
|
|
724
780
|
return { isValid: true };
|
|
725
781
|
}
|
|
726
|
-
// 2. 자체 토큰 확인 및 리프레시
|
|
727
782
|
const authCheck = await verifyAndRefreshToken(req, secret, options);
|
|
728
783
|
return authCheck;
|
|
729
784
|
}
|
|
730
785
|
/**
|
|
731
786
|
* 기본 미들웨어 설정을 생성하는 함수
|
|
732
|
-
* @param config 커스텀 설정 (
|
|
787
|
+
* @param config 커스텀 설정 (필수: serviceId 포함)
|
|
788
|
+
* @param defaults 기본 설정값 (선택사항, 제공하지 않으면 최소 기본값 사용)
|
|
733
789
|
* @returns 미들웨어 설정 객체
|
|
734
790
|
*/
|
|
735
|
-
function createMiddlewareConfig(config) {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
},
|
|
778
|
-
roleAccessConfig: {},
|
|
779
|
-
systemAdminRole: 'SYSTEM_ADMIN',
|
|
780
|
-
errorPath: '/error',
|
|
781
|
-
serviceId: config?.serviceId,
|
|
791
|
+
function createMiddlewareConfig(config, defaults) {
|
|
792
|
+
// 기본값 설정
|
|
793
|
+
const defaultPublicPaths = defaults?.publicPaths || [
|
|
794
|
+
'/robots.txt',
|
|
795
|
+
'/sitemap.xml',
|
|
796
|
+
'/ads.txt',
|
|
797
|
+
'/images',
|
|
798
|
+
'/login',
|
|
799
|
+
'/register',
|
|
800
|
+
'/forgot-password',
|
|
801
|
+
'/reset-password',
|
|
802
|
+
'/about',
|
|
803
|
+
'/pricing',
|
|
804
|
+
'/support',
|
|
805
|
+
'/terms',
|
|
806
|
+
'/privacy',
|
|
807
|
+
'/refund',
|
|
808
|
+
'/error',
|
|
809
|
+
];
|
|
810
|
+
const defaultSubscriptionRequiredPaths = defaults?.subscriptionRequiredPaths || [
|
|
811
|
+
'/admin',
|
|
812
|
+
'/teacher',
|
|
813
|
+
'/student',
|
|
814
|
+
'/personal',
|
|
815
|
+
];
|
|
816
|
+
const defaultSubscriptionExemptApiPaths = defaults?.subscriptionExemptApiPaths || [
|
|
817
|
+
'/api/auth',
|
|
818
|
+
'/api/sso',
|
|
819
|
+
'/api/admin/subscription',
|
|
820
|
+
'/api/admin/payment-methods',
|
|
821
|
+
];
|
|
822
|
+
const defaultAuthApiPaths = defaults?.authApiPaths || [
|
|
823
|
+
'/api/auth/send-verification',
|
|
824
|
+
'/api/auth/verify-code',
|
|
825
|
+
'/api/auth/verify-user',
|
|
826
|
+
'/api/auth/select-academy',
|
|
827
|
+
];
|
|
828
|
+
const defaultRolePaths = defaults?.rolePaths || {
|
|
829
|
+
SYSTEM_ADMIN: '/system',
|
|
830
|
+
ADMIN: '/admin',
|
|
831
|
+
TEACHER: '/teacher',
|
|
832
|
+
STUDENT: '/student',
|
|
782
833
|
};
|
|
834
|
+
const defaultSystemAdminRole = defaults?.systemAdminRole || 'SYSTEM_ADMIN';
|
|
835
|
+
const defaultErrorPath = defaults?.errorPath || '/error';
|
|
783
836
|
// 커스텀 설정으로 병합
|
|
784
837
|
return {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
838
|
+
publicPaths: config.publicPaths || defaultPublicPaths,
|
|
839
|
+
subscriptionRequiredPaths: config.subscriptionRequiredPaths || defaultSubscriptionRequiredPaths,
|
|
840
|
+
subscriptionExemptApiPaths: config.subscriptionExemptApiPaths || defaultSubscriptionExemptApiPaths,
|
|
841
|
+
authApiPaths: config.authApiPaths || defaultAuthApiPaths,
|
|
842
|
+
rolePaths: config.rolePaths || defaultRolePaths,
|
|
843
|
+
roleAccessConfig: config.roleAccessConfig || {},
|
|
844
|
+
systemAdminRole: config.systemAdminRole || defaultSystemAdminRole,
|
|
845
|
+
errorPath: config.errorPath || defaultErrorPath,
|
|
846
|
+
serviceId: config.serviceId,
|
|
793
847
|
};
|
|
794
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* 통합 미들웨어 핸들러 함수
|
|
851
|
+
* 모든 인증, 권한, 구독 체크를 포함한 완전한 미들웨어 로직
|
|
852
|
+
* @param req NextRequest 객체
|
|
853
|
+
* @param config 미들웨어 설정
|
|
854
|
+
* @param options 미들웨어 실행 옵션 (secret 필수)
|
|
855
|
+
* @returns NextResponse 또는 null (다음 미들웨어로 진행)
|
|
856
|
+
*/
|
|
857
|
+
async function handleMiddleware(req, config, options) {
|
|
858
|
+
try {
|
|
859
|
+
const pathname = req.nextUrl.pathname;
|
|
860
|
+
const { secret, isProduction, cookieDomain, getNextAuthToken, licenseKey } = options;
|
|
861
|
+
checkLicenseKey(licenseKey);
|
|
862
|
+
if (!config.serviceId) {
|
|
863
|
+
throw new Error('serviceId is required in middleware config');
|
|
864
|
+
}
|
|
865
|
+
const serviceId = config.serviceId;
|
|
866
|
+
const cookiePrefix = serviceId;
|
|
867
|
+
let token = null;
|
|
868
|
+
if (getNextAuthToken) {
|
|
869
|
+
token = await getNextAuthToken(req);
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
// 기본적으로 secret을 사용하여 토큰 가져오기
|
|
873
|
+
try {
|
|
874
|
+
const { getToken } = await Promise.resolve().then(() => __importStar(require('next-auth/jwt')));
|
|
875
|
+
token = await getToken({ req, secret });
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
// NextAuth가 없으면 null 유지
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const effectiveRole = getEffectiveRole(token, serviceId);
|
|
882
|
+
// 1. API 요청 처리
|
|
883
|
+
if (pathname.startsWith('/api/')) {
|
|
884
|
+
if (config.authApiPaths.includes(pathname)) {
|
|
885
|
+
return server_1.NextResponse.next();
|
|
886
|
+
}
|
|
887
|
+
if (config.subscriptionExemptApiPaths.some((path) => pathname.startsWith(path))) {
|
|
888
|
+
return server_1.NextResponse.next();
|
|
889
|
+
}
|
|
890
|
+
const authCheck = await verifyAndRefreshTokenWithNextAuth(req, token, secret, {
|
|
891
|
+
cookiePrefix,
|
|
892
|
+
serviceId,
|
|
893
|
+
isProduction,
|
|
894
|
+
cookieDomain,
|
|
895
|
+
text: serviceId,
|
|
896
|
+
ssoBaseURL: options.ssoBaseURL,
|
|
897
|
+
authServiceKey: options.authServiceKey,
|
|
898
|
+
licenseKey: options.licenseKey,
|
|
899
|
+
});
|
|
900
|
+
if (authCheck.response) {
|
|
901
|
+
return authCheck.response;
|
|
902
|
+
}
|
|
903
|
+
if (!authCheck.isValid) {
|
|
904
|
+
const response = redirectToError(req, 'UNAUTHORIZED', '인증이 필요합니다.', config.errorPath);
|
|
905
|
+
return clearAuthCookies(response, cookiePrefix);
|
|
906
|
+
}
|
|
907
|
+
return server_1.NextResponse.next();
|
|
908
|
+
}
|
|
909
|
+
// 2. 루트 경로 처리 - SSO 토큰 처리 (인증 체크보다 먼저!)
|
|
910
|
+
if (pathname === '/') {
|
|
911
|
+
const tokenParam = req.nextUrl.searchParams.get('token');
|
|
912
|
+
if (tokenParam) {
|
|
913
|
+
try {
|
|
914
|
+
// 1. 토큰 검증
|
|
915
|
+
const tokenResult = await verifyToken(tokenParam, secret);
|
|
916
|
+
if (!tokenResult) {
|
|
917
|
+
throw new Error('Invalid token');
|
|
918
|
+
}
|
|
919
|
+
const { payload } = tokenResult;
|
|
920
|
+
// 2. 역할 추출
|
|
921
|
+
const defaultRole = Object.keys(config.rolePaths)[0] || 'ADMIN';
|
|
922
|
+
const tokenRole = extractRoleFromPayload(payload, serviceId, defaultRole);
|
|
923
|
+
// 3. Refresh token 가져오기 (서버 간 통신)
|
|
924
|
+
const userId = payload.sub || payload.userId || '';
|
|
925
|
+
const ssoBaseURL = options.ssoBaseURL;
|
|
926
|
+
const authServiceKey = options.authServiceKey;
|
|
927
|
+
const refreshToken = authServiceKey
|
|
928
|
+
? await getRefreshTokenFromSSO(userId, tokenParam, { ssoBaseURL, authServiceKey }) || ''
|
|
929
|
+
: '';
|
|
930
|
+
// 4. 자체 토큰 생성 및 쿠키 설정
|
|
931
|
+
const redirectPath = config.rolePaths[tokenRole] || config.rolePaths[defaultRole] || '/admin';
|
|
932
|
+
const response = await createAuthResponse(tokenParam, secret, {
|
|
933
|
+
refreshToken: refreshToken || undefined,
|
|
934
|
+
redirectPath,
|
|
935
|
+
text: serviceId,
|
|
936
|
+
cookiePrefix,
|
|
937
|
+
isProduction,
|
|
938
|
+
cookieDomain,
|
|
939
|
+
serviceId,
|
|
940
|
+
licenseKey: options.licenseKey,
|
|
941
|
+
});
|
|
942
|
+
return response;
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
// 토큰 검증 실패 시 SSO 로그인으로 리다이렉트
|
|
946
|
+
const ssoBaseURL = options.ssoBaseURL;
|
|
947
|
+
return redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
// 토큰이 없고 이미 인증된 경우 역할별 대시보드로 리다이렉트
|
|
951
|
+
if (token && effectiveRole) {
|
|
952
|
+
return redirectToRoleDashboard(req, effectiveRole, config.rolePaths);
|
|
953
|
+
}
|
|
954
|
+
// 인증되지 않은 경우 SSO 로그인 페이지로 리다이렉트
|
|
955
|
+
const ssoBaseURL = options.ssoBaseURL;
|
|
956
|
+
return redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
957
|
+
}
|
|
958
|
+
// 3. 공개 경로 처리
|
|
959
|
+
if (config.publicPaths.some((path) => pathname === path || pathname.startsWith(path))) {
|
|
960
|
+
if (pathname === '/error' || pathname === '/verification') {
|
|
961
|
+
return server_1.NextResponse.next();
|
|
962
|
+
}
|
|
963
|
+
if (token && effectiveRole) {
|
|
964
|
+
return redirectToRoleDashboard(req, effectiveRole, config.rolePaths);
|
|
965
|
+
}
|
|
966
|
+
return server_1.NextResponse.next();
|
|
967
|
+
}
|
|
968
|
+
// 4. 인증 체크
|
|
969
|
+
const authCheck = await verifyAndRefreshTokenWithNextAuth(req, token, secret, {
|
|
970
|
+
cookiePrefix,
|
|
971
|
+
serviceId,
|
|
972
|
+
isProduction,
|
|
973
|
+
cookieDomain,
|
|
974
|
+
text: serviceId,
|
|
975
|
+
ssoBaseURL: options.ssoBaseURL,
|
|
976
|
+
authServiceKey: options.authServiceKey,
|
|
977
|
+
licenseKey: options.licenseKey,
|
|
978
|
+
});
|
|
979
|
+
if (authCheck.response) {
|
|
980
|
+
return authCheck.response;
|
|
981
|
+
}
|
|
982
|
+
if (!authCheck.isValid) {
|
|
983
|
+
const ssoBaseURL = options.ssoBaseURL;
|
|
984
|
+
return redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
985
|
+
}
|
|
986
|
+
// 5. 토큰 확인 및 변환
|
|
987
|
+
let finalToken = token;
|
|
988
|
+
if (!finalToken && getNextAuthToken) {
|
|
989
|
+
finalToken = await getNextAuthToken(req);
|
|
990
|
+
}
|
|
991
|
+
else if (!finalToken) {
|
|
992
|
+
try {
|
|
993
|
+
const { getToken } = await Promise.resolve().then(() => __importStar(require('next-auth/jwt')));
|
|
994
|
+
finalToken = await getToken({ req, secret });
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
// NextAuth가 없으면 null 유지
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
// verifyAndRefreshToken이 성공했는데 NextAuth 토큰이 없으면, 자체 토큰을 사용
|
|
1001
|
+
if (!finalToken && authCheck.isValid) {
|
|
1002
|
+
const accessToken = req.cookies.get(`${cookiePrefix}_access_token`)?.value;
|
|
1003
|
+
if (accessToken) {
|
|
1004
|
+
const tokenResult = await verifyToken(accessToken, secret);
|
|
1005
|
+
if (tokenResult) {
|
|
1006
|
+
const { payload } = tokenResult;
|
|
1007
|
+
finalToken = createNextAuthJWT(payload, serviceId, true); // academies 포함
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (!finalToken) {
|
|
1012
|
+
const ssoBaseURL = options.ssoBaseURL;
|
|
1013
|
+
return redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
1014
|
+
}
|
|
1015
|
+
// 6. 토큰 에러 체크
|
|
1016
|
+
if (finalToken.error === "RefreshAccessTokenError") {
|
|
1017
|
+
const ssoBaseURL = options.ssoBaseURL;
|
|
1018
|
+
return redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
1019
|
+
}
|
|
1020
|
+
// 7. 토큰 유효성 체크
|
|
1021
|
+
if (!finalToken.role || !finalToken.email) {
|
|
1022
|
+
const ssoBaseURL = options.ssoBaseURL;
|
|
1023
|
+
return redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
1024
|
+
}
|
|
1025
|
+
// 8. 역할 기반 접근 제어
|
|
1026
|
+
const finalEffectiveRole = finalToken.role || getEffectiveRole(finalToken, serviceId) || effectiveRole || '';
|
|
1027
|
+
if (config.roleAccessConfig && Object.keys(config.roleAccessConfig).length > 0 && finalEffectiveRole) {
|
|
1028
|
+
const roleCheck = checkRoleAccess(pathname, finalEffectiveRole, config.roleAccessConfig);
|
|
1029
|
+
if (!roleCheck.allowed) {
|
|
1030
|
+
return redirectToError(req, 'ACCESS_DENIED', roleCheck.message || '접근 권한이 없습니다.', config.errorPath);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
// 9. 구독 상태 확인 (시스템 관리자 제외)
|
|
1034
|
+
if (finalEffectiveRole && requiresSubscription(pathname, finalEffectiveRole, config.subscriptionRequiredPaths, config.systemAdminRole)) {
|
|
1035
|
+
const services = finalToken.services || [];
|
|
1036
|
+
const ssoBaseURL = options.ssoBaseURL;
|
|
1037
|
+
if (!ssoBaseURL) {
|
|
1038
|
+
throw new Error('ssoBaseURL is required in middleware options');
|
|
1039
|
+
}
|
|
1040
|
+
const subscriptionCheck = validateServiceSubscription(services, serviceId, ssoBaseURL);
|
|
1041
|
+
if (!subscriptionCheck.isValid) {
|
|
1042
|
+
return server_1.NextResponse.redirect(subscriptionCheck.redirectUrl);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return null; // 다음 미들웨어로 진행
|
|
1046
|
+
}
|
|
1047
|
+
catch (error) {
|
|
1048
|
+
console.error('Middleware error:', error);
|
|
1049
|
+
return redirectToError(req, 'INTERNAL_ERROR', '서버 오류가 발생했습니다.', config.errorPath);
|
|
1050
|
+
}
|
|
1051
|
+
}
|