diff --git a/.env.example b/.env.example index 241121b..e9b420f 100644 Binary files a/.env.example and b/.env.example differ 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 0000000..12b982e Binary files /dev/null and b/public/img/favicon.png differ diff --git a/server.js b/server.js index 3905493..c0e766d 100644 --- a/server.js +++ b/server.js @@ -121,14 +121,13 @@ app.use((req, res, next) => { // 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 @@