From f5b2365e83702072d41be8d1463dc95ef527a7f6 Mon Sep 17 00:00:00 2001
From: Juan Castro
Date: Thu, 3 Sep 2026 18:44:57 -0400
Subject: [PATCH] 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.
---
.../database/SqliteDatabaseClient.test.ts | 2 +-
.../clients/database/SqliteDatabaseClient.ts | 1 +
.../migrations/mysql/mysql_mig_37.sql | 21 +++
.../migrations/postgres/postgres_mig_26.sql | 21 +++
.../sqlite/0082_temp-password-expiry.sql | 21 +++
src/backend/clients/email/templates.ts | 13 ++
.../controllers/auth/AuthController.ts | 32 +++-
.../team/TeamController.http.test.ts | 156 ++++++++++++++++
.../controllers/team/TeamController.ts | 34 +++-
.../core/http/middleware/gates.test.ts | 15 ++
src/backend/core/http/middleware/gates.ts | 31 ++--
src/backend/services/team/TeamService.test.ts | 166 ++++++++++++++++++
src/backend/services/team/TeamService.ts | 139 +++++++++++----
src/backend/stores/user/UserStore.ts | 12 ++
src/backend/util/temporaryPassword.test.ts | 85 +++++++++
src/backend/util/temporaryPassword.ts | 58 ++++++
src/docs/src/rate-limits-and-quotas.md | 3 +
17 files changed, 767 insertions(+), 43 deletions(-)
create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_37.sql
create mode 100644 src/backend/clients/database/migrations/postgres/postgres_mig_26.sql
create mode 100644 src/backend/clients/database/migrations/sqlite/0082_temp-password-expiry.sql
create mode 100644 src/backend/util/temporaryPassword.test.ts
create mode 100644 src/backend/util/temporaryPassword.ts
diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts
index 6dd7f55c4..1a85074d6 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.test.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
-const CURRENT_SCHEMA_VERSION = 77;
+const CURRENT_SCHEMA_VERSION = 78;
/**
* These suites migrate real files on disk. Idle they finish in well under a
diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts
index 9092873fd..f3e173628 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.ts
@@ -111,6 +111,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[74, ['0079_team-audit-and-group-shares.sql']],
[75, ['0080_kv-share-handles.sql']],
[76, ['0081_event-subscriptions-indexes.sql']],
+ [77, ['0082_temp-password-expiry.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_37.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_37.sql
new file mode 100644
index 000000000..e3c0df2ee
--- /dev/null
+++ b/src/backend/clients/database/migrations/mysql/mysql_mig_37.sql
@@ -0,0 +1,21 @@
+-- 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 .
+
+-- See sqlite/0082_temp-password-expiry.sql for the column rationale.
+-- No per-file applied-state tracking, so the column goes through _puter_add_col.
+
+CALL _puter_add_col('user', 'temp_password_expires_at', '`temp_password_expires_at` bigint DEFAULT NULL');
diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_26.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_26.sql
new file mode 100644
index 000000000..5641fbfb1
--- /dev/null
+++ b/src/backend/clients/database/migrations/postgres/postgres_mig_26.sql
@@ -0,0 +1,21 @@
+-- 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 .
+
+-- See sqlite/0082_temp-password-expiry.sql for the column rationale.
+-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
+
+ALTER TABLE "user" ADD COLUMN IF NOT EXISTS temp_password_expires_at bigint DEFAULT NULL;
diff --git a/src/backend/clients/database/migrations/sqlite/0082_temp-password-expiry.sql b/src/backend/clients/database/migrations/sqlite/0082_temp-password-expiry.sql
new file mode 100644
index 000000000..a936be4a8
--- /dev/null
+++ b/src/backend/clients/database/migrations/sqlite/0082_temp-password-expiry.sql
@@ -0,0 +1,21 @@
+-- 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 .
+
+-- Unix seconds after which an admin-issued temporary password stops
+-- authenticating, so an unused reset dies instead of becoming a standing
+-- credential. NULL for every password the account chose itself.
+ALTER TABLE `user` ADD COLUMN `temp_password_expires_at` INTEGER DEFAULT NULL;
diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts
index 3e903d5de..95c727877 100644
--- a/src/backend/clients/email/templates.ts
+++ b/src/backend/clients/email/templates.ts
@@ -362,6 +362,19 @@ choose your own the first time you sign in.
as you. Choose your own password promptly.
Sincerely,
+Puter
+ `,
+ },
+ team_password_reset: {
+ subject: 'Your {{team_name}} account password was reset',
+ html: `
+Hi there,
+An administrator of {{team_name}} reset the password on your Puter account
+{{username}}. They will send you a temporary password separately, and you
+will be asked to choose your own the next time you sign in.
+If you did not expect this, the record of who reset it and when is in your
+team activity, along with every sign-in to your account.
+Sincerely,
Puter
`,
},
diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts
index 09340afa4..25eda395c 100644
--- a/src/backend/controllers/auth/AuthController.ts
+++ b/src/backend/controllers/auth/AuthController.ts
@@ -70,6 +70,7 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { generate_identifier } from '../../util/identifier.js';
import { parsePhone } from '../../util/phone.js';
+import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
import { getTaskbarItems } from '../../util/taskbarItems.js';
import {
generateDefaultFsentries,
@@ -469,6 +470,15 @@ export class AuthController extends PuterController {
legacyCode: 'password_mismatch',
});
}
+ // An administrator-issued temporary password that was never used dies
+ // rather than becoming a standing credential the team holds.
+ if (isTemporaryPasswordExpired(user)) {
+ throw new HttpError(
+ 401,
+ 'This temporary password has expired. Ask your team administrator for a new one.',
+ { legacyCode: 'temporary_password_expired' },
+ );
+ }
const reauthAuthId = this.#extractAuthIdFromReauthToken(
req.body.reauth_token,
@@ -2431,7 +2441,8 @@ export class AuthController extends PuterController {
let result;
try {
result = await this.clients.db.write(
- 'UPDATE `user` SET `password` = ?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL WHERE `id` = ? AND `pass_recovery_token` = ?',
+ 'UPDATE `user` SET `password` = ?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL, ' +
+ '`requires_password_change` = 0, `temp_password_expires_at` = NULL WHERE `id` = ? AND `pass_recovery_token` = ?',
[password_hash, user.id, decoded.token],
);
} catch (e) {
@@ -2459,6 +2470,13 @@ export class AuthController extends PuterController {
});
}
await this.stores.user.invalidateById(user.id);
+ // Best effort: the password is already committed, and an audit write
+ // must not skip the eviction below.
+ await this.services.team
+ .recordPasswordSelfChange(user.id as number)
+ .catch((err: unknown) => {
+ console.warn('[team] password self-change audit failed:', err);
+ });
// A password reset is the "I think someone else has access" flow —
// evict every interactive session so a hijacked one doesn't survive.
@@ -2496,11 +2514,20 @@ export class AuthController extends PuterController {
const user = req.userProtected!.user;
const password_hash = await bcrypt.hash(new_pass, 8);
+ // Clearing the forced-change gate is what lets a team seat back
+ // in; nothing else writes these two columns to their cleared state.
await this.stores.user.update(user.id, {
password: password_hash,
pass_recovery_token: null,
change_email_confirm_token: null,
+ requires_password_change: 0,
+ temp_password_expires_at: null,
});
+ await this.services.team
+ .recordPasswordSelfChange(user.id)
+ .catch((err: unknown) => {
+ console.warn('[team] password self-change audit failed:', err);
+ });
// Sign out every other web session (cascading to their derived
// rows); only the session that changed the password survives.
@@ -4412,6 +4439,9 @@ export class AuthController extends PuterController {
'/user-protected/change-password',
{
requireUserActor: true,
+ // The forced-change gate refuses everything else, so this is
+ // the one route an account owing a password change may reach.
+ allowUnconfirmed: true,
rateLimit: {
scope: 'passwd',
limit: 10,
diff --git a/src/backend/controllers/team/TeamController.http.test.ts b/src/backend/controllers/team/TeamController.http.test.ts
index 76cc916ed..dcaf93189 100644
--- a/src/backend/controllers/team/TeamController.http.test.ts
+++ b/src/backend/controllers/team/TeamController.http.test.ts
@@ -345,6 +345,162 @@ describe('team endpoints over HTTP', () => {
};
expect(body.results[0].recipient).toBe(handle);
});
+
+ // -- the forced-change gate ---------------------------------------
+
+ /**
+ * A seat that has signed in on its temporary password. Email confirmation
+ * is cleared first so the gate under test is the one that answers.
+ */
+ const signedInSeat = async (teamUid: string) => {
+ const username = `seat_${Math.random().toString(36).slice(2, 9)}`;
+ const res = await call(
+ 'POST',
+ `/teams/${teamUid}/members`,
+ env.users.user.token,
+ { username, email: `${username}@test.local` },
+ );
+ expect(res.status).toBe(200);
+ const { temporary_password: password } = (await res.json()) as {
+ temporary_password: string;
+ };
+
+ const row = (await env.server.stores.user.getByUsername(username))!;
+ await env.server.stores.user.update(row.id, {
+ requires_email_confirmation: false,
+ email_confirmed: true,
+ });
+ await env.server.stores.user.invalidateById(row.id);
+
+ const fresh = (await env.server.stores.user.getById(row.id))!;
+ const { token } = await env.server.services.auth.createSessionToken(
+ fresh,
+ { user_agent: 'puter-test-seat' },
+ );
+ return { username, password, userId: row.id, token };
+ };
+
+ /** Cookie-credentialed on the GUI origin, as the user-protected gate insists. */
+ const changePassword = (token: string, password: string, next: string) =>
+ fetch(new URL('/user-protected/change-password', env.origin), {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${token}`,
+ cookie: `puter_auth_token=${token}`,
+ },
+ body: JSON.stringify({ password, new_pass: next }),
+ });
+
+ it('signs the seat in but refuses every authenticated route after that', async () => {
+ const { team } = await makeTeam();
+ const seat = await signedInSeat(team.uid);
+
+ const res = await call('GET', '/teams', seat.token);
+ expect(res.status).toBe(403);
+ expect(await res.text()).toContain('password_change_required');
+ });
+
+ it('leaves change-password reachable, since it is what clears the gate', async () => {
+ const { team } = await makeTeam();
+ const seat = await signedInSeat(team.uid);
+
+ const changed = await changePassword(
+ seat.token,
+ seat.password,
+ 'chosen-by-the-member',
+ );
+ expect(changed.status).toBe(200);
+
+ const row = await env.server.stores.user.getByProperty(
+ 'id',
+ seat.userId,
+ { force: true },
+ );
+ expect(Number(row?.requires_password_change)).toBe(0);
+ expect(row?.temp_password_expires_at ?? null).toBeNull();
+ });
+
+ it('admits the seat to the product once it has chosen a password', async () => {
+ const { team } = await makeTeam();
+ const seat = await signedInSeat(team.uid);
+ expect(
+ (await changePassword(seat.token, seat.password, 'my-own-password'))
+ .status,
+ ).toBe(200);
+
+ // The session that changed the password is the one that survives.
+ const res = await call('GET', '/teams', seat.token);
+ expect(res.status).toBe(200);
+ });
+
+ it('closes re-issue behind a seat that has activated', async () => {
+ const { team } = await makeTeam();
+ const seat = await signedInSeat(team.uid);
+ await changePassword(seat.token, seat.password, 'a-password-of-mine');
+
+ const res = await call(
+ 'POST',
+ `/teams/${team.uid}/members/${seat.username}/activation`,
+ env.users.user.token,
+ );
+ expect(res.status).toBe(409);
+ });
+
+ it('shows the member the reset and their own sign-in, and nothing else', async () => {
+ const { team } = await makeTeam();
+ const seat = await signedInSeat(team.uid);
+ await changePassword(seat.token, seat.password, 'chosen-once-already');
+
+ const reset = await call(
+ 'POST',
+ `/teams/${team.uid}/members/${seat.username}/password-reset`,
+ env.users.user.token,
+ );
+ expect(reset.status).toBe(200);
+ const { temporary_password: issued } = (await reset.json()) as {
+ temporary_password: string;
+ };
+
+ const { token } = await env.server.services.auth.createSessionToken(
+ (await env.server.stores.user.getById(seat.userId))!,
+ { user_agent: 'puter-test-seat' },
+ );
+ const mine = await call('GET', `/teams/${team.uid}/audit/me`, token);
+ // The seat owes a password change again, so its own view is all it reaches.
+ expect(mine.status).toBe(403);
+
+ const admin = await call(
+ 'GET',
+ `/teams/${team.uid}/audit`,
+ env.users.user.token,
+ );
+ const body = (await admin.json()) as { items: { action: string }[] };
+ expect(body.items.map((e) => e.action)).toContain(
+ 'reset_member_password',
+ );
+ expect(JSON.stringify(body)).not.toContain(issued);
+ });
+
+ it('refuses a temporary password that was never used in time', async () => {
+ const { team } = await makeTeam();
+ const seat = await signedInSeat(team.uid);
+ await env.server.stores.user.update(seat.userId, {
+ temp_password_expires_at: Math.floor(Date.now() / 1000) - 1,
+ });
+ await env.server.stores.user.invalidateById(seat.userId);
+
+ const res = await fetch(new URL('/login', env.origin), {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ username: seat.username,
+ password: seat.password,
+ }),
+ });
+ expect(res.status).toBe(401);
+ expect(await res.text()).toContain('temporary_password_expired');
+ });
});
describe('team endpoints with teams_enabled off', () => {
diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts
index 8f7024b1c..a54d0cdd7 100644
--- a/src/backend/controllers/team/TeamController.ts
+++ b/src/backend/controllers/team/TeamController.ts
@@ -40,6 +40,17 @@ const TEAM_LIMIT = [
},
];
+/** Tighter than TEAM_LIMIT: a reset hands the admin a working credential. */
+const TEAM_RESET_LIMIT = [
+ ...TEAM_LIMIT,
+ {
+ scope: 'team:reset-password',
+ limit: 20,
+ window: 24 * 60 * 60_000,
+ key: 'user' as const,
+ },
+];
+
const TEAM_READ_LIMIT = {
scope: 'team:read',
limit: 600,
@@ -215,11 +226,13 @@ export class TeamController extends PuterController {
});
}
+ // Same budget as a reset: both hand the administrator a working credential,
+ // and a reset re-arms the flag this route needs.
@Post('/:uid/members/:username/activation', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
- rateLimit: TEAM_LIMIT,
+ rateLimit: TEAM_RESET_LIMIT,
})
async reissueCredential(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
@@ -234,6 +247,25 @@ export class TeamController extends PuterController {
res.json({ temporary_password: temporaryPassword });
}
+ @Post('/:uid/members/:username/password-reset', {
+ subdomain: 'api',
+ requireUserActor: true,
+ requireVerified: true,
+ rateLimit: TEAM_RESET_LIMIT,
+ })
+ async resetMemberPassword(req: Request, res: Response): Promise {
+ const userId = this.#requireUserId(req);
+ const uid = this.#param(req, 'uid');
+ // Authority first, or resolving `:username` is an existence oracle.
+ await this.services.team.requireOwner(uid, userId);
+ const target = await this.#requireTargetUserId(req);
+
+ const { temporaryPassword } =
+ await this.services.team.resetMemberPassword(uid, userId, target);
+ // Shown once; it is not retrievable afterwards.
+ res.json({ temporary_password: temporaryPassword });
+ }
+
@Post('/:uid/members/:username/disable', {
subdomain: 'api',
requireUserActor: true,
diff --git a/src/backend/core/http/middleware/gates.test.ts b/src/backend/core/http/middleware/gates.test.ts
index 4775865f8..3d98145eb 100644
--- a/src/backend/core/http/middleware/gates.test.ts
+++ b/src/backend/core/http/middleware/gates.test.ts
@@ -742,6 +742,20 @@ describe('requireVerifiedAccount', () => {
expectHttpError(got, 403, 'card_verification_required');
});
+ it('returns 403 password_change_required while the account owes a password', () => {
+ const got = runGate(requireVerifiedAccount(), {
+ actor: {
+ user: {
+ uuid: 'u-1',
+ requires_email_confirmation: false,
+ email_confirmed: true,
+ requires_password_change: 1,
+ },
+ },
+ });
+ expectHttpError(got, 403, 'password_change_required');
+ });
+
it('passes through once every gate is cleared', () => {
const got = runGate(requireVerifiedAccount(), {
actor: {
@@ -751,6 +765,7 @@ describe('requireVerifiedAccount', () => {
email_confirmed: true,
requires_phone_verification: false,
requires_card_verification: false,
+ requires_password_change: 0,
},
},
});
diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts
index d870ef3cb..38ba2e6d4 100644
--- a/src/backend/core/http/middleware/gates.ts
+++ b/src/backend/core/http/middleware/gates.ts
@@ -325,10 +325,10 @@ export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
* still reach the screens that clear the gate).
*
* Returns 403 with a per-gate legacy code (`email_confirmation_required` /
- * `phone_verification_required` / `card_verification_required`) so clients can
- * show the right prompt instead of a generic error. There is no state where a
- * user should be allowed in with one verification pending, so any pending gate
- * rejects.
+ * `phone_verification_required` / `card_verification_required` /
+ * `password_change_required`) so clients can show the right prompt instead of a
+ * generic error. There is no state where a user should be allowed in with one
+ * verification pending, so any pending gate rejects.
*/
export const requireVerifiedAccount = (): RequestHandler => {
return (req, _res, next) => {
@@ -353,10 +353,10 @@ export const requireVerifiedAccount = (): RequestHandler => {
* WebDAV came to bypass the phone/card gate to begin with).
*
* Throws 403 with a per-gate legacy code (`email_confirmation_required` /
- * `phone_verification_required` / `card_verification_required`) so clients can
- * show the right prompt instead of a generic error. There is no state where a
- * user should be let in with any verification pending, so the first pending
- * gate rejects.
+ * `phone_verification_required` / `card_verification_required` /
+ * `password_change_required`) so clients can show the right prompt instead of a
+ * generic error. There is no state where a user should be let in with any
+ * verification pending, so the first pending gate rejects.
*/
export const assertVerifiedAccount = (
user:
@@ -365,6 +365,7 @@ export const assertVerifiedAccount = (
email_confirmed?: unknown;
requires_phone_verification?: unknown;
requires_card_verification?: unknown;
+ requires_password_change?: unknown;
}
| undefined,
): void => {
@@ -387,6 +388,17 @@ export const assertVerifiedAccount = (
legacyCode: 'card_verification_required',
});
}
+ // A team seat signs in on a password its administrator still holds,
+ // so it reaches nothing until it has replaced that password.
+ if (user?.requires_password_change) {
+ throw new HttpError(
+ 403,
+ 'Please choose your own password to continue',
+ {
+ legacyCode: 'password_change_required',
+ },
+ );
+ }
};
// -- Per-verification gates ------------------------------------------
@@ -413,8 +425,7 @@ export const assertVerifiedAccount = (
*/
export const assertPhoneVerified = (
user:
- | { phone?: unknown; requires_phone_verification?: unknown }
- | undefined,
+ { phone?: unknown; requires_phone_verification?: unknown } | undefined,
): void => {
if (user?.phone && !user?.requires_phone_verification) return;
throw new HttpError(403, 'Please verify your phone number to continue', {
diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts
index 9b16984f0..761e2433a 100644
--- a/src/backend/services/team/TeamService.test.ts
+++ b/src/backend/services/team/TeamService.test.ts
@@ -386,6 +386,172 @@ describe('TeamService', () => {
service.reissueCredential(team.uid, owner.id, result.userId),
).rejects.toMatchObject({ statusCode: 409 });
});
+
+ // -- password reset ------------------------------------------------
+
+ it('bounds an issued credential so an unused one dies', async () => {
+ const { team } = await makeTeam();
+ const username = `exp_${Math.random().toString(36).slice(2, 9)}`;
+ const result = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+
+ const user = await server.stores.user.getByProperty(
+ 'id',
+ result.userId,
+ { force: true },
+ );
+ const expiry = Number(user?.temp_password_expires_at);
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ expect(expiry).toBeGreaterThan(nowSeconds);
+ expect(expiry).toBeLessThanOrEqual(nowSeconds + 24 * 60 * 60);
+ });
+
+ it('takes a live account back with a fresh credential', async () => {
+ const { team } = await makeTeam();
+ const username = `rst_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ // The member chose their own password, so reissue is closed to them.
+ await server.stores.user.update(created.userId, {
+ requires_password_change: 0,
+ temp_password_expires_at: null,
+ });
+
+ const { temporaryPassword } = await service.resetMemberPassword(
+ team.uid,
+ owner.id,
+ created.userId,
+ );
+
+ expect(temporaryPassword).toMatch(/^[A-Za-z2-9]{16}$/u);
+ const user = await server.stores.user.getByProperty(
+ 'id',
+ created.userId,
+ { force: true },
+ );
+ expect(Number(user?.requires_password_change)).toBe(1);
+ expect(user?.password).not.toBe(temporaryPassword);
+ });
+
+ it('records the reset without recording the credential', async () => {
+ const { team } = await makeTeam();
+ const username = `aur_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+
+ const { temporaryPassword } = await service.resetMemberPassword(
+ team.uid,
+ owner.id,
+ created.userId,
+ );
+
+ const { items } = await service.listOwnAudit(team.uid, created.userId);
+ expect(items.map((e) => e.action)).toEqual([
+ 'reset_member_password',
+ 'provision',
+ ]);
+ expect(items[0].actor_username).toBe(ownerUsername);
+ expect(JSON.stringify(items)).not.toContain(temporaryPassword);
+ });
+
+ it('leaves 2FA in place, so a reset alone is not takeover', async () => {
+ const { team } = await makeTeam();
+ const username = `otp_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ await server.stores.user.update(created.userId, {
+ otp_enabled: 1,
+ otp_secret: 'ABCDEFGHIJKLMNOP',
+ });
+
+ await service.resetMemberPassword(team.uid, owner.id, created.userId);
+
+ const user = await server.stores.user.getByProperty(
+ 'id',
+ created.userId,
+ { force: true },
+ );
+ expect(Boolean(user?.otp_enabled)).toBe(true);
+ expect(user?.otp_secret).toBe('ABCDEFGHIJKLMNOP');
+ });
+
+ it('records a re-issue too, so no credential is handed over unlogged', async () => {
+ const { team } = await makeTeam();
+ const username = `rei_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+
+ await service.reissueCredential(team.uid, owner.id, created.userId);
+
+ const { items } = await service.listOwnAudit(team.uid, created.userId);
+ expect(items.map((e) => e.action)).toEqual([
+ 'reset_member_password',
+ 'provision',
+ ]);
+ });
+
+ it('refuses a reset ordered by someone who is not the owner', async () => {
+ const { team, member } = await makeTeam();
+ const username = `nres_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+
+ await expect(
+ service.resetMemberPassword(team.uid, member.id, created.userId),
+ ).rejects.toMatchObject({ statusCode: 403 });
+ });
+
+ it('refuses to reset the team owner, who is not org-owned', async () => {
+ const { team } = await makeTeam();
+ await expect(
+ service.resetMemberPassword(team.uid, owner.id, owner.id),
+ ).rejects.toMatchObject({ statusCode: 404 });
+ });
+
+ it('lets the member clear the gate and closes re-issue behind them', async () => {
+ const { team } = await makeTeam();
+ const username = `act_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+
+ // What the change-password route writes once the member sets their own.
+ await server.stores.user.update(created.userId, {
+ requires_password_change: 0,
+ temp_password_expires_at: null,
+ });
+ await service.recordPasswordSelfChange(created.userId);
+
+ const { items } = await service.listOwnAudit(team.uid, created.userId);
+ expect(items[0]).toMatchObject({
+ action: 'activate',
+ username,
+ actor_username: username,
+ });
+ await expect(
+ service.reissueCredential(team.uid, owner.id, created.userId),
+ ).rejects.toMatchObject({ statusCode: 409 });
+ });
+
+ it('ignores a password change by an account no team owns', async () => {
+ const outsider = await makeUser();
+ await expect(
+ service.recordPasswordSelfChange(outsider.id),
+ ).resolves.toBeUndefined();
+ });
it('refuses an email that already belongs to an account', async () => {
const { team } = await makeTeam();
const existing = await makeUser();
diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts
index 53dd1c2ac..dce94c585 100644
--- a/src/backend/services/team/TeamService.ts
+++ b/src/backend/services/team/TeamService.ts
@@ -18,7 +18,6 @@
*/
import bcrypt from 'bcrypt';
-import { randomBytes } from 'node:crypto';
import validator from 'validator';
import { v4 as uuidv4 } from 'uuid';
import {
@@ -40,23 +39,13 @@ import type {
} from '../../stores/team/TeamStore';
import type { UserRow } from '../../stores/user/UserStore';
import { cleanEmail } from '../../util/email.js';
+import {
+ generateTemporaryPassword,
+ temporaryPasswordExpiry,
+} from '../../util/temporaryPassword.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { PuterService } from '../types';
-/** Unambiguous alphabet -- no 0/O or 1/l, since a human retypes this. */
-const TEMP_PASSWORD_ALPHABET =
- 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
-
-/** ~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;
-};
-
/** Why an account was disabled. Free text in `0063`; this is the team one. */
export const DISABLED_BY_TEAM = 'disabled_by_team';
@@ -70,6 +59,14 @@ const CAP_LOCK_ATTEMPTS = 8;
const CAP_LOCK_RETRY_MS = 25;
const CAP_LOCK_TTL_SECONDS = 10;
+/**
+ * Audit vocabulary. `reset_member_password` covers both a reissue before first
+ * use and a reset of a live account: from the member's side both mean the
+ * administrator now holds a working credential for their account.
+ */
+export const AUDIT_RESET_PASSWORD = 'reset_member_password';
+export const AUDIT_ACTIVATE = 'activate';
+
export class TeamService extends PuterService {
// -- Billing ---- OSS emits; prod decides (see TEAMS-BILLING-SPLIT) ----
@@ -604,11 +601,7 @@ export class TeamService extends PuterService {
});
// Returned once; forced change on first use is what bounds it.
- const temporaryPassword = generateTemporaryPassword();
- await this.stores.user.update(user.id, {
- password: await bcrypt.hash(temporaryPassword, 8),
- requires_password_change: 1,
- });
+ const temporaryPassword = await this.#issueTemporaryPassword(user.id);
await this.#notifyAccountCreated(user, team);
// Last: the seat is only chargeable once it exists and can be used.
@@ -633,8 +626,82 @@ export class TeamService extends PuterService {
targetUserId: number,
): Promise<{ temporaryPassword: string }> {
const team = await this.requireOwner(teamUid, actorUserId);
- await this.requireOrgAccount(teamUid, targetUserId);
+ const user = await this.#requireTargetAccount(teamUid, targetUserId);
+ // Only before first use; changing a live account's password is reset.
+ if (!user.requires_password_change) {
+ throw new HttpError(409, 'That account is already activated', {
+ legacyCode: 'conflict',
+ });
+ }
+
+ // Recorded first, so a failed append cannot leave an unlogged credential.
+ await this.stores.team.appendAudit({
+ teamId: team.id,
+ userId: targetUserId,
+ actorUserId,
+ action: AUDIT_RESET_PASSWORD,
+ reason: 'reissue',
+ });
+ const temporaryPassword =
+ await this.#issueTemporaryPassword(targetUserId);
+ await this.#notifyAccountCreated(user, team);
+ return { temporaryPassword };
+ }
+
+ /**
+ * Takes a live account back with a fresh temporary password. The one route
+ * from a team to member data, and the answer to a locked-out
+ * employee.
+ */
+ async resetMemberPassword(
+ teamUid: string,
+ actorUserId: number,
+ targetUserId: number,
+ ): Promise<{ temporaryPassword: string }> {
+ const team = await this.requireOwner(teamUid, actorUserId);
+ const user = await this.#requireTargetAccount(teamUid, targetUserId);
+
+ // Recorded first, so a failed append cannot leave an unlogged reset.
+ await this.stores.team.appendAudit({
+ teamId: team.id,
+ userId: targetUserId,
+ actorUserId,
+ action: AUDIT_RESET_PASSWORD,
+ });
+ const temporaryPassword =
+ await this.#issueTemporaryPassword(targetUserId);
+ // 2FA is deliberately untouched: a reset alone is not takeover.
+ await this.#dropSessions(targetUserId);
+ await this.#notifyPasswordReset(user, team);
+ return { temporaryPassword };
+ }
+
+ /**
+ * Records that a member replaced the credential their administrator issued.
+ * A no-op for everyone who is not a seat, which is almost every account.
+ */
+ async recordPasswordSelfChange(userId: number): Promise {
+ const seat = await this.stores.team.getOrgSeat(userId);
+ if (!seat) return;
+ const team = await this.stores.team.getByUidIncludingDeleted(
+ seat.team_uid,
+ );
+ if (!team) return;
+ await this.stores.team.appendAudit({
+ teamId: team.id,
+ userId,
+ actorUserId: userId,
+ action: AUDIT_ACTIVATE,
+ });
+ }
+
+ /** The target of a member route, read past the cache the caller just wrote. */
+ async #requireTargetAccount(
+ teamUid: string,
+ targetUserId: number,
+ ): Promise {
+ await this.requireOrgAccount(teamUid, targetUserId);
const user = await this.stores.user.getByProperty('id', targetUserId, {
force: true,
});
@@ -643,20 +710,32 @@ export class TeamService extends PuterService {
legacyCode: 'not_found',
});
}
- // Only before first use; changing a live account's password is reset.
- if (!user.requires_password_change) {
- throw new HttpError(409, 'That account is already activated', {
- legacyCode: 'conflict',
- });
- }
+ return user as UserRow;
+ }
+ /** Never logged and never stored in plaintext; the caller shows it once. */
+ async #issueTemporaryPassword(userId: number): Promise {
const temporaryPassword = generateTemporaryPassword();
- await this.stores.user.update(targetUserId, {
+ await this.stores.user.update(userId, {
password: await bcrypt.hash(temporaryPassword, 8),
requires_password_change: 1,
+ temp_password_expires_at: temporaryPasswordExpiry(),
});
- await this.#notifyAccountCreated(user, team);
- return { temporaryPassword };
+ await this.stores.user.invalidateById(userId);
+ return temporaryPassword;
+ }
+
+ /** Carries no credential -- the administrator delivers that out of band. */
+ async #notifyPasswordReset(user: UserRow, team: TeamRow): Promise {
+ if (!this.clients.email || !user.email) return;
+ try {
+ await this.clients.email.send(user.email, 'team_password_reset', {
+ username: user.username,
+ team_name: team.name ?? 'Your team',
+ });
+ } catch (e) {
+ console.warn('[team-reset] notice failed:', e);
+ }
}
/** A notice only -- it carries no credential, so delivery is best effort. */
diff --git a/src/backend/stores/user/UserStore.ts b/src/backend/stores/user/UserStore.ts
index 2fde969f8..21ba8a6ef 100644
--- a/src/backend/stores/user/UserStore.ts
+++ b/src/backend/stores/user/UserStore.ts
@@ -66,6 +66,18 @@ export interface UserRow {
requires_phone_verification?: boolean;
/** True while the account must complete credit-card verification before use. */
requires_card_verification?: boolean;
+ /**
+ * 1 while the account still holds a password its team administrator
+ * issued; enforced by `assertVerifiedAccount` and cleared only by the
+ * account choosing its own. Unlike the other `requires_*` flags this is a
+ * numeric column on every dialect, so it is not normalized to a boolean.
+ */
+ requires_password_change?: number;
+ /**
+ * Unix seconds after which the administrator-issued password stops
+ * authenticating. Null for every password the account chose itself.
+ */
+ temp_password_expires_at?: number | string | null;
/**
* Payment-provider fingerprint of the card this account verified with —
* stable per card, written only on a successful check. Its presence is what
diff --git a/src/backend/util/temporaryPassword.test.ts b/src/backend/util/temporaryPassword.test.ts
new file mode 100644
index 000000000..82f894bac
--- /dev/null
+++ b/src/backend/util/temporaryPassword.test.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 .
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+ generateTemporaryPassword,
+ isTemporaryPasswordExpired,
+ TEMP_PASSWORD_TTL_SECONDS,
+ temporaryPasswordExpiry,
+} from './temporaryPassword.js';
+
+describe('generateTemporaryPassword', () => {
+ it('avoids the glyphs a human would mistype', () => {
+ const joined = Array.from({ length: 200 }, () =>
+ generateTemporaryPassword(),
+ ).join('');
+ expect(joined).not.toMatch(/[0O1lI]/u);
+ });
+
+ it('never repeats a credential', () => {
+ const seen = new Set(
+ Array.from({ length: 200 }, () => generateTemporaryPassword()),
+ );
+ expect(seen.size).toBe(200);
+ });
+});
+
+describe('isTemporaryPasswordExpired', () => {
+ const now = 1_800_000_000_000;
+
+ it('treats a password the account chose itself as never expiring', () => {
+ expect(isTemporaryPasswordExpired({}, now)).toBe(false);
+ expect(
+ isTemporaryPasswordExpired({ temp_password_expires_at: null }, now),
+ ).toBe(false);
+ });
+
+ it('accepts an unexpired credential', () => {
+ const expiry = temporaryPasswordExpiry(now);
+ expect(
+ isTemporaryPasswordExpired(
+ { temp_password_expires_at: expiry },
+ now,
+ ),
+ ).toBe(false);
+ });
+
+ it('rejects one issued more than the TTL ago', () => {
+ const expiry = temporaryPasswordExpiry(now);
+ const later = now + (TEMP_PASSWORD_TTL_SECONDS + 1) * 1000;
+ expect(
+ isTemporaryPasswordExpired(
+ { temp_password_expires_at: expiry },
+ later,
+ ),
+ ).toBe(true);
+ });
+
+ it('reads the string postgres returns for a bigint column', () => {
+ const expiry = String(temporaryPasswordExpiry(now));
+ const later = now + (TEMP_PASSWORD_TTL_SECONDS + 1) * 1000;
+ expect(
+ isTemporaryPasswordExpired(
+ { temp_password_expires_at: expiry },
+ later,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/src/backend/util/temporaryPassword.ts b/src/backend/util/temporaryPassword.ts
new file mode 100644
index 000000000..726e12da1
--- /dev/null
+++ b/src/backend/util/temporaryPassword.ts
@@ -0,0 +1,58 @@
+/*
+ * 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 .
+ */
+
+// 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;
+};
diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md
index 00c63d452..ce5e7a32f 100644
--- a/src/docs/src/rate-limits-and-quotas.md
+++ b/src/docs/src/rate-limits-and-quotas.md
@@ -192,9 +192,12 @@ Available only where a deployment has turned teams on. Every team route is bound
| Team reads per minute | 600 |
| Teams one account may own | 1 |
| Seats one team may provision | 50 |
+| Member password resets per day | 20 |
A seat is a real Puter account on the ordinary tier, created by the team and paid for by its owner, so the seat limit is what bounds a team's size. Over it, provisioning fails with `seat_limit_reached`; over the team limit, creation fails with `team_limit_reached`. Both carry the limit in `fields.limit`.
+A reset returns a temporary password once and never again. It stops working 24 hours after it is issued, so an unused reset expires rather than becoming a standing credential; after that the administrator has to issue a new one. Until the member replaces it, every authenticated request from that account fails with `password_change_required` — signing in works, but nothing else does until they choose their own password.
+
Deleting a team frees the owner's slot, but it does **not** free the seats: the accounts it created still exist, still hold their files, and keep their usernames. They are disabled, not removed — deleting a team is not a way to stop paying for the accounts in it.
Lowering the seat limit never disables anyone. A team already above a reduced limit keeps every account it has and is simply refused new ones until it is back under.