From 0b8eafa128a5177ddff00d61689837fc24cb1cbb Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:12:51 -0500 Subject: [PATCH] dev(oidc): re-auth remaining protected endpoints The OIDC re-authentication flow, which replaces password confirmation for accounts that were created with OIDC and do not have a password, was previously added to "change username" for manual testing of the backend-side implementation. Add the re-authentication flow to the remaining user-protected endpoints, which are: - change password - change email - disable two-factor authentication When using "change password" on a new account created via OIDC, the account changes state to a passworded account which causes these flows to use password confirmation as before instead of re-authentication. --- src/gui/src/UI/Dashboard/TabSecurity.js | 94 +++++++--- src/gui/src/UI/Settings/UITabSecurity.js | 100 ++++++++--- .../src/UI/Settings/UIWindowChangeEmail.js | 169 ++++++++++++++---- src/gui/src/UI/UIWindowChangePassword.js | 169 ++++++++++++++---- 4 files changed, 417 insertions(+), 115 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabSecurity.js b/src/gui/src/UI/Dashboard/TabSecurity.js index b9990ba96..055853755 100644 --- a/src/gui/src/UI/Dashboard/TabSecurity.js +++ b/src/gui/src/UI/Dashboard/TabSecurity.js @@ -150,32 +150,79 @@ const TabSecurity = { $el_window.find('.dashboard-section-security .disable-2fa').on('click', async function (e) { let win; const password_confirm_promise = new TeePromise(); - const try_password = async () => { - const value = $win.find('.password-entry').val(); - // Do not send Authorization: user-protected endpoints use session cookie (hasHttpPowers) - const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - password: value, - }), - }); - if ( resp.status !== 200 ) { - /* eslint no-empty: ["error", { "allowEmptyCatch": true }] */ - let message; try { - message = (await resp.json()).message; - } catch (e) { + + function openRevalidatePopup (revalidateUrl, onDone) { + const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + if ( ! url ) { + onDone && onDone(new Error('No revalidate URL')); + return null; + } + let doneCalled = false; + const hint = $win.find('.disable-2fa-oidc-hint'); + hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); + const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); + const onMessage = (ev) => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + if ( doneCalled ) return; + doneCalled = true; + window.removeEventListener('message', onMessage); + hint.hide(); + onDone && onDone(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + hint.hide(); + if ( ! doneCalled ) { + doneCalled = true; + onDone && onDone(new Error('Popup closed')); + } } - message = message || i18n('error_unknown_cause'); - $win.find('.password-entry').addClass('error'); - $win.find('.error-message').text(message).show(); + }, 300); + return popup; + } + + const doRequest = () => fetch(`${window.api_origin}/user-protected/disable-2fa`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: $win.find('.password-entry').val() }), + }); + + const try_password = async () => { + const resp = await doRequest(); + if ( resp.status === 200 ) { + password_confirm_promise.resolve(true); + $(win).close(); return; } - password_confirm_promise.resolve(true); - $(win).close(); + const data = await resp.json().catch(() => ({})); + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + openRevalidatePopup(data.revalidate_url, async (err) => { + if ( err ) { + $win.find('.error-message').text(err.message || 'Re-validation required.').show(); + return; + } + const r2 = await doRequest(); + if ( r2.status === 200 ) { + password_confirm_promise.resolve(true); + $(win).close(); + } else { + let message; try { + message = (await r2.json()).message; + } catch (e) { + } + $win.find('.error-message').text(message || i18n('error_unknown_cause')).show(); + } + }); + return; + } + const message = data.message || i18n('error_unknown_cause'); + $win.find('.password-entry').addClass('error'); + $win.find('.error-message').text(message).show(); }; let h = ''; @@ -186,6 +233,7 @@ const TabSecurity = { h += ''; h += '
'; h += ''; + h += ''; h += ''; h += '
'; h += '
'; diff --git a/src/gui/src/UI/Settings/UITabSecurity.js b/src/gui/src/UI/Settings/UITabSecurity.js index af2ca184d..d4518d0fe 100644 --- a/src/gui/src/UI/Settings/UITabSecurity.js +++ b/src/gui/src/UI/Settings/UITabSecurity.js @@ -82,33 +82,83 @@ export default { }); $el_window.find('.disable-2fa').on('click', async function (e) { - let win, password_entry; + let win; const password_confirm_promise = new TeePromise(); - const try_password = async () => { - const value = password_entry.get('value'); - // No Authorization header: user-protected endpoints use session cookie (hasHttpPowers) - const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - password: value, - }), - }); - if ( resp.status !== 200 ) { - /* eslint no-empty: ["error", { "allowEmptyCatch": true }] */ - let message; try { - message = (await resp.json()).message; - } catch (e) { + + function openRevalidatePopup ($win, revalidateUrl, onDone) { + const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + if ( ! url ) { + onDone && onDone(new Error('No revalidate URL')); + return null; + } + let doneCalled = false; + const hint = $win.find('.disable-2fa-oidc-hint'); + hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); + const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); + const onMessage = (ev) => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + if ( doneCalled ) return; + doneCalled = true; + window.removeEventListener('message', onMessage); + hint.hide(); + onDone && onDone(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + hint.hide(); + if ( ! doneCalled ) { + doneCalled = true; + onDone && onDone(new Error('Popup closed')); + } } - message = message || i18n('error_unknown_cause'); - password_entry.set('error', message); + }, 300); + return popup; + } + + const doRequest = () => fetch(`${window.api_origin}/user-protected/disable-2fa`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + password: win ? $(win).find('.password-entry').val() : '', + }), + }); + + const try_password = async () => { + const resp = await doRequest(); + if ( resp.status === 200 ) { + password_confirm_promise.resolve(true); + $(win).close(); return; } - password_confirm_promise.resolve(true); - $(win).close(); + const data = await resp.json().catch(() => ({})); + const $win = $(win); + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + openRevalidatePopup($win, data.revalidate_url, async (err) => { + if ( err ) { + $win.find('.error-message').text(err.message || 'Re-validation required.').show(); + return; + } + const r2 = await doRequest(); + if ( r2.status === 200 ) { + password_confirm_promise.resolve(true); + $(win).close(); + } else { + let message; try { + message = (await r2.json()).message; + } catch (e) { + } + $win.find('.error-message').text(message || i18n('error_unknown_cause')).show(); + } + }); + return; + } + $win.find('.password-entry').addClass('error'); + $win.find('.error-message').text(data.message || i18n('error_unknown_cause')).show(); }; let h = ''; @@ -117,8 +167,10 @@ export default { h += `

${i18n('disable_2fa_confirm')}

`; h += `

${i18n('disable_2fa_instructions')}

`; h += '
'; - h += '
'; + h += '
'; h += ''; + h += ''; + h += ''; h += ``; h += ``; h += '
'; diff --git a/src/gui/src/UI/Settings/UIWindowChangeEmail.js b/src/gui/src/UI/Settings/UIWindowChangeEmail.js index 66f58b231..3ba7398bc 100644 --- a/src/gui/src/UI/Settings/UIWindowChangeEmail.js +++ b/src/gui/src/UI/Settings/UIWindowChangeEmail.js @@ -41,11 +41,18 @@ async function UIWindowChangeEmail (options) { h += ``; h += ``; h += '
'; - // password confirmation - h += '
'; + // password / OIDC revalidate + h += '
'; + h += '
'; h += ``; h += `${place_password_entry.html}`; h += '
'; + h += ''; + h += ''; + h += '
'; // Change Email h += ``; @@ -74,6 +81,15 @@ async function UIWindowChangeEmail (options) { show_in_taskbar: false, onAppend: function (this_window) { $(this_window).find('.new-email').get(0)?.focus({ preventScroll: true }); + const oidc_only = !!(window.user && window.user.oidc_only); + const authRow = $(this_window).find('.change-email-auth-row'); + if ( oidc_only ) { + authRow.find('.change-email-password-wrap').hide(); + const oidcWrap = authRow.find('.change-email-oidc-wrap').show(); + oidcWrap.find('.change-email-revalidate-btn').text(i18n('revalidate_with_google') || 'Re-validate with Google'); + } else { + authRow.find('.change-email-oidc-wrap').hide(); + } }, window_class: 'window-publishWebsite', body_css: { @@ -87,12 +103,16 @@ async function UIWindowChangeEmail (options) { password_entry.attach(place_password_entry); - $(el_window).find('.change-email-btn').on('click', function (e) { - // hide previous error/success msg - $(el_window).find('.form-success-msg, .form-success-msg').hide(); + const origin = window.gui_origin || window.api_origin || ''; + const apiUrl = `${origin}/user-protected/change-email`; + let revalidated = false; + + $(el_window).find('.change-email-btn').on('click', async function (e) { + $(el_window).find('.form-success-msg, .form-error-msg').hide(); const new_email = $(el_window).find('.new-email').val(); - const password = $(el_window).find('.password').val(); + const password = password_entry.get('value'); + const oidc_only = !!(window.user && window.user.oidc_only); if ( ! new_email ) { $(el_window).find('.form-error-msg').html(i18n('all_fields_required')); @@ -101,45 +121,118 @@ async function UIWindowChangeEmail (options) { } $(el_window).find('.form-error-msg').hide(); - - // disable button $(el_window).find('.change-email-btn').addClass('disabled'); - // disable input $(el_window).find('.new-email').attr('disabled', true); - $.ajax({ - url: `${window.gui_origin}/user-protected/change-email`, - type: 'POST', - async: true, - // headers: { - // 'Authorization': `Bearer ${window.auth_token}`, - // }, - contentType: 'application/json', - data: JSON.stringify({ - new_email: new_email, - password: password_entry.get('value'), + const doSubmit = () => fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + new_email, + password: password !== undefined && password !== '' ? password : undefined, }), - success: function (data) { - $(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent')); - $(el_window).find('.form-success-msg').fadeIn(); - $(el_window).find('input').val(''); - // update email - window.user.email = new_email; - // enable button - $(el_window).find('.change-email-btn').removeClass('disabled'); - // enable input - $(el_window).find('.new-email').attr('disabled', false); - }, - error: function (err) { - $(el_window).find('.form-error-msg').html(html_encode(err.responseJSON?.message)); + }); + + if ( oidc_only && !revalidated && !password ) { + openRevalidatePopup(null, async (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + return; + } + const res = await doSubmit(); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + }); + return; + } + + let res = await doSubmit(); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + openRevalidatePopup(data.revalidate_url, async (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + return; + } + const r2 = await doSubmit(); + const d2 = r2.ok ? await r2.json().catch(() => ({})) : await r2.json().catch(() => ({})); + if ( r2.ok ) onSuccess(); + else onError(d2.message || 'Request failed'); + }); + return; + } + onError(data.message || 'Request failed'); + }); + + function openRevalidatePopup (revalidateUrl, onDone) { + const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + if ( ! url ) { + onDone && onDone(new Error('No revalidate URL')); + return null; + } + let doneCalled = false; + const hint = $(el_window).find('.change-email-oidc-hint'); + hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); + const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); + const onMessage = (ev) => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + if ( doneCalled ) return; + doneCalled = true; + window.removeEventListener('message', onMessage); + revalidated = true; + hint.hide(); + $(el_window).find('.change-email-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + $(el_window).find('.change-email-revalidate-btn').hide(); + onDone && onDone(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + hint.hide(); + if ( ! doneCalled ) { + doneCalled = true; + onDone && onDone(new Error('Popup closed')); + } + } + }, 300); + return popup; + } + + $(el_window).find('.change-email-revalidate-btn').on('click', function () { + openRevalidatePopup(null, (err) => { + if ( err ) { + $(el_window).find('.form-error-msg').html(html_encode(err.message || 'Re-validation required.')); $(el_window).find('.form-error-msg').fadeIn(); - // enable button - $(el_window).find('.change-email-btn').removeClass('disabled'); - // enable input - $(el_window).find('.new-email').attr('disabled', false); - }, + } }); }); + + function onError (message) { + $(el_window).find('.form-error-msg').html(html_encode(message)); + $(el_window).find('.form-error-msg').fadeIn(); + $(el_window).find('.change-email-btn').removeClass('disabled'); + $(el_window).find('.new-email').attr('disabled', false); + } + + function onSuccess () { + const new_email = $(el_window).find('.new-email').val(); + $(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent')); + $(el_window).find('.form-success-msg').fadeIn(); + $(el_window).find('input').val(''); + window.user.email = new_email; + $(el_window).find('.change-email-btn').removeClass('disabled'); + $(el_window).find('.new-email').attr('disabled', false); + } } export default UIWindowChangeEmail; \ No newline at end of file diff --git a/src/gui/src/UI/UIWindowChangePassword.js b/src/gui/src/UI/UIWindowChangePassword.js index db210ea09..27d99e090 100644 --- a/src/gui/src/UI/UIWindowChangePassword.js +++ b/src/gui/src/UI/UIWindowChangePassword.js @@ -17,8 +17,8 @@ * along with this program. If not, see . */ -import UIWindow from './UIWindow.js'; import check_password_strength from '../helpers/check_password_strength.js'; +import UIWindow from './UIWindow.js'; async function UIWindowChangePassword (options) { options = options ?? {}; @@ -30,11 +30,17 @@ async function UIWindowChangePassword (options) { h += '
'; // success msg h += '
'; - // current password - h += '
'; + // current password / OIDC revalidate + h += '
'; + h += '
'; h += ``; h += ``; h += '
'; + h += ''; + h += '
'; // new password h += '
'; h += ``; @@ -45,6 +51,7 @@ async function UIWindowChangePassword (options) { h += ``; h += ``; h += '
'; + h += ''; // Change Password h += ``; @@ -72,7 +79,16 @@ async function UIWindowChangePassword (options) { dominant: true, show_in_taskbar: false, onAppend: function (this_window) { - $(this_window).find('.current-password').get(0).focus({ preventScroll: true }); + $(this_window).find('.current-password').get(0)?.focus({ preventScroll: true }); + const oidc_only = !!(window.user && window.user.oidc_only); + const authRow = $(this_window).find('.change-password-auth-row'); + if ( oidc_only ) { + authRow.find('.change-password-current-wrap').hide(); + const oidcWrap = authRow.find('.change-password-oidc-wrap').show(); + oidcWrap.find('.change-password-revalidate-btn').text(i18n('revalidate_with_google') || 'Re-validate with Google'); + } else { + authRow.find('.change-password-oidc-wrap').hide(); + } }, window_class: 'window-publishWebsite', body_css: { @@ -84,27 +100,47 @@ async function UIWindowChangePassword (options) { ...options.window_options, }); - $(el_window).find('.change-password-btn').on('click', function (e) { + const origin = window.gui_origin || window.api_origin || ''; + const apiUrl = `${origin}/user-protected/change-password`; + + $(el_window).find('.change-password-btn').on('click', async function (e) { const current_password = $(el_window).find('.current-password').val(); const new_password = $(el_window).find('.new-password').val(); const confirm_new_password = $(el_window).find('.confirm-new-password').val(); + const oidc_only = !!(window.user && window.user.oidc_only); - // hide success message - $(el_window).find('.form-success-msg').hide(); + $(el_window).find('.form-success-msg, .form-error-msg').hide(); - // check if all fields are filled - if ( !current_password || !new_password || !confirm_new_password ) { + if ( !new_password || !confirm_new_password ) { $(el_window).find('.form-error-msg').html('All fields are required.'); $(el_window).find('.form-error-msg').fadeIn(); return; } - // check if new password and confirm new password are the same - else if ( new_password !== confirm_new_password ) { + // For password users, current password is required; for OIDC, we need revalidated or will open popup + if ( !oidc_only && !current_password ) { + $(el_window).find('.form-error-msg').html('All fields are required.'); + $(el_window).find('.form-error-msg').fadeIn(); + return; + } + if ( oidc_only && !revalidated && !current_password ) { + $(el_window).find('.change-password-btn').addClass('disabled'); + openRevalidatePopup(null, async (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + return; + } + const res = await doSubmit(''); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + }); + return; + } + if ( new_password !== confirm_new_password ) { $(el_window).find('.form-error-msg').html(i18n('passwords_do_not_match')); $(el_window).find('.form-error-msg').fadeIn(); return; } - // check password strength const pass_strength = check_password_strength(new_password); if ( ! pass_strength.overallPass ) { $(el_window).find('.form-error-msg').html(i18n('password_strength_error')); @@ -113,29 +149,102 @@ async function UIWindowChangePassword (options) { } $(el_window).find('.form-error-msg').hide(); + $(el_window).find('.change-password-btn').addClass('disabled'); + $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', true); - // Do not send Authorization: user-protected endpoints use session cookie (hasHttpPowers) - $.ajax({ - url: `${window.api_origin }/user-protected/change-password`, - type: 'POST', - async: true, - xhrFields: { withCredentials: true }, - contentType: 'application/json', - data: JSON.stringify({ - password: current_password, + const doSubmit = (currentPass) => fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + password: currentPass !== undefined ? currentPass : current_password, new_pass: new_password, }), - success: function (data) { - $(el_window).find('.form-success-msg').html(i18n('password_changed')); - $(el_window).find('.form-success-msg').fadeIn(); - $(el_window).find('input').val(''); - }, - error: function (err) { - $(el_window).find('.form-error-msg').html(html_encode(err.responseText)); - $(el_window).find('.form-error-msg').fadeIn(); - }, + }); + + let res = await doSubmit(current_password); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + openRevalidatePopup(data.revalidate_url, async (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + return; + } + const r2 = await doSubmit(''); + const d2 = r2.ok ? await r2.json().catch(() => ({})) : await r2.json().catch(() => ({})); + if ( r2.ok ) onSuccess(); + else onError(d2.message || 'Request failed'); + }); + return; + } + onError(data.message || res.statusText || 'Request failed'); + }); + let revalidated = false; + + function openRevalidatePopup (revalidateUrl, onDone) { + const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + if ( ! url ) { + onDone && onDone(new Error('No revalidate URL')); + return null; + } + let doneCalled = false; + const hint = $(el_window).find('.change-password-oidc-hint'); + hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); + const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); + const onMessage = (ev) => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + if ( doneCalled ) return; + doneCalled = true; + window.removeEventListener('message', onMessage); + revalidated = true; + hint.hide(); + $(el_window).find('.change-password-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + $(el_window).find('.change-password-revalidate-btn').hide(); + onDone && onDone(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + hint.hide(); + if ( ! doneCalled ) { + doneCalled = true; + onDone && onDone(new Error('Popup closed')); + } + } + }, 300); + return popup; + } + + $(el_window).find('.change-password-revalidate-btn').on('click', function () { + openRevalidatePopup(null, (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + } }); }); + + function onError (message) { + $(el_window).find('.form-error-msg').html(html_encode(message)); + $(el_window).find('.form-error-msg').fadeIn(); + $(el_window).find('.change-password-btn').removeClass('disabled'); + $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', false); + } + + function onSuccess () { + $(el_window).find('.form-success-msg').html(i18n('password_changed')); + $(el_window).find('.form-success-msg').fadeIn(); + $(el_window).find('input').val(''); + $(el_window).find('.change-password-btn').removeClass('disabled'); + $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', false); + } } export default UIWindowChangePassword; \ No newline at end of file