소스 검색

Improve sessions

KernelDeimos 1 년 전
부모
커밋
19c49db538

+ 3 - 0
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 }) => {

+ 10 - 0
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
     //---------------------------------------------------------

+ 135 - 0
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 };

+ 31 - 33
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;
     }

+ 6 - 1
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}`);

+ 2 - 0
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;