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.