Add session version handling
This commit is contained in:
+19
-1
@@ -137,6 +137,23 @@ function sameOriginProtection(config) {
|
||||
};
|
||||
}
|
||||
|
||||
function enforceSessionVersion(repositories) {
|
||||
return (req, res, next) => {
|
||||
if (!req.session.user) return next();
|
||||
const user = repositories.getUser(req.session.user);
|
||||
const currentVersion = Number(user?.session_version || 0);
|
||||
const sessionVersion = Number(req.session.sessionVersion || 0);
|
||||
if (user && currentVersion === sessionVersion) return next();
|
||||
|
||||
req.session.destroy(() => {
|
||||
if (req.path === "/session") {
|
||||
return res.json({user: null});
|
||||
}
|
||||
return res.status(403).json({error: "Session expired. Please log in again."});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createApp(options = {}) {
|
||||
const config = createConfig(options.config);
|
||||
const app = express();
|
||||
@@ -181,6 +198,7 @@ export function createApp(options = {}) {
|
||||
}
|
||||
}));
|
||||
app.use(sameOriginProtection(config));
|
||||
app.use(enforceSessionVersion(repositories));
|
||||
|
||||
app.locals.config = config;
|
||||
app.locals.db = db;
|
||||
@@ -188,7 +206,7 @@ export function createApp(options = {}) {
|
||||
app.locals.modelService = modelService;
|
||||
app.locals.close = createCloseHandler(closeHandlers);
|
||||
|
||||
app.use(createAuthRouter({repositories, authRateLimiters}));
|
||||
app.use(createAuthRouter({repositories, authRateLimiters, config}));
|
||||
app.use(createSettingsRouter({repositories}));
|
||||
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
|
||||
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
|
||||
|
||||
@@ -37,6 +37,7 @@ export function createConfig(overrides = {}) {
|
||||
allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false),
|
||||
trustProxy: trustProxy(process.env.TRUST_PROXY, false),
|
||||
sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
|
||||
registrationInviteCode: process.env.REGISTRATION_INVITE_CODE || "",
|
||||
sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true),
|
||||
staticMaxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === "production" ? "1h" : 0),
|
||||
staticEtag: bool(process.env.STATIC_ETAG, true),
|
||||
|
||||
+32
-1
@@ -41,9 +41,10 @@ function normalizeMarketPersonaType(personaType) {
|
||||
export function createRepositories(db, config) {
|
||||
const statements = {
|
||||
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
|
||||
getUser: db.prepare("SELECT username, password FROM users WHERE username = ?"),
|
||||
getUser: db.prepare("SELECT username, password, session_version FROM users WHERE username = ?"),
|
||||
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
|
||||
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
|
||||
incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"),
|
||||
insertChatSession: db.prepare(
|
||||
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, folder_name, is_pinned, archived_at, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
),
|
||||
@@ -547,6 +548,17 @@ export function createRepositories(db, config) {
|
||||
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
|
||||
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
|
||||
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
|
||||
deleteChatMemoriesByUser: db.prepare("DELETE FROM chat_memories WHERE username = ?"),
|
||||
deleteChatSessionsByUser: db.prepare("DELETE FROM chat_sessions WHERE username = ?"),
|
||||
deletePersonasByUser: db.prepare("DELETE FROM personas WHERE username = ?"),
|
||||
deleteUserSettingsByUser: db.prepare("DELETE FROM user_settings WHERE username = ?"),
|
||||
deletePersonaVersionsByUser: db.prepare("DELETE FROM persona_versions WHERE username = ?"),
|
||||
deletePromptTemplatesByUser: db.prepare("DELETE FROM prompt_templates WHERE username = ?"),
|
||||
deleteMarketPersonasByCreator: db.prepare("DELETE FROM persona_market WHERE creator_username = ?"),
|
||||
deleteMarketFavoritesByUser: db.prepare("DELETE FROM persona_market_favorites WHERE username = ?"),
|
||||
deleteMarketRatingsByUser: db.prepare("DELETE FROM persona_market_ratings WHERE username = ?"),
|
||||
deleteMarketReportsByUser: db.prepare("DELETE FROM persona_market_reports WHERE reporter_username = ?"),
|
||||
deleteUser: db.prepare("DELETE FROM users WHERE username = ?"),
|
||||
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?"),
|
||||
incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?")
|
||||
};
|
||||
@@ -566,6 +578,20 @@ export function createRepositories(db, config) {
|
||||
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
|
||||
});
|
||||
|
||||
const deleteUserTransaction = db.transaction((username) => {
|
||||
statements.deleteMarketFavoritesByUser.run(username);
|
||||
statements.deleteMarketRatingsByUser.run(username);
|
||||
statements.deleteMarketReportsByUser.run(username);
|
||||
statements.deleteMarketPersonasByCreator.run(username);
|
||||
statements.deletePromptTemplatesByUser.run(username);
|
||||
statements.deletePersonaVersionsByUser.run(username);
|
||||
statements.deleteChatMemoriesByUser.run(username);
|
||||
statements.deleteChatSessionsByUser.run(username);
|
||||
statements.deleteUserSettingsByUser.run(username);
|
||||
statements.deletePersonasByUser.run(username);
|
||||
return statements.deleteUser.run(username);
|
||||
});
|
||||
|
||||
const savePersonaVersion = db.transaction((personaId, username) => {
|
||||
const persona = statements.getPersona.get(personaId, username);
|
||||
if (!persona) return null;
|
||||
@@ -670,7 +696,12 @@ export function createRepositories(db, config) {
|
||||
getUser: (username) => statements.getUser.get(username),
|
||||
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash),
|
||||
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username),
|
||||
incrementSessionVersion(username) {
|
||||
statements.incrementSessionVersion.run(username);
|
||||
return statements.getUser.get(username);
|
||||
},
|
||||
renameUser: (currentUsername, nextUsername) => renameUserTransaction(currentUsername, nextUsername),
|
||||
deleteUser: (username) => deleteUserTransaction(username),
|
||||
listChats: (username) => statements.listChatSessions.all(username),
|
||||
searchChats(username, query) {
|
||||
const like = `%${String(query || "").trim().toLowerCase()}%`;
|
||||
|
||||
+6
-1
@@ -6,7 +6,8 @@ export function initializeSchema(db) {
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
username TEXT PRIMARY KEY,
|
||||
password TEXT NOT NULL
|
||||
password TEXT NOT NULL,
|
||||
session_version INTEGER DEFAULT 0
|
||||
)
|
||||
`).run();
|
||||
|
||||
@@ -204,6 +205,10 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const userColumns = db.prepare("PRAGMA table_info(users)").all();
|
||||
ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0");
|
||||
db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run();
|
||||
|
||||
const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
|
||||
ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT");
|
||||
ensureColumn("personas", personaColumns, "persona_type", "ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'");
|
||||
|
||||
+12
-4
@@ -5,18 +5,22 @@ import {validateBody} from "../middleware/validate.js";
|
||||
const credentialsValidator = (body) => {
|
||||
const username = String(body?.username || "").trim();
|
||||
const password = String(body?.password || "");
|
||||
const inviteCode = String(body?.inviteCode || "").trim();
|
||||
if (!username) return {error: "Username is required"};
|
||||
if (username.length < 3) return {error: "Username must be at least 3 characters"};
|
||||
if (!password) return {error: "Password is required"};
|
||||
if (password.length < 6) return {error: "Password must be at least 6 characters"};
|
||||
return {value: {username, password}};
|
||||
return {value: {username, password, inviteCode}};
|
||||
};
|
||||
|
||||
export function createAuthRouter({repositories, authRateLimiters}) {
|
||||
export function createAuthRouter({repositories, authRateLimiters, config}) {
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
|
||||
const {username, password} = req.validatedBody;
|
||||
const {username, password, inviteCode} = req.validatedBody;
|
||||
if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) {
|
||||
return res.status(403).json({error: "Valid invite code is required"});
|
||||
}
|
||||
if (repositories.getUser(username)) {
|
||||
return res.status(400).json({error: "User already exists"});
|
||||
}
|
||||
@@ -33,6 +37,7 @@ export function createAuthRouter({repositories, authRateLimiters}) {
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) return res.status(403).json({error: "Invalid password"});
|
||||
req.session.user = username;
|
||||
req.session.sessionVersion = Number(user.session_version || 0);
|
||||
return res.json({message: "Login successful"});
|
||||
});
|
||||
|
||||
@@ -41,7 +46,10 @@ export function createAuthRouter({repositories, authRateLimiters}) {
|
||||
});
|
||||
|
||||
router.get("/session", (req, res) => {
|
||||
return res.json({user: req.session.user || null});
|
||||
return res.json({
|
||||
user: req.session.user || null,
|
||||
inviteOnlyRegistration: Boolean(config.registrationInviteCode)
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -20,6 +20,14 @@ const passwordValidator = (body) => {
|
||||
return {value: {currentPassword, newPassword}};
|
||||
};
|
||||
|
||||
const currentPasswordValidator = (body) => {
|
||||
const currentPassword = String(body?.currentPassword || "");
|
||||
if (!currentPassword) return {error: "Current password is required"};
|
||||
return {value: {currentPassword}};
|
||||
};
|
||||
|
||||
const destroySession = (req) => new Promise((resolve) => req.session.destroy(() => resolve()));
|
||||
|
||||
export function createSettingsRouter({repositories}) {
|
||||
const router = express.Router();
|
||||
router.use(requireLogin);
|
||||
@@ -59,8 +67,38 @@ export function createSettingsRouter({repositories}) {
|
||||
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
|
||||
const hashed = await bcrypt.hash(newPassword, 10);
|
||||
repositories.updatePassword(hashed, username);
|
||||
const updatedUser = repositories.incrementSessionVersion(username);
|
||||
req.session.sessionVersion = Number(updatedUser.session_version || 0);
|
||||
return res.json({message: "Password updated"});
|
||||
});
|
||||
|
||||
router.post("/settings/logout-all-sessions", (req, res) => {
|
||||
repositories.incrementSessionVersion(req.session.user);
|
||||
req.session.destroy(() => res.json({message: "All sessions logged out"}));
|
||||
});
|
||||
|
||||
router.get("/settings/export", (req, res) => {
|
||||
const user = repositories.getUser(req.session.user);
|
||||
if (!user) return res.status(404).json({error: "User not found"});
|
||||
return res.json({
|
||||
account: {
|
||||
username: user.username,
|
||||
exportedAt: new Date().toISOString(),
|
||||
workspace: repositories.exportWorkspace(req.session.user)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.delete("/settings/account", validateBody(currentPasswordValidator), async (req, res) => {
|
||||
const username = req.session.user;
|
||||
const user = repositories.getUser(username);
|
||||
if (!user) return res.status(404).json({error: "User not found"});
|
||||
const valid = await bcrypt.compare(req.validatedBody.currentPassword, user.password);
|
||||
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
|
||||
repositories.deleteUser(username);
|
||||
await destroySession(req);
|
||||
return res.json({message: "Account deleted"});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user