@seip/blue-bird 0.7.6 → 0.9.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/.env_example +34 -34
- package/AGENTS.md +174 -249
- package/LICENSE +21 -21
- package/README.md +331 -367
- package/{index.js → backend/index.js} +22 -30
- package/backend/routes/api.js +57 -57
- package/core/app.js +338 -402
- package/core/auth.js +262 -256
- package/core/cache.js +174 -174
- package/core/cli/docker.js +488 -457
- package/core/cli/init.js +333 -337
- package/core/cli/route.js +42 -42
- package/core/config.js +52 -52
- package/core/database.js +263 -263
- package/core/debug.js +248 -248
- package/core/logger.js +115 -115
- package/core/middleware.js +27 -27
- package/core/router.js +144 -144
- package/core/swagger.js +40 -40
- package/core/upload.js +77 -77
- package/core/validate.js +380 -380
- package/docker/Dockerfile +16 -16
- package/docker/docker-compose.dev.yml +6 -0
- package/docker/docker-compose.mysql.yml +92 -92
- package/docker/docker-compose.none.yml +68 -68
- package/docker/docker-compose.postgres.yml +93 -93
- package/docker/nginx.conf +98 -106
- package/docker-compose.yml +92 -92
- package/frontend/about.html +103 -0
- package/frontend/images/favicon.ico +0 -0
- package/frontend/index.html +141 -0
- package/frontend/js/tailwind.js +8 -0
- package/package.json +64 -71
- package/frontend/astro.config.mjs +0 -35
- package/frontend/public/css/app.css +0 -319
- package/frontend/public/favicon.ico +0 -0
- package/frontend/src/http/api.js +0 -29
- package/frontend/src/layouts/Layout.astro +0 -20
- package/frontend/src/pages/about.astro +0 -54
- package/frontend/src/pages/index.astro +0 -110
package/core/auth.js
CHANGED
|
@@ -1,256 +1,262 @@
|
|
|
1
|
-
import jwt from "jsonwebtoken";
|
|
2
|
-
import crypto from "node:crypto";
|
|
3
|
-
import Config from "./config.js";
|
|
4
|
-
import { getRedisClient } from "./cache.js";
|
|
5
|
-
|
|
6
|
-
const propsConfig = Config.props();
|
|
7
|
-
const jwtSecret = propsConfig.jwtSecret;
|
|
8
|
-
const production = !propsConfig.debug;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
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
|
-
return null;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
*
|
|
91
|
-
* @param {
|
|
92
|
-
* @
|
|
93
|
-
* @
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
req.
|
|
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
|
-
* @param {import('express').
|
|
162
|
-
* @
|
|
163
|
-
* @
|
|
164
|
-
*
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
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
|
-
|
|
1
|
+
import jwt from "jsonwebtoken";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import Config from "./config.js";
|
|
4
|
+
import { getRedisClient } from "./cache.js";
|
|
5
|
+
|
|
6
|
+
const propsConfig = Config.props();
|
|
7
|
+
const jwtSecret = propsConfig.jwtSecret;
|
|
8
|
+
const production = !propsConfig.debug;
|
|
9
|
+
|
|
10
|
+
const aesKey = crypto.createHash("sha256").update(jwtSecret || "default").digest();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Auth class to handle JWT generation, verification and protection with AES-256-GCM encryption.
|
|
14
|
+
*/
|
|
15
|
+
class Auth {
|
|
16
|
+
/**
|
|
17
|
+
* Encrypts a payload using AES-256-GCM.
|
|
18
|
+
* @param {Object} payload - The data to encrypt.
|
|
19
|
+
* @param {string} secret - The secret key for encryption.
|
|
20
|
+
* @returns {string} The encrypted string in format iv:tag:encrypted.
|
|
21
|
+
*/
|
|
22
|
+
static encrypt(payload, secret) {
|
|
23
|
+
const iv = crypto.randomBytes(12);
|
|
24
|
+
const key = secret === jwtSecret ? aesKey : crypto.createHash("sha256").update(secret).digest();
|
|
25
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
26
|
+
let encrypted = cipher.update(JSON.stringify(payload), "utf8", "hex");
|
|
27
|
+
encrypted += cipher.final("hex");
|
|
28
|
+
const tag = cipher.getAuthTag().toString("hex");
|
|
29
|
+
return `${iv.toString("hex")}:${tag}:${encrypted}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decrypts a payload using AES-256-GCM.
|
|
34
|
+
* @param {string} data - The encrypted string in format iv:tag:encrypted.
|
|
35
|
+
* @param {string} secret - The secret key for decryption.
|
|
36
|
+
* @returns {Object|null} The decrypted object or null if failed.
|
|
37
|
+
*/
|
|
38
|
+
static decrypt(data, secret) {
|
|
39
|
+
try {
|
|
40
|
+
const [ivHex, tagHex, encryptedHex] = data.split(":");
|
|
41
|
+
if (!ivHex || !tagHex || !encryptedHex) return null;
|
|
42
|
+
|
|
43
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
44
|
+
const tag = Buffer.from(tagHex, "hex");
|
|
45
|
+
const key = secret === jwtSecret ? aesKey : crypto.createHash("sha256").update(secret).digest();
|
|
46
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
47
|
+
decipher.setAuthTag(tag);
|
|
48
|
+
|
|
49
|
+
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
|
50
|
+
decrypted += decipher.final("utf8");
|
|
51
|
+
return JSON.parse(decrypted);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generates an encrypted JWT token.
|
|
59
|
+
* @param {Object} payload - The data to store in the token.
|
|
60
|
+
* @param {string} [secret=process.env.JWT_SECRET] - The secret key .
|
|
61
|
+
* @param {string} [expiresIn="24h"] - Expiration time.
|
|
62
|
+
* @returns {string} The generated token.
|
|
63
|
+
*/
|
|
64
|
+
static generateToken(payload, secret = jwtSecret, expiresIn = "24h") {
|
|
65
|
+
if (!secret)
|
|
66
|
+
throw new Error("FATAL: JWT_SECRET environment variable is not defined.");
|
|
67
|
+
const encrypted = this.encrypt(payload, secret);
|
|
68
|
+
return jwt.sign({ data: encrypted }, secret, { expiresIn });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verifies and decrypts a JWT token.
|
|
73
|
+
* @param {string} token - The token to verify.
|
|
74
|
+
* @param {string} [secret=process.env.JWT_SECRET] - The secret key.
|
|
75
|
+
* @returns {Object|null} The decoded and decrypted payload or null if invalid.
|
|
76
|
+
*/
|
|
77
|
+
static verifyToken(token, secret = jwtSecret) {
|
|
78
|
+
if (!secret)
|
|
79
|
+
throw new Error("FATAL: JWT_SECRET environment variable is not defined.");
|
|
80
|
+
try {
|
|
81
|
+
const decoded = jwt.verify(token, secret);
|
|
82
|
+
if (!decoded || !decoded.data) return null;
|
|
83
|
+
return this.decrypt(decoded.data, secret);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Middleware to protect routes. Checks for token in Cookies or Authorization header.
|
|
91
|
+
* @param {Object} [options={}] - Options for protection.
|
|
92
|
+
* @param {string} [options.redirect=null] - URL to redirect if not authenticated.
|
|
93
|
+
* @param {string} [options.key="user"] - Key to store the decoded token in the request.
|
|
94
|
+
* @param {string} [options.cookieKey="auth"] - The cookie key to look for the token.
|
|
95
|
+
* @returns {Function} Express middleware.
|
|
96
|
+
* @example
|
|
97
|
+
* router.get("/profile", Auth.protect(), (req, res) => { ... });
|
|
98
|
+
* // Or with custom cookie key:
|
|
99
|
+
* router.get("/admin", Auth.protect({ cookieKey: "admin_session" }), (req, res) => { ... });
|
|
100
|
+
*/
|
|
101
|
+
static protect(options = {}) {
|
|
102
|
+
const { redirect = null, key = "user", cookieKey = "auth" } = options;
|
|
103
|
+
|
|
104
|
+
return async (req, res, next) => {
|
|
105
|
+
const token =
|
|
106
|
+
req.cookies?.[cookieKey] || req.headers.authorization?.split(" ")[1];
|
|
107
|
+
|
|
108
|
+
const expectsJson =
|
|
109
|
+
req.xhr ||
|
|
110
|
+
req.headers.accept?.includes("application/json") ||
|
|
111
|
+
req.path.startsWith("/api");
|
|
112
|
+
|
|
113
|
+
if (!token) {
|
|
114
|
+
if (redirect && !expectsJson) return res.redirect(redirect);
|
|
115
|
+
return expectsJson
|
|
116
|
+
? res.status(401).json({ message: "Unauthorized" })
|
|
117
|
+
: res.status(401).send();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const decoded = this.verifyToken(token);
|
|
121
|
+
if (!decoded) {
|
|
122
|
+
if (redirect && !expectsJson) return res.redirect(redirect);
|
|
123
|
+
return expectsJson
|
|
124
|
+
? res.status(401).json({ message: "Unauthorized" })
|
|
125
|
+
: res.status(401).send();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const redisClient = getRedisClient();
|
|
129
|
+
if (redisClient && decoded._sessionId) {
|
|
130
|
+
try {
|
|
131
|
+
const sessionData = await redisClient.get(
|
|
132
|
+
`session:${decoded._sessionId}`,
|
|
133
|
+
);
|
|
134
|
+
if (!sessionData) {
|
|
135
|
+
if (redirect && !expectsJson) return res.redirect(redirect);
|
|
136
|
+
return expectsJson
|
|
137
|
+
? res.status(401).json({ message: "Unauthorized" })
|
|
138
|
+
: res.status(401).send();
|
|
139
|
+
}
|
|
140
|
+
req[key || "user"] = JSON.parse(sessionData);
|
|
141
|
+
return next();
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error(
|
|
144
|
+
"[AUTH ERROR] Failed to get session data from Redis:",
|
|
145
|
+
err.message,
|
|
146
|
+
);
|
|
147
|
+
if (redirect && !expectsJson) return res.redirect(redirect);
|
|
148
|
+
return expectsJson
|
|
149
|
+
? res.status(401).json({ message: "Unauthorized" })
|
|
150
|
+
: res.status(401).send();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
req[key || "user"] = decoded;
|
|
155
|
+
next();
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Logs in a user by setting an authentication cookie.
|
|
161
|
+
* @param {import('express').Response} res - The response object.
|
|
162
|
+
* @param {Object} data - The data to store in the token.
|
|
163
|
+
* @param {string} [key="auth"] - The key for the cookie.
|
|
164
|
+
* @param {Object} [options={}] - Options for the cookie and token.
|
|
165
|
+
* @param {string} [options.expiresIn="24h"] - Token expiration (e.g., "1h", "7d").
|
|
166
|
+
* @param {import('express').CookieOptions} [options.cookie] - Express cookie options.
|
|
167
|
+
* @returns {Promise<string>} The generated token.
|
|
168
|
+
* @example
|
|
169
|
+
* await Auth.login(res, { id: 1, name: "Admin" });
|
|
170
|
+
*/
|
|
171
|
+
static async login(res, data, key = "auth", options = {}) {
|
|
172
|
+
const { expiresIn = "24h", cookie = {} } = options;
|
|
173
|
+
const sessionId = crypto.randomUUID();
|
|
174
|
+
const tokenPayload = { ...data, _sessionId: sessionId };
|
|
175
|
+
|
|
176
|
+
const token = this.generateToken(tokenPayload, jwtSecret, expiresIn);
|
|
177
|
+
|
|
178
|
+
let ttl = 86400; // default 24h
|
|
179
|
+
if (typeof expiresIn === "string") {
|
|
180
|
+
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
|
181
|
+
if (match) {
|
|
182
|
+
const val = parseInt(match[1]);
|
|
183
|
+
const unit = match[2];
|
|
184
|
+
if (unit === "s") ttl = val;
|
|
185
|
+
else if (unit === "m") ttl = val * 60;
|
|
186
|
+
else if (unit === "h") ttl = val * 3600;
|
|
187
|
+
else if (unit === "d") ttl = val * 86400;
|
|
188
|
+
}
|
|
189
|
+
} else if (typeof expiresIn === "number") {
|
|
190
|
+
ttl = expiresIn;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const defaultCookieOptions = {
|
|
194
|
+
maxAge: ttl * 1000,
|
|
195
|
+
httpOnly: true,
|
|
196
|
+
secure: production,
|
|
197
|
+
sameSite: "strict",
|
|
198
|
+
path: "/",
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const finalCookieOptions = { ...defaultCookieOptions, ...cookie };
|
|
202
|
+
|
|
203
|
+
const redisClient = getRedisClient();
|
|
204
|
+
if (redisClient) {
|
|
205
|
+
try {
|
|
206
|
+
await redisClient.set(`session:${sessionId}`, JSON.stringify(data), {
|
|
207
|
+
EX: ttl,
|
|
208
|
+
});
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error(
|
|
211
|
+
"[AUTH ERROR] Failed to store session in Redis:",
|
|
212
|
+
err.message,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
res.cookie(key, token, finalCookieOptions);
|
|
218
|
+
return token;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Logs out a user by clearing the authentication cookie.
|
|
223
|
+
* @param {import('express').Response} res - The response object.
|
|
224
|
+
* @param {string} [key="auth"] - The key for the cookie.
|
|
225
|
+
* @param {import('express').CookieOptions} [options={}] - Options for clearing the cookie.
|
|
226
|
+
* @param {import('express').Request} [req=null] - The request object.
|
|
227
|
+
* @returns {Promise<boolean>} True if the cookie was cleared successfully.
|
|
228
|
+
* @example
|
|
229
|
+
* await Auth.logout(res);
|
|
230
|
+
*/
|
|
231
|
+
static async logout(res, key = "auth", options = {}, req = null) {
|
|
232
|
+
const defaultOptions = {
|
|
233
|
+
path: "/",
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (req) {
|
|
237
|
+
const token =
|
|
238
|
+
req.cookies?.[key] || req.headers.authorization?.split(" ")[1];
|
|
239
|
+
if (token) {
|
|
240
|
+
const decoded = this.verifyToken(token);
|
|
241
|
+
if (decoded && decoded._sessionId) {
|
|
242
|
+
const redisClient = getRedisClient();
|
|
243
|
+
if (redisClient) {
|
|
244
|
+
try {
|
|
245
|
+
await redisClient.del(`session:${decoded._sessionId}`);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error(
|
|
248
|
+
"[AUTH ERROR] Failed to delete session from Redis:",
|
|
249
|
+
err.message,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
res.clearCookie(key, { ...defaultOptions, ...options });
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export default Auth;
|