diff --git a/packages/backend/src/CoreModule.js b/packages/backend/src/CoreModule.js index 317c4e1d2..a151452cc 100644 --- a/packages/backend/src/CoreModule.js +++ b/packages/backend/src/CoreModule.js @@ -189,6 +189,9 @@ const install = async ({ services, app }) => { const { PuterVersionService } = require('./services/PuterVersionService'); services.registerService('puter-version', PuterVersionService); + + const { SessionService } = require('./services/SessionService'); + services.registerService('session', SessionService); } const install_legacy = async ({ services }) => { diff --git a/packages/backend/src/routers/logout.js b/packages/backend/src/routers/logout.js index 0e8eddf2b..771c9ad7c 100644 --- a/packages/backend/src/routers/logout.js +++ b/packages/backend/src/routers/logout.js @@ -31,6 +31,16 @@ router.post('/logout', auth, express.json(), async (req, res, next)=>{ next(); // delete cookie res.clearCookie(config.cookie_name); + // delete session + (async () => { + if ( ! req.token ) return; + try { + const svc_auth = req.services.get('auth'); + await svc_auth.remove_session_by_token(req.token); + } catch (e) { + console.log(e); + } + })() //--------------------------------------------------------- // DANGER ZONE: delete temp user and all its data //--------------------------------------------------------- diff --git a/packages/backend/src/services/SessionService.js b/packages/backend/src/services/SessionService.js new file mode 100644 index 000000000..8865daa1d --- /dev/null +++ b/packages/backend/src/services/SessionService.js @@ -0,0 +1,135 @@ +const { asyncSafeSetInterval } = require("../util/promise"); +const { MINUTE, SECOND } = require("../util/time"); +const BaseService = require("./BaseService"); +const { DB_WRITE } = require("./database/consts"); + +/** + * This service is responsible for updating session activity + * timestamps and maintaining the number of active sessions. + */ +class SessionService extends BaseService { + static MODULES = { + // uuidv5: require('uuid').v5, + uuidv4: require('uuid').v4, + } + + _construct () { + this.sessions = {}; + } + + async _init () { + this.db = await this.services.get('database').get(DB_WRITE, 'session'); + + (async () => { + // TODO: change to 5 minutes or configured value + asyncSafeSetInterval(async () => { + await this._update_sessions(); + }, 1000); + // }, 2 * MINUTE); + })(); + } + + async create_session (user, meta) { + const unix_ts = Math.floor(Date.now() / 1000); + + meta = { + // clone + ...(meta || {}), + }; + meta.created = new Date().toISOString(); + meta.created_unix = unix_ts; + const uuid = this.modules.uuidv4(); + await this.db.write( + 'INSERT INTO `sessions` ' + + '(`uuid`, `user_id`, `meta`, `last_activity`, `created_at`) ' + + 'VALUES (?, ?, ?, ?, ?)', + [uuid, user.id, JSON.stringify(meta), unix_ts, unix_ts], + ); + const session = { + last_touch: Date.now(), + last_store: Date.now(), + uuid, + user_uid: user.uuid, + meta, + }; + this.sessions[uuid] = session; + + return session; + } + + async get_session_ (uuid) { + let session = this.sessions[uuid]; + if ( session ) return session; + ;[session] = await this.db.read( + "SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1", + [uuid], + ); + if ( ! session ) return; + session.last_store = Date.now(); + session.meta = this.db.case({ + mysql: () => session.meta, + otherwise: () => JSON.parse(session.meta ?? "{}") + })(); + this.sessions[uuid] = session; + return session; + } + async get_session (uuid) { + const session = await this.get_session_(uuid); + if ( session ) { + session.last_touch = Date.now(); + session.meta.last_activity = (new Date()).toISOString(); + } + return this.remove_internal_values_(session); + } + + remove_internal_values_ (session) { + const copy = { + ...session, + }; + delete copy.last_touch; + delete copy.last_store; + delete copy.user_id; + return copy; + } + + get_user_sessions (user) { + const sessions = []; + for ( const session of Object.values(this.sessions) ) { + if ( session.user_id === user.id ) { + sessions.push(session); + } + } + return sessions.map(this.remove_internal_values_.bind(this)); + } + + remove_session (uuid) { + delete this.sessions[uuid]; + return this.db.write( + 'DELETE FROM `sessions` WHERE `uuid` = ?', + [uuid], + ); + } + + async _update_sessions () { + this.log.tick('UPDATING SESSIONS'); + const now = Date.now(); + const keys = Object.keys(this.sessions); + for ( const key of keys ) { + const session = this.sessions[key]; + // if ( now - session.last_store > 5 * MINUTE ) { + if ( now - session.last_store > 20 * SECOND ) { + this.log.debug('storing session meta: ' + session.uuid); + const unix_ts = Math.floor(now / 1000); + await this.db.write( + 'UPDATE `sessions` ' + + 'SET `meta` = ?, `last_activity` = ? ' + + 'WHERE `uuid` = ?', + [JSON.stringify(session.meta), unix_ts, session.uuid], + ); + session.last_store = now; + } + } + } +} + +module.exports = { SessionService }; diff --git a/packages/backend/src/services/auth/AuthService.js b/packages/backend/src/services/auth/AuthService.js index 4b264e2a3..e00317b32 100644 --- a/packages/backend/src/services/auth/AuthService.js +++ b/packages/backend/src/services/auth/AuthService.js @@ -36,6 +36,7 @@ class AuthService extends BaseService { async _init () { this.db = await this.services.get('database').get(DB_WRITE, 'auth'); + this.svc_session = await this.services.get('session'); this.sessions = {}; } @@ -214,40 +215,12 @@ class AuthService extends BaseService { } } - meta.created = new Date().toISOString(); - meta.created_unix = Math.floor(Date.now() / 1000); - - const uuid = this.modules.uuidv4(); - await this.db.write( - 'INSERT INTO `sessions` ' + - '(`uuid`, `user_id`, `meta`) ' + - 'VALUES (?, ?, ?)', - [uuid, user.id, JSON.stringify(meta)], - ); - const session = { uuid, user_uid: user.uuid, meta }; - this.sessions[uuid] = session; - return session; + return await this.svc_session.create_session(user, meta); } async get_session_ (uuid) { this.log.info(`USING SESSION`); - if ( this.sessions[uuid] ) { - return this.sessions[uuid]; - } - - const [session] = await this.db.read( - "SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1", - [uuid], - ); - - if ( ! session ) return; - - session.meta = this.db.case({ - mysql: () => session.meta, - otherwise: () => JSON.parse(session.meta ?? "{}") - })(); - - return session; + return await this.svc_session.get_session(uuid); } async create_session_token (user, meta) { @@ -314,6 +287,18 @@ class AuthService extends BaseService { return { actor, user, token }; } + async remove_session_by_token (token) { + const decoded = this.modules.jwt.verify( + token, this.global_config.jwt_secret + ); + + if ( decoded.type !== 'session' ) { + return; + } + + await this.svc_session.remove_session(decoded.uuid); + } + async create_access_token (authorizer, permissions) { const jwt_obj = {}; const authorizer_obj = {}; @@ -372,14 +357,26 @@ class AuthService extends BaseService { } async list_sessions (actor) { + const seen = new Set(); + const sessions = []; + + const cache_sessions = this.svc_session.get_user_sessions(actor.type.user); + for ( const session of cache_sessions ) { + seen.add(session.uuid); + sessions.push(session); + } + // We won't take the cached sessions here because it's // possible the user has sessions on other servers - const sessions = await this.db.read( + const db_sessions = await this.db.read( 'SELECT uuid, meta FROM `sessions` WHERE `user_id` = ?', [actor.type.user.id], ); - sessions.forEach(session => { + for ( const session of db_sessions ) { + if ( seen.has(session.uuid) ) { + continue; + } session.meta = this.db.case({ mysql: () => session.meta, otherwise: () => JSON.parse(session.meta ?? "{}") @@ -387,7 +384,8 @@ class AuthService extends BaseService { if ( session.uuid === actor.type.session ) { session.current = true; } - }); + sessions.push(session); + }; return sessions; } diff --git a/packages/backend/src/services/database/SqliteDatabaseAccessService.js b/packages/backend/src/services/database/SqliteDatabaseAccessService.js index 96581e692..8c7b581ae 100644 --- a/packages/backend/src/services/database/SqliteDatabaseAccessService.js +++ b/packages/backend/src/services/database/SqliteDatabaseAccessService.js @@ -42,7 +42,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { this.db = new Database(this.config.path); // Database upgrade logic - const TARGET_VERSION = 4; + const TARGET_VERSION = 5; if ( do_setup ) { this.log.noticeme(`SETUP: creating database at ${this.config.path}`); @@ -53,6 +53,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { '0004_sessions.sql', '0005_background-apps.sql', '0006_update-apps.sql', + '0007_sessions.sql', ].map(p => path_.join(__dirname, 'sqlite_setup', p)); const fs = require('fs'); for ( const filename of sql_files ) { @@ -85,6 +86,10 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { upgrade_files.push('0006_update-apps.sql'); } + if ( user_version <= 4 ) { + upgrade_files.push('0007_sessions.sql'); + } + if ( upgrade_files.length > 0 ) { this.log.noticeme(`Database out of date: ${this.config.path}`); this.log.noticeme(`UPGRADING DATABASE: ${user_version} -> ${TARGET_VERSION}`); diff --git a/packages/backend/src/services/database/sqlite_setup/0007_sessions.sql b/packages/backend/src/services/database/sqlite_setup/0007_sessions.sql new file mode 100644 index 000000000..d26f397fc --- /dev/null +++ b/packages/backend/src/services/database/sqlite_setup/0007_sessions.sql @@ -0,0 +1,2 @@ +ALTER TABLE `sessions` ADD COLUMN "created_at" INTEGER DEFAULT 0; +ALTER TABLE `sessions` ADD COLUMN "last_activity" INTEGER DEFAULT 0;