199 lines
6.6 KiB
JavaScript
199 lines
6.6 KiB
JavaScript
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
|
|
};
|