token image , api key header

This commit is contained in:
2026-04-11 14:22:45 +07:00
parent 6b7655aa16
commit 055ee69a71
9 changed files with 73 additions and 13 deletions

Binary file not shown.

View File

@@ -4,6 +4,7 @@ const Department = require('../models/department');
const Level = require('../models/level'); const Level = require('../models/level');
const writeAuditLog = require('../audit/writeAuditLog'); const writeAuditLog = require('../audit/writeAuditLog');
const AUDIT_ACTIONS = require('../constants/auditAction'); const AUDIT_ACTIONS = require('../constants/auditAction');
const { generateSignedUrl } = require('../utils/signedUrl');
function normalizePath(filePath) { function normalizePath(filePath) {
if (!filePath) return undefined; 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' }); if (cert.status === 'revoked') return res.status(404).json({ error: 'Certificate has been revoked' });
const baseUrl = `${req.protocol}://${req.get('host')}`; 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 = { const response = {
full_name: cert.student_name, full_name: cert.student_name,

View File

@@ -4,6 +4,7 @@ const Department = require('../models/department');
const Level = require('../models/level'); const Level = require('../models/level');
const writeAuditLog = require('../audit/writeAuditLog'); const writeAuditLog = require('../audit/writeAuditLog');
const AUDIT_ACTIONS = require('../constants/auditAction'); const AUDIT_ACTIONS = require('../constants/auditAction');
const { generateSignedUrl } = require('../utils/signedUrl');
function normalizePath(filePath) { function normalizePath(filePath) {
if (!filePath) return undefined; 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' }); if (qual.status === 'revoked') return res.status(404).json({ error: 'Degree has been revoked' });
const baseUrl = `${req.protocol}://${req.get('host')}`; 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 = { const response = {
full_name: qual.student_name, full_name: qual.student_name,

View File

@@ -1,11 +1,10 @@
/** /**
* API Key middleware * API Key middleware
* Validates the api_key query parameter against process.env.API_KEY * Reads key from X-API-Key header (preferred) or ?api_key= query param (fallback)
* Spec: GET /api/verify-degree/{id}?api_key={API_KEY}
*/ */
function validateApiKey(req, res, next) { 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) { if (!apiKey || apiKey !== process.env.API_KEY) {
return res.status(401).json({ error: 'Unauthorized - Invalid API key' }); return res.status(401).json({ error: 'Unauthorized - Invalid API key' });

BIN
public/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -121,14 +121,13 @@ app.use((req, res, next) => {
// Simple CORS middleware for API endpoints // Simple CORS middleware for API endpoints
app.use((req, res, next) => { app.use((req, res, next) => {
// Allow requests from configured FRONTEND_URL or allow all if not set
const origin = req.headers.origin; const origin = req.headers.origin;
const allowedOrigin = FRONTEND_URL || "*"; const allowedOrigin = FRONTEND_URL || "*";
if (allowedOrigin === "*" || origin === allowedOrigin) { if (allowedOrigin === "*" || origin === allowedOrigin) {
res.setHeader("Access-Control-Allow-Origin", allowedOrigin === "*" ? "*" : origin); res.setHeader("Access-Control-Allow-Origin", allowedOrigin === "*" ? "*" : origin);
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); 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"); res.setHeader("Access-Control-Allow-Credentials", "true");
} }
@@ -140,21 +139,24 @@ app.use((req, res, next) => {
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) => { app.get("/secure-files/:filename", (req, res) => {
const apiKey = req.query.api_key; const filename = path.basename(req.params.filename);
if (!apiKey || apiKey !== process.env.API_KEY) { const { token, expires } = req.query;
return res.status(401).json({ error: "Unauthorized - Invalid API key" });
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); const filePath = path.join(__dirname, "private", "uploads", "degree", filename);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: "File not found" }); return res.status(404).json({ error: "File not found" });
} }
res.setHeader("Access-Control-Allow-Origin", FRONTEND_URL || "*"); res.setHeader("Access-Control-Allow-Origin", FRONTEND_URL || "*");
res.setHeader("Cache-Control", "private, no-store"); // prevent caching of sensitive docs
res.sendFile(filePath); res.sendFile(filePath);
}); });

55
utils/signedUrl.js Normal file
View File

@@ -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 };

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> | ULDP Management</title> <title><%= title %> | ULDP Management</title>
<link rel="icon" type="image/png" href="/img/favicon.png" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/assets/css/variables.css"> <link rel="stylesheet" href="/assets/css/variables.css">

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 — Page Not Found | ULDP Management</title> <title>404 — Page Not Found | ULDP Management</title>
<link rel="icon" type="image/png" href="/img/favicon.png" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/assets/css/variables.css"> <link rel="stylesheet" href="/assets/css/variables.css">