mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-06 03:42:15 +00:00
feat: prompt a team seat to choose its own password on first sign-in
The backend already refused every route with `password_change_required` until a provisioned account replaced the password its administrator chose, and `/user-protected/change-password` was already exempt so the account could act. The client half was missing entirely: nothing in the GUI referenced that code, so a seat signed in and then failed at everything with no prompt and no way out. Two gaps, both closed here. The flag never reached the client. Neither the login response nor `/whoami` carried `requires_password_change`, so the GUI could not have known even if it wanted to. It ships from both now, alongside the other three verification flags the whoami extension already describes as "the flags the GUI acts on". There was no window to show. `UIWindowChangePassword` is a settings dialog -- closable, and it never resolves on success -- so it cannot act as a gate. `UIWindowPasswordChangeRequired` mirrors the existing `*Required` windows: it resolves true only once the change lands, and `initgui` loops on it. It runs last in the boot chain, matching the server order in assertVerifiedAccount. It refuses a new password equal to the current one. Without that the account stays on the credential its administrator still holds, which is the entire thing the gate exists to end. Falsified twice: dropping the same-password guard fails "refuses to reuse the password the admin handed over", and resolving on a rejected response fails "stays open on a rejected change, so the gate cannot be escaped" -- each breaking only its own test. 175 backend tests, 326 GUI/SDK tests, typecheck clean.
This commit is contained in:
1 parent
b19b312e30
commit
a983969f32
6 files changed
+303
-2
No files matched your search
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import check_password_strength from '../helpers/checkPasswordStrength.js';
|
||||
import UIWindow from './UIWindow.js';
|
||||
|
||||
/** Resolves true once a seat replaces the password its administrator chose. */
|
||||
function UIWindowPasswordChangeRequired (options) {
|
||||
return new Promise(async (resolve) => {
|
||||
options = options ?? {};
|
||||
options.window_options = options.window_options ?? {};
|
||||
|
||||
let h = '';
|
||||
if ( options.show_close_button !== false ) {
|
||||
h += '<div class="qr-code-window-close-btn generic-close-window-button"> × </div>';
|
||||
}
|
||||
h += '<div style="-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #3e5362; max-width: 350px; margin: 0 auto; padding: 20px;">';
|
||||
h += `<h3 style="text-align:center; font-weight: 500; font-size: 20px; margin-top: 0;">${i18n('password_change_required_title')}</h3>`;
|
||||
h += `<p style="text-align:center;">${i18n('password_change_required_hint')}</p>`;
|
||||
h += '<div class="form-error-msg"></div>';
|
||||
h += '<form>';
|
||||
h += `<label for="pcr-current">${i18n('current_password')}</label>`;
|
||||
h += '<input id="pcr-current" class="pcr-current" type="password" autocomplete="current-password" />';
|
||||
h += `<label for="pcr-new">${i18n('new_password')}</label>`;
|
||||
h += '<input id="pcr-new" class="pcr-new" type="password" autocomplete="new-password" />';
|
||||
h += `<label for="pcr-confirm">${i18n('confirm_new_password')}</label>`;
|
||||
h += '<input id="pcr-confirm" class="pcr-confirm" type="password" autocomplete="new-password" />';
|
||||
h += `<button type="submit" class="button button-block button-primary pcr-btn" style="margin-top:16px;">${i18n('change_password')}</button>`;
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: null,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
has_head: false,
|
||||
selectable_body: false,
|
||||
draggable_body: true,
|
||||
allow_context_menu: false,
|
||||
is_resizable: false,
|
||||
is_droppable: false,
|
||||
init_center: true,
|
||||
allow_native_ctxmenu: false,
|
||||
allow_user_select: false,
|
||||
backdrop: true,
|
||||
width: 390,
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
onAppend: function (this_window) {
|
||||
$(this_window).find('.pcr-current').get(0)?.focus({ preventScroll: true });
|
||||
},
|
||||
window_class: 'window-login',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
height: '100%',
|
||||
'background-color': 'rgb(245 247 249)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
},
|
||||
...options.window_options,
|
||||
});
|
||||
|
||||
const origin = window.gui_origin || window.api_origin || '';
|
||||
const $err = $(el_window).find('.form-error-msg');
|
||||
|
||||
const fail = (message) => {
|
||||
$err.html(html_encode(message)).fadeIn();
|
||||
$(el_window).find('.pcr-btn').removeClass('disabled');
|
||||
$(el_window).find('.pcr-current, .pcr-new, .pcr-confirm').attr('disabled', false);
|
||||
};
|
||||
|
||||
$(el_window).find('form').on('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
const current_password = $(el_window).find('.pcr-current').val();
|
||||
const new_password = $(el_window).find('.pcr-new').val();
|
||||
const confirm_new_password = $(el_window).find('.pcr-confirm').val();
|
||||
|
||||
$err.hide();
|
||||
if ( !current_password || !new_password || !confirm_new_password ) {
|
||||
return fail(i18n('all_fields_required'));
|
||||
}
|
||||
if ( new_password !== confirm_new_password ) {
|
||||
return fail(i18n('passwords_do_not_match'));
|
||||
}
|
||||
// Otherwise the account is still on the credential its admin holds.
|
||||
if ( new_password === current_password ) {
|
||||
return fail(i18n('password_change_required_same'));
|
||||
}
|
||||
const strength = check_password_strength(new_password);
|
||||
if ( !strength.overallPass ) {
|
||||
return fail(i18n('password_strength_error'));
|
||||
}
|
||||
|
||||
$(el_window).find('.pcr-btn').addClass('disabled');
|
||||
$(el_window).find('.pcr-current, .pcr-new, .pcr-confirm').attr('disabled', true);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${origin}/user-protected/change-password`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
password: current_password,
|
||||
new_pass: new_password,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
return fail(err?.message || 'Request failed');
|
||||
}
|
||||
|
||||
if ( res.ok ) {
|
||||
if ( window.user ) window.user.requires_password_change = false;
|
||||
$(el_window).close();
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
fail(data.message || res.statusText || 'Request failed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowPasswordChangeRequired;
|
||||
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Stubbed down to the element the submit handler binds to.
|
||||
const state = vi.hoisted(() => ({ el: null, closed: 0 }));
|
||||
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: vi.fn(async (opts) => {
|
||||
state.el = { body: opts.body_content, opts };
|
||||
return state.el;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../helpers/checkPasswordStrength.js', () => ({
|
||||
default: vi.fn((pw) => ({ overallPass: pw !== 'weak' })),
|
||||
}));
|
||||
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value);
|
||||
|
||||
/** Minimal jQuery stand-in: a selector -> value map drives the handler. */
|
||||
const fields = {};
|
||||
let submitHandler = null;
|
||||
globalThis.$ = () => ({
|
||||
find: (sel) => ({
|
||||
val: () => fields[sel],
|
||||
on: (evt, fn) => {
|
||||
if (evt === 'submit') submitHandler = fn;
|
||||
},
|
||||
html: () => ({ fadeIn: () => {} }),
|
||||
hide: () => {},
|
||||
fadeIn: () => {},
|
||||
addClass: () => {},
|
||||
removeClass: () => {},
|
||||
attr: () => {},
|
||||
get: () => [undefined],
|
||||
}),
|
||||
close: () => {
|
||||
state.closed++;
|
||||
},
|
||||
});
|
||||
|
||||
const { default: UIWindowPasswordChangeRequired } = await import(
|
||||
'./UIWindowPasswordChangeRequired.js'
|
||||
);
|
||||
|
||||
const submit = async () => {
|
||||
await submitHandler({ preventDefault: () => {} });
|
||||
};
|
||||
|
||||
describe('the forced password-change gate', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn();
|
||||
globalThis.window = { user: { requires_password_change: true } };
|
||||
state.closed = 0;
|
||||
submitHandler = null;
|
||||
fields['.pcr-current'] = 'TempPass1!';
|
||||
fields['.pcr-new'] = 'ChosenPass1!';
|
||||
fields['.pcr-confirm'] = 'ChosenPass1!';
|
||||
});
|
||||
|
||||
it('posts to the one route the gate lets through, with credentials', async () => {
|
||||
globalThis.fetch.mockResolvedValue({ ok: true });
|
||||
const gate = UIWindowPasswordChangeRequired({ show_close_button: false });
|
||||
await Promise.resolve();
|
||||
await submit();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = globalThis.fetch.mock.calls[0];
|
||||
expect(url).toContain('/user-protected/change-password');
|
||||
// The user-protected gate is cookie-only; a bearer token is refused.
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
password: 'TempPass1!',
|
||||
new_pass: 'ChosenPass1!',
|
||||
});
|
||||
await expect(gate).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('clears the local flag and closes once the change lands', async () => {
|
||||
globalThis.fetch.mockResolvedValue({ ok: true });
|
||||
UIWindowPasswordChangeRequired({ show_close_button: false });
|
||||
await Promise.resolve();
|
||||
await submit();
|
||||
|
||||
expect(globalThis.window.user.requires_password_change).toBe(false);
|
||||
expect(state.closed).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses to reuse the password the admin handed over', async () => {
|
||||
// The whole point of the gate: the admin still knows this one.
|
||||
fields['.pcr-new'] = 'TempPass1!';
|
||||
fields['.pcr-confirm'] = 'TempPass1!';
|
||||
UIWindowPasswordChangeRequired({ show_close_button: false });
|
||||
await Promise.resolve();
|
||||
await submit();
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit a mismatch, a blank, or a weak password', async () => {
|
||||
const cases = [
|
||||
{ '.pcr-confirm': 'Different1!' },
|
||||
{ '.pcr-new': '' },
|
||||
{ '.pcr-new': 'weak', '.pcr-confirm': 'weak' },
|
||||
];
|
||||
for (const over of cases) {
|
||||
fields['.pcr-current'] = 'TempPass1!';
|
||||
fields['.pcr-new'] = 'ChosenPass1!';
|
||||
fields['.pcr-confirm'] = 'ChosenPass1!';
|
||||
Object.assign(fields, over);
|
||||
submitHandler = null;
|
||||
UIWindowPasswordChangeRequired({ show_close_button: false });
|
||||
await Promise.resolve();
|
||||
await submit();
|
||||
}
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays open on a rejected change, so the gate cannot be escaped', async () => {
|
||||
globalThis.fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
statusText: 'Forbidden',
|
||||
json: async () => ({ message: 'Wrong password' }),
|
||||
});
|
||||
UIWindowPasswordChangeRequired({ show_close_button: false });
|
||||
await Promise.resolve();
|
||||
await submit();
|
||||
|
||||
expect(state.closed).toBe(0);
|
||||
expect(globalThis.window.user.requires_password_change).toBe(true);
|
||||
});
|
||||
|
||||
it('omits the close button when it is a gate', async () => {
|
||||
UIWindowPasswordChangeRequired({ show_close_button: false });
|
||||
await Promise.resolve();
|
||||
expect(state.el.body).not.toContain('generic-close-window-button');
|
||||
});
|
||||
});
|
||||
@@ -281,6 +281,9 @@ const en = {
|
||||
password_recovery_token_invalid: 'This password recovery token is no longer valid.',
|
||||
password_recovery_unknown_error: 'An unknown error occurred. Please try again later.',
|
||||
password_required: 'Password is required.',
|
||||
password_change_required_title: 'Choose your own password',
|
||||
password_change_required_hint: 'This account was created for you, and its password is still the one you were given. Pick your own to continue.',
|
||||
password_change_required_same: 'Your new password must be different from the one you were given.',
|
||||
password_strength_error: 'Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.',
|
||||
passwords_do_not_match: '`New Password` and `Confirm New Password` do not match.',
|
||||
paste: 'Paste',
|
||||
|
||||
@@ -28,6 +28,7 @@ import UIWindowAuthMe from './UI/UIWindowAuthMe.js';
|
||||
import UIWindowChangeUsername from './UI/UIWindowChangeUsername.js';
|
||||
import UIWindowCopyToken from './UI/UIWindowCopyToken.js';
|
||||
import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js';
|
||||
import UIWindowPasswordChangeRequired from './UI/UIWindowPasswordChangeRequired.js';
|
||||
import UIWindowPhoneVerificationRequired from './UI/UIWindowPhoneVerificationRequired.js';
|
||||
import UIWindowCardVerificationRequired from './UI/UIWindowCardVerificationRequired.js';
|
||||
import { openVerificationGateWindow } from './helpers/verification_gates.js';
|
||||
@@ -1797,6 +1798,20 @@ window.initgui = async function (options) {
|
||||
});
|
||||
} while (!is_verified);
|
||||
}
|
||||
// Last, matching assertVerifiedAccount's order.
|
||||
if (whoami.requires_password_change) {
|
||||
let changed;
|
||||
do {
|
||||
changed = await UIWindowPasswordChangeRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
window_options: {
|
||||
is_draggable: false,
|
||||
},
|
||||
});
|
||||
} while (!changed);
|
||||
}
|
||||
// if user is logging in using an auth token that means it's not their first ever visit to Puter.com
|
||||
// it might be their first visit to Puter on this specific device but it's not their first time ever visiting Puter.
|
||||
window.first_visit_ever = false;
|
||||
|
||||
Reference in new issue
Block a user