Merge pull request #266 from HeyPuter/eric/session-updates

session management
This commit is contained in:
Eric Dubé 2024-04-11 22:04:07 -04:00 committed by GitHub
commit 8135e076c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 564 additions and 30 deletions

View File

@ -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

View File

@ -17,35 +17,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
"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

View File

@ -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,26 @@ 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;
res.cookie(config.cookie_name, new_info.token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
next();
return;
}
const re = APIError.create('token_auth_failed');
re.write(res);
return;

View File

@ -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);
});

View File

@ -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 });
});

View File

@ -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, { req });
//set cookie
// res.cookie(config.cookie_name, token);
res.cookie(config.cookie_name, token, {

View File

@ -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,22 @@ 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, {
req,
});
// jwt.sign({uuid: user_uuid}, config.jwt_secret);
//-------------------------------------------------------------
// email confirmation
//-------------------------------------------------------------

View File

@ -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 = [];

View File

@ -32,6 +32,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'))

View File

@ -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'),
@ -34,6 +36,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 +47,7 @@ class AuthService extends BaseService {
);
if ( ! decoded.hasOwnProperty('type') ) {
throw new LegacyTokenError();
const user = await this.db.requireRead(
"SELECT * FROM `user` WHERE `uuid` = ? LIMIT 1",
[decoded.uuid],
@ -66,6 +71,26 @@ class AuthService extends BaseService {
});
}
if ( decoded.type === 'session' ) {
const session = await 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,
session: session.uuid,
});
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 +174,141 @@ class AuthService extends BaseService {
return token;
}
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;
}
}
}
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;
}
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],
);
session.meta = JSON.parse(session.meta ?? {});
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 { session, token };
}
async check_session (cur_token, meta) {
const decoded = this.modules.jwt.verify(
cur_token, this.global_config.jwt_secret
);
console.log('\x1B[36;1mDECODED SESSION', decoded);
if ( decoded.type && decoded.type !== 'session' ) {
return {};
}
const is_legacy = ! decoded.type;
const user = await get_user({ uuid:
is_legacy ? decoded.uuid : decoded.user_uid
});
if ( ! user ) {
return {};
}
if ( ! is_legacy ) {
// 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
// TODO: phase this out
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) {
const jwt_obj = {};
const authorizer_obj = {};
@ -206,6 +366,32 @@ 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;
}
session.meta = JSON.parse(session.meta ?? {});
});
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);
@ -264,4 +450,5 @@ class AuthService extends BaseService {
module.exports = {
AuthService,
LegacyTokenError,
};

View File

@ -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}`);

View File

@ -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
);

View File

@ -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) => {
@ -113,6 +114,14 @@ async function UIWindowSettings(options){
h += `</div>`;
h += `</div>`;
// session manager
h += `<div class="settings-card">`;
h += `<strong>${i18n('sessions')}</strong>`;
h += `<div style="flex-grow:1;">`;
h += `<button class="button manage-sessions" style="float:right;">${i18n('manage_sessions')}</button>`;
h += `</div>`;
h += `</div>`;
h += `</div>`;
// Personalization
@ -342,6 +351,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');

View File

@ -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;
// '<pre>' +
// JSON.stringify(session, null, 2) +
// '</pre>';
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;

View File

@ -3712,3 +3712,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;
}

View File

@ -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",
@ -179,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.",
@ -206,6 +209,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",

View File

@ -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