mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 08:30:39 +00:00
[OIDC] allow user deletion for accounts without a password (#2567)
* fix: user deletion for OIDC accounts * clean(backend): update copied license header * clean(backend): replace previously removed comments * fix: double-encoding
This commit is contained in:
@@ -106,6 +106,8 @@ const hardcoded_user_group_permissions = {
|
||||
'local-terminal:access': {},
|
||||
},
|
||||
'b7220104-7905-4985-b996-649fdcdb3c8f': {
|
||||
'driver': {},
|
||||
'service': {},
|
||||
'service:hello-world:ii:hello-world': policy_perm('temp.es'),
|
||||
'service:puter-kvstore:ii:puter-kvstore': policy_perm('temp.kv'),
|
||||
'driver:puter-kvstore': policy_perm('temp.kv'),
|
||||
@@ -119,6 +121,8 @@ const hardcoded_user_group_permissions = {
|
||||
'service:es\\Csubdomain:ii:crud-q': policy_perm('user.es'),
|
||||
},
|
||||
'78b1b1dd-c959-44d2-b02c-8735671f9997': {
|
||||
'driver': {},
|
||||
'service': {},
|
||||
'service:hello-world:ii:hello-world': policy_perm('user.es'),
|
||||
'service:puter-kvstore:ii:puter-kvstore': policy_perm('user.kv'),
|
||||
'driver:puter-kvstore': policy_perm('user.kv'),
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
const eggspress = require('../../api/eggspress');
|
||||
const { deleteUser, invalidate_cached_user } = require('../../helpers');
|
||||
|
||||
const config = require('../../config');
|
||||
|
||||
module.exports = eggspress('/delete-own-user', {
|
||||
subdomain: 'api',
|
||||
auth: true,
|
||||
allowedMethods: ['POST'],
|
||||
}, async (req, res) => {
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const validate_request = async () => {
|
||||
const user = req.user;
|
||||
|
||||
// `user` should always have a value, but this is checked
|
||||
// any way in case the auth middleware is broken.
|
||||
if ( ! user ) return false;
|
||||
|
||||
// temporary users don't require password verification
|
||||
if ( !user.email && !user.password ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( ! req.body.password ) return false;
|
||||
if ( !user || !user.password ) return false;
|
||||
if ( ! await bcrypt.compare(req.body.password, req.user.password) ) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if ( ! await validate_request() ) {
|
||||
return res.status(400).send({ success: false });
|
||||
}
|
||||
|
||||
res.clearCookie(config.cookie_name);
|
||||
|
||||
await deleteUser(req.user.id);
|
||||
invalidate_cached_user(req.user);
|
||||
|
||||
return res.send({ success: true });
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026-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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
const config = require('../../config');
|
||||
const { deleteUser, invalidate_cached_user } = require('../../helpers');
|
||||
|
||||
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
|
||||
|
||||
module.exports = {
|
||||
route: '/delete-own-user',
|
||||
methods: ['POST'],
|
||||
handler: async (req, res) => {
|
||||
res.clearCookie(config.cookie_name);
|
||||
res.clearCookie(REVALIDATION_COOKIE_NAME);
|
||||
|
||||
await deleteUser(req.user.id);
|
||||
invalidate_cached_user(req.user);
|
||||
|
||||
return res.send({ success: true });
|
||||
},
|
||||
};
|
||||
@@ -53,7 +53,6 @@ class PuterAPIService extends BaseService {
|
||||
app.use(require('../routers/auth/app-uid-from-origin'));
|
||||
app.use(require('../routers/auth/create-access-token'));
|
||||
app.use(require('../routers/auth/revoke-access-token'));
|
||||
app.use(require('../routers/auth/delete-own-user'));
|
||||
app.use(require('../routers/auth/configure-2fa'));
|
||||
app.use(require('../routers/drivers/call'));
|
||||
app.use(require('../routers/drivers/list-interfaces'));
|
||||
|
||||
@@ -109,9 +109,10 @@ class UserProtectedEndpointsService extends BaseService {
|
||||
next();
|
||||
});
|
||||
|
||||
// Do not allow temporary users
|
||||
// Do not allow temporary users (except for delete-own-user, which allows them)
|
||||
router.use(async (req, res, next) => {
|
||||
if ( req.method === 'OPTIONS' ) return next();
|
||||
if ( req.path === '/delete-own-user' ) return next();
|
||||
|
||||
if ( req.user.password === null && req.user.email === null ) {
|
||||
return APIError.create('temporary_account').write(res);
|
||||
@@ -122,6 +123,7 @@ class UserProtectedEndpointsService extends BaseService {
|
||||
/**
|
||||
* Middleware to validate identity: either password (bcrypt) or a valid OIDC revalidation cookie.
|
||||
* OIDC-only accounts (user.password === null) must use revalidation; password accounts may use either.
|
||||
* Temporary users (no password, no email) are allowed only for delete-own-user.
|
||||
*/
|
||||
router.use(async (req, res, next) => {
|
||||
if ( req.method === 'OPTIONS' ) return next();
|
||||
@@ -129,6 +131,10 @@ class UserProtectedEndpointsService extends BaseService {
|
||||
const user = await get_user({ id: req.user.id, force: true });
|
||||
const revalidationCookie = req.cookies && req.cookies[REVALIDATION_COOKIE_NAME];
|
||||
|
||||
if ( user.password === null && user.email === null ) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if ( req.body.password ) {
|
||||
if ( user.password === null ) {
|
||||
return (APIError.create('oidc_revalidation_required', null, await this.#revalidateUrlFields(req, user))).write(res);
|
||||
@@ -168,6 +174,8 @@ class UserProtectedEndpointsService extends BaseService {
|
||||
Endpoint(require('../../routers/user-protected/change-username.js')).attach(router);
|
||||
|
||||
Endpoint(require('../../routers/user-protected/disable-2fa.js')).attach(router);
|
||||
|
||||
Endpoint(require('../../routers/user-protected/delete-own-user.js')).attach(router);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { openRevalidatePopup } from '../../util/openid.js';
|
||||
import UIWindow from '../UIWindow.js';
|
||||
|
||||
async function UIWindowFinalizeUserDeletion (options) {
|
||||
return new Promise(async (resolve) => {
|
||||
options = options ?? {};
|
||||
const oidc_only = !!(window.user && window.user.oidc_only);
|
||||
|
||||
let h = '';
|
||||
|
||||
@@ -39,6 +41,22 @@ async function UIWindowFinalizeUserDeletion (options) {
|
||||
h += `<button class="button button-block button-secondary cancel-user-deletion">${i18n('cancel')}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
// OIDC-only: revalidate via popup (no password)
|
||||
else if ( oidc_only ) {
|
||||
h += '<div style="padding: 20px;">';
|
||||
h += '<div class="generic-close-window-button disable-user-select"> × </div>';
|
||||
h += `<img src="${window.icons['danger.svg']}" class="account-deletion-confirmation-icon">`;
|
||||
h += `<p class="account-deletion-confirmation-prompt">${i18n('confirm_delete_user')}</p>`;
|
||||
h += '<div class="delete-oidc-wrap" style="margin-top:10px;">';
|
||||
h += '<p class="delete-oidc-flow-notice" style="margin:0;font-size:12px;color:#666;"></p>';
|
||||
h += '<span class="delete-revalidated-msg" style="display:none;"></span>';
|
||||
h += '</div>';
|
||||
h += '<p class="delete-oidc-hint" style="margin-top:6px;font-size:12px;color:#666;display:none;"></p>';
|
||||
h += '<div class="error-message"></div>';
|
||||
h += `<button class="button button-block button-danger proceed-with-user-deletion">${i18n('delete_account')}</button>`;
|
||||
h += `<button class="button button-block button-secondary cancel-user-deletion">${i18n('cancel')}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
// otherwise ask for password
|
||||
else {
|
||||
h += '<div style="padding: 20px;">';
|
||||
@@ -72,6 +90,12 @@ async function UIWindowFinalizeUserDeletion (options) {
|
||||
allow_user_select: true,
|
||||
backdrop: true,
|
||||
onAppend: function (el_window) {
|
||||
if ( oidc_only ) {
|
||||
$(el_window).find('.delete-oidc-flow-notice').text(
|
||||
i18n('revalidate_flow_notice') ||
|
||||
'You will be asked to sign in with your linked account when you continue.',
|
||||
);
|
||||
}
|
||||
},
|
||||
width: 500,
|
||||
dominant: false,
|
||||
@@ -91,7 +115,25 @@ async function UIWindowFinalizeUserDeletion (options) {
|
||||
$(el_window).close();
|
||||
});
|
||||
|
||||
$(el_window).find('.proceed-with-user-deletion').on('click', function () {
|
||||
const origin = window.gui_origin || window.api_origin || '';
|
||||
const apiUrl = `${origin}/user-protected/delete-own-user`;
|
||||
const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.';
|
||||
let revalidated = false;
|
||||
|
||||
const doDeleteRequest = async (body = {}) => {
|
||||
return fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
const showError = (msg) => {
|
||||
$(el_window).find('.error-message').html(html_encode(msg)).show();
|
||||
};
|
||||
|
||||
$(el_window).find('.proceed-with-user-deletion').on('click', async function () {
|
||||
$(el_window).find('.error-message').hide();
|
||||
// if user is temporary, check if they typed 'confirm'
|
||||
if ( window.user.is_temp ) {
|
||||
@@ -99,55 +141,100 @@ async function UIWindowFinalizeUserDeletion (options) {
|
||||
|
||||
// user must type 'confirm' or the translation of 'confirm' to delete their account
|
||||
if ( confirm !== 'confirm' && confirm !== i18n('confirm').toLowerCase() ) {
|
||||
$(el_window).find('.error-message').html(i18n('type_confirm_to_delete_account'), false);
|
||||
$(el_window).find('.error-message').show();
|
||||
showError(i18n('type_confirm_to_delete_account', [], false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// otherwise, check if password is correct
|
||||
else {
|
||||
if ( $(el_window).find('.confirm-user-deletion-password').val() === '' ) {
|
||||
$(el_window).find('.error-message').html(i18n('all_fields_required'), false);
|
||||
$(el_window).find('.error-message').show();
|
||||
} else if ( oidc_only && !revalidated ) {
|
||||
$(el_window).find('.proceed-with-user-deletion').addClass('disabled');
|
||||
$(el_window).find('.delete-oidc-hint').text(REVALIDATE_POPUP_TEXT).show();
|
||||
try {
|
||||
const revalidateUrl = window.user && window.user.oidc_revalidate_url;
|
||||
await openRevalidatePopup(revalidateUrl);
|
||||
} catch (e) {
|
||||
showError(e.message || 'Authentication failed');
|
||||
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
|
||||
$(el_window).find('.delete-oidc-hint').hide();
|
||||
return;
|
||||
}
|
||||
$(el_window).find('.delete-oidc-hint').hide();
|
||||
$(el_window).find('.delete-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show();
|
||||
revalidated = true;
|
||||
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
|
||||
const res = await doDeleteRequest({});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if ( res.status === 401 ) {
|
||||
window.logout(); return;
|
||||
}
|
||||
if ( res.ok && data.success ) {
|
||||
window.user.deleted = true; window.logout(); return;
|
||||
}
|
||||
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
|
||||
try {
|
||||
await openRevalidatePopup(data.revalidate_url);
|
||||
} catch (e) {
|
||||
showError(e.message || 'Authentication failed');
|
||||
return;
|
||||
}
|
||||
const retry = await doDeleteRequest({});
|
||||
const retryData = await retry.json().catch(() => ({}));
|
||||
if ( retry.ok && retryData.success ) {
|
||||
window.user.deleted = true; window.logout(); return;
|
||||
}
|
||||
showError(retryData.message || 'Request failed');
|
||||
return;
|
||||
}
|
||||
showError(data.message || 'Request failed');
|
||||
return;
|
||||
} else if ( !window.user.is_temp && !oidc_only ) {
|
||||
const password = $(el_window).find('.confirm-user-deletion-password').val();
|
||||
if ( password === '' ) {
|
||||
showError(i18n('all_fields_required', [], false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// delete user
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/delete-own-user`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${ window.auth_token}`,
|
||||
},
|
||||
data: JSON.stringify({
|
||||
password: $(el_window).find('.confirm-user-deletion-password').val(),
|
||||
}),
|
||||
statusCode: {
|
||||
401: function () {
|
||||
window.logout();
|
||||
},
|
||||
400: function () {
|
||||
$(el_window).find('.error-message').html(i18n('incorrect_password'));
|
||||
$(el_window).find('.error-message').show();
|
||||
},
|
||||
},
|
||||
success: function (data) {
|
||||
if ( data.success ) {
|
||||
// mark user as deleted
|
||||
window.user.deleted = true;
|
||||
// log user out
|
||||
window.logout();
|
||||
}
|
||||
else {
|
||||
$(el_window).find('.error-message').html(html_encode(data.error));
|
||||
$(el_window).find('.error-message').show();
|
||||
let res = await doDeleteRequest(
|
||||
window.user.is_temp ? {} : { password: $(el_window).find('.confirm-user-deletion-password').val() || undefined },
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
}
|
||||
},
|
||||
});
|
||||
if ( res.status === 401 ) {
|
||||
window.logout();
|
||||
return;
|
||||
}
|
||||
if ( res.ok && data.success ) {
|
||||
window.user.deleted = true;
|
||||
window.logout();
|
||||
return;
|
||||
}
|
||||
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
|
||||
$(el_window).find('.proceed-with-user-deletion').addClass('disabled');
|
||||
$(el_window).find('.delete-oidc-hint').text(REVALIDATE_POPUP_TEXT).show();
|
||||
try {
|
||||
await openRevalidatePopup(data.revalidate_url);
|
||||
} catch (e) {
|
||||
showError(e.message || 'Authentication failed');
|
||||
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
|
||||
$(el_window).find('.delete-oidc-hint').hide();
|
||||
return;
|
||||
}
|
||||
$(el_window).find('.delete-oidc-hint').hide();
|
||||
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
|
||||
const retry = await doDeleteRequest({});
|
||||
const retryData = await retry.json().catch(() => ({}));
|
||||
if ( retry.ok && retryData.success ) {
|
||||
window.user.deleted = true;
|
||||
window.logout();
|
||||
return;
|
||||
}
|
||||
showError(retryData.message || 'Request failed');
|
||||
return;
|
||||
}
|
||||
if ( res.status === 403 && data.code === 'session_required' ) {
|
||||
showError(data.message || i18n('session_required', [], false) || 'This action requires a full session.');
|
||||
return;
|
||||
}
|
||||
showError(data.message || 'Request failed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user