mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-20 20:26:21 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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');
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -362,6 +362,19 @@ choose your own the first time you sign in.</p>
|
||||
as you. Choose your own password promptly.</li>
|
||||
</ul>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_password_reset: {
|
||||
subject: 'Your {{team_name}} account password was reset',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>An administrator of {{team_name}} reset the password on your Puter account
|
||||
<b>{{username}}</b>. They will send you a temporary password separately, and you
|
||||
will be asked to choose your own the next time you sign in.</p>
|
||||
<p>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.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void> {
|
||||
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<UserRow> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The credential a team administrator hands a member out of band. It is
|
||||
// minted by the team service and spent at login, so the two halves live here
|
||||
// rather than one layer importing the other.
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
/** Unambiguous alphabet -- no 0/O or 1/l, since a human retypes this. */
|
||||
const TEMP_PASSWORD_ALPHABET =
|
||||
'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||
|
||||
/** How long an unused temporary password keeps working. */
|
||||
export const TEMP_PASSWORD_TTL_SECONDS = 24 * 60 * 60;
|
||||
|
||||
/** ~95 bits, generated rather than chosen so it is never a reused pattern. */
|
||||
export const generateTemporaryPassword = (length = 16): string => {
|
||||
const bytes = randomBytes(length);
|
||||
let out = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += TEMP_PASSWORD_ALPHABET[bytes[i] % TEMP_PASSWORD_ALPHABET.length];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Unix seconds at which a temporary password issued now stops working. */
|
||||
export const temporaryPasswordExpiry = (now = Date.now()): number =>
|
||||
Math.floor(now / 1000) + TEMP_PASSWORD_TTL_SECONDS;
|
||||
|
||||
/**
|
||||
* Whether this account's password is an expired temporary one. A null column is
|
||||
* every password the account chose itself, which never expires.
|
||||
*/
|
||||
export const isTemporaryPasswordExpired = (
|
||||
user: { temp_password_expires_at?: unknown } | undefined,
|
||||
now = Date.now(),
|
||||
): boolean => {
|
||||
const expiresAt = Number(user?.temp_password_expires_at ?? 0);
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false;
|
||||
return Math.floor(now / 1000) >= expiresAt;
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user