Files
2026-06-08 15:51:52 +08:00

323 lines
12 KiB
JavaScript

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)`);
});