@ahhaohho/auth-middleware 1.0.8 → 2.0.0
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/package.json +5 -2
- package/src/index.js +54 -11
- package/src/middleware/apiKey.js +196 -0
- package/src/middleware/auth.js +24 -0
- package/src/middleware/authorization.js +348 -0
- package/src/utils/apiKeyValidator.js +71 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahhaohho/auth-middleware",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Shared authentication
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Shared authentication and authorization middleware for ahhaohho microservices",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "echo \"Warning: no test specified\" && exit 0",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"passport",
|
|
23
23
|
"jwt",
|
|
24
24
|
"authentication",
|
|
25
|
+
"authorization",
|
|
26
|
+
"rbac",
|
|
27
|
+
"api-key",
|
|
25
28
|
"middleware",
|
|
26
29
|
"ahhaohho"
|
|
27
30
|
],
|
package/src/index.js
CHANGED
|
@@ -1,36 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @ahhaohho/auth-middleware
|
|
3
3
|
*
|
|
4
|
-
* Shared authentication
|
|
4
|
+
* Shared authentication and authorization middleware
|
|
5
5
|
* for ahhaohho microservices
|
|
6
|
+
*
|
|
7
|
+
* @version 2.0.0
|
|
6
8
|
*/
|
|
7
9
|
|
|
10
|
+
// JWT 인증 미들웨어
|
|
8
11
|
const { authenticateJWT, authenticateRefresh, optionalAuth, authenticateHybrid } = require('./middleware/auth');
|
|
12
|
+
|
|
13
|
+
// API 키 인증 미들웨어
|
|
14
|
+
const { authenticateApiKey, optionalApiKey, combinedAuth, combinedAuthHybrid } = require('./middleware/apiKey');
|
|
15
|
+
|
|
16
|
+
// 역할/권한 인가 미들웨어
|
|
17
|
+
const {
|
|
18
|
+
requireRoles,
|
|
19
|
+
requirePermission,
|
|
20
|
+
requireAccess,
|
|
21
|
+
requireOwnership,
|
|
22
|
+
requireAny,
|
|
23
|
+
clearPermissionCache,
|
|
24
|
+
fetchRolePermissions
|
|
25
|
+
} = require('./middleware/authorization');
|
|
26
|
+
|
|
27
|
+
// 유틸리티
|
|
9
28
|
const { verifyTokenWithFallback, signToken, getCurrentSigningKey } = require('./utils/jwtValidator');
|
|
10
29
|
const { isBlacklisted, addToBlacklist, clearBlacklist } = require('./utils/blacklist');
|
|
11
30
|
const { getJwtKeys, invalidateCache } = require('./utils/secretManager');
|
|
31
|
+
const { verifyApiKey, getApiKeyTTL } = require('./utils/apiKeyValidator');
|
|
12
32
|
const redisManager = require('./config/redis');
|
|
13
33
|
|
|
14
|
-
// 메인 export - Express 미들웨어
|
|
15
34
|
module.exports = {
|
|
16
|
-
// 미들웨어
|
|
17
|
-
authenticateJWT,
|
|
18
|
-
authenticateRefresh,
|
|
19
|
-
optionalAuth,
|
|
20
|
-
authenticateHybrid,
|
|
35
|
+
// ===== JWT 인증 미들웨어 =====
|
|
36
|
+
authenticateJWT, // JWT Access Token 필수 인증
|
|
37
|
+
authenticateRefresh, // Refresh Token 인증
|
|
38
|
+
optionalAuth, // JWT 선택적 인증 (없어도 통과)
|
|
39
|
+
authenticateHybrid, // JWT + Refresh Token 자동 갱신
|
|
40
|
+
|
|
41
|
+
// ===== API 키 인증 미들웨어 =====
|
|
42
|
+
authenticateApiKey, // API 키 필수 인증
|
|
43
|
+
optionalApiKey, // API 키 선택적 인증
|
|
44
|
+
combinedAuth, // API 키 + JWT 복합 인증
|
|
45
|
+
combinedAuthHybrid, // API 키 + JWT Hybrid 복합 인증
|
|
46
|
+
|
|
47
|
+
// ===== 역할/권한 인가 미들웨어 =====
|
|
48
|
+
requireRoles, // 역할 기반 인가: requireRoles(['admin', 'teacher'])
|
|
49
|
+
requirePermission, // 권한 기반 인가: requirePermission('content:write')
|
|
50
|
+
requireAccess, // 접근 범위 기반 인가: requireAccess('AdminPanel')
|
|
51
|
+
requireOwnership, // 리소스 소유자 검증: requireOwnership('userId')
|
|
52
|
+
requireAny, // 복합 조건 인가: requireAny({ roles: [...], permissions: [...] })
|
|
21
53
|
|
|
22
|
-
// 유틸리티 함수
|
|
54
|
+
// ===== 유틸리티 함수 =====
|
|
23
55
|
utils: {
|
|
56
|
+
// JWT 관련
|
|
24
57
|
verifyTokenWithFallback,
|
|
25
58
|
signToken,
|
|
26
59
|
getCurrentSigningKey,
|
|
60
|
+
getJwtKeys,
|
|
61
|
+
invalidateCache,
|
|
62
|
+
|
|
63
|
+
// 블랙리스트 관련
|
|
27
64
|
isBlacklisted,
|
|
28
65
|
addToBlacklist,
|
|
29
66
|
clearBlacklist,
|
|
30
|
-
|
|
31
|
-
|
|
67
|
+
|
|
68
|
+
// API 키 관련
|
|
69
|
+
verifyApiKey,
|
|
70
|
+
getApiKeyTTL,
|
|
71
|
+
|
|
72
|
+
// 권한 캐시 관련
|
|
73
|
+
clearPermissionCache,
|
|
74
|
+
fetchRolePermissions
|
|
32
75
|
},
|
|
33
76
|
|
|
34
|
-
// Redis 관리자
|
|
77
|
+
// Redis 관리자
|
|
35
78
|
redisManager
|
|
36
79
|
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
const { verifyApiKey } = require('../utils/apiKeyValidator');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* API 키 인증 미들웨어
|
|
5
|
+
* Redis에서 로컬 검증 수행
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* API 키 필수 인증 미들웨어
|
|
10
|
+
* x-api-key와 device-id 헤더가 필수
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* router.post('/internal', authenticateApiKey, handler);
|
|
14
|
+
*/
|
|
15
|
+
async function authenticateApiKey(req, res, next) {
|
|
16
|
+
const apiKey = req.headers['x-api-key'];
|
|
17
|
+
const deviceId = req.headers['device-id'];
|
|
18
|
+
|
|
19
|
+
if (!apiKey || !deviceId) {
|
|
20
|
+
return res.status(401).json({
|
|
21
|
+
type: 'client',
|
|
22
|
+
error: 'Unauthorized',
|
|
23
|
+
message: 'API key and device ID are required'
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = await verifyApiKey(deviceId, apiKey);
|
|
28
|
+
|
|
29
|
+
if (!result.valid) {
|
|
30
|
+
return res.status(401).json({
|
|
31
|
+
type: 'client',
|
|
32
|
+
error: 'Unauthorized',
|
|
33
|
+
message: result.error || 'Invalid API key'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 인증 성공: 플래그 및 정보 설정
|
|
38
|
+
req.apiKeyVerified = true;
|
|
39
|
+
req.skipAuthorization = true; // 하위 호환성
|
|
40
|
+
req.deviceId = deviceId;
|
|
41
|
+
|
|
42
|
+
console.log('[@ahhaohho/auth-middleware] API key verified for device:', deviceId);
|
|
43
|
+
next();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* API 키 선택적 인증 미들웨어
|
|
48
|
+
* API 키가 없어도 통과, 있으면 검증
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* router.get('/public', optionalApiKey, handler);
|
|
52
|
+
*/
|
|
53
|
+
async function optionalApiKey(req, res, next) {
|
|
54
|
+
const apiKey = req.headers['x-api-key'];
|
|
55
|
+
const deviceId = req.headers['device-id'];
|
|
56
|
+
|
|
57
|
+
if (!apiKey || !deviceId) {
|
|
58
|
+
return next();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const result = await verifyApiKey(deviceId, apiKey);
|
|
62
|
+
|
|
63
|
+
if (result.valid) {
|
|
64
|
+
req.apiKeyVerified = true;
|
|
65
|
+
req.skipAuthorization = true;
|
|
66
|
+
req.deviceId = deviceId;
|
|
67
|
+
console.log('[@ahhaohho/auth-middleware] Optional API key verified for device:', deviceId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
next();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* API 키 + JWT 복합 인증 미들웨어
|
|
75
|
+
* API 키 먼저 시도, 실패하면 JWT로 fallback
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* router.get('/resource', combinedAuth, handler);
|
|
79
|
+
*/
|
|
80
|
+
async function combinedAuth(req, res, next) {
|
|
81
|
+
const apiKey = req.headers['x-api-key'];
|
|
82
|
+
const deviceId = req.headers['device-id'];
|
|
83
|
+
|
|
84
|
+
// 1. API 키가 있으면 먼저 시도
|
|
85
|
+
if (apiKey && deviceId) {
|
|
86
|
+
const result = await verifyApiKey(deviceId, apiKey);
|
|
87
|
+
|
|
88
|
+
if (result.valid) {
|
|
89
|
+
req.apiKeyVerified = true;
|
|
90
|
+
req.skipAuthorization = true;
|
|
91
|
+
req.deviceId = deviceId;
|
|
92
|
+
|
|
93
|
+
// 하위 호환성: req.userId, req.userRole 설정
|
|
94
|
+
req.userId = null;
|
|
95
|
+
req.userRole = 'api_client';
|
|
96
|
+
|
|
97
|
+
console.log('[@ahhaohho/auth-middleware] Combined auth: API key verified');
|
|
98
|
+
return next();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log('[@ahhaohho/auth-middleware] Combined auth: API key failed, trying JWT...');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. JWT 인증으로 fallback
|
|
105
|
+
const { authenticateJWT } = require('./auth');
|
|
106
|
+
|
|
107
|
+
authenticateJWT(req, res, (err) => {
|
|
108
|
+
if (err) {
|
|
109
|
+
return res.status(401).json({
|
|
110
|
+
type: 'client',
|
|
111
|
+
error: 'Unauthorized',
|
|
112
|
+
message: 'No valid authentication provided'
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// JWT 인증 실패 (req.user가 없음)
|
|
117
|
+
if (!req.user) {
|
|
118
|
+
return res.status(401).json({
|
|
119
|
+
type: 'client',
|
|
120
|
+
error: 'Unauthorized',
|
|
121
|
+
message: 'No valid authentication provided'
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 하위 호환성: req.userId, req.userRole 설정
|
|
126
|
+
req.userId = req.user.userId;
|
|
127
|
+
req.userRole = req.user.userRole;
|
|
128
|
+
|
|
129
|
+
next();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* API 키 + JWT Hybrid 인증 미들웨어
|
|
135
|
+
* API 키 먼저 시도, 실패하면 JWT Hybrid(자동 갱신)로 fallback
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* router.get('/resource', combinedAuthHybrid, handler);
|
|
139
|
+
*/
|
|
140
|
+
async function combinedAuthHybrid(req, res, next) {
|
|
141
|
+
const apiKey = req.headers['x-api-key'];
|
|
142
|
+
const deviceId = req.headers['device-id'];
|
|
143
|
+
|
|
144
|
+
// 1. API 키가 있으면 먼저 시도
|
|
145
|
+
if (apiKey && deviceId) {
|
|
146
|
+
const result = await verifyApiKey(deviceId, apiKey);
|
|
147
|
+
|
|
148
|
+
if (result.valid) {
|
|
149
|
+
req.apiKeyVerified = true;
|
|
150
|
+
req.skipAuthorization = true;
|
|
151
|
+
req.deviceId = deviceId;
|
|
152
|
+
|
|
153
|
+
req.userId = null;
|
|
154
|
+
req.userRole = 'api_client';
|
|
155
|
+
|
|
156
|
+
console.log('[@ahhaohho/auth-middleware] Combined hybrid auth: API key verified');
|
|
157
|
+
return next();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log('[@ahhaohho/auth-middleware] Combined hybrid auth: API key failed, trying JWT hybrid...');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. JWT Hybrid 인증으로 fallback (자동 토큰 갱신)
|
|
164
|
+
const { authenticateHybrid } = require('./auth');
|
|
165
|
+
|
|
166
|
+
authenticateHybrid(req, res, (err) => {
|
|
167
|
+
if (err) {
|
|
168
|
+
return res.status(401).json({
|
|
169
|
+
type: 'client',
|
|
170
|
+
error: 'Unauthorized',
|
|
171
|
+
message: 'No valid authentication provided'
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!req.user) {
|
|
176
|
+
return res.status(401).json({
|
|
177
|
+
type: 'client',
|
|
178
|
+
error: 'Unauthorized',
|
|
179
|
+
message: 'No valid authentication provided'
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 하위 호환성
|
|
184
|
+
req.userId = req.user.userId;
|
|
185
|
+
req.userRole = req.user.userRole;
|
|
186
|
+
|
|
187
|
+
next();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
authenticateApiKey,
|
|
193
|
+
optionalApiKey,
|
|
194
|
+
combinedAuth,
|
|
195
|
+
combinedAuthHybrid
|
|
196
|
+
};
|
package/src/middleware/auth.js
CHANGED
|
@@ -52,6 +52,11 @@ function authenticateJWT(req, res, next) {
|
|
|
52
52
|
|
|
53
53
|
// req.user에 사용자 정보 주입
|
|
54
54
|
req.user = user;
|
|
55
|
+
|
|
56
|
+
// 하위 호환성: req.userId, req.userRole 설정
|
|
57
|
+
req.userId = user.userId;
|
|
58
|
+
req.userRole = user.userRole;
|
|
59
|
+
|
|
55
60
|
next();
|
|
56
61
|
})(req, res, next);
|
|
57
62
|
}
|
|
@@ -85,6 +90,11 @@ function authenticateRefresh(req, res, next) {
|
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
req.user = user;
|
|
93
|
+
|
|
94
|
+
// 하위 호환성: req.userId, req.userRole 설정
|
|
95
|
+
req.userId = user.userId;
|
|
96
|
+
req.userRole = user.userRole;
|
|
97
|
+
|
|
88
98
|
next();
|
|
89
99
|
})(req, res, next);
|
|
90
100
|
}
|
|
@@ -109,6 +119,10 @@ function optionalAuth(req, res, next) {
|
|
|
109
119
|
// 에러나 인증 실패해도 통과
|
|
110
120
|
if (user) {
|
|
111
121
|
req.user = user;
|
|
122
|
+
|
|
123
|
+
// 하위 호환성: req.userId, req.userRole 설정
|
|
124
|
+
req.userId = user.userId;
|
|
125
|
+
req.userRole = user.userRole;
|
|
112
126
|
}
|
|
113
127
|
next();
|
|
114
128
|
})(req, res, next);
|
|
@@ -144,6 +158,11 @@ async function authenticateHybrid(req, res, next) {
|
|
|
144
158
|
// Access token이 유효한 경우
|
|
145
159
|
if (user) {
|
|
146
160
|
req.user = user;
|
|
161
|
+
|
|
162
|
+
// 하위 호환성: req.userId, req.userRole 설정
|
|
163
|
+
req.userId = user.userId;
|
|
164
|
+
req.userRole = user.userRole;
|
|
165
|
+
|
|
147
166
|
return next();
|
|
148
167
|
}
|
|
149
168
|
|
|
@@ -197,6 +216,11 @@ async function authenticateHybrid(req, res, next) {
|
|
|
197
216
|
|
|
198
217
|
// 6. req.user 설정하고 계속 진행
|
|
199
218
|
req.user = refreshUser;
|
|
219
|
+
|
|
220
|
+
// 하위 호환성: req.userId, req.userRole 설정
|
|
221
|
+
req.userId = refreshUser.userId;
|
|
222
|
+
req.userRole = refreshUser.userRole;
|
|
223
|
+
|
|
200
224
|
next();
|
|
201
225
|
} catch (tokenError) {
|
|
202
226
|
console.error('[@ahhaohho/auth-middleware] Failed to generate new token:', tokenError.message);
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
const redisManager = require('../config/redis');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 역할/권한 기반 인가 미들웨어
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// 권한 캐시 (메모리, 5분 TTL)
|
|
8
|
+
let permissionCache = {};
|
|
9
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 역할 권한 정보 조회 (캐시 포함)
|
|
13
|
+
* @param {string} role - 역할 이름
|
|
14
|
+
* @returns {Promise<{access: string[], permissions: string[]} | null>}
|
|
15
|
+
*/
|
|
16
|
+
async function fetchRolePermissions(role) {
|
|
17
|
+
const cacheKey = `role:${role}`;
|
|
18
|
+
const cached = permissionCache[cacheKey];
|
|
19
|
+
|
|
20
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
21
|
+
return cached.data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const redis = redisManager.getClient('default');
|
|
26
|
+
const roleDataStr = await redis.get(`role_permissions:${role}`);
|
|
27
|
+
|
|
28
|
+
if (roleDataStr) {
|
|
29
|
+
const roleData = JSON.parse(roleDataStr);
|
|
30
|
+
permissionCache[cacheKey] = {
|
|
31
|
+
data: roleData,
|
|
32
|
+
timestamp: Date.now()
|
|
33
|
+
};
|
|
34
|
+
return roleData;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('[@ahhaohho/auth-middleware] Failed to fetch role permissions:', error.message);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 권한 캐시 초기화
|
|
46
|
+
*/
|
|
47
|
+
function clearPermissionCache() {
|
|
48
|
+
permissionCache = {};
|
|
49
|
+
console.log('[@ahhaohho/auth-middleware] Permission cache cleared');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 역할 기반 인가 미들웨어
|
|
54
|
+
* 사용자가 지정된 역할 중 하나를 가지고 있는지 확인
|
|
55
|
+
*
|
|
56
|
+
* @param {string[]} allowedRoles - 허용된 역할 배열
|
|
57
|
+
* @returns {Function} Express 미들웨어
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* router.post('/admin', authenticateJWT, requireRoles(['admin']), handler);
|
|
61
|
+
* router.get('/content', combinedAuth, requireRoles(['admin', 'teacher', 'user']), handler);
|
|
62
|
+
*/
|
|
63
|
+
function requireRoles(allowedRoles) {
|
|
64
|
+
return async (req, res, next) => {
|
|
65
|
+
// API 키 인증인 경우 스킵
|
|
66
|
+
if (req.skipAuthorization || req.apiKeyVerified) {
|
|
67
|
+
return next();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 사용자 역할 확인 (req.user 또는 req.userRole)
|
|
71
|
+
const userRole = req.user?.userRole || req.userRole;
|
|
72
|
+
|
|
73
|
+
if (!userRole) {
|
|
74
|
+
return res.status(403).json({
|
|
75
|
+
type: 'client',
|
|
76
|
+
error: 'Forbidden',
|
|
77
|
+
message: 'No role information found'
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!allowedRoles.includes(userRole)) {
|
|
82
|
+
return res.status(403).json({
|
|
83
|
+
type: 'client',
|
|
84
|
+
error: 'Forbidden',
|
|
85
|
+
message: `Role '${userRole}' is not authorized. Required: ${allowedRoles.join(', ')}`
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
next();
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 권한 기반 인가 미들웨어
|
|
95
|
+
* 사용자가 특정 권한을 가지고 있는지 확인
|
|
96
|
+
*
|
|
97
|
+
* @param {string} requiredPermission - 필요한 권한 (예: 'challenge:content:write')
|
|
98
|
+
* @returns {Function} Express 미들웨어
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* router.delete('/post/:id', combinedAuth, requirePermission('community:post:delete'), handler);
|
|
102
|
+
*/
|
|
103
|
+
function requirePermission(requiredPermission) {
|
|
104
|
+
return async (req, res, next) => {
|
|
105
|
+
// API 키 인증인 경우 스킵
|
|
106
|
+
if (req.skipAuthorization || req.apiKeyVerified) {
|
|
107
|
+
return next();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const userRole = req.user?.userRole || req.userRole;
|
|
111
|
+
|
|
112
|
+
if (!userRole) {
|
|
113
|
+
return res.status(403).json({
|
|
114
|
+
type: 'client',
|
|
115
|
+
error: 'Forbidden',
|
|
116
|
+
message: 'No role information found'
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 1. 토큰에 권한이 포함되어 있는지 확인
|
|
121
|
+
const tokenPermissions = req.user?.permissions;
|
|
122
|
+
if (tokenPermissions && Array.isArray(tokenPermissions)) {
|
|
123
|
+
if (tokenPermissions.includes('*') || tokenPermissions.includes(requiredPermission)) {
|
|
124
|
+
return next();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 와일드카드 매칭 (예: 'challenge:*' → 'challenge:content:write' 허용)
|
|
128
|
+
const hasWildcardMatch = tokenPermissions.some(perm => {
|
|
129
|
+
if (perm.endsWith(':*')) {
|
|
130
|
+
const prefix = perm.slice(0, -1);
|
|
131
|
+
return requiredPermission.startsWith(prefix);
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (hasWildcardMatch) {
|
|
137
|
+
return next();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2. 캐시/Redis에서 역할 권한 조회
|
|
142
|
+
const roleData = await fetchRolePermissions(userRole);
|
|
143
|
+
|
|
144
|
+
if (roleData && roleData.permissions) {
|
|
145
|
+
const permissions = roleData.permissions;
|
|
146
|
+
|
|
147
|
+
if (permissions.includes('*') || permissions.includes(requiredPermission)) {
|
|
148
|
+
return next();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 와일드카드 매칭
|
|
152
|
+
const hasWildcardMatch = permissions.some(perm => {
|
|
153
|
+
if (perm.endsWith(':*')) {
|
|
154
|
+
const prefix = perm.slice(0, -1);
|
|
155
|
+
return requiredPermission.startsWith(prefix);
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (hasWildcardMatch) {
|
|
161
|
+
return next();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return res.status(403).json({
|
|
166
|
+
type: 'client',
|
|
167
|
+
error: 'Forbidden',
|
|
168
|
+
message: `Permission '${requiredPermission}' is required`
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 접근 범위 기반 인가 미들웨어
|
|
175
|
+
* 사용자 역할이 특정 접근 범위를 가지고 있는지 확인
|
|
176
|
+
*
|
|
177
|
+
* @param {string} requiredAccess - 필요한 접근 범위 (예: 'Content', 'AdminPanel')
|
|
178
|
+
* @returns {Function} Express 미들웨어
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* router.get('/admin/dashboard', combinedAuth, requireAccess('AdminPanel'), handler);
|
|
182
|
+
*/
|
|
183
|
+
function requireAccess(requiredAccess) {
|
|
184
|
+
return async (req, res, next) => {
|
|
185
|
+
// API 키 인증인 경우 스킵
|
|
186
|
+
if (req.skipAuthorization || req.apiKeyVerified) {
|
|
187
|
+
return next();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const userRole = req.user?.userRole || req.userRole;
|
|
191
|
+
|
|
192
|
+
if (!userRole) {
|
|
193
|
+
return res.status(403).json({
|
|
194
|
+
type: 'client',
|
|
195
|
+
error: 'Forbidden',
|
|
196
|
+
message: 'No role information found'
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const roleData = await fetchRolePermissions(userRole);
|
|
201
|
+
|
|
202
|
+
if (!roleData || !roleData.access) {
|
|
203
|
+
return res.status(403).json({
|
|
204
|
+
type: 'client',
|
|
205
|
+
error: 'Forbidden',
|
|
206
|
+
message: 'Failed to verify access'
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!roleData.access.includes('*') && !roleData.access.includes(requiredAccess)) {
|
|
211
|
+
return res.status(403).json({
|
|
212
|
+
type: 'client',
|
|
213
|
+
error: 'Forbidden',
|
|
214
|
+
message: `Access to '${requiredAccess}' is required`
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
next();
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 리소스 소유자 검증 미들웨어
|
|
224
|
+
* 사용자가 자신의 리소스에만 접근할 수 있도록 제한
|
|
225
|
+
* admin 역할은 모든 리소스 접근 가능
|
|
226
|
+
*
|
|
227
|
+
* @param {string} paramName - 라우트 파라미터 이름 (기본값: 'userId')
|
|
228
|
+
* @returns {Function} Express 미들웨어
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* router.get('/users/:userId/profile', combinedAuth, requireOwnership('userId'), handler);
|
|
232
|
+
* router.delete('/posts/:postUserId', combinedAuth, requireOwnership('postUserId'), handler);
|
|
233
|
+
*/
|
|
234
|
+
function requireOwnership(paramName = 'userId') {
|
|
235
|
+
return (req, res, next) => {
|
|
236
|
+
// API 키 인증인 경우 스킵
|
|
237
|
+
if (req.skipAuthorization || req.apiKeyVerified) {
|
|
238
|
+
return next();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const userId = req.user?.userId || req.userId;
|
|
242
|
+
const resourceOwnerId = req.params[paramName];
|
|
243
|
+
|
|
244
|
+
// admin은 모든 리소스 접근 가능
|
|
245
|
+
const userRole = req.user?.userRole || req.userRole;
|
|
246
|
+
if (userRole === 'admin') {
|
|
247
|
+
return next();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!userId) {
|
|
251
|
+
return res.status(403).json({
|
|
252
|
+
type: 'client',
|
|
253
|
+
error: 'Forbidden',
|
|
254
|
+
message: 'User ID not found'
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (userId !== resourceOwnerId) {
|
|
259
|
+
return res.status(403).json({
|
|
260
|
+
type: 'client',
|
|
261
|
+
error: 'Forbidden',
|
|
262
|
+
message: 'You can only access your own resources'
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
next();
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 복합 권한 검증 미들웨어
|
|
272
|
+
* 여러 조건 중 하나라도 만족하면 통과
|
|
273
|
+
*
|
|
274
|
+
* @param {Object} options - 권한 옵션
|
|
275
|
+
* @param {string[]} [options.roles] - 허용된 역할 배열
|
|
276
|
+
* @param {string[]} [options.permissions] - 필요한 권한 중 하나
|
|
277
|
+
* @param {string[]} [options.access] - 필요한 접근 범위 중 하나
|
|
278
|
+
* @returns {Function} Express 미들웨어
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* router.put('/content/:id', combinedAuth, requireAny({
|
|
282
|
+
* roles: ['admin'],
|
|
283
|
+
* permissions: ['content:write:any']
|
|
284
|
+
* }), handler);
|
|
285
|
+
*/
|
|
286
|
+
function requireAny(options = {}) {
|
|
287
|
+
return async (req, res, next) => {
|
|
288
|
+
// API 키 인증인 경우 스킵
|
|
289
|
+
if (req.skipAuthorization || req.apiKeyVerified) {
|
|
290
|
+
return next();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const userRole = req.user?.userRole || req.userRole;
|
|
294
|
+
|
|
295
|
+
if (!userRole) {
|
|
296
|
+
return res.status(403).json({
|
|
297
|
+
type: 'client',
|
|
298
|
+
error: 'Forbidden',
|
|
299
|
+
message: 'No role information found'
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 1. 역할 확인
|
|
304
|
+
if (options.roles && options.roles.includes(userRole)) {
|
|
305
|
+
return next();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 역할 권한 데이터 조회
|
|
309
|
+
const roleData = await fetchRolePermissions(userRole);
|
|
310
|
+
const tokenPermissions = req.user?.permissions || [];
|
|
311
|
+
const allPermissions = roleData?.permissions || [];
|
|
312
|
+
const allAccess = roleData?.access || [];
|
|
313
|
+
|
|
314
|
+
// 2. 권한 확인
|
|
315
|
+
if (options.permissions) {
|
|
316
|
+
for (const perm of options.permissions) {
|
|
317
|
+
if (tokenPermissions.includes(perm) || allPermissions.includes(perm) || allPermissions.includes('*')) {
|
|
318
|
+
return next();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 3. 접근 범위 확인
|
|
324
|
+
if (options.access) {
|
|
325
|
+
for (const acc of options.access) {
|
|
326
|
+
if (allAccess.includes(acc) || allAccess.includes('*')) {
|
|
327
|
+
return next();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return res.status(403).json({
|
|
333
|
+
type: 'client',
|
|
334
|
+
error: 'Forbidden',
|
|
335
|
+
message: 'Insufficient permissions'
|
|
336
|
+
});
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = {
|
|
341
|
+
requireRoles,
|
|
342
|
+
requirePermission,
|
|
343
|
+
requireAccess,
|
|
344
|
+
requireOwnership,
|
|
345
|
+
requireAny,
|
|
346
|
+
clearPermissionCache,
|
|
347
|
+
fetchRolePermissions
|
|
348
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const redisManager = require('../config/redis');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* API 키 검증 유틸리티
|
|
5
|
+
* Redis에 저장된 API 키와 비교하여 검증
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* API 키 검증
|
|
10
|
+
* @param {string} deviceId - 디바이스 ID (Redis 키)
|
|
11
|
+
* @param {string} apiKey - 검증할 API 키
|
|
12
|
+
* @returns {Promise<{valid: boolean, deviceId?: string, error?: string}>}
|
|
13
|
+
*/
|
|
14
|
+
async function verifyApiKey(deviceId, apiKey) {
|
|
15
|
+
if (!deviceId || !apiKey) {
|
|
16
|
+
return {
|
|
17
|
+
valid: false,
|
|
18
|
+
error: 'Device ID and API key are required'
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const redis = redisManager.getClient('apikey');
|
|
24
|
+
const storedKey = await redis.get(deviceId);
|
|
25
|
+
|
|
26
|
+
if (!storedKey) {
|
|
27
|
+
return {
|
|
28
|
+
valid: false,
|
|
29
|
+
error: 'API key not found or expired'
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (apiKey !== storedKey) {
|
|
34
|
+
return {
|
|
35
|
+
valid: false,
|
|
36
|
+
error: 'Invalid API key'
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
valid: true,
|
|
42
|
+
deviceId: deviceId
|
|
43
|
+
};
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('[@ahhaohho/auth-middleware] API key verification error:', error.message);
|
|
46
|
+
return {
|
|
47
|
+
valid: false,
|
|
48
|
+
error: 'API key verification failed'
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* API 키 TTL 확인
|
|
55
|
+
* @param {string} deviceId - 디바이스 ID
|
|
56
|
+
* @returns {Promise<number>} TTL in seconds (-1 if no expiry, -2 if not exists)
|
|
57
|
+
*/
|
|
58
|
+
async function getApiKeyTTL(deviceId) {
|
|
59
|
+
try {
|
|
60
|
+
const redis = redisManager.getClient('apikey');
|
|
61
|
+
return await redis.ttl(deviceId);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('[@ahhaohho/auth-middleware] API key TTL check error:', error.message);
|
|
64
|
+
return -2;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
verifyApiKey,
|
|
70
|
+
getApiKeyTTL
|
|
71
|
+
};
|