Files
puter/src/backend/util/temporaryPassword.ts
T
Juan Castro f5b2365e83 feat: enforce the forced password change on team seats
`user.requires_password_change` shipped with the team columns but nothing
enforced it and nothing ever cleared it, so a provisioned seat kept its
administrator-issued password indefinitely and `reissueCredential`'s
"already activated" 409 was unreachable.

Adds the fourth clause to `assertVerifiedAccount`, the only place a
verification gate may live -- WebDAV builds its own actor and calls that
function directly, so a second implementation would bypass it the way the
phone and card gates once were bypassed.

A gate that refuses everything also refuses the endpoint that clears it,
so `/user-protected/change-password` opts out with `allowUnconfirmed`.
That widens the route: an account pending email, phone or card
verification can now change its password, which it could not before. The
caller is authenticated and proves the current password, so this is
benign, but it is a behaviour change to a shared route.

Also here, because the gate is worthless without them:

- change-password and the recovery-token path clear the flag, and record
  an `activate` entry when the account is a seat.
- Reset takes a live account back with a fresh credential, capped at 20
  per day and audited as `reset_member_password` with no credential in
  the row. Re-issue is audited the same way; it stays closed once a seat
  has chosen its own password.
- An issued credential expires after 24h (new `temp_password_expires_at`
  column, three dialects) and login refuses it after that, so an unused
  reset dies instead of becoming a standing credential.
- 2FA is untouched by a reset, so a reset alone is not takeover.
2026-09-09 11:51:44 -04:00

59 lines
2.3 KiB
TypeScript

/*
* 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/>.
*/
// The credential a team administrator hands a member out of band. It is
// minted by the team service and spent at login, so the two halves live here
// rather than one layer importing the other.
import { randomBytes } from 'node:crypto';
/** Unambiguous alphabet -- no 0/O or 1/l, since a human retypes this. */
const TEMP_PASSWORD_ALPHABET =
'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
/** How long an unused temporary password keeps working. */
export const TEMP_PASSWORD_TTL_SECONDS = 24 * 60 * 60;
/** ~95 bits, generated rather than chosen so it is never a reused pattern. */
export const generateTemporaryPassword = (length = 16): string => {
const bytes = randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) {
out += TEMP_PASSWORD_ALPHABET[bytes[i] % TEMP_PASSWORD_ALPHABET.length];
}
return out;
};
/** Unix seconds at which a temporary password issued now stops working. */
export const temporaryPasswordExpiry = (now = Date.now()): number =>
Math.floor(now / 1000) + TEMP_PASSWORD_TTL_SECONDS;
/**
* Whether this account's password is an expired temporary one. A null column is
* every password the account chose itself, which never expires.
*/
export const isTemporaryPasswordExpired = (
user: { temp_password_expires_at?: unknown } | undefined,
now = Date.now(),
): boolean => {
const expiresAt = Number(user?.temp_password_expires_at ?? 0);
if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false;
return Math.floor(now / 1000) >= expiresAt;
};