diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 825ca37e9..13ff3ce08 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -79,6 +79,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [44, ['0048_old-app-names-unique-tuple.sql']], [45, ['0049_music-player-pdf-player-updates.sql']], [46, ['0050_add_preamble_version.sql']], + [47, ['0051_sessions_v2.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_7.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_7.sql index 672922b90..6b5b370e6 100644 --- a/src/backend/clients/database/migrations/mysql/mysql_mig_7.sql +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_7.sql @@ -14,5 +14,26 @@ -- -- You should have received a copy of the GNU Affero General Public License -- along with this program. If not, see . +-- +-- Idempotent: the ADD COLUMN is guarded by an INFORMATION_SCHEMA check, +-- so re-running the migration directory is safe (required — the runner +-- has no per-file tracking). -ALTER TABLE `subdomains` ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL; +DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version; +DELIMITER // +CREATE PROCEDURE _puter_add_subdomains_preamble_version() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subdomains' AND COLUMN_NAME = 'preamble_version' + ) THEN + ALTER TABLE `subdomains` + ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL; + END IF; +END// +DELIMITER ; + +CALL _puter_add_subdomains_preamble_version(); + +DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version; diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_8.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_8.sql new file mode 100644 index 000000000..b25ef2a93 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_8.sql @@ -0,0 +1,98 @@ +-- Extend `sessions` so a single row can represent any token kind +-- (web/app/access_token/asset), carry display metadata for the +-- manage-sessions UI, and be soft-revoked with row-level expiry. +-- Mirrors SQLite migration 0050. +-- +-- Idempotent: each ADD COLUMN / ADD INDEX is guarded by an +-- INFORMATION_SCHEMA check, so re-running the migration directory +-- is safe (required — the runner has no per-file tracking). + +DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2; +DELIMITER // +CREATE PROCEDURE _puter_extend_sessions_v2() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'kind' + ) THEN + ALTER TABLE `sessions` + ADD COLUMN `kind` ENUM('web', 'app', 'access_token', 'asset') + NOT NULL DEFAULT 'web'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'label' + ) THEN + ALTER TABLE `sessions` ADD COLUMN `label` VARCHAR(255) DEFAULT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'parent_session_id' + ) THEN + ALTER TABLE `sessions` + ADD COLUMN `parent_session_id` VARCHAR(64) DEFAULT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_ip' + ) THEN + ALTER TABLE `sessions` ADD COLUMN `last_ip` VARCHAR(64) DEFAULT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_user_agent' + ) THEN + ALTER TABLE `sessions` + ADD COLUMN `last_user_agent` VARCHAR(512) DEFAULT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'revoked_at' + ) THEN + ALTER TABLE `sessions` ADD COLUMN `revoked_at` BIGINT DEFAULT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'expires_at' + ) THEN + ALTER TABLE `sessions` ADD COLUMN `expires_at` BIGINT DEFAULT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' + AND INDEX_NAME = 'idx_sessions_user_revoked' + ) THEN + ALTER TABLE `sessions` + ADD INDEX `idx_sessions_user_revoked` (`user_id`, `revoked_at`); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' + AND INDEX_NAME = 'idx_sessions_parent' + ) THEN + ALTER TABLE `sessions` + ADD INDEX `idx_sessions_parent` (`parent_session_id`); + END IF; +END// +DELIMITER ; + +CALL _puter_extend_sessions_v2(); + +DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2; diff --git a/src/backend/clients/database/migrations/sqlite/0051_sessions_v2.sql b/src/backend/clients/database/migrations/sqlite/0051_sessions_v2.sql new file mode 100644 index 000000000..f23a21923 --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0051_sessions_v2.sql @@ -0,0 +1,34 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Extend `sessions` so a single row can represent any token kind +-- (web/app/access_token/asset), carry display metadata for the +-- manage-sessions UI, and be soft-revoked. + +ALTER TABLE `sessions` ADD COLUMN `kind` TEXT NOT NULL DEFAULT 'web' + CHECK (`kind` IN ('web', 'app', 'access_token', 'asset')); +ALTER TABLE `sessions` ADD COLUMN `label` TEXT; +ALTER TABLE `sessions` ADD COLUMN `parent_session_id` TEXT; +ALTER TABLE `sessions` ADD COLUMN `last_ip` TEXT; +ALTER TABLE `sessions` ADD COLUMN `last_user_agent` TEXT; +ALTER TABLE `sessions` ADD COLUMN `revoked_at` INTEGER; +ALTER TABLE `sessions` ADD COLUMN `expires_at` INTEGER; + +CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked` + ON `sessions` (`user_id`, `revoked_at`); +CREATE INDEX IF NOT EXISTS `idx_sessions_parent` + ON `sessions` (`parent_session_id`); diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 5b0d5fd39..0b859b1df 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -120,7 +120,12 @@ export class AuthService extends PuterService { token: string; gui_token: string; }> { - const session = await this.stores.session.create(user.id, meta); + const session = await this.stores.session.create(user.id, { + meta, + kind: 'web', + last_ip: (meta.ip as string | undefined) ?? null, + last_user_agent: (meta.user_agent as string | undefined) ?? null, + }); const token = this.services.token.sign('auth', { type: 'session', diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index 1a8d096a8..90fafd873 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -25,7 +25,10 @@ import { PuterStore } from '../types'; // the cached row doesn't gate anything, and eating a Redis write per // request isn't worth it. -const CACHE_KEY_PREFIX = 'sessions'; +// Prefix is versioned so a schema change (new columns added to the +// row shape) doesn't leak stale cached rows through Redis on a +// rolling deploy. Bump the suffix on the next schema change. +const CACHE_KEY_PREFIX = 'sessions:v2'; const CACHE_TTL_SECONDS = 15 * 60; // Min interval between successive activity flushes per session/user. // In-memory throttle keeps DB writes bounded; multi-node duplicates @@ -44,15 +47,21 @@ export class SessionStore extends PuterStore { #lastSessionTouchMs = new Map(); #lastUserTouchMs = new Map(); - /** Look up a session by its uuid. Returns `null` if not found. */ + /** + * Look up an active session by its uuid. Returns `null` if not + * found or soft-revoked. + */ async getByUuid(uuid) { if (!uuid) return null; const cached = await this.#readCache(uuid); - if (cached) return cached; + if (cached) { + if (cached.revoked_at != null) return null; + return cached; + } const rows = await this.clients.db.read( - 'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1', + 'SELECT * FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1', [uuid], ); const normalized = this.#normalizeRow(rows[0]); @@ -64,23 +73,44 @@ export class SessionStore extends PuterStore { return normalized; } - /** Get all sessions for a user. */ - async getByUserId(userId) { - const rows = await this.clients.db.read( - 'SELECT * FROM `sessions` WHERE `user_id` = ?', - [userId], - ); + /** + * Get sessions for a user. By default returns only active rows; + * pass `{ includeRevoked: true }` to include soft-revoked rows. + */ + async getByUserId(userId, { includeRevoked = false } = {}) { + const sql = includeRevoked + ? 'SELECT * FROM `sessions` WHERE `user_id` = ?' + : 'SELECT * FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL'; + const rows = await this.clients.db.read(sql, [userId]); return rows.map((r) => this.#normalizeRow(r)).filter(Boolean); } /** - * Create a new session. + * Create a new session row. * * @param userId - User ID (numeric) - * @param meta - Metadata object (IP, user-agent, etc.) - * @returns The created session row + * @param opts.meta - Request-context metadata (IP, UA, etc.) stored as JSON. + * @param opts.kind - 'web' (default), 'app', 'access_token', 'asset'. + * @param opts.label - User-editable label for manage-sessions UI. + * @param opts.parent_session_id - uuid of root session, for derived kinds. + * @param opts.last_ip - Request IP at creation. + * @param opts.last_user_agent - Request User-Agent at creation. + * @param opts.expires_at - Row-level expiry (unix seconds). NULL means + * JWT `exp` is the sole truth. AUTH-4 slides this forward on activity. + * @returns The created session row. */ - async create(userId, meta = {}) { + async create( + userId, + { + meta = {}, + kind = 'web', + label = null, + parent_session_id = null, + last_ip = null, + last_user_agent = null, + expires_at = null, + } = {}, + ) { const uuid = uuidv4(); const now = Math.floor(Date.now() / 1000); @@ -88,8 +118,20 @@ export class SessionStore extends PuterStore { meta.created_unix = now; await this.clients.db.write( - 'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`) VALUES (?, ?, ?, ?, ?)', - [uuid, userId, JSON.stringify(meta), now, now], + 'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`, `kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`, `expires_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + uuid, + userId, + JSON.stringify(meta), + now, + now, + kind, + label, + parent_session_id, + last_ip, + last_user_agent, + expires_at, + ], ); return { @@ -98,20 +140,62 @@ export class SessionStore extends PuterStore { meta, created_at: now, last_activity: now, + kind, + label, + parent_session_id, + last_ip, + last_user_agent, + revoked_at: null, + expires_at, }; } - /** Delete a session by uuid. Invalidates cache on this node + peers. */ + /** + * Soft-revoke a session by uuid. The row remains in the table + * with `revoked_at` set; subsequent `getByUuid` calls treat it + * as not found. Invalidates cache on this node + peers. + */ async removeByUuid(uuid) { - await this.clients.db.write('DELETE FROM `sessions` WHERE `uuid` = ?', [ - uuid, - ]); + const now = Math.floor(Date.now() / 1000); + await this.clients.db.write( + 'UPDATE `sessions` SET `revoked_at` = ? WHERE `uuid` = ? AND `revoked_at` IS NULL', + [now, uuid], + ); await this.publishCacheKeys({ keys: [this.#cacheKey(uuid)], broadcast: true, }); } + /** + * Soft-revoke a root session and every derived session that + * points back to it via `parent_session_id`. Broadcasts cache + * invalidation for each affected row. + */ + async revokeCascade(rootUuid) { + if (!rootUuid) return; + + // Collect affected uuids first so we can broadcast cache + // invalidation for each row. A single UPDATE ... RETURNING + // would be cleaner but isn't portable between sqlite/mysql. + const rows = await this.clients.db.read( + 'SELECT `uuid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL', + [rootUuid, rootUuid], + ); + if (rows.length === 0) return; + + const now = Math.floor(Date.now() / 1000); + await this.clients.db.write( + 'UPDATE `sessions` SET `revoked_at` = ? WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL', + [now, rootUuid, rootUuid], + ); + + await this.publishCacheKeys({ + keys: rows.map((r) => this.#cacheKey(r.uuid)), + broadcast: true, + }); + } + /** Update session activity timestamp. */ async updateActivity(uuid, lastActivity) { await this.clients.db.write( diff --git a/src/backend/stores/session/SessionStore.test.ts b/src/backend/stores/session/SessionStore.test.ts new file mode 100644 index 000000000..0dd00562f --- /dev/null +++ b/src/backend/stores/session/SessionStore.test.ts @@ -0,0 +1,257 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; +import { setupTestServer } from '../../testUtil.ts'; +import { PuterServer } from '../../server.ts'; + +describe('SessionStore', () => { + let server: PuterServer; + let target: any; + + beforeAll(async () => { + server = await setupTestServer(); + target = server.stores.session; + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + const makeUser = async () => { + const username = `ss-${Math.random().toString(36).slice(2, 10)}`; + return server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + email_confirmed: 1, + } as never); + }; + + // Reads the raw row (including revoked) so tests can assert + // soft-revoke semantics — `getByUuid` filters revoked rows. + const rawRow = async (uuid: string) => { + const rows = await server.clients.db.read( + 'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1', + [uuid], + ); + return rows[0] ?? null; + }; + + describe('create', () => { + it('defaults to kind="web" and stores request metadata', async () => { + const user = await makeUser(); + const session = await target.create(user.id, { + meta: { ip: '1.2.3.4' }, + last_ip: '1.2.3.4', + last_user_agent: 'test-agent', + }); + + expect(session.kind).toBe('web'); + expect(session.parent_session_id).toBeNull(); + expect(session.revoked_at).toBeNull(); + + const row = await rawRow(session.uuid); + expect(row.kind).toBe('web'); + expect(row.last_ip).toBe('1.2.3.4'); + expect(row.last_user_agent).toBe('test-agent'); + }); + + it('stores expires_at when provided', async () => { + const user = await makeUser(); + const future = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; + const session = await target.create(user.id, { + expires_at: future, + }); + + expect(session.expires_at).toBe(future); + const row = await rawRow(session.uuid); + expect(row.expires_at).toBe(future); + }); + + it('leaves expires_at NULL by default (JWT exp is sole truth)', async () => { + const user = await makeUser(); + const session = await target.create(user.id); + expect(session.expires_at).toBeNull(); + const row = await rawRow(session.uuid); + expect(row.expires_at).toBeNull(); + }); + + it('accepts derived kinds with a parent_session_id', async () => { + const user = await makeUser(); + const parent = await target.create(user.id); + const child = await target.create(user.id, { + kind: 'app', + parent_session_id: parent.uuid, + label: 'Some App', + }); + + expect(child.kind).toBe('app'); + expect(child.parent_session_id).toBe(parent.uuid); + + const row = await rawRow(child.uuid); + expect(row.parent_session_id).toBe(parent.uuid); + expect(row.label).toBe('Some App'); + }); + }); + + describe('getByUuid', () => { + it('returns the row for an active session', async () => { + const user = await makeUser(); + const session = await target.create(user.id); + const fetched = await target.getByUuid(session.uuid); + expect(fetched).toBeTruthy(); + expect(fetched.uuid).toBe(session.uuid); + }); + + it('returns null for a soft-revoked session', async () => { + const user = await makeUser(); + const session = await target.create(user.id); + await target.removeByUuid(session.uuid); + const fetched = await target.getByUuid(session.uuid); + expect(fetched).toBeNull(); + }); + + it('returns the row even when expires_at is in the past (AUTH-4 owns expiry)', async () => { + const user = await makeUser(); + const past = Math.floor(Date.now() / 1000) - 60; + const session = await target.create(user.id, { expires_at: past }); + const fetched = await target.getByUuid(session.uuid); + expect(fetched).toBeTruthy(); + expect(fetched.uuid).toBe(session.uuid); + expect(fetched.expires_at).toBe(past); + }); + + it('returns null when uuid is empty', async () => { + expect(await target.getByUuid('')).toBeNull(); + expect(await target.getByUuid(undefined)).toBeNull(); + }); + }); + + describe('getByUserId', () => { + it('returns only active sessions by default', async () => { + const user = await makeUser(); + const active = await target.create(user.id); + const revoked = await target.create(user.id); + await target.removeByUuid(revoked.uuid); + + const rows = await target.getByUserId(user.id); + const uuids = rows.map((r: { uuid: string }) => r.uuid); + expect(uuids).toContain(active.uuid); + expect(uuids).not.toContain(revoked.uuid); + }); + + it('returns revoked sessions when includeRevoked is true', async () => { + const user = await makeUser(); + const active = await target.create(user.id); + const revoked = await target.create(user.id); + await target.removeByUuid(revoked.uuid); + + const rows = await target.getByUserId(user.id, { + includeRevoked: true, + }); + const uuids = rows.map((r: { uuid: string }) => r.uuid); + expect(uuids).toContain(active.uuid); + expect(uuids).toContain(revoked.uuid); + }); + }); + + describe('removeByUuid', () => { + it('soft-revokes — row remains in DB with revoked_at set', async () => { + const user = await makeUser(); + const session = await target.create(user.id); + + await target.removeByUuid(session.uuid); + + const row = await rawRow(session.uuid); + expect(row).toBeTruthy(); + expect(row.revoked_at).not.toBeNull(); + expect(row.revoked_at).toBeGreaterThan(0); + }); + + it('is idempotent — second call does not overwrite revoked_at', async () => { + const user = await makeUser(); + const session = await target.create(user.id); + + await target.removeByUuid(session.uuid); + const firstRevokedAt = (await rawRow(session.uuid)).revoked_at; + + await new Promise((r) => setTimeout(r, 1100)); + await target.removeByUuid(session.uuid); + const secondRevokedAt = (await rawRow(session.uuid)).revoked_at; + + expect(secondRevokedAt).toBe(firstRevokedAt); + }); + }); + + describe('revokeCascade', () => { + it('revokes the root session and all child sessions', async () => { + const user = await makeUser(); + const parent = await target.create(user.id); + const child1 = await target.create(user.id, { + kind: 'app', + parent_session_id: parent.uuid, + }); + const child2 = await target.create(user.id, { + kind: 'access_token', + parent_session_id: parent.uuid, + }); + + await target.revokeCascade(parent.uuid); + + expect(await target.getByUuid(parent.uuid)).toBeNull(); + expect(await target.getByUuid(child1.uuid)).toBeNull(); + expect(await target.getByUuid(child2.uuid)).toBeNull(); + + expect((await rawRow(parent.uuid)).revoked_at).not.toBeNull(); + expect((await rawRow(child1.uuid)).revoked_at).not.toBeNull(); + expect((await rawRow(child2.uuid)).revoked_at).not.toBeNull(); + }); + + it('does not touch unrelated sessions', async () => { + const user = await makeUser(); + const parent = await target.create(user.id); + const sibling = await target.create(user.id); + const childOfSibling = await target.create(user.id, { + kind: 'app', + parent_session_id: sibling.uuid, + }); + + await target.revokeCascade(parent.uuid); + + expect(await target.getByUuid(sibling.uuid)).toBeTruthy(); + expect(await target.getByUuid(childOfSibling.uuid)).toBeTruthy(); + }); + + it('is a no-op when the root uuid does not exist', async () => { + await expect( + target.revokeCascade('nonexistent-uuid'), + ).resolves.toBeUndefined(); + }); + + it('is a no-op when called with a falsy uuid', async () => { + await expect(target.revokeCascade('')).resolves.toBeUndefined(); + await expect( + target.revokeCascade(undefined), + ).resolves.toBeUndefined(); + }); + }); +});