diff --git a/server.js b/server.js index c243c6c..15a5fc3 100644 --- a/server.js +++ b/server.js @@ -1,21 +1,228 @@ +/** + * 原型站轻量登录网关 + * + * 功能: + * - 所有静态文件访问前必须通过 Cookie 认证 + * - 未登录自动跳转到自定义登录页 + * - 密码写死在本文件中,按需修改 + * + * 启动:node server.js + * 默认端口:3000(可通过环境变量 PORT 修改) + */ + const http = require('http'); const fs = require('fs'); const path = require('path'); -const PORT = 3000; -const ROOT = __dirname; -const MIME = { - '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', - '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', - '.md': 'text/plain; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', +const url = require('url'); +const crypto = require('crypto'); +const querystring = require('querystring'); + +/* ========== 配置区 ========== */ + +/** 访问密码(按需修改) */ +const PASSWORD = 'JKCQ_2026!'; + +/** Cookie 签名密钥(部署时建议替换为随机字符串) */ +const SECRET = 'hc-prototype-secret-key-2026'; + +/** Cookie 名称 */ +const COOKIE_NAME = 'prototype_auth'; + +/** Cookie 有效期(秒),默认 7 天 */ +const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; + +/** 服务端口 */ +const PORT = process.env.PORT || 3010; + +/** 静态文件根目录(当前目录) */ +const STATIC_ROOT = __dirname; + +/** 不需要登录即可访问的路径 */ +const PUBLIC_PATHS = ['/login', '/login.html', '/favicon.ico']; + +/* ========== MIME 类型映射 ========== */ + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.md': 'text/plain; charset=utf-8', }; -http.createServer((req, res) => { - let fp = path.join(ROOT, decodeURIComponent(req.url.split('?')[0])); - if (fs.existsSync(fp) && fs.statSync(fp).isDirectory()) fp = path.join(fp, 'index.html'); - const ext = path.extname(fp).toLowerCase(); - fs.readFile(fp, (err, data) => { - if (err) { res.writeHead(404); res.end('404: ' + req.url); return; } - res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' }); - res.end(data); + +/* ========== 工具函数 ========== */ + +/** 生成签名 Cookie 值 */ +function signToken() { + var expires = Date.now() + COOKIE_MAX_AGE * 1000; + var payload = 'ok|' + expires; + var sig = crypto.createHmac('sha256', SECRET).update(payload).digest('hex').slice(0, 16); + return payload + '|' + sig; +} + +/** 校验签名 Cookie 值 */ +function verifyToken(token) { + if (!token) return false; + var parts = token.split('|'); + if (parts.length !== 3) return false; + var status = parts[0]; + var expires = parseInt(parts[1], 10); + var sig = parts[2]; + if (status !== 'ok' || isNaN(expires)) return false; + if (Date.now() > expires) return false; + var expected = crypto.createHmac('sha256', SECRET).update(status + '|' + expires).digest('hex').slice(0, 16); + return sig === expected; +} + +/** 从请求头解析指定 Cookie */ +function getCookie(req, name) { + var header = req.headers.cookie || ''; + var cookies = header.split(';'); + for (var i = 0; i < cookies.length; i++) { + var pair = cookies[i].trim().split('='); + if (pair[0] === name) return decodeURIComponent(pair.slice(1).join('=')); + } + return null; +} + +/** 读取 POST 请求体 */ +function readBody(req, callback) { + var chunks = []; + req.on('data', function(chunk) { chunks.push(chunk); }); + req.on('end', function() { callback(Buffer.concat(chunks).toString()); }); +} + +/** 安全校验回跳地址:只允许站内相对路径 */ +function safeRedirect(target) { + if (!target || typeof target !== 'string') return '/'; + if (target.charAt(0) !== '/') return '/'; + if (target.indexOf('//') === 0) return '/'; + return target; +} + +/** 发送静态文件 */ +function serveFile(res, filePath) { + /* 防止路径穿越攻击 */ + var resolved = path.resolve(filePath); + if (resolved.indexOf(path.resolve(STATIC_ROOT)) !== 0) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + /* 禁止访问敏感文件 */ + var basename = path.basename(resolved); + if (basename === 'server.js' || basename === '.env' || basename === '.gitignore') { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + fs.stat(resolved, function(err, stats) { + if (err || !stats.isFile()) { + /* 目录请求尝试补 index.html */ + if (!err && stats.isDirectory()) { + var indexFile = path.join(resolved, 'index.html'); + serveFile(res, indexFile); + return; + } + res.writeHead(404); + res.end('Not Found'); + return; + } + + var ext = path.extname(resolved).toLowerCase(); + var contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': stats.size }); + fs.createReadStream(resolved).pipe(res); }); -}).listen(PORT, () => console.log('Server: http://localhost:' + PORT)); +} + +/* ========== 请求处理 ========== */ + +var server = http.createServer(function(req, res) { + var parsed = url.parse(req.url, true); + var pathname = decodeURIComponent(parsed.pathname); + + /* --- POST /simple-login:校验密码 --- */ + if (req.method === 'POST' && pathname === '/simple-login') { + readBody(req, function(body) { + var data = querystring.parse(body); + var password = data.password || ''; + var redirect = safeRedirect(data.redirect); + + if (password !== PASSWORD) { + res.writeHead(302, { 'Location': '/login.html?error=1&redirect=' + encodeURIComponent(redirect) }); + res.end(); + return; + } + + var token = signToken(); + var cookie = COOKIE_NAME + '=' + encodeURIComponent(token) + + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + COOKIE_MAX_AGE; + + res.writeHead(302, { + 'Set-Cookie': cookie, + 'Location': redirect + }); + res.end(); + }); + return; + } + + /* --- POST /simple-logout:退出登录 --- */ + if (req.method === 'POST' && pathname === '/simple-logout') { + var clearCookie = COOKIE_NAME + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; + res.writeHead(302, { 'Set-Cookie': clearCookie, 'Location': '/login.html' }); + res.end(); + return; + } + + /* --- GET /simple-logout:也支持 GET 方式退出 --- */ + if (req.method === 'GET' && pathname === '/simple-logout') { + var clearCookie2 = COOKIE_NAME + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; + res.writeHead(302, { 'Set-Cookie': clearCookie2, 'Location': '/login.html' }); + res.end(); + return; + } + + /* --- 公开路径放行 --- */ + var isPublic = PUBLIC_PATHS.some(function(p) { return pathname === p; }); + + if (isPublic) { + var loginFile = pathname === '/login' ? '/login.html' : pathname; + var fullPath = path.join(STATIC_ROOT, loginFile); + serveFile(res, fullPath); + return; + } + + /* --- 鉴权:检查 Cookie --- */ + var token = getCookie(req, COOKIE_NAME); + if (!verifyToken(token)) { + res.writeHead(302, { 'Location': '/login.html?redirect=' + encodeURIComponent(pathname) }); + res.end(); + return; + } + + /* --- 已登录:返回静态文件 --- */ + if (pathname === '/') pathname = '/index.html'; + + var filePath = path.join(STATIC_ROOT, pathname); + serveFile(res, filePath); +}); + +server.listen(PORT, function() { + console.log('[原型网关] 已启动 -> http://localhost:' + PORT); + console.log('[原型网关] 访问密码: ' + PASSWORD); + console.log('[原型网关] 按 Ctrl+C 停止'); +});