Remove public sign-up; accounts are created by an administrator
This is a private instance, so anyone able to reach the login page could previously give themselves an account. The registration route is gone rather than hidden, and the login screen no longer hints that accounts can be made. Accounts now come from an admin under Account → Administration: create, delete, and reset a password. Appointing further admins is deliberately not offered — with one admin it is not needed, and it is the one power that turns a compromised account into a lost instance. The first admin comes from INKNOTE_ADMIN_USER / INKNOTE_ADMIN_PASSWORD, used only when the database has no users, or to choose whom to promote when an older database has users but no admin. Once an admin exists both are ignored, so leaving them in .env cannot reset anything. Two things that would otherwise have been quietly wrong: - a deleted user's token stayed valid until it expired, because tokens are signed rather than stored and there was no session to revoke. authMiddleware now checks the account still exists, so an open tab stops working the moment the account is deleted. - deleting an account takes its notes, notebooks, drawings and version history with it via ON DELETE CASCADE. Verified: no orphaned rows in notes, notebooks, sync_log, attachments or note_versions. Deletion asks for the username to be typed back, since it destroys data that version history cannot bring back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
55a6b3caab
commit
68e184163e
12 files changed
+859
-123
No files matched your search
@@ -0,0 +1,34 @@
|
||||
# Copy to ../.env next to docker-compose.yml and fill in.
|
||||
# Back this file up together with the database: without ENCRYPTION_KEY the
|
||||
# stored notes cannot be read again.
|
||||
|
||||
# Signs session tokens. Changing it logs everyone out.
|
||||
# openssl rand -hex 32
|
||||
JWT_SECRET=
|
||||
|
||||
# Encrypts note bodies at rest. LOSING THIS MEANS LOSING THE NOTES.
|
||||
# Must be exactly 32 bytes as hex.
|
||||
# openssl rand -hex 32
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# The first administrator.
|
||||
#
|
||||
# There is no public sign-up — accounts are created by an admin inside the app
|
||||
# under Account → Administration. These two settle who the first one is:
|
||||
#
|
||||
# empty database → the account is created from both values
|
||||
# existing users, → INKNOTE_ADMIN_USER names which of them becomes admin.
|
||||
# no admin yet Without it, the oldest account is promoted.
|
||||
# an admin exists → both are ignored entirely
|
||||
#
|
||||
# So they can never reset an existing password or take rights away; the worst
|
||||
# they can do on a running instance is nothing.
|
||||
INKNOTE_ADMIN_USER=
|
||||
INKNOTE_ADMIN_PASSWORD=
|
||||
|
||||
# ── Optional ──
|
||||
# PORT=4000
|
||||
# ALLOWED_ORIGINS= # only if the UI is served from another host
|
||||
# MAX_VERSIONS_PER_NOTE=50
|
||||
# VERSION_RETENTION_DAYS=90
|
||||
# VERSION_COALESCE_MINUTES=5
|
||||
+171
-31
@@ -81,6 +81,7 @@ db.exec(`
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -189,11 +190,20 @@ db.exec(`
|
||||
const tableColumns = (t) => db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
|
||||
|
||||
const USERNAME_RE = /^[a-zA-Z0-9._-]{3,32}$/;
|
||||
const MIN_PASSWORD_LENGTH = 6;
|
||||
function normalizeUsername(raw) {
|
||||
const v = String(raw ?? "").trim();
|
||||
return USERNAME_RE.test(v) ? v : null;
|
||||
}
|
||||
|
||||
/** The fields of a user row that may leave the server. Never the password hash. */
|
||||
const publicUser = (row) => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
isAdmin: !!row.is_admin,
|
||||
});
|
||||
|
||||
// Login used to be e-mail based. Rebuild the table with a username column and
|
||||
// derive each existing user's username from the local part of their address.
|
||||
(function migrateEmailToUsername() {
|
||||
@@ -237,6 +247,57 @@ if (!tableColumns("notes").includes("drawing_strokes")) {
|
||||
db.exec("ALTER TABLE notes ADD COLUMN drawing_strokes BLOB");
|
||||
}
|
||||
|
||||
if (!tableColumns("users").includes("is_admin")) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure exactly one way in exists.
|
||||
*
|
||||
* There is no public sign-up: accounts are created by an admin. That leaves the
|
||||
* question of where the first admin comes from, and the answer is this — a
|
||||
* username and password in the environment, used only when the database has no
|
||||
* users at all. An installation that already has users is never touched by the
|
||||
* credentials, so leaving them in .env cannot silently reset anything.
|
||||
*
|
||||
* The second branch covers upgrades: a database that predates admin rights has
|
||||
* users but no admin, and would otherwise be locked out of creating any more.
|
||||
*/
|
||||
(function ensureAdminExists() {
|
||||
const wanted = normalizeUsername(process.env.INKNOTE_ADMIN_USER);
|
||||
const password = process.env.INKNOTE_ADMIN_PASSWORD;
|
||||
const userCount = db.prepare("SELECT COUNT(*) AS n FROM users").get().n;
|
||||
|
||||
if (userCount === 0) {
|
||||
if (!wanted || !password) {
|
||||
console.warn(
|
||||
"⚠️ No users and no INKNOTE_ADMIN_USER / INKNOTE_ADMIN_PASSWORD set — nobody can log in.\n" +
|
||||
" Set both in .env and restart to create the first administrator."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (String(password).length < MIN_PASSWORD_LENGTH) {
|
||||
console.warn(`⚠️ INKNOTE_ADMIN_PASSWORD is shorter than ${MIN_PASSWORD_LENGTH} characters — not creating an admin.`);
|
||||
return;
|
||||
}
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, 1)"
|
||||
).run(uuid(), wanted, bcrypt.hashSync(String(password), 12), wanted);
|
||||
console.log(` ✅ created first administrator "${wanted}" from the environment`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (db.prepare("SELECT COUNT(*) AS n FROM users WHERE is_admin = 1").get().n > 0) return;
|
||||
|
||||
// Prefer the account named in the environment; otherwise the oldest one, so
|
||||
// an existing single-user installation promotes the obvious candidate.
|
||||
const promote =
|
||||
(wanted && db.prepare("SELECT id, username FROM users WHERE username = ?").get(wanted)) ||
|
||||
db.prepare("SELECT id, username FROM users ORDER BY created_at LIMIT 1").get();
|
||||
db.prepare("UPDATE users SET is_admin = 1 WHERE id = ?").run(promote.id);
|
||||
console.log(` ✅ granted administrator rights to existing user "${promote.username}"`);
|
||||
})();
|
||||
|
||||
/* ─────────── Encryption helpers ─────────── */
|
||||
/* AES-256-GCM. CBC gave confidentiality but no integrity — a flipped bit in the
|
||||
* ciphertext decrypted to garbage instead of failing loudly. GCM authenticates,
|
||||
@@ -304,6 +365,13 @@ function authMiddleware(req, res, next) {
|
||||
try {
|
||||
const token = header.slice(7);
|
||||
const payload = jwt.verify(token, JWT_SECRET);
|
||||
// Tokens are signed rather than stored, so there is no session to revoke
|
||||
// when an admin deletes an account. Checking that the user still exists is
|
||||
// what stops a deleted person's open tab from carrying on as if nothing
|
||||
// happened; the client treats the 401 as a sign-out.
|
||||
if (!userExists.get(payload.userId)) {
|
||||
return res.status(401).json({ error: "Account no longer exists" });
|
||||
}
|
||||
req.userId = payload.userId;
|
||||
req.deviceId = req.headers["x-device-id"] || "unknown";
|
||||
next();
|
||||
@@ -311,6 +379,7 @@ function authMiddleware(req, res, next) {
|
||||
res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
}
|
||||
const userExists = db.prepare("SELECT 1 FROM users WHERE id = ?").pluck();
|
||||
|
||||
/* ─────────── Express app ─────────── */
|
||||
const app = express();
|
||||
@@ -363,30 +432,8 @@ const upload = multer({
|
||||
});
|
||||
|
||||
/* ─── Auth routes ─── */
|
||||
app.post("/api/auth/register", async (req, res, next) => {
|
||||
try {
|
||||
const { username, password, displayName } = req.body;
|
||||
const name = normalizeUsername(username);
|
||||
if (!name || !password) return res.status(400).json({ error: "Username and password required" });
|
||||
if (String(password).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
|
||||
|
||||
const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(name);
|
||||
if (existing) return res.status(409).json({ error: "Username already taken" });
|
||||
|
||||
const id = uuid();
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
try {
|
||||
db.prepare("INSERT INTO users (id, username, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, name, hash, displayName || name);
|
||||
} catch (err) {
|
||||
// Someone claimed the name between the check and the insert.
|
||||
if (String(err.message).includes("UNIQUE")) return res.status(409).json({ error: "Username already taken" });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" });
|
||||
res.json({ token, user: { id, username: name, displayName: displayName || name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
/* There is no registration route on purpose: this is a private instance, and
|
||||
* accounts are created by an administrator under /api/admin/users. */
|
||||
|
||||
app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
|
||||
try {
|
||||
@@ -399,23 +446,22 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
|
||||
|
||||
clearLoginAttempts(req);
|
||||
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
|
||||
res.json({ token, user: { id: user.id, username: user.username, displayName: user.display_name } });
|
||||
res.json({ token, user: publicUser(user) });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/* ─── Auth profile routes ─── */
|
||||
app.get("/api/auth/me", authMiddleware, (req, res) => {
|
||||
const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId);
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
res.json({ id: user.id, username: user.username, displayName: user.display_name });
|
||||
res.json(publicUser(user));
|
||||
});
|
||||
|
||||
app.put("/api/auth/profile", authMiddleware, (req, res) => {
|
||||
const { displayName } = req.body;
|
||||
if (!displayName || !displayName.trim()) return res.status(400).json({ error: "Display name required" });
|
||||
db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(displayName.trim(), req.userId);
|
||||
const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId);
|
||||
res.json({ id: user.id, username: user.username, displayName: user.display_name });
|
||||
res.json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId)));
|
||||
});
|
||||
|
||||
app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
|
||||
@@ -429,7 +475,7 @@ app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
|
||||
const exists = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(name, req.userId);
|
||||
if (exists) return res.status(409).json({ error: "Username already taken" });
|
||||
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(name, req.userId);
|
||||
res.json({ id: user.id, username: name, displayName: user.display_name });
|
||||
res.json(publicUser({ ...user, username: name }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
@@ -437,7 +483,7 @@ app.put("/api/auth/password", authMiddleware, async (req, res, next) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" });
|
||||
if (String(newPassword).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
|
||||
if (String(newPassword).length < MIN_PASSWORD_LENGTH) return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
if (!(await bcrypt.compare(String(currentPassword), user.password_hash))) return res.status(401).json({ error: "Current password is wrong" });
|
||||
@@ -447,6 +493,100 @@ app.put("/api/auth/password", authMiddleware, async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/* ─── Admin routes ─── */
|
||||
|
||||
/** Gate for everything under /api/admin. Runs after authMiddleware. */
|
||||
function requireAdmin(req, res, next) {
|
||||
const me = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(req.userId);
|
||||
// Deliberately the same 404-ish silence an ordinary user gets elsewhere: a
|
||||
// non-admin should not learn that an admin area exists.
|
||||
if (!me || !me.is_admin) return res.status(403).json({ error: "Not allowed" });
|
||||
next();
|
||||
}
|
||||
|
||||
app.get("/api/admin/users", authMiddleware, requireAdmin, (req, res) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT u.*, (SELECT COUNT(*) FROM notes n WHERE n.user_id = u.id AND n.deleted_at IS NULL) AS note_count
|
||||
FROM users u ORDER BY u.created_at`
|
||||
)
|
||||
.all();
|
||||
res.json(
|
||||
rows.map((r) => ({
|
||||
...publicUser(r),
|
||||
createdAt: r.created_at,
|
||||
noteCount: r.note_count,
|
||||
isSelf: r.id === req.userId,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/admin/users", authMiddleware, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { username, password, displayName } = req.body;
|
||||
const name = normalizeUsername(username);
|
||||
if (!name)
|
||||
return res.status(400).json({ error: "Username must be 3-32 characters: letters, digits, . _ -" });
|
||||
if (!password || String(password).length < MIN_PASSWORD_LENGTH)
|
||||
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
|
||||
const id = uuid();
|
||||
const hash = await bcrypt.hash(String(password), 12);
|
||||
try {
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, 0)"
|
||||
).run(id, name, hash, String(displayName || name).trim() || name);
|
||||
} catch (err) {
|
||||
if (String(err.message).includes("UNIQUE"))
|
||||
return res.status(409).json({ error: "Username already taken" });
|
||||
throw err;
|
||||
}
|
||||
console.log(` 👤 admin ${req.userId} created user "${name}"`);
|
||||
res.status(201).json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(id)));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/admin/users/:id/password", authMiddleware, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { newPassword } = req.body;
|
||||
if (!newPassword || String(newPassword).length < MIN_PASSWORD_LENGTH)
|
||||
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
const target = db.prepare("SELECT * FROM users WHERE id = ?").get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
const hash = await bcrypt.hash(String(newPassword), 12);
|
||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||
hash,
|
||||
target.id
|
||||
);
|
||||
console.log(` 🔑 admin ${req.userId} reset the password of "${target.username}"`);
|
||||
// Sessions elsewhere keep working: tokens are signed, not stored, so there
|
||||
// is nothing to revoke. Resetting a password locks nobody out mid-edit.
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/admin/users/:id", authMiddleware, requireAdmin, (req, res) => {
|
||||
const target = db.prepare("SELECT * FROM users WHERE id = ?").get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: "User not found" });
|
||||
if (target.id === req.userId) return res.status(400).json({ error: "You cannot delete your own account" });
|
||||
// A backstop against leaving the instance with no way to add users. Today the
|
||||
// self-delete check above already covers it — the only admin can only be
|
||||
// yourself — but that stops being true the moment admins can appoint admins.
|
||||
if (target.is_admin && db.prepare("SELECT COUNT(*) AS n FROM users WHERE is_admin = 1").get().n <= 1)
|
||||
return res.status(400).json({ error: "This is the last administrator" });
|
||||
|
||||
// Notes, notebooks, versions and attachment rows go with the user through
|
||||
// ON DELETE CASCADE; the files behind them are swept up by pruneOrphanAttachments.
|
||||
db.prepare("DELETE FROM users WHERE id = ?").run(target.id);
|
||||
console.log(` 🗑️ admin ${req.userId} deleted user "${target.username}" and all their data`);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/* ─── Notebook routes ─── */
|
||||
app.get("/api/notebooks", authMiddleware, (req, res) => {
|
||||
const notebooks = db.prepare("SELECT * FROM notebooks WHERE user_id = ? AND deleted_at IS NULL ORDER BY sort_order").all(req.userId);
|
||||
|
||||
Reference in new issue
Block a user