diff --git a/src/backend/src/data/hardcoded-permissions.js b/src/backend/src/data/hardcoded-permissions.js index fd09e20d8..12643f8ee 100644 --- a/src/backend/src/data/hardcoded-permissions.js +++ b/src/backend/src/data/hardcoded-permissions.js @@ -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'), diff --git a/src/backend/src/routers/auth/delete-own-user.js b/src/backend/src/routers/auth/delete-own-user.js deleted file mode 100644 index 6145b3912..000000000 --- a/src/backend/src/routers/auth/delete-own-user.js +++ /dev/null @@ -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 . - */ -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 }); -}); diff --git a/src/backend/src/routers/user-protected/delete-own-user.js b/src/backend/src/routers/user-protected/delete-own-user.js new file mode 100644 index 000000000..470ebdd3a --- /dev/null +++ b/src/backend/src/routers/user-protected/delete-own-user.js @@ -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 . + */ +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 }); + }, +}; diff --git a/src/backend/src/services/PuterAPIService.js b/src/backend/src/services/PuterAPIService.js index b98f0342e..15e5b3653 100644 --- a/src/backend/src/services/PuterAPIService.js +++ b/src/backend/src/services/PuterAPIService.js @@ -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')); diff --git a/src/backend/src/services/web/UserProtectedEndpointsService.js b/src/backend/src/services/web/UserProtectedEndpointsService.js index 1288eb004..7e67f0e46 100644 --- a/src/backend/src/services/web/UserProtectedEndpointsService.js +++ b/src/backend/src/services/web/UserProtectedEndpointsService.js @@ -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); } } diff --git a/src/gui/src/UI/Settings/UIWindowFinalizeUserDeletion.js b/src/gui/src/UI/Settings/UIWindowFinalizeUserDeletion.js index 88333d75a..fbf7c397a 100644 --- a/src/gui/src/UI/Settings/UIWindowFinalizeUserDeletion.js +++ b/src/gui/src/UI/Settings/UIWindowFinalizeUserDeletion.js @@ -17,11 +17,13 @@ * along with this program. If not, see . */ +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 += ``; h += ''; } + // OIDC-only: revalidate via popup (no password) + else if ( oidc_only ) { + h += '
'; + h += '
×
'; + h += ``; + h += ``; + h += '
'; + h += '

'; + h += ''; + h += '
'; + h += ''; + h += '
'; + h += ``; + h += ``; + h += '
'; + } // otherwise ask for password else { h += '
'; @@ -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'); }); }); }