This commit is contained in:
2026-06-08 15:51:52 +08:00
commit f51c0ee636
74 changed files with 1223619 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
PORT=3000
JWT_PRIVATE_KEY_PATH=./private.pem
JWT_PUBLIC_KEY_PATH=./public.pem
+4
View File
@@ -0,0 +1,4 @@
node_modules/
skeet_server.db
logs/
.env
+157
View File
@@ -0,0 +1,157 @@
const argon2 = require('argon2');
const jwt = require('jsonwebtoken');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ─── Load RSA keys ────────────────────────────────────
function loadKeys() {
const privatePath = process.env.JWT_PRIVATE_KEY_PATH || path.join(__dirname, 'private.pem');
const publicPath = process.env.JWT_PUBLIC_KEY_PATH || path.join(__dirname, 'public.pem');
return {
privateKey: fs.readFileSync(privatePath, 'utf8'),
publicKey: fs.readFileSync(publicPath, 'utf8')
};
}
// ─── Argon2id (with client-side SHA-256 for HTTP transport) ──
/**
* Hash a raw password for storage.
* Step 1: SHA-256(raw) → 64-char hex
* Step 2: Argon2id(sha256_hex) → stored in DB
*
* This way the client sends SHA-256(password) over HTTP,
* and the server never sees or stores the raw password.
*/
async function hashPassword(password) {
const sha256 = crypto.createHash('sha256').update(password).digest('hex');
return argon2.hash(sha256, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4
});
}
/**
* Verify a client-submitted password hash against stored Argon2id hash.
* @param {string} storedHash — Argon2id(SHA-256(raw_password))
* @param {string} receivedSha256 — SHA-256(raw_password) from client (64-char hex)
*/
async function verifyPassword(storedHash, receivedSha256) {
return argon2.verify(storedHash, receivedSha256);
}
// ─── JWT ──────────────────────────────────────────────
function signAccessToken(user, deviceId) {
const { privateKey } = loadKeys();
return jwt.sign(
{
sub: user.id,
username: user.username,
role: user.role,
device_id: deviceId
},
privateKey,
{
algorithm: 'RS256',
expiresIn: '30m'
}
);
}
function verifyAccessToken(token) {
const { publicKey } = loadKeys();
return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
}
// ─── Refresh Token ────────────────────────────────────
function generateRefreshToken() {
return crypto.randomBytes(32).toString('hex'); // 256-bit → 64 hex chars
}
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
// ─── HWID Matching ────────────────────────────────────
/**
* Dynamic HWID matching.
* Rule 1: CPU + Board both match → immediate pass (core hardware).
* Rule 2: Weighted score ≥ 80%.
* CPU: 45%, Board: 45%, Disk: 8%, MAC: 2%
*/
function hwidMatchRate(device, incoming) {
const cpuMatch = device.cpu_hash === incoming.cpu;
const boardMatch = device.board_hash === incoming.board;
const diskMatch = device.disk_hash === incoming.disk;
const macMatch = device.mac_hash === incoming.mac;
// Rule 1: CPU + Board
if (cpuMatch && boardMatch) return { match: true, rate: 0.80 };
// Rule 2: weighted
let score = 0;
if (cpuMatch) score += 0.45;
if (boardMatch) score += 0.45;
if (diskMatch) score += 0.08;
if (macMatch) score += 0.02;
return { match: score >= 0.80, rate: score };
}
/**
* Compute a combined HWID hash for quick lookup.
*/
function combinedHwidHash(components) {
const combined = `${components.cpu}|${components.board}|${components.disk}|${components.mac}`;
return crypto.createHash('sha256').update(combined).digest('hex');
}
// ─── Admin RBAC ───────────────────────────────────────
function requireAdmin(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, error: 'UNAUTHORIZED' });
}
try {
const token = authHeader.slice(7);
const payload = verifyAccessToken(token);
if (payload.role !== 'admin') {
return res.status(403).json({ success: false, error: 'FORBIDDEN', message: 'Admin role required' });
}
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ success: false, error: 'TOKEN_INVALID' });
}
}
/**
* Hash a pre-computed SHA-256 hex string (from client) with Argon2id.
* Used for registration when the client sends SHA-256(password).
*/
async function hashPasswordFromHash(sha256hex) {
return argon2.hash(sha256hex, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4
});
}
module.exports = {
hashPassword, hashPasswordFromHash, verifyPassword,
signAccessToken, verifyAccessToken,
generateRefreshToken, hashToken,
hwidMatchRate, combinedHwidHash,
requireAdmin
};
+98
View File
@@ -0,0 +1,98 @@
/**
* Batch user creation script.
*
* Usage:
* node create_users.js user1 pass1 [user2 pass2 ...]
* node create_users.js --file users.json
* node create_users.js --interactive
*
* JSON format (users.json):
* [
* { "username": "user1", "password": "pass1", "role": "user", "expires_at": null },
* { "username": "user2", "password": "pass2", "role": "user", "expires_at": "2026-12-31" }
* ]
*/
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
const fs = require('fs');
const readline = require('readline');
const db = require('./db');
const auth = require('./auth');
db.initDB();
async function createOne(username, password, role = 'user', expiresAt = null) {
const existing = db.getUserByUsername(username);
if (existing) {
console.log(` [SKIP] '${username}' already exists`);
return false;
}
const hash = await auth.hashPassword(password);
db.createUser(username, hash, role, expiresAt || null);
console.log(` [OK] '${username}' created (role: ${role}${expiresAt ? ', expires: ' + expiresAt : ''})`);
return true;
}
async function main() {
const args = process.argv.slice(2);
let created = 0;
if (args.length === 0 || args.includes('--interactive') || args.includes('-i')) {
// ── Interactive mode ──
console.log('=== Interactive User Creation ===');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
while (true) {
const username = await new Promise(resolve => rl.question('\nUsername (empty to finish): ', resolve));
if (!username) break;
const password = await new Promise(resolve => rl.question('Password: ', resolve));
if (!password) { console.log(' Password required, skipping.'); continue; }
const role = await new Promise(resolve => rl.question('Role [user/admin] (default: user): ', resolve));
const expires = await new Promise(resolve => rl.question('Expiry YYYY-MM-DD (default: never): ', resolve));
if (await createOne(username, password, role || 'user', expires || null)) created++;
}
rl.close();
} else if (args.includes('--file') || args.includes('-f')) {
// ── JSON file mode ──
const fileIdx = args.indexOf('--file');
const fIdx = args.indexOf('-f');
const filePath = args[fileIdx !== -1 ? fileIdx + 1 : fIdx + 1];
if (!filePath || !fs.existsSync(filePath)) {
console.error('File not found:', filePath);
process.exit(1);
}
const users = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (!Array.isArray(users)) {
console.error('JSON must be an array of user objects.');
process.exit(1);
}
console.log(`Creating ${users.length} users from ${filePath}...\n`);
for (const u of users) {
if (await createOne(u.username, u.password, u.role || 'user', u.expires_at || null)) created++;
}
} else {
// ── CLI args mode: pairs of username password ──
if (args.length % 2 !== 0) {
console.error('Arguments must be in pairs: username password [username password ...]');
process.exit(1);
}
console.log(`Creating ${args.length / 2} users...\n`);
for (let i = 0; i < args.length; i += 2) {
if (await createOne(args[i], args[i + 1])) created++;
}
}
console.log(`\nDone. ${created} user(s) created.`);
process.exit(0);
}
main().catch(err => { console.error('Error:', err); process.exit(1); });
+198
View File
@@ -0,0 +1,198 @@
const Database = require('better-sqlite3');
const path = require('path');
let db;
function initDB(dbPath) {
db = new Database(dbPath || path.join(__dirname, 'skeet_server.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
display_name TEXT NOT NULL,
cpu_hash TEXT NOT NULL,
board_hash TEXT NOT NULL,
disk_hash TEXT NOT NULL,
mac_hash TEXT NOT NULL,
last_login TEXT,
last_ip TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
device_id INTEGER,
hwid_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS login_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
device_id INTEGER,
ip_address TEXT,
user_agent TEXT,
hwid_match_rate REAL,
success INTEGER NOT NULL,
reason TEXT,
attempted_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT NOT NULL,
action TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
window_start TEXT NOT NULL,
PRIMARY KEY (key, action)
);
`);
return db;
}
function getDB() {
if (!db) throw new Error('Database not initialized. Call initDB() first.');
return db;
}
// ─── Users ────────────────────────────────────────────
function getUserByUsername(username) {
return getDB().prepare('SELECT * FROM users WHERE username = ?').get(username);
}
function getUserById(id) {
return getDB().prepare('SELECT * FROM users WHERE id = ?').get(id);
}
function createUser(username, passwordHash, role = 'user', expiresAt = null) {
return getDB().prepare(
'INSERT INTO users (username, password_hash, role, expires_at) VALUES (?, ?, ?, ?)'
).run(username, passwordHash, role, expiresAt);
}
// ─── Devices ──────────────────────────────────────────
function findMatchingDevice(userId, cpuHash, boardHash, diskHash, macHash) {
const devices = getDB().prepare(
'SELECT * FROM devices WHERE user_id = ?'
).all(userId);
return devices;
}
function createDevice(userId, displayName, cpuHash, boardHash, diskHash, macHash, ip) {
return getDB().prepare(
`INSERT INTO devices (user_id, display_name, cpu_hash, board_hash, disk_hash, mac_hash, last_login, last_ip)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)`
).run(userId, displayName, cpuHash, boardHash, diskHash, macHash, ip);
}
function updateDeviceLogin(deviceId, ip) {
getDB().prepare(
'UPDATE devices SET last_login = datetime(\'now\'), last_ip = ? WHERE id = ?'
).run(ip, deviceId);
}
// ─── Refresh Tokens ───────────────────────────────────
function saveRefreshToken(tokenHash, userId, deviceId, hwidHash, expiresAt) {
return getDB().prepare(
'INSERT INTO refresh_tokens (token_hash, user_id, device_id, hwid_hash, expires_at) VALUES (?, ?, ?, ?, ?)'
).run(tokenHash, userId, deviceId, hwidHash, expiresAt);
}
function findRefreshToken(tokenHash) {
return getDB().prepare(
'SELECT * FROM refresh_tokens WHERE token_hash = ?'
).get(tokenHash);
}
function revokeRefreshToken(tokenHash) {
getDB().prepare(
'UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?'
).run(tokenHash);
}
// ─── Login Log ────────────────────────────────────────
function logLogin(userId, deviceId, ip, userAgent, hwidMatchRate, success, reason) {
getDB().prepare(
`INSERT INTO login_log (user_id, device_id, ip_address, user_agent, hwid_match_rate, success, reason)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(userId, deviceId, ip, userAgent, hwidMatchRate, success ? 1 : 0, reason);
}
function countRecentFailures(username, windowMinutes = 10) {
const row = getDB().prepare(
`SELECT COUNT(*) as cnt FROM login_log
JOIN users ON login_log.user_id = users.id
WHERE users.username = ? AND login_log.success = 0
AND login_log.attempted_at > datetime('now', '-' || ? || ' minutes')`
).get(username, windowMinutes);
return row ? row.cnt : 0;
}
// ─── Rate Limits ──────────────────────────────────────
function checkRateLimit(key, action, maxCount, windowSeconds) {
const now = new Date().toISOString();
const db = getDB();
// UPSERT — single row per (key, action)
db.prepare(
`INSERT INTO rate_limits (key, action, count, window_start)
VALUES (?, ?, 1, ?)
ON CONFLICT(key, action) DO UPDATE SET
count = CASE
WHEN (unixepoch(?) - unixepoch(window_start)) > ? THEN 1
ELSE count + 1
END,
window_start = CASE
WHEN (unixepoch(?) - unixepoch(window_start)) > ? THEN ?
ELSE window_start
END`
).run(key, action, now, now, windowSeconds, now, windowSeconds, now);
const row = db.prepare(
'SELECT count, window_start FROM rate_limits WHERE key = ? AND action = ?'
).get(key, action);
if (!row) return { allowed: true, remaining: maxCount };
const windowAge = (Date.now() - new Date(row.window_start + 'Z').getTime()) / 1000;
if (windowAge > windowSeconds) return { allowed: true, remaining: maxCount };
return {
allowed: row.count <= maxCount,
remaining: Math.max(0, maxCount - row.count),
retryAfter: row.count > maxCount ? Math.ceil(windowSeconds - windowAge) : 0
};
}
module.exports = {
initDB, getDB,
getUserByUsername, getUserById, createUser,
findMatchingDevice, createDevice, updateDeviceLogin,
saveRefreshToken, findRefreshToken, revokeRefreshToken,
logLogin, countRecentFailures,
checkRateLimit
};
+89
View File
@@ -0,0 +1,89 @@
@echo off
chcp 65001 >nul
title Skeet Loader Server
echo ==========================================
echo Skeet Loader - Server Deployment
echo ==========================================
echo.
:: Check Node.js
node --version >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Node.js is not installed!
echo Download from: https://nodejs.org (LTS version)
pause
exit /b 1
)
echo Node.js: OK
:: Check npm
npm --version >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] npm not found!
pause
exit /b 1
)
echo npm: OK
:: Check if dependencies installed
if not exist "node_modules\" (
echo Installing dependencies...
npm install
if %errorlevel% neq 0 (
echo [ERROR] npm install failed!
pause
exit /b 1
)
)
echo Dependencies: OK
:: Check RSA keys
if not exist "private.pem" (
echo [ERROR] RSA keys not found! Generate them first:
echo openssl genrsa -out private.pem 2048
echo openssl rsa -in private.pem -pubout -out public.pem
pause
exit /b 1
)
echo RSA Keys: OK
:: Check if admin user exists
if not exist "skeet_server.db" (
echo.
echo No database found. Creating admin user...
node setup.js
if %errorlevel% neq 0 (
echo [ERROR] Admin setup failed!
pause
exit /b 1
)
)
:: Check PM2
echo.
where pm2 >nul 2>&1
if %errorlevel% equ 0 (
echo PM2 found - starting with PM2...
pm2 start ecosystem.config.js
pm2 status
echo.
echo Server started with PM2! Commands:
echo pm2 status - View status
echo pm2 logs - View logs
echo pm2 restart skeet-server
echo pm2 stop skeet-server
) else (
echo PM2 not installed - starting directly...
echo To install PM2: npm install -g pm2
echo.
echo Starting server directly (Ctrl+C to stop)...
node server.js
)
echo.
echo ==========================================
echo Server should be running on port 3000
echo Test: curl http://localhost:3000/api/status
echo ==========================================
pause
+32
View File
@@ -0,0 +1,32 @@
// PM2 process manager config
// Install: npm install -g pm2
// Start: pm2 start ecosystem.config.js
// Status: pm2 status
// Logs: pm2 logs skeet-server
// Restart: pm2 restart skeet-server
// Stop: pm2 stop skeet-server
// Auto-start on boot: pm2 startup && pm2 save
module.exports = {
apps: [{
name: 'skeet-server',
script: 'server.js',
cwd: __dirname,
env: {
NODE_ENV: 'production',
PORT: 3000
},
// Restart if memory exceeds 200MB
max_memory_restart: '200M',
// Restart if it crashes
autorestart: true,
// Max 5 restarts in 60 seconds, then stop
max_restarts: 5,
min_uptime: '60s',
// Log settings
log_date_format: 'YYYY-MM-DD HH:mm:ss',
error_file: './logs/error.log',
out_file: './logs/out.log',
merge_logs: true
}]
};
+1430
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "skeet-loader-server",
"version": "1.0.0",
"description": "Cloud verification server for skeet_loader",
"main": "server.js",
"scripts": {
"start": "node server.js",
"setup": "node setup.js"
},
"dependencies": {
"argon2": "^0.41.1",
"better-sqlite3": "^11.7.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"uuid": "^11.1.0"
}
}
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCfL9+eXaLxbwOf
l42JstBlTgcPrfe5pqaq99bm+3cplEMCgfcX8rgJjae2vzjIdVA8CraS5dmPuV4R
vxbLN+Drz0P7PrzjGEf6gb8QJiQNQUsnIKjLaJtvgXj2r/qCpQN43fGnPbAakVCC
wYEgjsLTnwPFrOqPmjUSdhdqu+qe/umWc9ahqL4YyWyrMOEZsCOnJi8rrPlYd4uv
H0XOt7T4giSJPCQIRF2d+KujFOMwHdmutxQWgkPGmUM7npVQE+iBBpAj2IBhXHVV
2A5Dq4bznnT1Hh/ihCRWd1e6D1DRv6jL9BvL6tcASp+kaSiPwLaSjrWgj6kMnZDl
OL1WQFH7AgMBAAECggEASmNjQsysA9nmXhJC1Imoo+aKGzQnMuzjLrrhhJAXd+ku
RUI5l0nX4bL+IEKMxPQ5Wc2B0vSxLd4oTO+4tZ93roptUL/ZIeBrZm9yOxgvubf8
6Bx1dJR+KmXdAp5q2NlG3ZkNv6LupAOrFWdasb204pKI/zuWYyPjUCy6Naj583Eb
UmJxv8C/H95UL8BzGHkb4feFcJjv4Bm0dTLr45kt+GLaPSqTmMksXntCN7r64uNT
mZSBKN2/kRxmNjeyFPJvTbj6ZgsYCgyE6oeFTAKYXaAhEyyUTZqNYTQiADngvNCL
Us4RwRLY9rlw3RNFWATojyA6MgUWIWNuHFDxJst82QKBgQDfSoXThtv9cnUNMH72
qkyJdo4GJkbyvJk6TyyVgu4hd8eI0oFrgRCrKgCRbcmoi1oyu8tqeaCM5Y3qHtri
ggG8zUpmG5MqUv2LEd3cGAOd9ZP2oX5yoAg2QRoZTjoJJa9b8vg6y6JfYBNK4if6
37tTPvfDf+N2/Vc7c/fBRX4TyQKBgQC2gW35uWRR3thyCSLdTtD7TOhC93GzD1O8
psRQ7F9bcT25GZBp2YlK3LpTzVbPP2ek6x7ltyeq6PxXRbugE0ij7c+ywcMo91uZ
3Rcsj7TL8EpXpZU5V+BI4aeyN1KWKXa337jhcnZu8cJqomFkBv6sznLRyvLAxQu/
17f2oU5xowKBgDWjUjh42dmtJ/8OGkGosRAIYZ+KjFp9AZXnNP+JXyi8/DqazqoD
a/yh71b/94Q8TWOIhxnBs9aEwi1uUgg9UKuI6QlUMGrnWq6QkSnwvtWwC2Ygbx6b
4L0fsGRJzVkrK0+8MvL0vcGJc7j2UMJMAlTB+ISG8R5BRzYp6mcMCXg5AoGABhwb
JGsScrM1J8wqgKIs2NzgQa2q/sWalgw3MkZXguYtnM7ASOrhb8In8rpDF7kVrS25
4RLanxwhpoJNH7TFj8dcVq2p7OsrA+Gk7vb4pIMs2fZPIpZQieAUDyFPAHvu34T7
2YBNerVZPtykygZ57CsVKPTUX6O5GvkLzZPk3mUCgYEA14/OzSOSfYgO8AeZloTZ
IwBBMVTrK37vm/oP4nvnfhI34dpbPU6dT5PDR4WizUJsq5BqGfaxUgAJudMueAqb
E9q8RLF7l3ZEoiSut62VEllulrtk/EIBcGFJU4X0TthUskYrYE4GfO5UAuwfbxO5
O2Wkxljb2feNBBih8QDHJhY=
-----END PRIVATE KEY-----
+9
View File
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAny/fnl2i8W8Dn5eNibLQ
ZU4HD633uaamqvfW5vt3KZRDAoH3F/K4CY2ntr84yHVQPAq2kuXZj7leEb8Wyzfg
689D+z684xhH+oG/ECYkDUFLJyCoy2ibb4F49q/6gqUDeN3xpz2wGpFQgsGBII7C
058Dxazqj5o1EnYXarvqnv7plnPWoai+GMlsqzDhGbAjpyYvK6z5WHeLrx9Fzre0
+IIkiTwkCERdnfiroxTjMB3ZrrcUFoJDxplDO56VUBPogQaQI9iAYVx1VdgOQ6uG
85509R4f4oQkVndXug9Q0b+oy/Qby+rXAEqfpGkoj8C2ko61oI+pDJ2Q5Ti9VkBR
+wIDAQAB
-----END PUBLIC KEY-----
+365
View File
@@ -0,0 +1,365 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skeet Loader — Admin</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#0f1117;color:#e0e0e0;min-height:100vh}
/* Login */
#loginBox{max-width:380px;margin:100px auto;padding:40px 30px;background:#1a1d27;border-radius:12px;border:1px solid #2a2d37}
#loginBox h1{text-align:center;font-size:22px;margin-bottom:8px;background:linear-gradient(90deg,#00b9ff,#ff01f7,#e8ff00);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
#loginBox .sub{text-align:center;font-size:12px;color:#666;margin-bottom:24px}
#loginBox input{width:100%;padding:12px;margin-bottom:12px;background:#0f1117;border:1px solid #2a2d37;border-radius:6px;color:#fff;font-size:14px;outline:none}
#loginBox input:focus{border-color:#00b9ff}
#loginBox button{width:100%;padding:12px;background:linear-gradient(90deg,#00b9ff,#ff01f7);border:none;border-radius:6px;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
#loginBox button:hover{opacity:0.9}
#loginBox .err{color:#ff5050;font-size:13px;margin-top:8px;text-align:center}
/* Dashboard */
#dashboard{display:none;max-width:1200px;margin:0 auto;padding:20px}
header{display:flex;justify-content:space-between;align-items:center;padding:16px 0;border-bottom:1px solid #2a2d37;margin-bottom:24px}
header h1{font-size:20px;background:linear-gradient(90deg,#00b9ff,#ff01f7,#e8ff00);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
header .user{font-size:13px;color:#888;display:flex;gap:16px;align-items:center}
header .user button{background:#2a2d37;color:#ccc;border:1px solid #3a3d47;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:12px}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:24px}
.stat{background:#1a1d27;border:1px solid #2a2d37;border-radius:8px;padding:16px 20px}
.stat .n{font-size:28px;font-weight:700}
.stat .l{font-size:12px;color:#666;margin-top:4px}
.tabs{display:flex;gap:8px;margin-bottom:16px;border-bottom:1px solid #2a2d37;padding-bottom:8px}
.tab{padding:8px 16px;background:transparent;color:#888;border:none;cursor:pointer;font-size:13px;border-radius:4px 4px 0 0}
.tab.active{background:#1a1d27;color:#fff;border:1px solid #2a2d37;border-bottom:1px solid #1a1d27}
.toolbar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.toolbar button{background:#2a2d37;color:#ccc;border:1px solid #3a3d47;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:12px}
.toolbar button:hover{background:#3a3d47}
.toolbar input{background:#1a1d27;border:1px solid #2a2d37;color:#fff;padding:7px 12px;border-radius:4px;font-size:12px;outline:none;width:180px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;padding:10px 12px;border-bottom:2px solid #2a2d37;color:#888;font-weight:600}
td{padding:9px 12px;border-bottom:1px solid #1a1d27}
tr:hover td{background:#1a1d2733}
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.badge-ok{background:#0a3;color:#fff}
.badge-bad{background:#a00;color:#fff}
.badge-warn{background:#a80;color:#fff}
.badge-info{background:#049;color:#fff}
.btn-sm{background:#2a2d37;color:#ccc;border:1px solid #3a3d47;padding:4px 10px;border-radius:3px;cursor:pointer;font-size:11px;margin-right:4px}
.btn-sm:hover{background:#3a3d47}
.btn-sm.danger{color:#ff5050;border-color:#a03030}
.btn-sm.danger:hover{background:#3a1a1a}
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:100;justify-content:center;align-items:center}
.modal{background:#1a1d27;border:1px solid #2a2d37;border-radius:12px;padding:24px;min-width:350px}
.modal h3{margin-bottom:16px}
.modal input{width:100%;padding:10px;margin-bottom:10px;background:#0f1117;border:1px solid #2a2d37;border-radius:6px;color:#fff;font-size:13px}
.modal .btns{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
.modal .btns button{padding:8px 18px;border-radius:6px;border:none;cursor:pointer;font-size:13px}
.modal .btn-primary{background:linear-gradient(90deg,#00b9ff,#ff01f7);color:#fff}
.modal .btn-cancel{background:#2a2d37;color:#ccc}
.toast{position:fixed;top:20px;right:20px;background:#0a3;color:#fff;padding:12px 20px;border-radius:6px;font-size:13px;z-index:200;animation:fadein 0.3s}
@keyframes fadein{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
</style>
</head>
<body>
<!-- Login -->
<div id="loginBox">
<h1>Skeet Loader</h1>
<div class="sub">Admin Panel</div>
<input id="loginUser" placeholder="Username" autofocus>
<input id="loginPwd" type="password" placeholder="Password">
<button onclick="doLogin()">Login</button>
<div class="err" id="loginErr"></div>
</div>
<!-- Dashboard -->
<div id="dashboard">
<header>
<h1>Skeet Admin</h1>
<div class="user">
<span id="adminUser"></span>
<button onclick="logout()">Logout</button>
</div>
</header>
<div class="stats" id="stats"></div>
<div class="tabs">
<button class="tab active" onclick="switchTab('users')">Users</button>
<button class="tab" onclick="switchTab('logs')">Login Logs</button>
</div>
<div class="toolbar" id="userToolbar">
<input id="searchInput" placeholder="Search username..." oninput="renderUsers()">
<button onclick="showCreateModal()">+ Create User</button>
<button onclick="loadUsers()">↻ Refresh</button>
</div>
<table id="userTable"><thead><tr><th>ID</th><th>Username</th><th>Role</th><th>Status</th><th>HWID Bound</th><th>Created</th><th>Expires</th><th>Actions</th></tr></thead><tbody></tbody></table>
<table id="logTable" style="display:none"><thead><tr><th>Time</th><th>User</th><th>IP</th><th>Result</th><th>HWID Match</th><th>Reason</th></tr></thead><tbody></tbody></table>
</div>
<!-- Create User Modal -->
<div class="modal-overlay" id="createModal"><div class="modal">
<h3>Create User</h3>
<input id="newUser" placeholder="Username">
<input id="newPwd" type="password" placeholder="Password">
<input id="newExp" placeholder="Expiry (YYYY-MM-DD, optional)">
<div class="btns">
<button class="btn-cancel" onclick="closeModal('createModal')">Cancel</button>
<button class="btn-primary" onclick="createUser()">Create</button>
</div>
</div></div>
<!-- Toast -->
<div id="toast" class="toast" style="display:none"></div>
<script>
const API = ''; // same origin
let token = localStorage.getItem('admin_token');
let allUsers = [];
let allLogs = [];
let currentTab = 'users';
// ─── Init ──────────────────────────────
if (token) {
document.getElementById('loginBox').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
loadAll();
}
// ─── Login ─────────────────────────────
async function doLogin() {
const u = document.getElementById('loginUser').value.trim();
const p = document.getElementById('loginPwd').value;
if (!u || !p) return;
const h = sha256(p);
try {
const r = await fetch(API + '/api/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: u, password_hash: h,
hwid_components: { cpu: 'admin', board: 'panel', disk: 'panel', mac: 'panel' }
})
});
const d = await r.json();
if (!d.success) { document.getElementById('loginErr').textContent = d.error || 'Login failed'; return; }
// Verify admin role
const payload = JSON.parse(atob(d.access_token.split('.')[1]));
if (payload.role !== 'admin') {
document.getElementById('loginErr').textContent = 'Not an admin account';
return;
}
localStorage.setItem('admin_token', d.access_token);
token = d.access_token;
document.getElementById('loginBox').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
document.getElementById('adminUser').textContent = payload.username;
loadAll();
} catch(e) {
document.getElementById('loginErr').textContent = 'Connection failed';
}
}
function logout() {
localStorage.removeItem('admin_token');
token = null;
location.reload();
}
// ─── SHA-256 (pure JS — works without crypto.subtle, over HTTP) ──
function sha256(msg) {
function R(n,w){return((n>>>w)|(n<<(32-w)))>>>0}
const K=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
// UTF-8 encode
const m=unescape(encodeURIComponent(msg));
const bytes=[];for(let i=0;i<m.length;i++)bytes.push(m.charCodeAt(i)&0xFF);
const l=bytes.length*8;
bytes.push(0x80);while((bytes.length+8)%64!==0)bytes.push(0);
// Big-endian 64-bit length (high 32 bits always 0 for <4GB messages)
bytes.push(0);bytes.push(0);bytes.push(0);bytes.push(0);
bytes.push((l>>>24)&0xFF);bytes.push((l>>>16)&0xFF);bytes.push((l>>>8)&0xFF);bytes.push(l&0xFF);
const H=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
for(let p=0;p<bytes.length;p+=64){
const W=[];for(let t=0;t<16;t++)W[t]=(bytes[p+t*4]<<24)|(bytes[p+t*4+1]<<16)|(bytes[p+t*4+2]<<8)|bytes[p+t*4+3];
for(let t=16;t<64;t++){const s0=R(W[t-15],7)^R(W[t-15],18)^(W[t-15]>>>3),s1=R(W[t-2],17)^R(W[t-2],19)^(W[t-2]>>>10);W[t]=(W[t-16]+s0+W[t-7]+s1)>>>0}
let[a,b,c,d,e,f,g,h]=H;
for(let t=0;t<64;t++){const S1=R(e,6)^R(e,11)^R(e,25),ch=(e&f)^(~e&g),t1=(h+S1+ch+K[t]+W[t])>>>0,S0=R(a,2)^R(a,13)^R(a,22),maj=(a&b)^(a&c)^(b&c),t2=(S0+maj)>>>0;h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0}
H[0]=(H[0]+a)>>>0;H[1]=(H[1]+b)>>>0;H[2]=(H[2]+c)>>>0;H[3]=(H[3]+d)>>>0;H[4]=(H[4]+e)>>>0;H[5]=(H[5]+f)>>>0;H[6]=(H[6]+g)>>>0;H[7]=(H[7]+h)>>>0;
}
return H.map(v=>('0000000'+v.toString(16)).slice(-8)).join('');
}
// ─── Load Data ─────────────────────────
async function loadAll() {
await Promise.all([loadUsers(), loadStats()]);
}
async function loadUsers() {
try {
const r = await fetch(API + '/api/admin/users', { headers: authHeader() });
const d = await r.json();
allUsers = d.users || [];
renderUsers();
} catch(e) { console.error(e); }
}
async function loadStats() {
try {
const r = await fetch(API + '/api/admin/stats', { headers: authHeader() });
const d = await r.json();
document.getElementById('stats').innerHTML =
`<div class="stat"><div class="n">${d.totalUsers}</div><div class="l">Total Users</div></div>
<div class="stat"><div class="n" style="color:#0a3">${d.activeUsers}</div><div class="l">Active</div></div>
<div class="stat"><div class="n" style="color:#0af">${d.logins24h}</div><div class="l">Logins (24h)</div></div>
<div class="stat"><div class="n" style="color:#f55">${d.failures24h}</div><div class="l">Failures (24h)</div></div>`;
} catch(e) {}
}
async function loadLogs() {
try {
const r = await fetch(API + '/api/admin/logs', { headers: authHeader() });
const d = await r.json();
allLogs = d.logs || [];
renderLogs();
} catch(e) {}
}
function authHeader() {
return { 'Authorization': 'Bearer ' + token };
}
// ─── Render ────────────────────────────
function renderUsers() {
const q = (document.getElementById('searchInput')?.value || '').toLowerCase();
let users = q ? allUsers.filter(u => u.username.toLowerCase().includes(q)) : allUsers;
const tbody = document.querySelector('#userTable tbody');
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td><b>${esc(u.username)}</b></td>
<td><span class="badge ${u.role==='admin'?'badge-warn':'badge-info'}">${u.role}</span></td>
<td><span class="badge ${u.is_active?'badge-ok':'badge-bad'}">${u.is_active?'Active':'Banned'}</span></td>
<td>${u.hwid_bound_at || '<span style="color:#666">—</span>'}</td>
<td>${fmtDate(u.created_at)}</td>
<td>${u.expires_at ? fmtDate(u.expires_at) : '<span style="color:#666">Never</span>'}</td>
<td>
<button class="btn-sm" onclick="toggleUser(${u.id},${u.is_active})">${u.is_active?'Ban':'Unban'}</button>
<button class="btn-sm danger" onclick="resetHwid(${u.id})">Reset HWID</button>
</td>
</tr>`).join('');
}
function renderLogs() {
const tbody = document.querySelector('#logTable tbody');
tbody.innerHTML = allLogs.map(l => `
<tr>
<td>${fmtDate(l.attempted_at)}</td>
<td>${esc(l.username||'—')}</td>
<td>${l.ip_address||'—'}</td>
<td><span class="badge ${l.success?'badge-ok':'badge-bad'}">${l.success?'OK':'FAIL'}</span></td>
<td>${l.hwid_match_rate != null ? (l.hwid_match_rate*100).toFixed(0)+'%' : '—'}</td>
<td style="color:#888">${esc(l.reason||'')}</td>
</tr>`).join('');
}
// ─── Tabs ──────────────────────────────
function switchTab(t) {
currentTab = t;
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('userTable').style.display = t === 'users' ? '' : 'none';
document.getElementById('userToolbar').style.display = t === 'users' ? '' : 'none';
document.getElementById('logTable').style.display = t === 'logs' ? '' : 'none';
if (t === 'logs') loadLogs();
if (t === 'users') loadUsers();
}
// ─── Actions ───────────────────────────
function showCreateModal() {
document.getElementById('createModal').style.display = 'flex';
document.getElementById('newUser').focus();
}
function closeModal(id) {
document.getElementById(id).style.display = 'none';
document.getElementById('newUser').value = '';
document.getElementById('newPwd').value = '';
document.getElementById('newExp').value = '';
}
async function createUser() {
const u = document.getElementById('newUser').value.trim();
const p = document.getElementById('newPwd').value;
const e = document.getElementById('newExp').value;
if (!u || !p) return;
// Admin creates user with raw password (server hashes internally)
try {
const r = await fetch(API + '/api/admin/create-user', {
method: 'POST', headers: { ...authHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p, expires_at: e || null })
});
const d = await r.json();
if (d.success) { toast('User created: ' + u); closeModal('createModal'); loadUsers(); }
else toast(d.error || 'Failed', true);
} catch(e) { toast('Connection error', true); }
}
async function toggleUser(id, current) {
try {
const r = await fetch(API + '/api/admin/user/' + id + '/toggle', {
method: 'POST', headers: authHeader()
});
const d = await r.json();
if (d.success) { toast('User ' + (d.is_active ? 'activated' : 'banned')); loadUsers(); }
} catch(e) { toast('Error', true); }
}
async function resetHwid(id) {
if (!confirm('Reset HWID for this user? They can bind to a new machine on next login.')) return;
try {
const r = await fetch(API + '/api/admin/user/' + id + '/reset-hwid', {
method: 'POST', headers: authHeader()
});
const d = await r.json();
if (d.success) toast('HWID reset — user can re-bind'); else toast(d.error||'Failed', true);
} catch(e) { toast('Error', true); }
}
// ─── Helpers ───────────────────────────
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function fmtDate(s) {
if (!s) return '—';
const d = new Date(s + (s.endsWith('Z')?'':'Z'));
return d.toLocaleString('zh-CN', { hour12: false });
}
function toast(msg, isErr) {
const el = document.getElementById('toast');
el.textContent = msg;
el.style.background = isErr ? '#a00' : '#0a3';
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 3000);
}
// Close modal on overlay click
document.getElementById('createModal').addEventListener('click', function(e) {
if (e.target === this) closeModal('createModal');
});
// Enter key on login
document.getElementById('loginPwd').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
</script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
const { checkRateLimit } = require('./db');
const LIMITS = {
ip: { max: 20, window: 300 }, // 20 req / 5 min
user: { max: 5, window: 600 }, // 5 failures / 10 min lock
hwid: { max: 10, window: 900 } // 10 failures / 15 min lock
};
/**
* Check all three rate limit dimensions.
* Returns { allowed, retryAfter } — retryAfter is the longest among triggered limits.
*/
function checkLoginRateLimit(ip, username, hwidCombined) {
let maxRetryAfter = 0;
// IP-based
const ipLimit = checkRateLimit(`ip:${ip}`, 'login', LIMITS.ip.max, LIMITS.ip.window);
if (!ipLimit.allowed) {
maxRetryAfter = Math.max(maxRetryAfter, ipLimit.retryAfter);
}
// Username-based (only for failed attempts — counted in login handler)
const userLimit = checkRateLimit(`user:${username}`, 'login', LIMITS.user.max, LIMITS.user.window);
if (!userLimit.allowed) {
maxRetryAfter = Math.max(maxRetryAfter, userLimit.retryAfter);
}
// HWID-based
if (hwidCombined) {
const hwidLimit = checkRateLimit(`hwid:${hwidCombined}`, 'login', LIMITS.hwid.max, LIMITS.hwid.window);
if (!hwidLimit.allowed) {
maxRetryAfter = Math.max(maxRetryAfter, hwidLimit.retryAfter);
}
}
return {
allowed: maxRetryAfter === 0,
retryAfter: maxRetryAfter
};
}
module.exports = { checkLoginRateLimit };
+322
View File
@@ -0,0 +1,322 @@
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
const express = require('express');
const db = require('./db');
const auth = require('./auth');
const rateLimit = require('./rate_limit');
const path = require('path');
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// Init DB
db.initDB();
// GET /api/status
app.get('/api/status', (req, res) => {
res.json({ status: 'ok', server_time: new Date().toISOString() });
});
// POST /api/login
app.post('/api/login', async (req, res) => {
const { username, password_hash, hwid_components } = req.body;
const ip = req.ip || req.socket.remoteAddress || 'unknown';
const userAgent = req.headers['user-agent'] || '';
if (!username || !password_hash || !hwid_components) {
return res.status(400).json({ success: false, error: 'MISSING_FIELDS' });
}
const { cpu, board, disk, mac } = hwid_components;
if (!cpu || !board || !disk || !mac) {
return res.status(400).json({ success: false, error: 'MISSING_HWID_COMPONENTS' });
}
const hwidCombined = auth.combinedHwidHash(hwid_components);
// Rate limit
const rl = rateLimit.checkLoginRateLimit(ip, username, hwidCombined);
if (!rl.allowed) {
return res.status(429).json({
success: false, error: 'RATE_LIMITED',
message: 'Too many attempts', retry_after: rl.retryAfter
});
}
// User lookup
const user = db.getUserByUsername(username);
if (!user) {
db.logLogin(null, null, ip, userAgent, 0, false, 'USER_NOT_FOUND');
return res.json({ success: false, error: 'INVALID_CREDENTIALS' });
}
if (!user.is_active) return res.json({ success: false, error: 'ACCOUNT_DISABLED' });
if (user.expires_at && new Date(user.expires_at) < new Date())
return res.json({ success: false, error: 'ACCOUNT_EXPIRED' });
// Verify password (Argon2id)
let passwordValid = false;
try {
passwordValid = await auth.verifyPassword(user.password_hash, password_hash);
} catch (err) {
console.error('[login] argon2 error:', err.message);
return res.status(500).json({ success: false, error: 'INTERNAL_ERROR' });
}
if (!passwordValid) {
db.logLogin(user.id, null, ip, userAgent, 0, false, 'WRONG_PASSWORD');
return res.json({ success: false, error: 'INVALID_CREDENTIALS' });
}
// Admin accounts skip HWID check (for web panel access)
if (user.role === 'admin') {
const accessToken = auth.signAccessToken(user, 0);
const refreshToken = auth.generateRefreshToken();
const tokenHash = auth.hashToken(refreshToken);
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString();
db.saveRefreshToken(tokenHash, user.id, 0, 'admin', expiresAt);
db.logLogin(user.id, 0, ip, userAgent, 1.0, true, null);
return res.json({
success: true,
access_token: accessToken,
refresh_token: refreshToken,
username: user.username,
match_rate: 1.0
});
}
// HWID matching
const userDevices = db.findMatchingDevice(user.id, cpu, board, disk, mac);
let matchedDevice = null;
let bestRate = 0;
for (const device of userDevices) {
const result = auth.hwidMatchRate(device, hwid_components);
if (result.match && result.rate > bestRate) {
matchedDevice = device;
bestRate = result.rate;
}
}
if (!matchedDevice) {
if (userDevices.length === 0) {
// First login — auto-create device
const displayName = req.headers['x-device-name'] || 'Unknown Device';
const result = db.createDevice(user.id, displayName, cpu, board, disk, mac, ip);
matchedDevice = { id: result.lastInsertRowid, cpu_hash: cpu, board_hash: board, disk_hash: disk, mac_hash: mac };
bestRate = 1.0;
} else {
// HWID threshold failed
db.logLogin(user.id, null, ip, userAgent, bestRate, false, 'HWID_THRESHOLD');
return res.json({
success: false, error: 'HWID_THRESHOLD',
message: 'Hardware does not match any bound device', match_rate: bestRate
});
}
} else {
db.updateDeviceLogin(matchedDevice.id, ip);
}
// Generate tokens
const accessToken = auth.signAccessToken(user, matchedDevice.id);
const refreshToken = auth.generateRefreshToken();
const tokenHash = auth.hashToken(refreshToken);
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString();
db.saveRefreshToken(tokenHash, user.id, matchedDevice.id, hwidCombined, expiresAt);
db.logLogin(user.id, matchedDevice.id, ip, userAgent, bestRate, true, null);
res.json({
success: true,
access_token: accessToken,
refresh_token: refreshToken,
username: user.username,
match_rate: bestRate
});
});
// POST /api/refresh
app.post('/api/refresh', (req, res) => {
const { refresh_token, hwid_components } = req.body;
if (!refresh_token || !hwid_components) {
return res.status(400).json({ success: false, error: 'MISSING_FIELDS' });
}
const tokenHash = auth.hashToken(refresh_token);
const stored = db.findRefreshToken(tokenHash);
if (!stored || stored.revoked) return res.json({ success: false, error: 'TOKEN_EXPIRED' });
if (new Date(stored.expires_at) < new Date()) return res.json({ success: false, error: 'TOKEN_EXPIRED' });
// Verify HWID
const hwidCombined = auth.combinedHwidHash(hwid_components);
if (stored.hwid_hash !== hwidCombined) {
const user = db.getUserById(stored.user_id);
if (!user) return res.json({ success: false, error: 'TOKEN_EXPIRED' });
const userDevices = db.findMatchingDevice(user.id,
hwid_components.cpu, hwid_components.board,
hwid_components.disk, hwid_components.mac);
let matched = false;
for (const device of userDevices) {
if (auth.hwidMatchRate(device, hwid_components).match) { matched = true; break; }
}
if (!matched) return res.json({ success: false, error: 'HWID_THRESHOLD' });
}
// Rotate
db.revokeRefreshToken(tokenHash);
const user = db.getUserById(stored.user_id);
const newAccessToken = auth.signAccessToken(user, stored.device_id);
const newRefreshToken = auth.generateRefreshToken();
const newTokenHash = auth.hashToken(newRefreshToken);
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString();
db.saveRefreshToken(newTokenHash, user.id, stored.device_id,
auth.combinedHwidHash(hwid_components), expiresAt);
if (stored.device_id) db.updateDeviceLogin(stored.device_id, req.ip || req.socket.remoteAddress);
res.json({ success: true, access_token: newAccessToken, refresh_token: newRefreshToken });
});
// POST /api/logout
app.post('/api/logout', (req, res) => {
const { refresh_token } = req.body;
if (refresh_token) db.revokeRefreshToken(auth.hashToken(refresh_token));
res.json({ success: true });
});
// POST /api/register (public self-registration)
app.post('/api/register', async (req, res) => {
const { username, password_hash } = req.body;
const ip = req.ip || req.socket.remoteAddress || 'unknown';
if (!username || !password_hash) {
return res.status(400).json({ success: false, error: 'MISSING_FIELDS' });
}
// Username rules
if (username.length < 3 || username.length > 32) {
return res.json({ success: false, error: 'INVALID_USERNAME', message: 'Username must be 3-32 characters' });
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return res.json({ success: false, error: 'INVALID_USERNAME', message: 'Only letters, numbers, underscores' });
}
// Rate limit registration per IP (5 per hour)
const rl = db.checkRateLimit(`register:${ip}`, 'register', 5, 3600);
if (!rl.allowed) {
return res.status(429).json({
success: false, error: 'RATE_LIMITED',
message: 'Too many registrations. Try again later.', retry_after: rl.retryAfter
});
}
// Check if username taken
if (db.getUserByUsername(username)) {
return res.json({ success: false, error: 'USERNAME_TAKEN' });
}
// password_hash comes as SHA-256 from client — verify it's 64-char hex
if (password_hash.length !== 64 || !/^[a-f0-9]{64}$/.test(password_hash)) {
return res.json({ success: false, error: 'INVALID_PASSWORD_HASH' });
}
// password_hash is already SHA-256(password) — Argon2id it directly
let storedHash;
try {
storedHash = await auth.hashPasswordFromHash(password_hash);
} catch (err) {
return res.status(500).json({ success: false, error: 'INTERNAL_ERROR' });
}
db.createUser(username, storedHash, 'user', null);
console.log(`[register] New user: ${username} (ip: ${ip})`);
res.json({ success: true, message: 'Registration successful. You can now login.' });
});
// Admin: POST /api/admin/create-user
app.post('/api/admin/create-user', auth.requireAdmin, async (req, res) => {
const { username, password, role, expires_at } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, error: 'MISSING_FIELDS' });
}
if (db.getUserByUsername(username)) {
return res.json({ success: false, error: 'USERNAME_TAKEN' });
}
let passwordHash;
try { passwordHash = await auth.hashPassword(password); }
catch (err) { return res.status(500).json({ success: false, error: 'INTERNAL_ERROR' }); }
db.createUser(username, passwordHash, role || 'user', expires_at || null);
res.json({ success: true, message: `User '${username}' created` });
});
// Admin: GET /api/admin/users (list all users)
app.get('/api/admin/users', auth.requireAdmin, (req, res) => {
const db = require('./db');
const users = db.getDB().prepare(
`SELECT u.id, u.username, u.role, u.is_active, u.created_at, u.expires_at,
(SELECT MIN(d.created_at) FROM devices d WHERE d.user_id = u.id) as hwid_bound_at
FROM users u ORDER BY u.id DESC`
).all();
res.json({ users });
});
// Admin: GET /api/admin/logs (recent login logs, last 100)
app.get('/api/admin/logs', auth.requireAdmin, (req, res) => {
const db = require('./db');
const logs = db.getDB().prepare(
`SELECT login_log.*, users.username FROM login_log
LEFT JOIN users ON login_log.user_id = users.id
ORDER BY login_log.id DESC LIMIT 100`
).all();
res.json({ logs });
});
// Admin: GET /api/admin/stats
app.get('/api/admin/stats', auth.requireAdmin, (req, res) => {
const db = require('./db');
const totalUsers = db.getDB().prepare('SELECT COUNT(*) as cnt FROM users').get().cnt;
const activeUsers = db.getDB().prepare('SELECT COUNT(*) as cnt FROM users WHERE is_active = 1').get().cnt;
const logins24h = db.getDB().prepare(
"SELECT COUNT(*) as cnt FROM login_log WHERE success = 1 AND attempted_at > datetime('now', '-1 day')"
).get().cnt;
const failures24h = db.getDB().prepare(
"SELECT COUNT(*) as cnt FROM login_log WHERE success = 0 AND attempted_at > datetime('now', '-1 day')"
).get().cnt;
res.json({ totalUsers, activeUsers, logins24h, failures24h });
});
// Admin: POST /api/admin/user/:id/toggle (ban/unban)
app.post('/api/admin/user/:id/toggle', auth.requireAdmin, (req, res) => {
const db = require('./db');
const user = db.getUserById(req.params.id);
if (!user) return res.json({ success: false, error: 'NOT_FOUND' });
const newStatus = user.is_active ? 0 : 1;
db.getDB().prepare('UPDATE users SET is_active = ? WHERE id = ?').run(newStatus, req.params.id);
res.json({ success: true, is_active: newStatus });
});
// Admin: POST /api/admin/user/:id/reset-hwid
app.post('/api/admin/user/:id/reset-hwid', auth.requireAdmin, (req, res) => {
const db = require('./db');
db.getDB().prepare('DELETE FROM devices WHERE user_id = ?').run(req.params.id);
db.getDB().prepare(
'UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?'
).run(req.params.id);
res.json({ success: true, message: 'HWID reset. User can bind to new machine on next login.' });
});
// Start
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`[server] running on port ${PORT}`);
console.log(` GET /api/status`);
console.log(` POST /api/login`);
console.log(` POST /api/refresh`);
console.log(` POST /api/logout`);
console.log(` POST /api/admin/create-user (admin)`);
});
+47
View File
@@ -0,0 +1,47 @@
/**
* Setup script — create the first admin user.
* Usage: node setup.js <admin_username> <admin_password>
*/
require('dotenv').config();
const readline = require('readline');
const db = require('./db');
const auth = require('./auth');
db.initDB();
async function main() {
const args = process.argv.slice(2);
let username = args[0];
let password = args[1];
if (!username || !password) {
// Prompt interactively
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
username = await new Promise(resolve => rl.question('Admin username: ', resolve));
password = await new Promise(resolve => rl.question('Admin password: ', resolve));
rl.close();
}
if (!username || !password) {
console.error('Username and password are required.');
process.exit(1);
}
const existing = db.getUserByUsername(username);
if (existing) {
console.error(`User '${username}' already exists.`);
process.exit(1);
}
const passwordHash = await auth.hashPassword(password);
db.createUser(username, passwordHash, 'admin', null);
console.log(`Admin user '${username}' created successfully.`);
process.exit(0);
}
main().catch(err => {
console.error('Setup failed:', err);
process.exit(1);
});
Binary file not shown.
Binary file not shown.