main
This commit is contained in:
@@ -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)`);
|
||||
});
|
||||
Reference in New Issue
Block a user