From e436693d3ed0decbb5a793c0d8d9901463791f2a Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Wed, 10 Apr 2024 21:54:16 -0400 Subject: [PATCH 1/7] Improve session mgmt (part 1) --- packages/backend/src/middleware/auth.js | 31 +++---- .../backend/src/routers/auth/list-sessions.js | 0 .../backend/src/services/auth/AuthService.js | 88 +++++++++++++++++++ .../database/SqliteDatabaseAccessService.js | 7 +- .../database/sqlite_setup/0004_sessions.sql | 7 ++ 5 files changed, 112 insertions(+), 21 deletions(-) create mode 100644 packages/backend/src/routers/auth/list-sessions.js create mode 100644 packages/backend/src/services/database/sqlite_setup/0004_sessions.sql diff --git a/packages/backend/src/middleware/auth.js b/packages/backend/src/middleware/auth.js index a231d8b9b..0c4305578 100644 --- a/packages/backend/src/middleware/auth.js +++ b/packages/backend/src/middleware/auth.js @@ -17,35 +17,26 @@ * along with this program. If not, see . */ "use strict" +const APIError = require('../api/APIError'); const {jwt_auth} = require('../helpers'); +const { UserActorType } = require('../services/auth/Actor'); const { DB_WRITE } = require('../services/database/consts'); const { Context } = require('../util/context'); +const auth2 = require('./auth2'); const auth = async (req, res, next)=>{ + let auth2_ok = false; try{ - let auth_res = await jwt_auth(req); + // Delegate to new middleware + await auth2(req, res, () => { auth2_ok = true; }); + if ( ! auth2_ok ) return; - // is account suspended? - if(auth_res.user.suspended) - return res.status(401).send({error: 'Account suspended'}); - - // successful auth - req.user = auth_res.user; - req.token = auth_res.token; - - // let's add it to the context too - try { - const x = Context.get(); - x.set('user', req.user); - } catch (e) { - console.error(e); + // Everything using the old reference to the auth middleware + // should only allow session tokens + if ( ! (req.actor.type instanceof UserActorType) ) { + throw APIError.create('forbidden'); } - // record as daily active users - const db = req.services.get('database').get(DB_WRITE, 'auth'); - db.write('UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1', [req.user.id]); - - // go to next next(); } // auth failed diff --git a/packages/backend/src/routers/auth/list-sessions.js b/packages/backend/src/routers/auth/list-sessions.js new file mode 100644 index 000000000..e69de29bb diff --git a/packages/backend/src/services/auth/AuthService.js b/packages/backend/src/services/auth/AuthService.js index 292186225..539caf02c 100644 --- a/packages/backend/src/services/auth/AuthService.js +++ b/packages/backend/src/services/auth/AuthService.js @@ -34,6 +34,8 @@ class AuthService extends BaseService { async _init () { this.db = await this.services.get('database').get(DB_WRITE, 'auth'); + + this.sessions = {}; } async authenticate_from_token (token) { @@ -43,6 +45,7 @@ class AuthService extends BaseService { ); if ( ! decoded.hasOwnProperty('type') ) { + throw new Error('legacy token'); const user = await this.db.requireRead( "SELECT * FROM `user` WHERE `uuid` = ? LIMIT 1", [decoded.uuid], @@ -66,6 +69,25 @@ class AuthService extends BaseService { }); } + if ( decoded.type === 'session' ) { + const session = this.get_session_(decoded.uuid); + + if ( ! session ) { + throw APIError.create('token_auth_failed'); + } + + const user = await get_user({ uuid: decoded.user_uid }); + + const actor_type = new UserActorType({ + user, + }); + + return new Actor({ + user_uid: decoded.user_uid, + type: actor_type, + }); + } + if ( decoded.type === 'app-under-user' ) { const user = await get_user({ uuid: decoded.user_uid }); if ( ! user ) { @@ -149,6 +171,72 @@ class AuthService extends BaseService { return token; } + async create_session_ (user, meta = {}) { + this.log.info(`CREATING SESSION`); + 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; + } + + 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], + ); + + return session; + } + + async create_session_token (user, meta) { + const session = await this.create_session_(user, meta); + + const token = this.modules.jwt.sign({ + type: 'session', + version: '0.0.0', + uuid: session.uuid, + meta: session.meta, + user_uid: user.uuid, + }, this.global_config.jwt_secret); + + return token; + } + + async check_session (cur_token) { + const decoded = this.modules.jwt.verify( + cur_token, this.global_config.jwt_secret + ); + + if ( decoded.type && decoded.type !== 'session' ) { + // throw APIError.create('token_auth_failed'); + return {}; + } + + const user = await get_user({ uuid: decoded.user_uid }); + if ( ! user ) { + return {}; + } + + if ( decoded.type ) return { user, token: cur_token }; + + this.log.info(`UPGRADING SESSION`); + + // Upgrade legacy token + const token = await this.create_session_token(user); + return { user, token }; + } + async create_access_token (authorizer, permissions) { const jwt_obj = {}; const authorizer_obj = {}; diff --git a/packages/backend/src/services/database/SqliteDatabaseAccessService.js b/packages/backend/src/services/database/SqliteDatabaseAccessService.js index 90049c9f7..c54d29efe 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 = 1; + const TARGET_VERSION = 2; if ( do_setup ) { this.log.noticeme(`SETUP: creating database at ${this.config.path}`); @@ -50,6 +50,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { '0001_create-tables.sql', '0002_add-default-apps.sql', '0003_user-permissions.sql', + '0004_sessions.sql', ].map(p => path_.join(__dirname, 'sqlite_setup', p)); const fs = require('fs'); for ( const filename of sql_files ) { @@ -70,6 +71,10 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { upgrade_files.push('0003_user-permissions.sql'); } + if ( user_version <= 1 ) { + upgrade_files.push('0004_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/0004_sessions.sql b/packages/backend/src/services/database/sqlite_setup/0004_sessions.sql new file mode 100644 index 000000000..a5a986acf --- /dev/null +++ b/packages/backend/src/services/database/sqlite_setup/0004_sessions.sql @@ -0,0 +1,7 @@ +CREATE TABLE `sessions` ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER NOT NULL, + "uuid" TEXT NOT NULL, + "meta" JSON DEFAULT NULL, + FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); From 18b3e06fe897aec69876f22c66cdb895fc821a38 Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Wed, 10 Apr 2024 23:00:37 -0400 Subject: [PATCH 2/7] Add session listing and revocation --- .../backend/src/routers/auth/list-sessions.js | 23 ++++++++++ .../src/routers/auth/revoke-session.js | 33 ++++++++++++++ packages/backend/src/routers/login.js | 3 +- packages/backend/src/routers/signup.js | 16 ++++--- .../backend/src/services/PuterAPIService.js | 2 + .../backend/src/services/auth/AuthService.js | 45 +++++++++++++++++-- 6 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 packages/backend/src/routers/auth/revoke-session.js diff --git a/packages/backend/src/routers/auth/list-sessions.js b/packages/backend/src/routers/auth/list-sessions.js index e69de29bb..a163c78c7 100644 --- a/packages/backend/src/routers/auth/list-sessions.js +++ b/packages/backend/src/routers/auth/list-sessions.js @@ -0,0 +1,23 @@ +const eggspress = require("../../api/eggspress"); +const { UserActorType } = require("../../services/auth/Actor"); +const { Context } = require("../../util/context"); + +module.exports = eggspress('/auth/list-sessions', { + subdomain: 'api', + auth2: true, + allowedMethods: ['GET'], +}, async (req, res, next) => { + const x = Context.get(); + const svc_auth = x.get('services').get('auth'); + + // Only users can list their own sessions + // apps, access tokens, etc should NEVER access this + const actor = x.get('actor'); + if ( ! (actor.type instanceof UserActorType) ) { + throw APIError.create('forbidden'); + } + + const sessions = await svc_auth.list_sessions(actor); + + res.json(sessions); +}); diff --git a/packages/backend/src/routers/auth/revoke-session.js b/packages/backend/src/routers/auth/revoke-session.js new file mode 100644 index 000000000..5604066c4 --- /dev/null +++ b/packages/backend/src/routers/auth/revoke-session.js @@ -0,0 +1,33 @@ +const APIError = require("../../api/APIError"); +const eggspress = require("../../api/eggspress"); +const { UserActorType } = require("../../services/auth/Actor"); +const { Context } = require("../../util/context"); + +module.exports = eggspress('/auth/revoke-session', { + subdomain: 'api', + auth2: true, + allowedMethods: ['POST'], +}, async (req, res, next) => { + const x = Context.get(); + const svc_auth = x.get('services').get('auth'); + + // Only users can list their own sessions + // apps, access tokens, etc should NEVER access this + const actor = x.get('actor'); + if ( ! (actor.type instanceof UserActorType) ) { + throw APIError.create('forbidden'); + } + + // Ensure valid UUID + if ( ! req.body.uuid || typeof req.body.uuid !== 'string' ) { + throw APIError.create('field_invalid', null, { + key: 'uuid', + expected: 'string' + }); + } + + const sessions = await svc_auth.revoke_session( + actor, req.body.uuid); + + res.json({ sessions }); +}); diff --git a/packages/backend/src/routers/login.js b/packages/backend/src/routers/login.js index 795497900..1f5c6c0ca 100644 --- a/packages/backend/src/routers/login.js +++ b/packages/backend/src/routers/login.js @@ -89,7 +89,8 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res return res.status(400).send('Incorrect password.') // check password if(await bcrypt.compare(req.body.password, user.password)){ - const token = await jwt.sign({uuid: user.uuid}, config.jwt_secret) + const svc_auth = req.services.get('auth'); + const token = await svc_auth.create_session_token(user); //set cookie // res.cookie(config.cookie_name, token); res.cookie(config.cookie_name, token, { diff --git a/packages/backend/src/routers/signup.js b/packages/backend/src/routers/signup.js index ca45d9d7a..012fdfb21 100644 --- a/packages/backend/src/routers/signup.js +++ b/packages/backend/src/routers/signup.js @@ -52,6 +52,7 @@ module.exports = eggspress(['/signup'], { const validator = require('validator') let uuid_user; + const svc_auth = Context.get('services').get('auth'); const svc_authAudit = Context.get('services').get('auth-audit'); svc_authAudit.record({ requester: Context.get('requester'), @@ -67,9 +68,11 @@ module.exports = eggspress(['/signup'], { // check if user is already logged in if ( req.body.is_temp && req.cookies[config.cookie_name] ) { - const token = req.cookies[config.cookie_name]; - const decoded = await jwt.verify(token, config.jwt_secret); - const user = await get_user({ uuid: decoded.uuid }); + const { user, token } = await svc_auth.check_session( + req.cookies[config.cookie_name] + ); + // const decoded = await jwt.verify(token, config.jwt_secret); + // const user = await get_user({ uuid: decoded.uuid }); if ( user ) { return res.send({ token: token, @@ -233,17 +236,20 @@ module.exports = eggspress(['/signup'], { db.write('UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1', [pseudo_user.id]); invalidate_cached_user_by_id(pseudo_user.id); } - // create token for login - const token = await jwt.sign({uuid: user_uuid}, config.jwt_secret); // user id // todo if pseudo user, assign directly no need to do another DB lookup const user_id = (pseudo_user === undefined) ? insert_res.insertId : pseudo_user.id; + const [user] = await db.read( 'SELECT * FROM `user` WHERE `id` = ? LIMIT 1', [user_id] ); + // create token for login + const token = await svc_auth.create_session_token(user); + // jwt.sign({uuid: user_uuid}, config.jwt_secret); + //------------------------------------------------------------- // email confirmation //------------------------------------------------------------- diff --git a/packages/backend/src/services/PuterAPIService.js b/packages/backend/src/services/PuterAPIService.js index a285ef94c..27d29b399 100644 --- a/packages/backend/src/services/PuterAPIService.js +++ b/packages/backend/src/services/PuterAPIService.js @@ -33,6 +33,8 @@ class PuterAPIService extends BaseService { app.use(require('../routers/auth/grant-user-user')); app.use(require('../routers/auth/revoke-user-user')); app.use(require('../routers/auth/list-permissions')) + app.use(require('../routers/auth/list-sessions')) + app.use(require('../routers/auth/revoke-session')) app.use(require('../routers/auth/check-app')) app.use(require('../routers/auth/app-uid-from-origin')) app.use(require('../routers/auth/create-access-token')) diff --git a/packages/backend/src/services/auth/AuthService.js b/packages/backend/src/services/auth/AuthService.js index 539caf02c..0f5380d7d 100644 --- a/packages/backend/src/services/auth/AuthService.js +++ b/packages/backend/src/services/auth/AuthService.js @@ -70,7 +70,7 @@ class AuthService extends BaseService { } if ( decoded.type === 'session' ) { - const session = this.get_session_(decoded.uuid); + const session = await this.get_session_(decoded.uuid); if ( ! session ) { throw APIError.create('token_auth_failed'); @@ -80,6 +80,7 @@ class AuthService extends BaseService { const actor_type = new UserActorType({ user, + session: session.uuid, }); return new Actor({ @@ -218,8 +219,9 @@ class AuthService extends BaseService { cur_token, this.global_config.jwt_secret ); + console.log('\x1B[36;1mDECODED SESSION', decoded); + if ( decoded.type && decoded.type !== 'session' ) { - // throw APIError.create('token_auth_failed'); return {}; } @@ -228,12 +230,22 @@ class AuthService extends BaseService { return {}; } - if ( decoded.type ) return { user, token: cur_token }; + if ( decoded.type ) { + // Ensure session exists + const session = await this.get_session_(decoded.uuid); + if ( ! session ) { + return {}; + } + + // Return the session + return { user, token: cur_token }; + } this.log.info(`UPGRADING SESSION`); // Upgrade legacy token - const token = await this.create_session_token(user); + // TODO: phase this out + const { token } = await this.create_session_token(user); return { user, token }; } @@ -294,6 +306,31 @@ class AuthService extends BaseService { return jwt; } + async list_sessions (actor) { + // 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( + 'SELECT uuid, meta FROM `sessions` WHERE `user_id` = ?', + [actor.type.user.id], + ); + + sessions.forEach(session => { + if ( session.uuid === actor.type.session ) { + session.current = true; + } + }); + + return sessions; + } + + async revoke_session (actor, uuid) { + delete this.sessions[uuid]; + await this.db.write( + `DELETE FROM sessions WHERE uuid = ? AND user_id = ?`, + [uuid, actor.type.user.id] + ); + } + async get_user_app_token_from_origin (origin) { origin = this._origin_from_url(origin); const app_uid = await this._app_uid_from_origin(origin); From 09bf422686572398acdd496e5f2c0e1ba4c3133e Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Thu, 11 Apr 2024 00:29:39 -0400 Subject: [PATCH 3/7] Add session manager ui --- packages/backend/src/routers/login.js | 2 +- packages/backend/src/routers/signup.js | 4 +- .../backend/src/services/auth/AuthService.js | 45 +++++- src/UI/Settings/UIWindowSettings.js | 13 ++ src/UI/UIWindowManageSessions.js | 148 ++++++++++++++++++ src/css/style.css | 58 +++++++ src/i18n/translations/en.js | 4 + 7 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 src/UI/UIWindowManageSessions.js diff --git a/packages/backend/src/routers/login.js b/packages/backend/src/routers/login.js index 1f5c6c0ca..3a75c1990 100644 --- a/packages/backend/src/routers/login.js +++ b/packages/backend/src/routers/login.js @@ -90,7 +90,7 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res // check password if(await bcrypt.compare(req.body.password, user.password)){ const svc_auth = req.services.get('auth'); - const token = await svc_auth.create_session_token(user); + const token = await svc_auth.create_session_token(user, { req }); //set cookie // res.cookie(config.cookie_name, token); res.cookie(config.cookie_name, token, { diff --git a/packages/backend/src/routers/signup.js b/packages/backend/src/routers/signup.js index 012fdfb21..91a9cd607 100644 --- a/packages/backend/src/routers/signup.js +++ b/packages/backend/src/routers/signup.js @@ -247,7 +247,9 @@ module.exports = eggspress(['/signup'], { ); // create token for login - const token = await svc_auth.create_session_token(user); + const token = await svc_auth.create_session_token(user, { + req, + }); // jwt.sign({uuid: user_uuid}, config.jwt_secret); //------------------------------------------------------------- diff --git a/packages/backend/src/services/auth/AuthService.js b/packages/backend/src/services/auth/AuthService.js index 0f5380d7d..c00345421 100644 --- a/packages/backend/src/services/auth/AuthService.js +++ b/packages/backend/src/services/auth/AuthService.js @@ -174,6 +174,44 @@ class AuthService extends BaseService { async create_session_ (user, meta = {}) { this.log.info(`CREATING SESSION`); + + if ( meta.req ) { + const req = meta.req; + delete meta.req; + + const ip = this.global_config.fowarded + ? req.headers['x-forwarded-for'] || + req.connection.remoteAddress + : req.connection.remoteAddress + ; + + meta.ip = ip; + + meta.server = this.global_config.server_id; + + if ( req.headers['user-agent'] ) { + meta.user_agent = req.headers['user-agent']; + } + + if ( req.headers['referer'] ) { + meta.referer = req.headers['referer']; + } + + if ( req.headers['origin'] ) { + const origin = this._origin_from_url(req.headers['origin']); + if ( origin ) { + meta.origin = origin; + } + } + + if ( req.headers['host'] ) { + const host = this._origin_from_url(req.headers['host']); + if ( host ) { + meta.host = host; + } + } + } + const uuid = this.modules.uuidv4(); await this.db.write( 'INSERT INTO `sessions` ' + @@ -197,6 +235,8 @@ class AuthService extends BaseService { [uuid], ); + session.meta = JSON.parse(session.meta ?? {}); + return session; } @@ -214,7 +254,7 @@ class AuthService extends BaseService { return token; } - async check_session (cur_token) { + async check_session (cur_token, meta) { const decoded = this.modules.jwt.verify( cur_token, this.global_config.jwt_secret ); @@ -245,7 +285,7 @@ class AuthService extends BaseService { // Upgrade legacy token // TODO: phase this out - const { token } = await this.create_session_token(user); + const { token } = await this.create_session_token(user, meta); return { user, token }; } @@ -318,6 +358,7 @@ class AuthService extends BaseService { if ( session.uuid === actor.type.session ) { session.current = true; } + session.meta = JSON.parse(session.meta ?? {}); }); return sessions; diff --git a/src/UI/Settings/UIWindowSettings.js b/src/UI/Settings/UIWindowSettings.js index 6a8560d43..518b84078 100644 --- a/src/UI/Settings/UIWindowSettings.js +++ b/src/UI/Settings/UIWindowSettings.js @@ -26,6 +26,7 @@ import changeLanguage from "../../i18n/i18nChangeLanguage.js" import UIWindowConfirmUserDeletion from './UIWindowConfirmUserDeletion.js'; import UITabAbout from './UITabAbout.js'; import UIWindowThemeDialog from '../UIWindowThemeDialog.js'; +import UIWindowManageSessions from '../UIWindowManageSessions.js'; async function UIWindowSettings(options){ return new Promise(async (resolve) => { @@ -111,6 +112,14 @@ async function UIWindowSettings(options){ h += ``; h += ``; + // session manager + h += `
`; + h += `${i18n('sessions')}`; + h += `
`; + h += ``; + h += `
`; + h += `
`; + h += ``; // Personalization @@ -324,6 +333,10 @@ async function UIWindowSettings(options){ UIWindowThemeDialog(); }) + $(el_window).find('.manage-sessions').on('click', function (e) { + UIWindowManageSessions(); + }) + $(el_window).on('click', '.settings-sidebar-item', function(){ const $this = $(this); const settings = $this.attr('data-settings'); diff --git a/src/UI/UIWindowManageSessions.js b/src/UI/UIWindowManageSessions.js new file mode 100644 index 000000000..561597fb5 --- /dev/null +++ b/src/UI/UIWindowManageSessions.js @@ -0,0 +1,148 @@ +import UIAlert from "./UIAlert.js"; +import UIWindow from "./UIWindow.js"; + +const UIWindowManageSessions = async function UIWindowManageSessions () { + const services = globalThis.services; + + const w = await UIWindow({ + title: i18n('ui_manage_sessions'), + icon: null, + uid: null, + is_dir: false, + message: 'message', + // body_icon: options.body_icon, + // backdrop: options.backdrop ?? false, + is_droppable: false, + has_head: true, + selectable_body: false, + draggable_body: true, + allow_context_menu: false, + window_class: 'window-session-manager', + dominant: true, + body_content: '', + // width: 600, + // parent_uuid: options.parent_uuid, + // ...options.window_options, + }); + + const SessionWidget = ({ session }) => { + const el = document.createElement('div'); + el.classList.add('session-widget'); + el.dataset.uuid = session.uuid; + // '
' +
+        //    JSON.stringify(session, null, 2) +
+        //     '
'; + + const el_uuid = document.createElement('div'); + el_uuid.textContent = session.uuid; + el.appendChild(el_uuid); + el_uuid.classList.add('session-widget-uuid'); + + const el_meta = document.createElement('div'); + el_meta.classList.add('session-widget-meta'); + for ( const key in session.meta ) { + const el_entry = document.createElement('div'); + el_entry.classList.add('session-widget-meta-entry'); + + const el_key = document.createElement('div'); + el_key.textContent = key; + el_key.classList.add('session-widget-meta-key'); + el_entry.appendChild(el_key); + + const el_value = document.createElement('div'); + el_value.textContent = session.meta[key]; + el_value.classList.add('session-widget-meta-value'); + el_entry.appendChild(el_value); + + el_meta.appendChild(el_entry); + } + el.appendChild(el_meta); + + const el_actions = document.createElement('div'); + el_actions.classList.add('session-widget-actions'); + + const el_btn_revoke = document.createElement('button'); + el_btn_revoke.textContent = i18n('ui_revoke'); + el_btn_revoke.classList.add('button', 'button-danger'); + el_btn_revoke.addEventListener('click', async () => { + try{ + const alert_resp = await UIAlert({ + message: i18n('confirm_session_revoke'), + buttons:[ + { + label: i18n('yes'), + value: 'yes', + type: 'primary', + }, + { + label: i18n('cancel') + }, + ] + }); + + if ( alert_resp !== 'yes' ) { + return; + } + + const resp = await fetch(`${api_origin}/auth/revoke-session`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + uuid: session.uuid, + }), + }); + if ( resp.ok ) { + el.remove(); + return; + } + UIAlert({ message: await resp.text() }).appendTo(w_body); + } catch ( e ) { + UIAlert({ message: e.toString() }).appendTo(w_body); + } + }); + el_actions.appendChild(el_btn_revoke); + el.appendChild(el_actions); + + return { + appendTo (parent) { + parent.appendChild(el); + return this; + } + }; + }; + + const reload_sessions = async () => { + const resp = await fetch(`${api_origin}/auth/list-sessions`, { + method: 'GET', + }); + + const sessions = await resp.json(); + + for ( const el of w_body.querySelectorAll('.session-widget') ) { + if ( !sessions.find(s => s.uuid === el.dataset.uuid) ) { + el.remove(); + } + } + + for ( const session of sessions ) { + if ( w.querySelector(`.session-widget[data-uuid="${session.uuid}"]`) ) { + continue; + } + SessionWidget({ session }).appendTo(w_body); + } + }; + + const w_body = w.querySelector('.window-body'); + + w_body.classList.add('session-manager-list'); + + reload_sessions(); + const interval = setInterval(reload_sessions, 8000); + w.on_close = () => { + clearInterval(interval); + } +}; + +export default UIWindowManageSessions; diff --git a/src/css/style.css b/src/css/style.css index b1b349ea9..299f7f243 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -3703,3 +3703,61 @@ label { background: #04AA6D; cursor: pointer; } + +.session-manager-list { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + box-sizing: border-box; + height: 100% !important; +} + +.session-widget { + display: flex; + flex-direction: column; + padding: 10px; + border: 1px solid #e0e0e0; + border-radius: 4px; + gap: 4px; +} + +.session-widget-uuid { + font-size: 12px; + font-weight: 600; + color: #9c185b; +} + +.session-widget-meta { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 100px; + overflow-y: scroll; +} + +.session-widget-meta-entry { + display: flex; + flex-direction: row; + align-items: center; +} + +.session-widget-meta-key { + font-size: 12px; + color: #666; + flex-basis: 40%; + flex-shrink: 0; +} + +.session-widget-meta-value { + font-size: 12px; + color: #666; + flex-grow: 1; +} + +.session-widget-actions { + display: flex; + flex-direction: row; + gap: 10px; + justify-content: flex-end; +} \ No newline at end of file diff --git a/src/i18n/translations/en.js b/src/i18n/translations/en.js index 3fed31897..473966097 100644 --- a/src/i18n/translations/en.js +++ b/src/i18n/translations/en.js @@ -51,6 +51,7 @@ const en = { confirm_new_password: "Confirm New Password", confirm_delete_user: "Are you sure you want to delete your account? All your files and data will be permanently deleted. This action cannot be undone.", confirm_delete_user_title: "Delete Account?", + confirm_session_revoke: "Are you sure you want to revoke this session?", contact_us: "Contact Us", contain: 'Contain', continue: "Continue", @@ -112,6 +113,7 @@ const en = { log_in: "Log In", log_into_another_account_anyway: 'Log into another account anyway', log_out: 'Log Out', + manage_sessions: "Manage Sessions", move: 'Move', moving: "Moving", my_websites: "My Websites", @@ -205,6 +207,8 @@ const en = { type: 'Type', type_confirm_to_delete_account: "Type 'confirm' to delete your account.", ui_colors: "UI Colors", + ui_manage_sessions: "Session Manager", + ui_revoke: "Revoke", undo: 'Undo', unlimited: 'Unlimited', unzip: "Unzip", From bb9edc4f65ecd23eb47f83acc749f586e31cf991 Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Thu, 11 Apr 2024 21:33:44 -0400 Subject: [PATCH 4/7] Add automatic token migration --- packages/backend/src/config.js | 4 +++ packages/backend/src/middleware/auth2.js | 30 +++++++++++++++++++ packages/backend/src/routers/whoami.js | 17 +++++++++-- .../backend/src/services/auth/AuthService.js | 30 +++++++++++++++---- src/initgui.js | 2 +- 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/config.js b/packages/backend/src/config.js index 580a5ca7e..55b13fadc 100644 --- a/packages/backend/src/config.js +++ b/packages/backend/src/config.js @@ -97,6 +97,10 @@ if (config.server_id) { config.contact_email = 'hey@' + config.domain; +// TODO: default value will be changed to false in a future release; +// details to follow in a future announcement. +config.legacy_token_migrate = true; + module.exports = config; // NEW_CONFIG_LOADING diff --git a/packages/backend/src/middleware/auth2.js b/packages/backend/src/middleware/auth2.js index d9eaa2f33..8b592b96b 100644 --- a/packages/backend/src/middleware/auth2.js +++ b/packages/backend/src/middleware/auth2.js @@ -18,8 +18,24 @@ */ const APIError = require("../api/APIError"); const config = require("../config"); +const { UserActorType } = require("../services/auth/Actor"); +const { LegacyTokenError } = require("../services/auth/AuthService"); const { Context } = require("../util/context"); +// The "/whoami" endpoint is a special case where we want to allow +// a legacy token to be used for authentication. The "/whoami" +// endpoint will then return a new token for further requests. +// +const is_whoami = (req) => { + if ( ! config.legacy_token_migrate ) return; + + if ( req.path !== '/whoami' ) return; + + // const subdomain = req.subdomains[res.subdomains.length - 1]; + // if ( subdomain !== 'api' ) return; + return true; +} + // TODO: Allow auth middleware to be used without requiring // authentication. This will allow us to use the auth middleware // in endpoints that do not require authentication, but can @@ -70,6 +86,20 @@ const auth2 = async (req, res, next) => { e.write(res); return; } + if ( e instanceof LegacyTokenError && is_whoami(req) ) { + const new_info = await svc_auth.check_session(token, { + req, + from_upgrade: true, + }) + context.set('actor', new_info.actor); + context.set('user', new_info.user); + req.new_token = new_info.token; + req.token = new_info.token; + req.user = new_info.user; + req.actor = new_info.actor; + next(); + return; + } const re = APIError.create('token_auth_failed'); re.write(res); return; diff --git a/packages/backend/src/routers/whoami.js b/packages/backend/src/routers/whoami.js index e02661237..61ce36de3 100644 --- a/packages/backend/src/routers/whoami.js +++ b/packages/backend/src/routers/whoami.js @@ -54,6 +54,7 @@ const WHOAMI_GET = eggspress('/whoami', { is_temp: (req.user.password === null && req.user.email === null), taskbar_items: await get_taskbar_items(req.user), referral_code: req.user.referral_code, + ...(req.new_token ? { token: req.token } : {}) }; if ( ! is_user ) { @@ -65,6 +66,7 @@ const WHOAMI_GET = eggspress('/whoami', { delete details.desktop_bg_color; delete details.desktop_bg_fit; delete details.taskbar_items; + delete details.token; } res.send(details); @@ -76,8 +78,19 @@ const WHOAMI_GET = eggspress('/whoami', { const WHOAMI_POST = new express.Router(); WHOAMI_POST.post('/whoami', auth, fs, express.json(), async (req, response, next)=>{ // check subdomain - if(require('../helpers').subdomain(req) !== 'api') - next(); + if(require('../helpers').subdomain(req) !== 'api') { + return; + } + + const actor = Context.get('actor'); + if ( ! actor ) { + throw Error('actor not found in context'); + } + + const is_user = actor.type instanceof UserActorType; + if ( ! is_user ) { + throw Error('actor is not a user'); + } let desktop_items = []; diff --git a/packages/backend/src/services/auth/AuthService.js b/packages/backend/src/services/auth/AuthService.js index c00345421..a1cfc4bd6 100644 --- a/packages/backend/src/services/auth/AuthService.js +++ b/packages/backend/src/services/auth/AuthService.js @@ -25,6 +25,8 @@ const { DB_WRITE } = require("../database/consts"); const APP_ORIGIN_UUID_NAMESPACE = '33de3768-8ee0-43e9-9e73-db192b97a5d8'; +const LegacyTokenError = class extends Error {}; + class AuthService extends BaseService { static MODULES = { jwt: require('jsonwebtoken'), @@ -45,7 +47,7 @@ class AuthService extends BaseService { ); if ( ! decoded.hasOwnProperty('type') ) { - throw new Error('legacy token'); + throw new LegacyTokenError(); const user = await this.db.requireRead( "SELECT * FROM `user` WHERE `uuid` = ? LIMIT 1", [decoded.uuid], @@ -251,7 +253,7 @@ class AuthService extends BaseService { user_uid: user.uuid, }, this.global_config.jwt_secret); - return token; + return { session, token }; } async check_session (cur_token, meta) { @@ -264,13 +266,17 @@ class AuthService extends BaseService { if ( decoded.type && decoded.type !== 'session' ) { return {}; } + + const is_legacy = ! decoded.type; - const user = await get_user({ uuid: decoded.user_uid }); + const user = await get_user({ uuid: + is_legacy ? decoded.uuid : decoded.user_uid + }); if ( ! user ) { return {}; } - if ( decoded.type ) { + if ( ! is_legacy ) { // Ensure session exists const session = await this.get_session_(decoded.uuid); if ( ! session ) { @@ -285,8 +291,19 @@ class AuthService extends BaseService { // Upgrade legacy token // TODO: phase this out - const { token } = await this.create_session_token(user, meta); - return { user, token }; + const { session, token } = await this.create_session_token(user, meta); + + const actor_type = new UserActorType({ + user, + session, + }); + + const actor = new Actor({ + user_uid: user.uuid, + type: actor_type, + }); + + return { actor, user, token }; } async create_access_token (authorizer, permissions) { @@ -430,4 +447,5 @@ class AuthService extends BaseService { module.exports = { AuthService, + LegacyTokenError, }; diff --git a/src/initgui.js b/src/initgui.js index dfb1e3b04..1cc6ac4a3 100644 --- a/src/initgui.js +++ b/src/initgui.js @@ -366,7 +366,7 @@ window.initgui = async function(){ } while(!is_verified) } - update_auth_data(window.auth_token, whoami); + update_auth_data(whoami.token || window.auth_token, whoami); // ------------------------------------------------------------------------------------- // Load desktop, only if we're not embedded in a popup From 2d76025c9c968a4f336af88a1cc41f2aa81612ce Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Thu, 11 Apr 2024 21:40:10 -0400 Subject: [PATCH 5/7] Send cookie on token upgrade --- packages/backend/src/middleware/auth2.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/backend/src/middleware/auth2.js b/packages/backend/src/middleware/auth2.js index 8b592b96b..bfc69a98c 100644 --- a/packages/backend/src/middleware/auth2.js +++ b/packages/backend/src/middleware/auth2.js @@ -97,6 +97,12 @@ const auth2 = async (req, res, next) => { req.token = new_info.token; req.user = new_info.user; req.actor = new_info.actor; + + res.cookie(config.cookie_name, new_info.token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); next(); return; } From 2f6f7e39bb9ccc80164ab40d7a9080a3474ed549 Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Thu, 11 Apr 2024 21:41:19 -0400 Subject: [PATCH 6/7] Add timestamp --- packages/backend/src/services/auth/AuthService.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/backend/src/services/auth/AuthService.js b/packages/backend/src/services/auth/AuthService.js index a1cfc4bd6..c1cdc9e5f 100644 --- a/packages/backend/src/services/auth/AuthService.js +++ b/packages/backend/src/services/auth/AuthService.js @@ -214,6 +214,9 @@ 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` ' + From 5effca4bbc6f76ea05a95a20e7de8edb4062c71d Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Thu, 11 Apr 2024 21:57:26 -0400 Subject: [PATCH 7/7] Add missing i18n key --- src/i18n/translations/en.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/translations/en.js b/src/i18n/translations/en.js index 473966097..544b96c90 100644 --- a/src/i18n/translations/en.js +++ b/src/i18n/translations/en.js @@ -181,6 +181,7 @@ const en = { select: "Select", selected: 'selected', select_color: 'Select color…', + sessions: "Sessions", send: "Send", send_password_recovery_email: "Send Password Recovery Email", session_saved: "Thank you for creating an account. This session has been saved.",