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.