From 055ee69a71ff4a2e020055c76c1c9d7cb0895b04 Mon Sep 17 00:00:00 2001 From: nthanhtoan61 Date: Sat, 11 Apr 2026 14:22:45 +0700 Subject: [PATCH] token image , api key header --- .env.example | Bin 954 -> 1204 bytes controllers/certificateController.js | 3 +- controllers/qualificationController.js | 3 +- middleware/apiKey.js | 5 +-- public/img/favicon.png | Bin 0 -> 4272 bytes server.js | 18 ++++---- utils/signedUrl.js | 55 +++++++++++++++++++++++++ views/auth/login.ejs | 1 + views/page/404.ejs | 1 + 9 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 public/img/favicon.png create mode 100644 utils/signedUrl.js diff --git a/.env.example b/.env.example index 241121b186b5f727721bdf496c55994f58a05e27..e9b420f17e1efbf9da0c0a96123ca4c031e73c69 100644 GIT binary patch delta 259 zcmXw!y$ZrW5QIOl5c3oP8wG6y?JPv138+QXT0)E_NPbA7jfJmdVU?$IE|B8(W_IUh z_L}~0@NIe@3yUE(M)mrREE^Jega3@pEblY6j4|$SGm$k|>K0E1bM!4)*v1Tea zS7^qZ1ryC4(nl0RHL=``zEDE)=0%`p#8``L@MPw2k%7mSLyH_)`s$uoDm<3nEE6fF bGcza`4|K|QZWc|&D(Sj!8CILn_Pxa)fI=;1 delta 7 OcmdnOxr=?nE@l7>ZUXE8 diff --git a/controllers/certificateController.js b/controllers/certificateController.js index 3d4f3f1..d5f3b0c 100644 --- a/controllers/certificateController.js +++ b/controllers/certificateController.js @@ -4,6 +4,7 @@ const Department = require('../models/department'); const Level = require('../models/level'); const writeAuditLog = require('../audit/writeAuditLog'); const AUDIT_ACTIONS = require('../constants/auditAction'); +const { generateSignedUrl } = require('../utils/signedUrl'); function normalizePath(filePath) { if (!filePath) return undefined; @@ -141,7 +142,7 @@ exports.apiVerify = async (req, res) => { if (cert.status === 'revoked') return res.status(404).json({ error: 'Certificate has been revoked' }); const baseUrl = `${req.protocol}://${req.get('host')}`; - const buildUrl = (f) => f ? [`${baseUrl}/secure-files/${path.basename(f)}?api_key=${req.query.api_key}`] : undefined; + const buildUrl = (f) => f ? [generateSignedUrl(baseUrl, path.basename(f))] : undefined; const response = { full_name: cert.student_name, diff --git a/controllers/qualificationController.js b/controllers/qualificationController.js index b28e5f9..dfe6ae2 100644 --- a/controllers/qualificationController.js +++ b/controllers/qualificationController.js @@ -4,6 +4,7 @@ const Department = require('../models/department'); const Level = require('../models/level'); const writeAuditLog = require('../audit/writeAuditLog'); const AUDIT_ACTIONS = require('../constants/auditAction'); +const { generateSignedUrl } = require('../utils/signedUrl'); function normalizePath(filePath) { if (!filePath) return undefined; @@ -141,7 +142,7 @@ exports.apiVerify = async (req, res) => { if (qual.status === 'revoked') return res.status(404).json({ error: 'Degree has been revoked' }); const baseUrl = `${req.protocol}://${req.get('host')}`; - const buildUrl = (f) => f ? [`${baseUrl}/secure-files/${path.basename(f)}?api_key=${req.query.api_key}`] : undefined; + const buildUrl = (f) => f ? [generateSignedUrl(baseUrl, path.basename(f))] : undefined; const response = { full_name: qual.student_name, diff --git a/middleware/apiKey.js b/middleware/apiKey.js index f2c3dc2..b1f1449 100644 --- a/middleware/apiKey.js +++ b/middleware/apiKey.js @@ -1,11 +1,10 @@ /** * API Key middleware - * Validates the api_key query parameter against process.env.API_KEY - * Spec: GET /api/verify-degree/{id}?api_key={API_KEY} + * Reads key from X-API-Key header (preferred) or ?api_key= query param (fallback) */ function validateApiKey(req, res, next) { - const apiKey = req.query.api_key; + const apiKey = req.headers['x-api-key'] || req.query.api_key; if (!apiKey || apiKey !== process.env.API_KEY) { return res.status(401).json({ error: 'Unauthorized - Invalid API key' }); diff --git a/public/img/favicon.png b/public/img/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..12b982ee09c477b917f4b56a89cc2d7829bd9ed7 GIT binary patch literal 4272 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 { // Simple CORS middleware for API endpoints app.use((req, res, next) => { - // Allow requests from configured FRONTEND_URL or allow all if not set const origin = req.headers.origin; const allowedOrigin = FRONTEND_URL || "*"; if (allowedOrigin === "*" || origin === allowedOrigin) { res.setHeader("Access-Control-Allow-Origin", allowedOrigin === "*" ? "*" : origin); res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key"); res.setHeader("Access-Control-Allow-Credentials", "true"); } @@ -140,21 +139,24 @@ app.use((req, res, next) => { next(); }); -// Protected file serving — degree/certificate documents require API key +// Protected file serving — degree/certificate documents via signed URLs +const { verifySignedUrl } = require('./utils/signedUrl'); app.get("/secure-files/:filename", (req, res) => { - const apiKey = req.query.api_key; - if (!apiKey || apiKey !== process.env.API_KEY) { - return res.status(401).json({ error: "Unauthorized - Invalid API key" }); + const filename = path.basename(req.params.filename); + const { token, expires } = req.query; + + const result = verifySignedUrl(filename, token, expires); + if (!result.valid) { + return res.status(401).json({ error: result.reason || 'Unauthorized' }); } - const filename = path.basename(req.params.filename); // prevent path traversal const filePath = path.join(__dirname, "private", "uploads", "degree", filename); - if (!fs.existsSync(filePath)) { return res.status(404).json({ error: "File not found" }); } res.setHeader("Access-Control-Allow-Origin", FRONTEND_URL || "*"); + res.setHeader("Cache-Control", "private, no-store"); // prevent caching of sensitive docs res.sendFile(filePath); }); diff --git a/utils/signedUrl.js b/utils/signedUrl.js new file mode 100644 index 0000000..3d41049 --- /dev/null +++ b/utils/signedUrl.js @@ -0,0 +1,55 @@ +const crypto = require('crypto'); + +const SECRET = process.env.FILE_SIGN_SECRET || process.env.SESSION_SECRET || 'file-sign-secret'; +const EXPIRY_SECONDS = 15 * 60; // 15 minutes + +/** + * Generate a signed URL for a protected file + * @param {string} baseUrl - e.g. "https://host" + * @param {string} filename - basename of the file + * @returns {string} full signed URL + */ +function generateSignedUrl(baseUrl, filename) { + const expires = Math.floor(Date.now() / 1000) + EXPIRY_SECONDS; + const payload = `${filename}:${expires}`; + const token = crypto + .createHmac('sha256', SECRET) + .update(payload) + .digest('base64url'); + + return `${baseUrl}/secure-files/${encodeURIComponent(filename)}?token=${token}&expires=${expires}`; +} + +/** + * Verify a signed URL token + * @param {string} filename + * @param {string} token + * @param {number} expires - unix timestamp + * @returns {{ valid: boolean, reason?: string }} + */ +function verifySignedUrl(filename, token, expires) { + const now = Math.floor(Date.now() / 1000); + + if (!token || !expires) { + return { valid: false, reason: 'Missing token or expires' }; + } + + if (now > Number(expires)) { + return { valid: false, reason: 'URL has expired' }; + } + + const payload = `${filename}:${expires}`; + const expected = crypto + .createHmac('sha256', SECRET) + .update(payload) + .digest('base64url'); + + const valid = crypto.timingSafeEqual( + Buffer.from(token), + Buffer.from(expected) + ); + + return valid ? { valid: true } : { valid: false, reason: 'Invalid token' }; +} + +module.exports = { generateSignedUrl, verifySignedUrl }; diff --git a/views/auth/login.ejs b/views/auth/login.ejs index 97401f3..cccfcb6 100644 --- a/views/auth/login.ejs +++ b/views/auth/login.ejs @@ -4,6 +4,7 @@ <%= title %> | ULDP Management + diff --git a/views/page/404.ejs b/views/page/404.ejs index e92e1f0..5529081 100644 --- a/views/page/404.ejs +++ b/views/page/404.ejs @@ -4,6 +4,7 @@ 404 — Page Not Found | ULDP Management +