forked from UKSOURCE/cms.hailearning.edu.vn
token image , api key header
This commit is contained in:
BIN
.env.example
BIN
.env.example
Binary file not shown.
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
BIN
public/img/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
18
server.js
18
server.js
@@ -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
55
utils/signedUrl.js
Normal 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 };
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user