@ahhaohho/auth-middleware 1.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/README.md +214 -0
- package/package.json +43 -0
- package/src/config/redis.js +70 -0
- package/src/index.js +35 -0
- package/src/middleware/auth.js +121 -0
- package/src/strategies/jwt.strategy.js +96 -0
- package/src/strategies/refresh.strategy.js +71 -0
- package/src/utils/blacklist.js +84 -0
- package/src/utils/jwtValidator.js +68 -0
- package/src/utils/secretManager.js +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# @ahhaohho/auth-middleware
|
|
2
|
+
|
|
3
|
+
Shared authentication middleware with Passport.js for ahhaohho microservices.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- ✅ Passport.js JWT authentication strategy
|
|
8
|
+
- ✅ Multi-key JWT verification with fallback support
|
|
9
|
+
- ✅ Redis-based token blacklist
|
|
10
|
+
- ✅ AWS Secrets Manager integration
|
|
11
|
+
- ✅ Express middleware ready
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
### Using Git (recommended for private packages)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install git+ssh://git@github.com:ahhaohho/auth-middleware.git#v1.0.0
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or add to `package.json`:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@ahhaohho/auth-middleware": "git+ssh://git@github.com:ahhaohho/auth-middleware.git#v1.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### Basic Setup
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
const express = require('express');
|
|
37
|
+
const { authenticateJWT, authenticateRefresh } = require('@ahhaohho/auth-middleware');
|
|
38
|
+
|
|
39
|
+
const app = express();
|
|
40
|
+
|
|
41
|
+
// Environment variables required
|
|
42
|
+
// AWS_REGION=ap-northeast-2
|
|
43
|
+
// REDIS_HOST=your-redis-host
|
|
44
|
+
// REDIS_PORT=6379
|
|
45
|
+
// JWT_SECRET_NAME=your-secret-name
|
|
46
|
+
|
|
47
|
+
// Protected routes
|
|
48
|
+
app.get('/api/verify', authenticateJWT, (req, res) => {
|
|
49
|
+
res.json({
|
|
50
|
+
userId: req.user.userId,
|
|
51
|
+
userRole: req.user.userRole
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
app.get('/api/refresh', authenticateRefresh, (req, res) => {
|
|
56
|
+
// Generate new access token
|
|
57
|
+
res.json({ newAccessToken: '...' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
app.listen(3000);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Environment Variables
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Required
|
|
67
|
+
AWS_REGION=ap-northeast-2
|
|
68
|
+
REDIS_HOST=your-redis-host
|
|
69
|
+
REDIS_PORT=6379
|
|
70
|
+
JWT_SECRET_NAME=your-secret-name
|
|
71
|
+
|
|
72
|
+
# Optional
|
|
73
|
+
ELASTICACHE_ENDPOINT=your-elasticache-endpoint # If using ElastiCache
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Architecture
|
|
77
|
+
|
|
78
|
+
### JWT Verification Flow
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
Request with JWT
|
|
82
|
+
↓
|
|
83
|
+
authenticateJWT middleware
|
|
84
|
+
↓
|
|
85
|
+
Extract token from Authorization header
|
|
86
|
+
↓
|
|
87
|
+
Verify with current JWT key
|
|
88
|
+
↓ (if fails)
|
|
89
|
+
Fallback to previous JWT key
|
|
90
|
+
↓
|
|
91
|
+
Check Redis blacklist
|
|
92
|
+
↓
|
|
93
|
+
Inject user data to req.user
|
|
94
|
+
↓
|
|
95
|
+
Next middleware
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Multi-Key Support
|
|
99
|
+
|
|
100
|
+
Supports seamless JWT key rotation:
|
|
101
|
+
- Verifies with current key first
|
|
102
|
+
- Falls back to previous key if current fails
|
|
103
|
+
- Allows zero-downtime key rotation
|
|
104
|
+
|
|
105
|
+
### Token Blacklist
|
|
106
|
+
|
|
107
|
+
Uses Redis to maintain revoked tokens:
|
|
108
|
+
- Stores blacklisted tokens per user
|
|
109
|
+
- Automatically expires with token TTL
|
|
110
|
+
- Checked on every authentication
|
|
111
|
+
|
|
112
|
+
## API Reference
|
|
113
|
+
|
|
114
|
+
### `authenticateJWT(req, res, next)`
|
|
115
|
+
|
|
116
|
+
Passport.js middleware for JWT authentication.
|
|
117
|
+
|
|
118
|
+
**Headers:**
|
|
119
|
+
- `Authorization: Bearer <access_token>`
|
|
120
|
+
|
|
121
|
+
**Sets:**
|
|
122
|
+
- `req.user`: `{ userId, userRole, phoneNumber }`
|
|
123
|
+
|
|
124
|
+
**Errors:**
|
|
125
|
+
- 401: Unauthorized (invalid or expired token)
|
|
126
|
+
- 500: Authentication error
|
|
127
|
+
|
|
128
|
+
### `authenticateRefresh(req, res, next)`
|
|
129
|
+
|
|
130
|
+
Passport.js middleware for refresh token authentication.
|
|
131
|
+
|
|
132
|
+
**Headers:**
|
|
133
|
+
- `Refresh-Token: Bearer <refresh_token>`
|
|
134
|
+
|
|
135
|
+
**Sets:**
|
|
136
|
+
- `req.user`: `{ userId, userRole, phoneNumber }`
|
|
137
|
+
|
|
138
|
+
**Errors:**
|
|
139
|
+
- 401: Invalid refresh token
|
|
140
|
+
- 500: Token refresh error
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
### Project Structure
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
auth-middleware/
|
|
148
|
+
├── src/
|
|
149
|
+
│ ├── index.js # Main export
|
|
150
|
+
│ ├── strategies/
|
|
151
|
+
│ │ ├── jwt.strategy.js # Passport JWT strategy
|
|
152
|
+
│ │ └── refresh.strategy.js # Refresh token strategy
|
|
153
|
+
│ ├── middleware/
|
|
154
|
+
│ │ └── auth.js # Express middleware
|
|
155
|
+
│ ├── utils/
|
|
156
|
+
│ │ ├── jwtValidator.js # Multi-key verification
|
|
157
|
+
│ │ ├── blacklist.js # Redis blacklist
|
|
158
|
+
│ │ └── secretManager.js # AWS Secrets Manager
|
|
159
|
+
│ └── config/
|
|
160
|
+
│ └── redis.js # Redis client singleton
|
|
161
|
+
├── package.json
|
|
162
|
+
└── README.md
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Testing Locally
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
# Clone the repository
|
|
169
|
+
git clone git@github.com:ahhaohho/auth-middleware.git
|
|
170
|
+
cd auth-middleware
|
|
171
|
+
|
|
172
|
+
# Install dependencies
|
|
173
|
+
npm install
|
|
174
|
+
|
|
175
|
+
# Link locally for testing
|
|
176
|
+
npm link
|
|
177
|
+
|
|
178
|
+
# In your service directory
|
|
179
|
+
npm link @ahhaohho/auth-middleware
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Versioning
|
|
183
|
+
|
|
184
|
+
This package follows [Semantic Versioning](https://semver.org/).
|
|
185
|
+
|
|
186
|
+
### Creating a New Version
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
# Update version in package.json
|
|
190
|
+
npm version patch # 1.0.0 -> 1.0.1
|
|
191
|
+
npm version minor # 1.0.0 -> 1.1.0
|
|
192
|
+
npm version major # 1.0.0 -> 2.0.0
|
|
193
|
+
|
|
194
|
+
# Push with tags
|
|
195
|
+
git push origin main --tags
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Using Specific Versions
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{
|
|
202
|
+
"dependencies": {
|
|
203
|
+
"@ahhaohho/auth-middleware": "git+ssh://git@github.com:ahhaohho/auth-middleware.git#v1.0.0"
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Migration Guide
|
|
209
|
+
|
|
210
|
+
See [MIGRATION.md](./MIGRATION.md) for detailed migration guide from HTTP-based authentication to Passport.js.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ahhaohho/auth-middleware",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared authentication middleware with Passport.js for ahhaohho microservices",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Warning: no test specified\" && exit 0",
|
|
8
|
+
"prepublishOnly": "echo \"Publishing package...\""
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/ahhaohho/auth-middleware.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/ahhaohho/auth-middleware/issues"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/ahhaohho/auth-middleware#readme",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"passport",
|
|
23
|
+
"jwt",
|
|
24
|
+
"authentication",
|
|
25
|
+
"middleware",
|
|
26
|
+
"ahhaohho"
|
|
27
|
+
],
|
|
28
|
+
"author": "ahhaohho",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"passport": "^0.7.0",
|
|
32
|
+
"passport-jwt": "^4.0.1",
|
|
33
|
+
"jsonwebtoken": "^9.0.2",
|
|
34
|
+
"ioredis": "^5.4.1",
|
|
35
|
+
"@aws-sdk/client-secrets-manager": "^3.552.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"express": "^4.x"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=16.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const Redis = require('ioredis');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Redis 클라이언트 싱글톤
|
|
5
|
+
* 모든 서비스에서 하나의 Redis 연결만 사용
|
|
6
|
+
*/
|
|
7
|
+
class RedisManager {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.clients = {};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Redis 클라이언트 가져오기
|
|
14
|
+
* @param {string} type - 클라이언트 타입 (session, token, keys)
|
|
15
|
+
* @returns {Redis} Redis 클라이언트 인스턴스
|
|
16
|
+
*/
|
|
17
|
+
getClient(type = 'default') {
|
|
18
|
+
if (!this.clients[type]) {
|
|
19
|
+
this.clients[type] = this.createClient(type);
|
|
20
|
+
}
|
|
21
|
+
return this.clients[type];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Redis 클라이언트 생성
|
|
26
|
+
* @param {string} type - 클라이언트 타입
|
|
27
|
+
* @returns {Redis} Redis 클라이언트
|
|
28
|
+
*/
|
|
29
|
+
createClient(type) {
|
|
30
|
+
const config = {
|
|
31
|
+
host: process.env.REDIS_HOST || process.env.ELASTICACHE_ENDPOINT || 'localhost',
|
|
32
|
+
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
|
33
|
+
connectTimeout: 10000,
|
|
34
|
+
retryDelayOnFailover: 100,
|
|
35
|
+
enableReadyCheck: false,
|
|
36
|
+
maxRetriesPerRequest: null
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ElastiCache 사용 시 TLS 활성화
|
|
40
|
+
if (process.env.ELASTICACHE_ENDPOINT) {
|
|
41
|
+
config.tls = {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const client = new Redis(config);
|
|
45
|
+
|
|
46
|
+
client.on('error', (err) => {
|
|
47
|
+
console.error(`[@ahhaohho/auth-middleware] Redis (${type}) error:`, err.message);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
client.on('connect', () => {
|
|
51
|
+
console.log(`[@ahhaohho/auth-middleware] Redis (${type}) connected`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return client;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 모든 클라이언트 연결 종료
|
|
59
|
+
*/
|
|
60
|
+
async closeAll() {
|
|
61
|
+
const closePromises = Object.values(this.clients).map(client => client.quit());
|
|
62
|
+
await Promise.all(closePromises);
|
|
63
|
+
this.clients = {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 싱글톤 인스턴스
|
|
68
|
+
const redisManager = new RedisManager();
|
|
69
|
+
|
|
70
|
+
module.exports = redisManager;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ahhaohho/auth-middleware
|
|
3
|
+
*
|
|
4
|
+
* Shared authentication middleware with Passport.js
|
|
5
|
+
* for ahhaohho microservices
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { authenticateJWT, authenticateRefresh, optionalAuth } = require('./middleware/auth');
|
|
9
|
+
const { verifyTokenWithFallback, signToken, getCurrentSigningKey } = require('./utils/jwtValidator');
|
|
10
|
+
const { isBlacklisted, addToBlacklist, clearBlacklist } = require('./utils/blacklist');
|
|
11
|
+
const { getJwtKeys, invalidateCache } = require('./utils/secretManager');
|
|
12
|
+
const redisManager = require('./config/redis');
|
|
13
|
+
|
|
14
|
+
// 메인 export - Express 미들웨어
|
|
15
|
+
module.exports = {
|
|
16
|
+
// 미들웨어
|
|
17
|
+
authenticateJWT,
|
|
18
|
+
authenticateRefresh,
|
|
19
|
+
optionalAuth,
|
|
20
|
+
|
|
21
|
+
// 유틸리티 함수 (필요시 직접 사용)
|
|
22
|
+
utils: {
|
|
23
|
+
verifyTokenWithFallback,
|
|
24
|
+
signToken,
|
|
25
|
+
getCurrentSigningKey,
|
|
26
|
+
isBlacklisted,
|
|
27
|
+
addToBlacklist,
|
|
28
|
+
clearBlacklist,
|
|
29
|
+
getJwtKeys,
|
|
30
|
+
invalidateCache
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Redis 관리자 (필요시 직접 접근)
|
|
34
|
+
redisManager
|
|
35
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const passport = require('passport');
|
|
2
|
+
const createJwtStrategy = require('../strategies/jwt.strategy');
|
|
3
|
+
const createRefreshStrategy = require('../strategies/refresh.strategy');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Passport 초기화 상태
|
|
7
|
+
*/
|
|
8
|
+
let initialized = false;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Passport 전략 초기화
|
|
12
|
+
* 한 번만 실행됨
|
|
13
|
+
*/
|
|
14
|
+
function initializePassport() {
|
|
15
|
+
if (!initialized) {
|
|
16
|
+
console.log('[@ahhaohho/auth-middleware] Initializing Passport strategies...');
|
|
17
|
+
|
|
18
|
+
passport.use('jwt', createJwtStrategy());
|
|
19
|
+
passport.use('refresh', createRefreshStrategy());
|
|
20
|
+
|
|
21
|
+
initialized = true;
|
|
22
|
+
console.log('[@ahhaohho/auth-middleware] Passport strategies initialized');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* JWT Access Token 인증 미들웨어
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* router.get('/verify', authenticateJWT, (req, res) => {
|
|
31
|
+
* res.json({ userId: req.user.userId });
|
|
32
|
+
* });
|
|
33
|
+
*/
|
|
34
|
+
function authenticateJWT(req, res, next) {
|
|
35
|
+
initializePassport();
|
|
36
|
+
|
|
37
|
+
passport.authenticate('jwt', { session: false }, (err, user, info) => {
|
|
38
|
+
if (err) {
|
|
39
|
+
console.error('[@ahhaohho/auth-middleware] Authentication error:', err.message);
|
|
40
|
+
return res.status(500).json({
|
|
41
|
+
error: 'Authentication error',
|
|
42
|
+
message: err.message
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!user) {
|
|
47
|
+
return res.status(401).json({
|
|
48
|
+
error: 'Unauthorized',
|
|
49
|
+
message: info?.message || 'Invalid or expired token'
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// req.user에 사용자 정보 주입
|
|
54
|
+
req.user = user;
|
|
55
|
+
next();
|
|
56
|
+
})(req, res, next);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Refresh Token 인증 미들웨어
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* router.get('/refresh', authenticateRefresh, (req, res) => {
|
|
64
|
+
* // Generate new access token
|
|
65
|
+
* res.json({ newAccessToken: '...' });
|
|
66
|
+
* });
|
|
67
|
+
*/
|
|
68
|
+
function authenticateRefresh(req, res, next) {
|
|
69
|
+
initializePassport();
|
|
70
|
+
|
|
71
|
+
passport.authenticate('refresh', { session: false }, (err, user, info) => {
|
|
72
|
+
if (err) {
|
|
73
|
+
console.error('[@ahhaohho/auth-middleware] Refresh token error:', err.message);
|
|
74
|
+
return res.status(500).json({
|
|
75
|
+
error: 'Token refresh error',
|
|
76
|
+
message: err.message
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!user) {
|
|
81
|
+
return res.status(401).json({
|
|
82
|
+
error: 'Invalid refresh token',
|
|
83
|
+
message: info?.message || 'Invalid or expired refresh token'
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
req.user = user;
|
|
88
|
+
next();
|
|
89
|
+
})(req, res, next);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 선택적 인증 미들웨어 (인증 실패해도 통과)
|
|
94
|
+
* req.user가 있으면 인증된 사용자, 없으면 비인증 사용자
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* router.get('/public', optionalAuth, (req, res) => {
|
|
98
|
+
* if (req.user) {
|
|
99
|
+
* res.json({ message: 'Authenticated', userId: req.user.userId });
|
|
100
|
+
* } else {
|
|
101
|
+
* res.json({ message: 'Anonymous' });
|
|
102
|
+
* }
|
|
103
|
+
* });
|
|
104
|
+
*/
|
|
105
|
+
function optionalAuth(req, res, next) {
|
|
106
|
+
initializePassport();
|
|
107
|
+
|
|
108
|
+
passport.authenticate('jwt', { session: false }, (err, user) => {
|
|
109
|
+
// 에러나 인증 실패해도 통과
|
|
110
|
+
if (user) {
|
|
111
|
+
req.user = user;
|
|
112
|
+
}
|
|
113
|
+
next();
|
|
114
|
+
})(req, res, next);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
authenticateJWT,
|
|
119
|
+
authenticateRefresh,
|
|
120
|
+
optionalAuth
|
|
121
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
|
2
|
+
const { getJwtKeys } = require('../utils/secretManager');
|
|
3
|
+
const { isBlacklisted } = require('../utils/blacklist');
|
|
4
|
+
const jwt = require('jsonwebtoken');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Passport JWT 전략 생성
|
|
8
|
+
* Access Token 검증용
|
|
9
|
+
*/
|
|
10
|
+
function createJwtStrategy() {
|
|
11
|
+
const options = {
|
|
12
|
+
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
13
|
+
// 다중 키 지원을 위한 secretOrKeyProvider 사용
|
|
14
|
+
secretOrKeyProvider: async (request, rawJwtToken, done) => {
|
|
15
|
+
try {
|
|
16
|
+
const keys = await getJwtKeys();
|
|
17
|
+
|
|
18
|
+
// 다중 키 검증 로직
|
|
19
|
+
let decoded;
|
|
20
|
+
let keyUsed;
|
|
21
|
+
let actualKey;
|
|
22
|
+
|
|
23
|
+
// 1. 현재 키로 검증 시도
|
|
24
|
+
try {
|
|
25
|
+
decoded = jwt.verify(rawJwtToken, keys.current);
|
|
26
|
+
keyUsed = 'current';
|
|
27
|
+
actualKey = keys.current;
|
|
28
|
+
} catch (currentKeyError) {
|
|
29
|
+
// 2. 이전 키가 있으면 fallback 검증 시도
|
|
30
|
+
if (keys.previous) {
|
|
31
|
+
try {
|
|
32
|
+
decoded = jwt.verify(rawJwtToken, keys.previous);
|
|
33
|
+
keyUsed = 'previous';
|
|
34
|
+
actualKey = keys.previous;
|
|
35
|
+
console.warn(
|
|
36
|
+
'[@ahhaohho/auth-middleware] ⚠️ Token verified with previous key (fallback)'
|
|
37
|
+
);
|
|
38
|
+
} catch (previousKeyError) {
|
|
39
|
+
return done(currentKeyError, false);
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
return done(currentKeyError, false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 검증 성공 시 decoded와 keyUsed를 request에 임시 저장
|
|
47
|
+
request._jwtDecoded = decoded;
|
|
48
|
+
request._jwtKeyUsed = keyUsed;
|
|
49
|
+
|
|
50
|
+
// Passport에게 사용된 실제 키 반환 (이미 검증 완료되었으므로 다시 검증해도 성공)
|
|
51
|
+
done(null, actualKey);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('[@ahhaohho/auth-middleware] ❌ JWT verification failed:', error.message);
|
|
54
|
+
done(error, false);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
passReqToCallback: true // request 객체를 verify 콜백으로 전달
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return new JwtStrategy(options, async (request, jwtPayload, done) => {
|
|
61
|
+
try {
|
|
62
|
+
// request에서 미리 검증된 decoded 가져오기
|
|
63
|
+
const decoded = request._jwtDecoded;
|
|
64
|
+
const keyUsed = request._jwtKeyUsed;
|
|
65
|
+
|
|
66
|
+
if (!decoded || !decoded.userId) {
|
|
67
|
+
return done(new Error('Invalid token payload'), false);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 블랙리스트 확인
|
|
71
|
+
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
|
72
|
+
const blacklisted = await isBlacklisted(decoded.userId, 'access', token);
|
|
73
|
+
if (blacklisted) {
|
|
74
|
+
return done(new Error('Token has been revoked'), false);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(
|
|
78
|
+
`[@ahhaohho/auth-middleware] ✅ JWT verified with ${keyUsed} key for user ${decoded.userId}`
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// req.user에 주입할 사용자 정보 반환
|
|
82
|
+
const user = {
|
|
83
|
+
userId: decoded.userId,
|
|
84
|
+
userRole: decoded.userRole,
|
|
85
|
+
phoneNumber: decoded.phoneNumber
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return done(null, user);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error('[@ahhaohho/auth-middleware] Error in JWT strategy:', error.message);
|
|
91
|
+
return done(error, false);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = createJwtStrategy;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const { Strategy: JwtStrategy } = require('passport-jwt');
|
|
2
|
+
const { verifyTokenWithFallback } = require('../utils/jwtValidator');
|
|
3
|
+
const { isBlacklisted } = require('../utils/blacklist');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Refresh Token 헤더에서 토큰 추출
|
|
7
|
+
*/
|
|
8
|
+
function extractRefreshToken(req) {
|
|
9
|
+
let token = null;
|
|
10
|
+
|
|
11
|
+
if (req && req.headers && req.headers['refresh-token']) {
|
|
12
|
+
let refreshToken = req.headers['refresh-token'];
|
|
13
|
+
|
|
14
|
+
// Bearer 접두사 제거
|
|
15
|
+
if (refreshToken.startsWith('Bearer ')) {
|
|
16
|
+
refreshToken = refreshToken.substring(7);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
token = refreshToken.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return token;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Passport Refresh Token 전략 생성
|
|
27
|
+
*/
|
|
28
|
+
function createRefreshStrategy() {
|
|
29
|
+
const options = {
|
|
30
|
+
jwtFromRequest: extractRefreshToken,
|
|
31
|
+
secretOrKeyProvider: async (request, rawJwtToken, done) => {
|
|
32
|
+
try {
|
|
33
|
+
// 1. 다중 키로 토큰 검증
|
|
34
|
+
const { decoded, keyUsed } = await verifyTokenWithFallback(rawJwtToken);
|
|
35
|
+
|
|
36
|
+
if (!decoded || !decoded.userId) {
|
|
37
|
+
return done(new Error('Invalid refresh token payload'), false);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. 블랙리스트 확인 (refresh 타입)
|
|
41
|
+
const blacklisted = await isBlacklisted(decoded.userId, 'refresh', rawJwtToken);
|
|
42
|
+
if (blacklisted) {
|
|
43
|
+
return done(new Error('Refresh token has been revoked'), false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 3. 검증 성공
|
|
47
|
+
console.log(
|
|
48
|
+
`[@ahhaohho/auth-middleware] ✅ Refresh token verified with ${keyUsed} key for user ${decoded.userId}`
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
done(null, decoded);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('[@ahhaohho/auth-middleware] ❌ Refresh token verification failed:', error.message);
|
|
54
|
+
done(error, false);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
passReqToCallback: false
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return new JwtStrategy(options, (jwtPayload, done) => {
|
|
61
|
+
const user = {
|
|
62
|
+
userId: jwtPayload.userId,
|
|
63
|
+
userRole: jwtPayload.userRole,
|
|
64
|
+
phoneNumber: jwtPayload.phoneNumber
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return done(null, user);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = createRefreshStrategy;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const redisManager = require('../config/redis');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Redis 기반 토큰 블랙리스트 관리
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 토큰이 블랙리스트에 있는지 확인
|
|
9
|
+
* @param {string} userId - 사용자 ID
|
|
10
|
+
* @param {string} tokenType - 토큰 타입 ('access' | 'refresh')
|
|
11
|
+
* @param {string} token - JWT 토큰
|
|
12
|
+
* @returns {Promise<boolean>} 블랙리스트에 있으면 true
|
|
13
|
+
*/
|
|
14
|
+
async function isBlacklisted(userId, tokenType, token) {
|
|
15
|
+
try {
|
|
16
|
+
const redisClient = redisManager.getClient('token');
|
|
17
|
+
const key = `blacklist:${userId}:${tokenType}`;
|
|
18
|
+
|
|
19
|
+
const tokens = await redisClient.smembers(key);
|
|
20
|
+
return tokens.includes(token);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error('[@ahhaohho/auth-middleware] Error checking blacklist:', error.message);
|
|
23
|
+
// Redis 에러 시 안전하게 false 반환 (통과시킴)
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 토큰을 블랙리스트에 추가
|
|
30
|
+
* @param {string} userId - 사용자 ID
|
|
31
|
+
* @param {string} tokenType - 토큰 타입 ('access' | 'refresh')
|
|
32
|
+
* @param {string} token - JWT 토큰
|
|
33
|
+
* @param {number} ttl - TTL (초 단위, 기본값: 토큰 만료 시간)
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
async function addToBlacklist(userId, tokenType, token, ttl = null) {
|
|
37
|
+
try {
|
|
38
|
+
const redisClient = redisManager.getClient('token');
|
|
39
|
+
const key = `blacklist:${userId}:${tokenType}`;
|
|
40
|
+
|
|
41
|
+
await redisClient.sadd(key, token);
|
|
42
|
+
|
|
43
|
+
// TTL 설정
|
|
44
|
+
if (ttl) {
|
|
45
|
+
await redisClient.expire(key, ttl);
|
|
46
|
+
} else {
|
|
47
|
+
// 기본 TTL: Access Token 1시간, Refresh Token 90일
|
|
48
|
+
const defaultTtl = tokenType === 'access' ? 60 * 60 : 90 * 24 * 60 * 60;
|
|
49
|
+
await redisClient.expire(key, defaultTtl);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(`[@ahhaohho/auth-middleware] Token added to blacklist: ${userId}:${tokenType}`);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('[@ahhaohho/auth-middleware] Error adding to blacklist:', error.message);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 사용자의 모든 토큰을 블랙리스트에서 제거
|
|
61
|
+
* @param {string} userId - 사용자 ID
|
|
62
|
+
* @returns {Promise<void>}
|
|
63
|
+
*/
|
|
64
|
+
async function clearBlacklist(userId) {
|
|
65
|
+
try {
|
|
66
|
+
const redisClient = redisManager.getClient('token');
|
|
67
|
+
|
|
68
|
+
await Promise.all([
|
|
69
|
+
redisClient.del(`blacklist:${userId}:access`),
|
|
70
|
+
redisClient.del(`blacklist:${userId}:refresh`)
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
console.log(`[@ahhaohho/auth-middleware] Blacklist cleared for user: ${userId}`);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('[@ahhaohho/auth-middleware] Error clearing blacklist:', error.message);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
isBlacklisted,
|
|
82
|
+
addToBlacklist,
|
|
83
|
+
clearBlacklist
|
|
84
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const jwt = require('jsonwebtoken');
|
|
2
|
+
const { getJwtKeys } = require('./secretManager');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 다중 키를 사용한 JWT 토큰 검증
|
|
6
|
+
* 현재 키로 검증 실패 시 이전 키로 fallback 검증
|
|
7
|
+
*
|
|
8
|
+
* @param {string} token - JWT 토큰
|
|
9
|
+
* @returns {Promise<{decoded: object, keyUsed: string}>}
|
|
10
|
+
*/
|
|
11
|
+
async function verifyTokenWithFallback(token) {
|
|
12
|
+
const keys = await getJwtKeys();
|
|
13
|
+
|
|
14
|
+
// 1. 현재 키로 검증 시도
|
|
15
|
+
try {
|
|
16
|
+
const decoded = jwt.verify(token, keys.current);
|
|
17
|
+
return { decoded, keyUsed: 'current' };
|
|
18
|
+
} catch (currentKeyError) {
|
|
19
|
+
// 2. 이전 키가 있으면 fallback 검증 시도
|
|
20
|
+
if (keys.previous) {
|
|
21
|
+
try {
|
|
22
|
+
const decoded = jwt.verify(token, keys.previous);
|
|
23
|
+
console.warn(
|
|
24
|
+
'[@ahhaohho/auth-middleware] ⚠️ Token verified with previous key (fallback) for userId:',
|
|
25
|
+
decoded.userId
|
|
26
|
+
);
|
|
27
|
+
return { decoded, keyUsed: 'previous' };
|
|
28
|
+
} catch (previousKeyError) {
|
|
29
|
+
// 두 키 모두 실패한 경우 현재 키의 에러를 던짐 (더 중요한 에러)
|
|
30
|
+
throw currentKeyError;
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
// 이전 키가 없으면 현재 키 에러를 그대로 던짐
|
|
34
|
+
throw currentKeyError;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 토큰 서명을 위한 현재 키 가져오기
|
|
41
|
+
* @returns {Promise<string>}
|
|
42
|
+
*/
|
|
43
|
+
async function getCurrentSigningKey() {
|
|
44
|
+
const keys = await getJwtKeys();
|
|
45
|
+
return keys.current;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* JWT 토큰 생성 (항상 현재 키 사용)
|
|
50
|
+
* @param {object} payload - JWT payload
|
|
51
|
+
* @param {object} options - JWT options (expiresIn 등)
|
|
52
|
+
* @returns {Promise<string>} JWT 토큰
|
|
53
|
+
*/
|
|
54
|
+
async function signToken(payload, options = {}) {
|
|
55
|
+
const signingKey = await getCurrentSigningKey();
|
|
56
|
+
|
|
57
|
+
if (!signingKey) {
|
|
58
|
+
throw new Error('JWT signing key is not available');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return jwt.sign(payload, signingKey, options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
verifyTokenWithFallback,
|
|
66
|
+
getCurrentSigningKey,
|
|
67
|
+
signToken
|
|
68
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
|
|
2
|
+
const redisManager = require('../config/redis');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AWS Secrets Manager에서 JWT 키 가져오기
|
|
6
|
+
*/
|
|
7
|
+
class SecretManager {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.client = new SecretsManagerClient({
|
|
10
|
+
region: process.env.AWS_REGION || 'ap-northeast-2'
|
|
11
|
+
});
|
|
12
|
+
this.secretName = process.env.JWT_SECRET_NAME;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* JWT 키 가져오기 (Redis 캐싱 포함)
|
|
17
|
+
* @returns {Promise<{current: string, previous: string|null}>}
|
|
18
|
+
*/
|
|
19
|
+
async getJwtKeys() {
|
|
20
|
+
if (!this.secretName) {
|
|
21
|
+
throw new Error('JWT_SECRET_NAME environment variable is not set');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
// 1. Redis 캐시 확인
|
|
26
|
+
const redisClient = redisManager.getClient('keys');
|
|
27
|
+
const cachedKeys = await redisClient.get(`jwt-keys:${this.secretName}`);
|
|
28
|
+
|
|
29
|
+
if (cachedKeys) {
|
|
30
|
+
console.log('[@ahhaohho/auth-middleware] Using cached JWT keys from Redis');
|
|
31
|
+
return JSON.parse(cachedKeys);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. AWS Secrets Manager에서 가져오기
|
|
35
|
+
console.log('[@ahhaohho/auth-middleware] Fetching JWT keys from AWS Secrets Manager');
|
|
36
|
+
const command = new GetSecretValueCommand({ SecretId: this.secretName });
|
|
37
|
+
const response = await this.client.send(command);
|
|
38
|
+
|
|
39
|
+
if (!response.SecretString) {
|
|
40
|
+
throw new Error('Secret value is empty');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const secret = JSON.parse(response.SecretString);
|
|
44
|
+
const keys = {
|
|
45
|
+
current: secret.current || secret.jwt_secret_key || secret.dev,
|
|
46
|
+
previous: secret.previous || null
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// 3. Redis에 캐싱 (5분 TTL)
|
|
50
|
+
await redisClient.set(
|
|
51
|
+
`jwt-keys:${this.secretName}`,
|
|
52
|
+
JSON.stringify(keys),
|
|
53
|
+
'EX',
|
|
54
|
+
300 // 5분
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return keys;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('[@ahhaohho/auth-middleware] Error fetching JWT keys:', error.message);
|
|
60
|
+
throw new Error('Failed to fetch JWT keys from AWS Secrets Manager');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 캐시 무효화
|
|
66
|
+
*/
|
|
67
|
+
async invalidateCache() {
|
|
68
|
+
const redisClient = redisManager.getClient('keys');
|
|
69
|
+
await redisClient.del(`jwt-keys:${this.secretName}`);
|
|
70
|
+
console.log('[@ahhaohho/auth-middleware] JWT keys cache invalidated');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const secretManager = new SecretManager();
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
getJwtKeys: () => secretManager.getJwtKeys(),
|
|
78
|
+
invalidateCache: () => secretManager.invalidateCache()
|
|
79
|
+
};
|