mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat: update authentication for the web app
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { getDashboardAuthSession } from "./dashboardSession.js";
|
||||
import { getUserById, verifyUserPassword } from "@/lib/db";
|
||||
|
||||
export async function getCurrentDashboardUser() {
|
||||
const cookieStore = await cookies();
|
||||
const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value);
|
||||
if (!session?.userId || !session?.username) return null;
|
||||
const persistedUser = await getUserById(String(session.userId));
|
||||
if (!persistedUser || !persistedUser.isActive) return null;
|
||||
|
||||
return {
|
||||
id: persistedUser.id,
|
||||
username: persistedUser.username,
|
||||
role: persistedUser.role,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireCurrentDashboardUser() {
|
||||
const user = await getCurrentDashboardUser();
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireAdminUser() {
|
||||
const user = await requireCurrentDashboardUser();
|
||||
if (user.role !== "admin") throw new Error("Forbidden");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function verifyCurrentDashboardUserPassword(password) {
|
||||
const user = await getCurrentDashboardUser();
|
||||
if (!user) return false;
|
||||
return verifyUserPassword(user.id, password);
|
||||
}
|
||||
@@ -7,6 +7,12 @@ export {
|
||||
getSettings, updateSettings, isCloudEnabled, getCloudUrl, exportSettings,
|
||||
} from "./repos/settingsRepo.js";
|
||||
|
||||
// Users
|
||||
export {
|
||||
getUsers, getUserById, getUserByUsername, createUser, updateUser, deleteUser,
|
||||
countActiveAdmins, verifyUserCredentials, verifyUserPassword, resetAdminPassword,
|
||||
} from "./repos/usersRepo.js";
|
||||
|
||||
// Provider connections
|
||||
export {
|
||||
getProviderConnections, getProviderConnectionById,
|
||||
@@ -74,6 +80,7 @@ export async function exportDb() {
|
||||
|
||||
const out = {
|
||||
settings: await exportSettings(),
|
||||
users: db.all(`SELECT id, username, password, role, isActive, createdAt, updatedAt FROM users`).map((r) => ({ ...r, isActive: r.isActive === 1 || r.isActive === true })),
|
||||
providerConnections: db.all(`SELECT * FROM providerConnections`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, provider: r.provider, authType: r.authType, name: r.name, email: r.email, priority: r.priority, isActive: r.isActive === 1, createdAt: r.createdAt, updatedAt: r.updatedAt })),
|
||||
providerNodes: db.all(`SELECT * FROM providerNodes`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, type: r.type, name: r.name, createdAt: r.createdAt, updatedAt: r.updatedAt })),
|
||||
proxyPools: db.all(`SELECT * FROM proxyPools`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, isActive: r.isActive === 1, testStatus: r.testStatus, createdAt: r.createdAt, updatedAt: r.updatedAt })),
|
||||
@@ -97,11 +104,23 @@ export async function importDb(payload) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
throw new Error("Invalid database payload");
|
||||
}
|
||||
if (Array.isArray(payload.users)) {
|
||||
const validUsers = payload.users.filter(
|
||||
(user) => user?.id && user?.username && user?.password && ["admin", "user"].includes(user.role)
|
||||
);
|
||||
if (validUsers.length !== payload.users.length) throw new Error("Invalid users data in database payload");
|
||||
if (!validUsers.some((user) => user.role === "admin" && user.isActive !== false)) {
|
||||
throw new Error("Database import requires at least one active administrator");
|
||||
}
|
||||
}
|
||||
const db = await getAdapter();
|
||||
|
||||
db.transaction(() => {
|
||||
// Wipe all tables (keep _meta)
|
||||
db.run(`DELETE FROM settings`);
|
||||
// Old backups predate multi-user authentication. Preserve the local
|
||||
// administrator unless the payload explicitly carries a users array.
|
||||
if (Array.isArray(payload.users)) db.run(`DELETE FROM users`);
|
||||
db.run(`DELETE FROM providerConnections`);
|
||||
db.run(`DELETE FROM providerNodes`);
|
||||
db.run(`DELETE FROM proxyPools`);
|
||||
@@ -114,6 +133,16 @@ export async function importDb(payload) {
|
||||
db.run(`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`, [stringifyJson(payload.settings)]);
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.users)) {
|
||||
for (const user of payload.users) {
|
||||
if (!user?.id || !user?.username || !user?.password || !["admin", "user"].includes(user.role)) continue;
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO users(id, username, password, role, isActive, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||
[user.id, user.username, user.password, user.role, user.isActive === false ? 0 : 1, user.createdAt || new Date().toISOString(), user.updatedAt || new Date().toISOString()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of payload.providerConnections || []) {
|
||||
const { id, provider, authType, name, email, priority, isActive, createdAt, updatedAt, ...rest } = c;
|
||||
db.run(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { TABLES, buildCreateTableSql } from "../schema.js";
|
||||
import { parseJson } from "../helpers/jsonCol.js";
|
||||
|
||||
function getInitialAdminPasswordHash(db) {
|
||||
const settingsRow = db.get(`SELECT data FROM settings WHERE id = 1`);
|
||||
const settings = settingsRow ? parseJson(settingsRow.data, {}) : {};
|
||||
if (settings.password) return settings.password;
|
||||
|
||||
const initialPassword = process.env.INITIAL_PASSWORD || "123456";
|
||||
return bcrypt.hashSync(initialPassword, 10);
|
||||
}
|
||||
|
||||
const migration = {
|
||||
version: 2,
|
||||
name: "users-table",
|
||||
up(db) {
|
||||
const users = TABLES.users;
|
||||
db.exec(buildCreateTableSql("users", users));
|
||||
for (const index of users.indexes || []) db.exec(index);
|
||||
|
||||
const existingAdmin = db.get(`SELECT id FROM users WHERE role = 'admin' LIMIT 1`);
|
||||
if (existingAdmin) return;
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
db.run(
|
||||
`INSERT INTO users(id, username, password, role, isActive, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||
[uuidv4(), "admin", getInitialAdminPasswordHash(db), "admin", 1, timestamp, timestamp]
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default migration;
|
||||
@@ -2,8 +2,9 @@
|
||||
// Each migration: { version: number, name: string, up(db): void }
|
||||
// Versions MUST be unique and monotonically increasing.
|
||||
import m001 from "./001-initial.js";
|
||||
import m002 from "./002-users-table.js";
|
||||
|
||||
export const MIGRATIONS = [m001].sort((a, b) => a.version - b.version);
|
||||
export const MIGRATIONS = [m001, m002].sort((a, b) => a.version - b.version);
|
||||
|
||||
export function latestVersion() {
|
||||
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getAdapter } from "../driver.js";
|
||||
|
||||
const USER_ROLES = new Set(["admin", "user"]);
|
||||
|
||||
function normalizeUsername(username) {
|
||||
return typeof username === "string" ? username.trim() : "";
|
||||
}
|
||||
|
||||
function rowToUser(row, includePassword = false) {
|
||||
if (!row) return null;
|
||||
const user = {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
role: row.role,
|
||||
isActive: row.isActive === 1 || row.isActive === true,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
if (includePassword) user.password = row.password;
|
||||
return user;
|
||||
}
|
||||
|
||||
function assertRole(role) {
|
||||
if (!USER_ROLES.has(role)) throw new Error("Invalid user role");
|
||||
}
|
||||
|
||||
export async function getUsers() {
|
||||
const db = await getAdapter();
|
||||
return db.all(`SELECT * FROM users ORDER BY createdAt ASC`).map((row) => rowToUser(row));
|
||||
}
|
||||
|
||||
export async function getUserById(id, includePassword = false) {
|
||||
const db = await getAdapter();
|
||||
return rowToUser(db.get(`SELECT * FROM users WHERE id = ?`, [id]), includePassword);
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username, includePassword = false) {
|
||||
const db = await getAdapter();
|
||||
return rowToUser(
|
||||
db.get(`SELECT * FROM users WHERE username = ? COLLATE NOCASE`, [normalizeUsername(username)]),
|
||||
includePassword
|
||||
);
|
||||
}
|
||||
|
||||
export async function createUser({ username, password, role = "user" }) {
|
||||
const normalizedUsername = normalizeUsername(username);
|
||||
if (normalizedUsername.length < 3) throw new Error("Username must be at least 3 characters");
|
||||
if (typeof password !== "string" || password.length < 6) throw new Error("Password must be at least 6 characters");
|
||||
assertRole(role);
|
||||
|
||||
const db = await getAdapter();
|
||||
const duplicate = db.get(`SELECT id FROM users WHERE username = ? COLLATE NOCASE`, [normalizedUsername]);
|
||||
if (duplicate) throw new Error("Username is already in use");
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const user = {
|
||||
id: uuidv4(),
|
||||
username: normalizedUsername,
|
||||
password: await bcrypt.hash(password, 10),
|
||||
role,
|
||||
isActive: true,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
db.run(
|
||||
`INSERT INTO users(id, username, password, role, isActive, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||
[user.id, user.username, user.password, user.role, 1, user.createdAt, user.updatedAt]
|
||||
);
|
||||
return rowToUser(user);
|
||||
}
|
||||
|
||||
export async function updateUser(id, updates = {}) {
|
||||
const db = await getAdapter();
|
||||
let result = null;
|
||||
let passwordHash = null;
|
||||
|
||||
if (Object.hasOwn(updates, "password")) {
|
||||
if (typeof updates.password !== "string" || updates.password.length < 6) {
|
||||
throw new Error("Password must be at least 6 characters");
|
||||
}
|
||||
passwordHash = await bcrypt.hash(updates.password, 10);
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
const row = db.get(`SELECT * FROM users WHERE id = ?`, [id]);
|
||||
if (!row) return;
|
||||
const current = rowToUser(row, true);
|
||||
const username = Object.hasOwn(updates, "username") ? normalizeUsername(updates.username) : current.username;
|
||||
const role = Object.hasOwn(updates, "role") ? updates.role : current.role;
|
||||
const isActive = Object.hasOwn(updates, "isActive") ? updates.isActive === true : current.isActive;
|
||||
let password = current.password;
|
||||
|
||||
if (username.length < 3) throw new Error("Username must be at least 3 characters");
|
||||
assertRole(role);
|
||||
if (passwordHash) password = passwordHash;
|
||||
|
||||
const duplicate = db.get(`SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?`, [username, id]);
|
||||
if (duplicate) throw new Error("Username is already in use");
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
db.run(
|
||||
`UPDATE users SET username = ?, password = ?, role = ?, isActive = ?, updatedAt = ? WHERE id = ?`,
|
||||
[username, password, role, isActive ? 1 : 0, updatedAt, id]
|
||||
);
|
||||
result = { ...current, username, password, role, isActive, updatedAt };
|
||||
});
|
||||
|
||||
return rowToUser(result);
|
||||
}
|
||||
|
||||
export async function countActiveAdmins() {
|
||||
const db = await getAdapter();
|
||||
return db.get(`SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND isActive = 1`)?.count || 0;
|
||||
}
|
||||
|
||||
export async function deleteUser(id) {
|
||||
const db = await getAdapter();
|
||||
const result = db.run(`DELETE FROM users WHERE id = ?`, [id]);
|
||||
return (result?.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function verifyUserCredentials(username, password) {
|
||||
if (typeof password !== "string" || !password) return null;
|
||||
const user = await getUserByUsername(username, true);
|
||||
if (!user || !user.isActive || !(await bcrypt.compare(password, user.password))) return null;
|
||||
return rowToUser(user);
|
||||
}
|
||||
|
||||
export async function verifyUserPassword(id, password) {
|
||||
if (typeof password !== "string" || !password) return false;
|
||||
const user = await getUserById(id, true);
|
||||
if (!user || !user.isActive) return false;
|
||||
return bcrypt.compare(password, user.password);
|
||||
}
|
||||
|
||||
export async function resetAdminPassword(password) {
|
||||
if (typeof password !== "string" || !password) throw new Error("Password is required");
|
||||
const db = await getAdapter();
|
||||
const admin = db.get(`SELECT id FROM users WHERE username = ? COLLATE NOCASE`, ["admin"])
|
||||
|| db.get(`SELECT id FROM users WHERE role = 'admin' ORDER BY createdAt ASC LIMIT 1`);
|
||||
if (!admin) throw new Error("No administrator account exists");
|
||||
return updateUser(admin.id, { password, isActive: true });
|
||||
}
|
||||
+16
-1
@@ -3,7 +3,7 @@
|
||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
||||
// to bump only skips that backup — it does NOT break the additive auto-sync.
|
||||
export const SCHEMA_VERSION = 1;
|
||||
export const SCHEMA_VERSION = 2;
|
||||
|
||||
export const PRAGMA_SQL = `
|
||||
PRAGMA journal_mode = WAL;
|
||||
@@ -31,6 +31,21 @@ export const TABLES = {
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
username: "TEXT UNIQUE NOT NULL",
|
||||
password: "TEXT NOT NULL",
|
||||
role: "TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user'))",
|
||||
isActive: "INTEGER NOT NULL DEFAULT 1",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_role_active ON users(role, isActive)",
|
||||
],
|
||||
},
|
||||
providerConnections: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Kept for backward compatibility with existing imports.
|
||||
export {
|
||||
getSettings, updateSettings, isCloudEnabled, getCloudUrl,
|
||||
getUsers, getUserById, getUserByUsername, createUser, updateUser, deleteUser,
|
||||
countActiveAdmins, verifyUserCredentials, verifyUserPassword, resetAdminPassword,
|
||||
getProviderConnections, getProviderConnectionById,
|
||||
createProviderConnection, updateProviderConnection,
|
||||
deleteProviderConnection, deleteProviderConnectionsByProvider,
|
||||
|
||||
Reference in New Issue
Block a user