mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-22 13:15:49 +00:00
fix: duplicate emails (#3556)
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 = 61;
|
||||
const CURRENT_SCHEMA_VERSION = 62;
|
||||
const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238';
|
||||
|
||||
const sqliteConfig = (
|
||||
|
||||
@@ -95,6 +95,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[58, ['0063_add_suspended_reason.sql']],
|
||||
[59, ['0064_abuse-moderation-events.sql']],
|
||||
[60, ['0065_app-feedback.sql']],
|
||||
[61, ['0066_owned-email-unique.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Enforce "at most one account owns an email address". Mirrors SQLite
|
||||
-- migration 0066.
|
||||
--
|
||||
-- `user.email` is deliberately not UNIQUE: several rows may legitimately hold
|
||||
-- the same address while unconfirmed (admin-provisioned placeholders, signups
|
||||
-- that were never confirmed, temp accounts on their way to becoming real). What
|
||||
-- must never happen is two rows both *owning* an address — owning meaning the
|
||||
-- row is confirmed, or holds a password and so can drive password recovery for
|
||||
-- that inbox.
|
||||
--
|
||||
-- Signup, save-account, change-email, OIDC and admin provisioning each check for
|
||||
-- an owner before writing, but a check and an insert are not one operation: two
|
||||
-- requests can both read "free" and both write. This index is what actually
|
||||
-- holds the invariant; the application checks just produce a nicer error most of
|
||||
-- the time.
|
||||
--
|
||||
-- SQLite expresses that with a partial index. MySQL has none, so the predicate
|
||||
-- lives in a generated column that evaluates to NULL for every row that does not
|
||||
-- own its address — and NULLs do not collide in an InnoDB unique index, which is
|
||||
-- exactly the "unlimited unconfirmed placeholders" behaviour we need.
|
||||
--
|
||||
-- The column is VIRTUAL, not STORED, on purpose: adding a stored generated
|
||||
-- column rebuilds the table, while a virtual one is a metadata-only change and
|
||||
-- the index that follows builds INPLACE. On a `user` table of any size with
|
||||
-- read replicas attached, that is the difference between a routine change and an
|
||||
-- outage. The ALGORITHM/LOCK clauses are spelled out so a server that cannot
|
||||
-- honour them refuses the statement instead of quietly copying the table.
|
||||
--
|
||||
-- Matching is on the canonical address so provider aliases
|
||||
-- (`foo.bar+tag@gmail.com` vs `foobar@gmail.com`) collide. `clean_email` is
|
||||
-- written on every modern write path; the COALESCE covers rows old enough to
|
||||
-- predate the column. Run the `clean_email` backfill before this migration or
|
||||
-- alias collisions among those rows go unnoticed.
|
||||
--
|
||||
-- Idempotent: both steps are guarded on INFORMATION_SCHEMA so the directory
|
||||
-- replays safely.
|
||||
--
|
||||
-- If the index creation fails with ER_DUP_ENTRY, the DB already contains
|
||||
-- duplicate owners. Collapse them first (admin → One-off Jobs → Collapse
|
||||
-- Duplicate Emails) — there is no safe automatic merge of two accounts.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_owned_email;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_owned_email()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND COLUMN_NAME = 'owned_email'
|
||||
) THEN
|
||||
ALTER TABLE `user`
|
||||
ADD COLUMN `owned_email` VARCHAR(256)
|
||||
CHARACTER SET latin1 COLLATE latin1_swedish_ci
|
||||
GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN `email` IS NOT NULL
|
||||
AND (`email_confirmed` = 1 OR `password` IS NOT NULL)
|
||||
THEN COALESCE(`clean_email`, LOWER(`email`))
|
||||
ELSE NULL
|
||||
END
|
||||
) VIRTUAL,
|
||||
ALGORITHM=INSTANT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_owned_email'
|
||||
) THEN
|
||||
ALTER TABLE `user`
|
||||
ADD UNIQUE KEY `idx_user_owned_email` (`owned_email`),
|
||||
ALGORITHM=INPLACE, LOCK=NONE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_owned_email();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_owned_email;
|
||||
|
||||
-- One Puter account per external identity. OIDCStore.link already assumes this
|
||||
-- constraint exists — it catches the unique violation to tell "re-linking the
|
||||
-- same account" apart from "this sub belongs to someone else" — but the table
|
||||
-- never actually had it, so two concurrent first-time logins could each create
|
||||
-- an account and each link the same sub. Subsequent logins then resolved to
|
||||
-- whichever row came back first.
|
||||
--
|
||||
-- Dedupe `user_oidc_providers` before applying this: keep the lowest `id` per
|
||||
-- (provider, provider_sub) and point it at the account the collapse job kept.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_oidc_sub_unique;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_oidc_sub_unique()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_oidc_providers'
|
||||
AND INDEX_NAME = 'idx_user_oidc_provider_sub'
|
||||
) THEN
|
||||
ALTER TABLE `user_oidc_providers`
|
||||
ADD UNIQUE KEY `idx_user_oidc_provider_sub` (`provider`, `provider_sub`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_oidc_sub_unique();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_oidc_sub_unique;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Enforce "at most one account owns an email address". Mirrors SQLite
|
||||
-- migration 0066 and MySQL migration 21; see those for the full rationale.
|
||||
--
|
||||
-- In short: `user.email` is deliberately not UNIQUE because several rows may
|
||||
-- hold the same address while unconfirmed. What must not happen is two rows
|
||||
-- both owning it — confirmed, or holding a password and so able to drive
|
||||
-- password recovery for that inbox. The application checks for an owner before
|
||||
-- every write, but a check and a write are not one operation.
|
||||
--
|
||||
-- If this fails, the DB already contains duplicate owners; collapse them first
|
||||
-- (admin → One-off Jobs → Collapse Duplicate Emails).
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_owned_email
|
||||
ON "user" (COALESCE(clean_email, LOWER(email)))
|
||||
WHERE email IS NOT NULL
|
||||
AND (email_confirmed = TRUE OR password IS NOT NULL);
|
||||
|
||||
-- One Puter account per external identity. OIDCStore.link already assumes this
|
||||
-- constraint exists — it catches the unique violation to tell "re-linking the
|
||||
-- same account" apart from "this sub belongs to someone else" — but the table
|
||||
-- never actually had it.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_oidc_provider_sub
|
||||
ON user_oidc_providers (provider, provider_sub);
|
||||
@@ -0,0 +1,53 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Enforce "at most one account owns an email address".
|
||||
--
|
||||
-- `user.email` is deliberately not UNIQUE: several rows may legitimately hold
|
||||
-- the same address while unconfirmed (admin-provisioned placeholders, signups
|
||||
-- that were never confirmed, temp accounts on their way to becoming real). What
|
||||
-- must never happen is two rows both *owning* an address — owning meaning the
|
||||
-- row is confirmed, or holds a password and so can drive password recovery for
|
||||
-- that inbox.
|
||||
--
|
||||
-- Signup, save-account, change-email, OIDC and admin provisioning each check for
|
||||
-- an owner before writing, but a check and an insert are not one operation: two
|
||||
-- requests can both read "free" and both write. This index is what actually
|
||||
-- holds the invariant; the application checks just produce a nicer error most of
|
||||
-- the time.
|
||||
--
|
||||
-- Matching is on the canonical address so provider aliases
|
||||
-- (`foo.bar+tag@gmail.com` vs `foobar@gmail.com`) collide. `clean_email` is
|
||||
-- written on every modern write path; the COALESCE covers rows old enough to
|
||||
-- predate the column.
|
||||
--
|
||||
-- If this CREATE fails, the DB already contains duplicate owners. Collapse them
|
||||
-- first (admin → One-off Jobs → Collapse Duplicate Emails) — there is no safe
|
||||
-- automatic merge of two accounts.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_owned_email
|
||||
ON user(COALESCE(clean_email, lower(email)))
|
||||
WHERE email IS NOT NULL
|
||||
AND (email_confirmed = 1 OR password IS NOT NULL);
|
||||
|
||||
-- One Puter account per external identity. OIDCStore.link already assumes this
|
||||
-- constraint exists — it catches the unique violation to tell "re-linking the
|
||||
-- same account" apart from "this sub belongs to someone else" — but the table
|
||||
-- never actually had it, so two concurrent first-time logins could each create
|
||||
-- an account and each link the same sub. Subsequent logins then resolved to
|
||||
-- whichever row came back first.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_oidc_provider_sub
|
||||
ON user_oidc_providers(provider, provider_sub);
|
||||
@@ -346,6 +346,188 @@ describe('puter.signup.validate event', () => {
|
||||
|
||||
// ── Signup flow ─────────────────────────────────────────────────────
|
||||
|
||||
// ── Concurrent claims on one address ────────────────────────────────
|
||||
//
|
||||
// The duplicate checks these flows run are not atomic with the writes that
|
||||
// follow them — the validate hook and bcrypt sit in between, and both are slow
|
||||
// enough for a second request to pass the same check. These tests fire the
|
||||
// requests together and assert the address still ends up on exactly one row.
|
||||
|
||||
describe('concurrent claims on one email address', () => {
|
||||
const uniq = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
const countOwners = async (email: string): Promise<number> => {
|
||||
// `email_confirmed` is TINYINT on MySQL and BOOLEAN on Postgres, so the
|
||||
// literal has to come from the client rather than be hardcoded.
|
||||
const isTrue = server.clients.db.booleanLiteral(true);
|
||||
const rows = (await server.clients.db.read(
|
||||
`SELECT COUNT(*) AS n FROM \`user\` WHERE \`email\` = ? AND (\`email_confirmed\` = ${isTrue} OR \`password\` IS NOT NULL)`,
|
||||
[email],
|
||||
)) as Array<{ n: number }>;
|
||||
return Number(rows[0]?.n ?? 0);
|
||||
};
|
||||
|
||||
it('lets only one of two simultaneous signups take the address', async () => {
|
||||
const email = `race-${uniq()}@test.local`;
|
||||
const signup = (username: string) =>
|
||||
controller.handleSignup(
|
||||
makeReq({ username, email, password: 'correct-horse-battery' }),
|
||||
makeRes(),
|
||||
);
|
||||
|
||||
// Distinct usernames on purpose: the UNIQUE on `username` would
|
||||
// otherwise be what rejects the second request, and the email race
|
||||
// would go untested.
|
||||
const results = await Promise.allSettled([
|
||||
signup(`r_a_${uniq()}`),
|
||||
signup(`r_b_${uniq()}`),
|
||||
]);
|
||||
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||
expect(await countOwners(email)).toBe(1);
|
||||
|
||||
const rejected = results.find((r) => r.status === 'rejected') as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
expect(rejected?.reason).toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('lets only one of many simultaneous signups take the address', async () => {
|
||||
const email = `race-many-${uniq()}@test.local`;
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 5 }, () =>
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username: `r_m_${uniq()}`,
|
||||
email,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||
expect(await countOwners(email)).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects a signup racing an admin-provisioned placeholder claim', async () => {
|
||||
const email = `race-pseudo-${uniq()}@test.local`;
|
||||
// The placeholder shape signup is allowed to convert: unconfirmed,
|
||||
// no password. Two signups both see it as claimable.
|
||||
await server.stores.user.create({
|
||||
username: `r_p_${uniq()}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
clean_email: email,
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username: `r_p_a_${uniq()}`,
|
||||
email,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username: `r_p_b_${uniq()}`,
|
||||
email,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||
expect(await countOwners(email)).toBe(1);
|
||||
});
|
||||
|
||||
it('reports a duplicate address as a 400, not a constraint error', async () => {
|
||||
const email = `dupe-${uniq()}@test.local`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username: `d_a_${uniq()}`,
|
||||
email,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
|
||||
// The message has to be the one the pre-check produces — a user who
|
||||
// loses the race should not be able to tell.
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username: `d_b_${uniq()}`,
|
||||
email,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message:
|
||||
'This email already exists in our database. Please use another one.',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to give a placeholder row a password for a taken address', async () => {
|
||||
const email = `recover-${uniq()}@test.local`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username: `rec_own_${uniq()}`,
|
||||
email,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
|
||||
// An unconfirmed, password-less row is allowed to sit on the same
|
||||
// address — but password recovery accepts a username, so it can be
|
||||
// driven for this row rather than for the account that owns the
|
||||
// address. Setting a password here would make it a second account able
|
||||
// to recover that inbox.
|
||||
const placeholderName = `rec_ph_${uniq()}`;
|
||||
const placeholder = await server.stores.user.create({
|
||||
username: placeholderName,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
clean_email: email,
|
||||
});
|
||||
const token = uuidv4();
|
||||
await server.stores.user.update(placeholder.id, {
|
||||
pass_recovery_token: token,
|
||||
});
|
||||
|
||||
const jwt = server.services.token.sign(
|
||||
'otp',
|
||||
{
|
||||
token,
|
||||
user_uid: placeholder.uuid,
|
||||
email,
|
||||
purpose: 'pass-recovery',
|
||||
},
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.handleSetPassUsingToken(
|
||||
makeReq({ token: jwt, password: 'another-strong-password' }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message:
|
||||
'This email is already in use. Recover the account that uses it instead.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController.handleSignup', () => {
|
||||
const uniq = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
@@ -3847,6 +4029,151 @@ describe('AuthController.handleConfirmEmail', () => {
|
||||
});
|
||||
expect(after!.email_confirmed).toBeFalsy();
|
||||
});
|
||||
|
||||
it('strips a rival placeholder before confirming, not after', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const email = user.email as string;
|
||||
|
||||
// A placeholder sitting on the same address. Confirming first and
|
||||
// demoting second would momentarily leave two rows owning it, which
|
||||
// the unique index rejects — turning a legitimate confirmation into a
|
||||
// 500.
|
||||
const rival = await server.stores.user.create({
|
||||
username: `rival_${Math.random().toString(36).slice(2, 10)}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
clean_email: email,
|
||||
});
|
||||
|
||||
const refreshed = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleConfirmEmail(
|
||||
makeReq({ code: refreshed!.email_confirm_code! }, { actor }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.body).toMatchObject({ email_confirmed: true });
|
||||
const confirmed = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(confirmed!.email_confirmed).toBe(true);
|
||||
const strippedRival = await server.stores.user.getById(rival.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(strippedRival!.email).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses when another account already confirmed the address', async () => {
|
||||
const email = `owned-${uniq()}@test.local`;
|
||||
|
||||
// Confirmed with no password is the shape an identity provider
|
||||
// creates, and it is what the old rival check (which also demanded a
|
||||
// password) let through. Demoting it would take the address off an
|
||||
// account that proved it owns the inbox.
|
||||
const owner = await server.stores.user.create({
|
||||
username: `owner_${uniq()}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
clean_email: email,
|
||||
email_confirmed: true,
|
||||
});
|
||||
|
||||
// A second, unconfirmed row on the same address holding a confirm
|
||||
// code — the legacy duplicate this path used to resolve in its favour.
|
||||
const claimant = await server.stores.user.create({
|
||||
username: `claim_${uniq()}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
clean_email: email,
|
||||
email_confirm_code: '123456',
|
||||
});
|
||||
const actor = {
|
||||
user: {
|
||||
id: claimant.id,
|
||||
uuid: claimant.uuid,
|
||||
username: claimant.username,
|
||||
email,
|
||||
email_confirmed: false,
|
||||
},
|
||||
} as Actor;
|
||||
|
||||
await expect(
|
||||
controller.handleConfirmEmail(
|
||||
makeReq({ code: '123456' }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
const untouched = await server.stores.user.getById(owner.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(untouched!.email).toBe(email);
|
||||
expect(untouched!.email_confirmed).toBe(true);
|
||||
const stillUnconfirmed = await server.stores.user.getById(claimant.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(stillUnconfirmed!.email_confirmed).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController.handleSaveAccount address conflicts', () => {
|
||||
it('refuses to promote a temp account onto a taken address', async () => {
|
||||
const email = `save-${Math.random().toString(36).slice(2, 10)}@test.local`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username: `save_own_${Math.random().toString(36).slice(2, 10)}`,
|
||||
email,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
|
||||
const tempRes = makeRes();
|
||||
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
|
||||
const tempUser = (
|
||||
tempRes.body as { user: { username: string; uuid: string } }
|
||||
).user;
|
||||
const tempRow = await server.stores.user.getByUuid(tempUser.uuid);
|
||||
const actor = {
|
||||
user: {
|
||||
id: tempRow!.id,
|
||||
uuid: tempRow!.uuid,
|
||||
username: tempRow!.username,
|
||||
email: null,
|
||||
email_confirmed: false,
|
||||
},
|
||||
} as Actor;
|
||||
|
||||
await expect(
|
||||
controller.handleSaveAccount(
|
||||
makeReq(
|
||||
{
|
||||
username: `save_new_${Math.random().toString(36).slice(2, 10)}`,
|
||||
email,
|
||||
password: 'another-strong-password',
|
||||
},
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'email_already_in_use',
|
||||
});
|
||||
|
||||
// The temp row must be left alone — a failed promotion that already
|
||||
// wrote the username would strand the account half-converted.
|
||||
const untouched = await server.stores.user.getById(tempRow!.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(untouched!.email).toBeNull();
|
||||
expect(untouched!.password).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Password recovery flow ──────────────────────────────────────────
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
createSecret as otpCreateSecret,
|
||||
verify as verifyOtp,
|
||||
} from '../../services/auth/OTPUtil.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { isOwnedEmailConflict } from '../../stores/user/UserStore.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
@@ -775,11 +777,9 @@ export class AuthController extends PuterController {
|
||||
if (this.config.disable_user_signup) {
|
||||
let claimable = false;
|
||||
if (!is_temp) {
|
||||
const existing =
|
||||
(await this.stores.user.getByEmail(body.email)) ??
|
||||
(await this.stores.user.getByCleanEmail(
|
||||
cleanEmail(body.email),
|
||||
));
|
||||
const existing = await this.stores.user.findEmailOwner(
|
||||
body.email,
|
||||
);
|
||||
claimable = Boolean(
|
||||
existing &&
|
||||
!existing.email_confirmed &&
|
||||
@@ -817,28 +817,18 @@ export class AuthController extends PuterController {
|
||||
// password to an OIDC account, the owner logs in via OIDC and
|
||||
// uses the authenticated change-password flow.
|
||||
//
|
||||
// Match on both raw `email` and canonical `clean_email` so
|
||||
// Matching runs against both raw `email` and canonical `clean_email` so
|
||||
// gmail-style aliases (`foo.bar+tag@gmail.com` vs
|
||||
// `foobar@gmail.com`) collapse to the same account.
|
||||
let pseudo_user = null;
|
||||
if (!is_temp) {
|
||||
const canonical = cleanEmail(body.email);
|
||||
const existing =
|
||||
(await this.stores.user.getByEmail(body.email)) ??
|
||||
(await this.stores.user.getByCleanEmail(canonical));
|
||||
if (existing) {
|
||||
// Confirmed account (regardless of credential type) → reject.
|
||||
if (existing.email_confirmed || existing.password !== null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'This email already exists in our database. Please use another one.',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// Password-null AND unconfirmed → treat as pseudo.
|
||||
pseudo_user = existing;
|
||||
}
|
||||
}
|
||||
//
|
||||
// This is the cheap early check: it keeps an obvious duplicate from
|
||||
// paying for the validate hook and a bcrypt round. It is NOT the
|
||||
// guarantee — everything between here and the insert widens the window,
|
||||
// so the check runs again against the primary immediately before the
|
||||
// write, and the unique index catches whatever still slips through.
|
||||
let pseudo_user = is_temp
|
||||
? null
|
||||
: await this.#resolveSignupEmailClaim(body.email);
|
||||
|
||||
// Extension-level validation gate. Abuse-prevention extensions
|
||||
// inspect the incoming signup and can:
|
||||
@@ -945,24 +935,54 @@ export class AuthController extends PuterController {
|
||||
.slice(0, 19)
|
||||
.replace('T', ' ');
|
||||
|
||||
// Re-run the claim against the primary now that the slow work is done.
|
||||
// The check above ran before the validate hook (network round-trips to
|
||||
// the abuse listeners) and before bcrypt — hundreds of milliseconds in
|
||||
// which a concurrent signup can take the address, or claim the very
|
||||
// placeholder row we were about to convert.
|
||||
if (!is_temp) {
|
||||
pseudo_user = await this.#resolveSignupEmailClaim(body.email, {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
let user;
|
||||
if (pseudo_user) {
|
||||
// -- Pseudo-user claim (convert the placeholder row) --
|
||||
await this.stores.user.update(pseudo_user.id, {
|
||||
username: body.username,
|
||||
password: password_hash,
|
||||
uuid: user_uuid,
|
||||
email_confirm_code,
|
||||
email_confirm_token,
|
||||
email_confirmed: 0,
|
||||
requires_email_confirmation: 1,
|
||||
last_activity_ts: signupSqlTs,
|
||||
...(validateEvent.reputation != null
|
||||
? { reputation: validateEvent.reputation }
|
||||
: {}),
|
||||
requires_phone_verification: force_phone_verification ? 1 : 0,
|
||||
requires_card_verification: force_card_verification ? 1 : 0,
|
||||
});
|
||||
//
|
||||
// Guarded, not a plain update: the address never changes hands here
|
||||
// (the row already holds it), so the unique index has nothing to
|
||||
// catch. Two signups that both read this row as claimable would
|
||||
// otherwise both "succeed", the second overwriting the first's
|
||||
// username and password on a row the first was already given a
|
||||
// session for.
|
||||
const claimed = await this.stores.user.claimPlaceholder(
|
||||
pseudo_user.id,
|
||||
{
|
||||
username: body.username,
|
||||
password: password_hash,
|
||||
uuid: user_uuid,
|
||||
email_confirm_code,
|
||||
email_confirm_token,
|
||||
email_confirmed: 0,
|
||||
requires_email_confirmation: 1,
|
||||
last_activity_ts: signupSqlTs,
|
||||
...(validateEvent.reputation != null
|
||||
? { reputation: validateEvent.reputation }
|
||||
: {}),
|
||||
requires_phone_verification: force_phone_verification
|
||||
? 1
|
||||
: 0,
|
||||
requires_card_verification: force_card_verification ? 1 : 0,
|
||||
},
|
||||
);
|
||||
if (!claimed) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'This email already exists in our database. Please use another one.',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
// Move from temp group to regular user group
|
||||
if (this.config.default_temp_group) {
|
||||
@@ -994,37 +1014,51 @@ export class AuthController extends PuterController {
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || null;
|
||||
const proxyIpChain = req.headers['x-forwarded-for'];
|
||||
|
||||
user = await this.stores.user.create({
|
||||
username: body.username,
|
||||
uuid: user_uuid,
|
||||
password: password_hash,
|
||||
email: is_temp ? null : body.email,
|
||||
clean_email: is_temp ? null : cleanEmail(body.email),
|
||||
free_storage: this.config.storage_capacity ?? null,
|
||||
requires_email_confirmation:
|
||||
!is_temp || force_email_confirmation,
|
||||
email_confirm_code,
|
||||
email_confirm_token,
|
||||
audit_metadata: {
|
||||
ip: clientIp,
|
||||
ip_fwd: proxyIpChain,
|
||||
user_agent: req.headers?.['user-agent'],
|
||||
origin: req.headers?.origin,
|
||||
fingerprint,
|
||||
},
|
||||
signup_ip: clientIp,
|
||||
signup_ip_forwarded: proxyIpChain,
|
||||
signup_user_agent: req.headers?.['user-agent'] ?? null,
|
||||
signup_origin: (req.headers?.origin as string | null) ?? null,
|
||||
signup_server: (this.config as { serverId?: string }).serverId,
|
||||
referrer: req.body.referrer ?? null,
|
||||
last_activity_ts: signupSqlTs,
|
||||
reputation: validateEvent.reputation,
|
||||
// Phone collected later in the verification dialog (null now).
|
||||
phone: null,
|
||||
requires_phone_verification: force_phone_verification,
|
||||
requires_card_verification: force_card_verification,
|
||||
} as never);
|
||||
try {
|
||||
user = await this.stores.user.create({
|
||||
username: body.username,
|
||||
uuid: user_uuid,
|
||||
password: password_hash,
|
||||
email: is_temp ? null : body.email,
|
||||
clean_email: is_temp ? null : cleanEmail(body.email),
|
||||
free_storage: this.config.storage_capacity ?? null,
|
||||
requires_email_confirmation:
|
||||
!is_temp || force_email_confirmation,
|
||||
email_confirm_code,
|
||||
email_confirm_token,
|
||||
audit_metadata: {
|
||||
ip: clientIp,
|
||||
ip_fwd: proxyIpChain,
|
||||
user_agent: req.headers?.['user-agent'],
|
||||
origin: req.headers?.origin,
|
||||
fingerprint,
|
||||
},
|
||||
signup_ip: clientIp,
|
||||
signup_ip_forwarded: proxyIpChain,
|
||||
signup_user_agent: req.headers?.['user-agent'] ?? null,
|
||||
signup_origin:
|
||||
(req.headers?.origin as string | null) ?? null,
|
||||
signup_server: (this.config as { serverId?: string })
|
||||
.serverId,
|
||||
referrer: req.body.referrer ?? null,
|
||||
last_activity_ts: signupSqlTs,
|
||||
reputation: validateEvent.reputation,
|
||||
// Phone collected later in the verification dialog (null now).
|
||||
phone: null,
|
||||
requires_phone_verification: force_phone_verification,
|
||||
requires_card_verification: force_card_verification,
|
||||
} as never);
|
||||
} catch (e) {
|
||||
// Lost the race to another signup between the re-check above and
|
||||
// this insert. The index is the only thing that can see that, so
|
||||
// translate it into the answer the pre-check would have given.
|
||||
if (!isOwnedEmailConflict(e)) throw e;
|
||||
throw new HttpError(
|
||||
400,
|
||||
'This email already exists in our database. Please use another one.',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
// Add to default group
|
||||
const defaultGroup = is_temp
|
||||
@@ -1101,7 +1135,8 @@ export class AuthController extends PuterController {
|
||||
is_temp: user!.password === null && user!.email === null,
|
||||
ip:
|
||||
(req?.headers?.['x-forwarded-for'] as
|
||||
string | undefined) ||
|
||||
| string
|
||||
| undefined) ||
|
||||
(
|
||||
req as unknown as {
|
||||
connection?: { remoteAddress?: string };
|
||||
@@ -1274,6 +1309,34 @@ export class AuthController extends PuterController {
|
||||
// after signup but before confirmation.
|
||||
await this.#validateEmail(user.email!);
|
||||
|
||||
// An account that already confirmed this address proved access to the
|
||||
// inbox, and revoking it below would hand the address to whoever
|
||||
// confirmed second. Refuse instead — a duplicate this old is data to
|
||||
// repair, not a race to resolve.
|
||||
const canonical = cleanEmail(user.email!);
|
||||
const confirmedRival = await this.stores.user.findConfirmedOtherByEmail(
|
||||
user.id,
|
||||
user.email!,
|
||||
canonical,
|
||||
);
|
||||
if (confirmedRival) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'This email was confirmed on a different account.',
|
||||
{ legacyCode: 'email_already_in_use' as never },
|
||||
);
|
||||
}
|
||||
|
||||
// Revoke the address from every remaining (unconfirmed) account holding
|
||||
// it, THEN confirm this one. Only one row may own an address, so
|
||||
// confirming first would momentarily create a second owner — which the
|
||||
// unique index rejects, turning a legitimate confirmation into a 500.
|
||||
await this.stores.user.unconfirmOthersByEmail(
|
||||
user.id,
|
||||
user.email!,
|
||||
canonical,
|
||||
);
|
||||
|
||||
await this.stores.user.update(user.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
@@ -1281,16 +1344,6 @@ export class AuthController extends PuterController {
|
||||
email_confirm_token: null,
|
||||
});
|
||||
|
||||
// Revoke confirmation from any other accounts sharing this
|
||||
// email so only the account whose owner just proved inbox
|
||||
// access retains verified status.
|
||||
const canonical = cleanEmail(user.email!);
|
||||
await this.stores.user.unconfirmOthersByEmail(
|
||||
user.id,
|
||||
user.email!,
|
||||
canonical,
|
||||
);
|
||||
|
||||
await promoteToVerifiedGroup(this.stores.group, this.config, user);
|
||||
|
||||
try {
|
||||
@@ -2277,10 +2330,26 @@ export class AuthController extends PuterController {
|
||||
|
||||
// Atomic check: only update if the recovery token still matches
|
||||
const password_hash = await bcrypt.hash(password, 8);
|
||||
const 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` = ?',
|
||||
[password_hash, user.id, decoded.token],
|
||||
);
|
||||
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` = ?',
|
||||
[password_hash, user.id, decoded.token],
|
||||
);
|
||||
} catch (e) {
|
||||
if (!isOwnedEmailConflict(e)) throw e;
|
||||
// Recovery can be requested by username, so this row may be an
|
||||
// unconfirmed placeholder that shares its address with a real
|
||||
// account. Giving it a password would make it a second account able
|
||||
// to drive recovery for that inbox, which is the thing the address
|
||||
// constraint exists to stop. The inbox owner has an account
|
||||
// already — they should be recovering that one.
|
||||
throw new HttpError(
|
||||
400,
|
||||
'This email is already in use. Recover the account that uses it instead.',
|
||||
{ legacyCode: 'email_already_in_use' as never },
|
||||
);
|
||||
}
|
||||
const affected =
|
||||
(result as { affectedRows?: number; changes?: number })
|
||||
?.affectedRows ??
|
||||
@@ -2441,10 +2510,7 @@ export class AuthController extends PuterController {
|
||||
// aliases — which is also why the caller has to be excluded: an
|
||||
// alias of your own current address resolves back to you, and
|
||||
// "already in use" about yourself is nonsense.
|
||||
const canonical = cleanEmail(new_email);
|
||||
const existing =
|
||||
(await this.stores.user.getByEmail(new_email)) ??
|
||||
(await this.stores.user.getByCleanEmail(canonical));
|
||||
const existing = await this.stores.user.findEmailOwner(new_email);
|
||||
if (
|
||||
existing &&
|
||||
existing.id !== req.actor!.user.id &&
|
||||
@@ -2545,7 +2611,7 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT * FROM `user` WHERE `change_email_confirm_token` = ? LIMIT 1',
|
||||
'SELECT * FROM `user` WHERE `change_email_confirm_token` = ? ORDER BY `id` ASC LIMIT 1',
|
||||
[decoded.token],
|
||||
)) as Array<Record<string, unknown>>;
|
||||
const user = rows[0] as
|
||||
@@ -2566,11 +2632,12 @@ export class AuthController extends PuterController {
|
||||
|
||||
// Re-check nobody claimed the new email meanwhile. Match raw +
|
||||
// canonical; block if any real account (confirmed OR
|
||||
// password-holding) already owns it.
|
||||
// password-holding) already owns it. Read the primary — the request
|
||||
// that took the address may have landed moments ago.
|
||||
const canonical = cleanEmail(newEmail);
|
||||
const owner =
|
||||
(await this.stores.user.getByEmail(newEmail)) ??
|
||||
(await this.stores.user.getByCleanEmail(canonical));
|
||||
const owner = await this.stores.user.findEmailOwner(newEmail, {
|
||||
force: true,
|
||||
});
|
||||
if (
|
||||
owner &&
|
||||
owner.id !== user.id &&
|
||||
@@ -2581,15 +2648,30 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
await this.stores.user.update(user.id, {
|
||||
email: newEmail,
|
||||
clean_email: cleanEmail(newEmail),
|
||||
unconfirmed_change_email: null,
|
||||
change_email_confirm_token: null,
|
||||
pass_recovery_token: null,
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
});
|
||||
// Strip the address off any unconfirmed placeholder still holding it
|
||||
// before taking it, so this row is the only owner.
|
||||
await this.stores.user.unconfirmOthersByEmail(
|
||||
user.id,
|
||||
newEmail,
|
||||
canonical,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.stores.user.update(user.id, {
|
||||
email: newEmail,
|
||||
clean_email: canonical,
|
||||
unconfirmed_change_email: null,
|
||||
change_email_confirm_token: null,
|
||||
pass_recovery_token: null,
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isOwnedEmailConflict(e)) throw e;
|
||||
throw new HttpError(400, 'This email is already in use.', {
|
||||
legacyCode: 'email_already_in_use' as never,
|
||||
});
|
||||
}
|
||||
|
||||
await this.stores.oidc.unlinkAllByUserId(user.id);
|
||||
|
||||
@@ -2692,9 +2774,7 @@ export class AuthController extends PuterController {
|
||||
// reject on ANY confirmed account (OIDC accounts have
|
||||
// password=null but are real) — not just password-holders.
|
||||
const canonical = cleanEmail(email);
|
||||
const existingEmail =
|
||||
(await this.stores.user.getByEmail(email)) ??
|
||||
(await this.stores.user.getByCleanEmail(canonical));
|
||||
const existingEmail = await this.stores.user.findEmailOwner(email);
|
||||
if (
|
||||
existingEmail &&
|
||||
existingEmail.id !== user.id &&
|
||||
@@ -2710,16 +2790,38 @@ export class AuthController extends PuterController {
|
||||
const email_confirm_code = String(crypto.randomInt(100000, 1000000));
|
||||
const email_confirm_token = uuidv4();
|
||||
|
||||
await this.stores.user.update(user.id, {
|
||||
username,
|
||||
email,
|
||||
clean_email: cleanEmail(email),
|
||||
password: password_hash,
|
||||
email_confirm_code,
|
||||
email_confirm_token,
|
||||
email_confirmed: 0,
|
||||
requires_email_confirmation: 1,
|
||||
// bcrypt above is slow enough for someone else to take the address in
|
||||
// the meantime, so re-check against the primary before the write.
|
||||
const raced = await this.stores.user.findEmailOwner(email, {
|
||||
force: true,
|
||||
});
|
||||
if (
|
||||
raced &&
|
||||
raced.id !== user.id &&
|
||||
(raced.email_confirmed || raced.password !== null)
|
||||
) {
|
||||
throw new HttpError(400, 'This email is already in use.', {
|
||||
legacyCode: 'email_already_in_use' as never,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await this.stores.user.update(user.id, {
|
||||
username,
|
||||
email,
|
||||
clean_email: canonical,
|
||||
password: password_hash,
|
||||
email_confirm_code,
|
||||
email_confirm_token,
|
||||
email_confirmed: 0,
|
||||
requires_email_confirmation: 1,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isOwnedEmailConflict(e)) throw e;
|
||||
throw new HttpError(400, 'This email is already in use.', {
|
||||
legacyCode: 'email_already_in_use' as never,
|
||||
});
|
||||
}
|
||||
|
||||
// Rename the user's FS home so `/<temp>/Desktop` etc.
|
||||
// become `/<new>/Desktop`. Without this cascade, any
|
||||
@@ -4425,57 +4527,7 @@ export class AuthController extends PuterController {
|
||||
// -- Private helpers ----------------------------------------------
|
||||
|
||||
async #cascadeDeleteUser(userId: number): Promise<void> {
|
||||
// Capture the identifiers downstream teardown needs before the row is
|
||||
// gone — the marketplace extension cancels the user's Stripe
|
||||
// subscriptions off `user.delete`, keyed by uuid / customer id.
|
||||
let userUuid: string | undefined;
|
||||
let stripeCustomerId: string | null = null;
|
||||
try {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT `uuid`, `stripe_customer_id` FROM `user` WHERE `id` = ?',
|
||||
[userId],
|
||||
)) as Array<{ uuid?: string; stripe_customer_id?: string | null }>;
|
||||
userUuid = rows[0]?.uuid;
|
||||
stripeCustomerId = rows[0]?.stripe_customer_id ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[cascade-delete-user] identifier lookup failed:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.services.fs.removeAllForUser(userId);
|
||||
} catch (e) {
|
||||
// Proceed with user-row delete anyway — orphaned fsentries are
|
||||
// better than a resurrected account.
|
||||
console.warn('[cascade-delete-user] fs cleanup failed:', e);
|
||||
}
|
||||
|
||||
// Sessions FK is SET NULL, so delete explicitly to avoid dangling rows.
|
||||
await this.clients.db.write(
|
||||
'DELETE FROM `sessions` WHERE `user_id` = ?',
|
||||
[userId],
|
||||
);
|
||||
await this.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
|
||||
userId,
|
||||
]);
|
||||
await this.stores.user.invalidateById(userId);
|
||||
|
||||
// Fire-and-forget: let listeners purge external state tied to the
|
||||
// account (Stripe subscriptions are cancelled immediately, without
|
||||
// proration). Emitted after the row delete — listeners key off the
|
||||
// payload, not the DB row.
|
||||
try {
|
||||
this.clients.event?.emit(
|
||||
'user.delete',
|
||||
{
|
||||
user_id: userId,
|
||||
user_uuid: userUuid,
|
||||
stripe_customer_id: stripeCustomerId,
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// ignore — event emission shouldn't block deletion
|
||||
}
|
||||
await this.services.userAccount.cascadeDelete(userId);
|
||||
}
|
||||
|
||||
async #generateRandomUsername(): Promise<string> {
|
||||
@@ -4494,6 +4546,34 @@ export class AuthController extends PuterController {
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a signup may take `email`, and hand back the placeholder
|
||||
* row it should convert instead of inserting a new one.
|
||||
*
|
||||
* Throws when a live account already owns the address. Returns the
|
||||
* unconfirmed, password-less pseudo row when one exists (admin
|
||||
* pre-provisioning — signup claims it), or null when the address is free.
|
||||
*
|
||||
* Called twice per signup: once early, to fail fast before the validate
|
||||
* hook and bcrypt, and once against the primary immediately before the
|
||||
* write.
|
||||
*/
|
||||
async #resolveSignupEmailClaim(
|
||||
email: string,
|
||||
opts: { force?: boolean } = {},
|
||||
): Promise<UserRow | null> {
|
||||
const existing = await this.stores.user.findEmailOwner(email, opts);
|
||||
if (!existing) return null;
|
||||
if (existing.email_confirmed || existing.password !== null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'This email already exists in our database. Please use another one.',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-blocklist + extension-driven email validation. Config blocklist
|
||||
* (suffix match on cleaned email) blocks first; then the `email.validate`
|
||||
|
||||
@@ -661,6 +661,7 @@ if (window.opener) {
|
||||
provider: string,
|
||||
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
|
||||
referrer?: string | null,
|
||||
attempt = 0,
|
||||
): Promise<
|
||||
| { error: string; code?: string; requestCode?: string }
|
||||
| {
|
||||
@@ -679,8 +680,12 @@ if (window.opener) {
|
||||
const claimedEmail =
|
||||
typeof userinfo.email === 'string' ? userinfo.email : null;
|
||||
if (claimedEmail) {
|
||||
const byEmail =
|
||||
await this.services.oidc.findUserByEmail(claimedEmail);
|
||||
// On the retry after a lost race, read the primary — the winning
|
||||
// row may be younger than the replica snapshot.
|
||||
const byEmail = await this.services.oidc.findUserByEmail(
|
||||
claimedEmail,
|
||||
{ force: attempt > 0 },
|
||||
);
|
||||
if (byEmail) {
|
||||
if (!byEmail.email_confirmed) {
|
||||
return {
|
||||
@@ -707,6 +712,18 @@ if (window.opener) {
|
||||
userinfo as { sub: string; email?: string },
|
||||
referrer,
|
||||
);
|
||||
// A concurrent callback (a second tab, a provider retry) created the
|
||||
// account between step 2 and the insert. Nothing went wrong for the
|
||||
// user — start over and we'll find the winner at step 1 or 2. One retry
|
||||
// only: a second miss means something other than a race is going on.
|
||||
if (outcome.raced && attempt === 0) {
|
||||
return this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
referrer,
|
||||
attempt + 1,
|
||||
);
|
||||
}
|
||||
if (!outcome.success || !outcome.user) {
|
||||
return {
|
||||
error: outcome.error ?? 'Account creation failed.',
|
||||
|
||||
@@ -280,15 +280,19 @@ export class StaticPagesController extends PuterController {
|
||||
(user.clean_email as string | null | undefined) ??
|
||||
String(user.email ?? '').toLowerCase();
|
||||
|
||||
const [dupe] = (await this.clients.db.read(
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM \`user\` WHERE (\`email\` = ? OR \`clean_email\` = ?)
|
||||
AND \`email_confirmed\` = ${this.clients.db.booleanLiteral(true)}
|
||||
AND \`password\` IS NOT NULL
|
||||
) AS email_exists`,
|
||||
[user.email, cleanEmail],
|
||||
)) as Array<{ email_exists: number }>;
|
||||
if (dupe?.email_exists) {
|
||||
// An account that already confirmed this address proved access
|
||||
// to the inbox. The strip below would take it away from them,
|
||||
// so refuse here instead. Password-less accounts count: an
|
||||
// identity provider verified the address for those, and they
|
||||
// are exactly what the old `password IS NOT NULL` clause let
|
||||
// through.
|
||||
const confirmedRival =
|
||||
await this.stores.user.findConfirmedOtherByEmail(
|
||||
user.id as number,
|
||||
user.email as string,
|
||||
cleanEmail,
|
||||
);
|
||||
if (confirmedRival) {
|
||||
res.send(
|
||||
err('This email was confirmed on a different account.'),
|
||||
);
|
||||
@@ -302,6 +306,15 @@ export class StaticPagesController extends PuterController {
|
||||
[user.email],
|
||||
);
|
||||
|
||||
// Take the address off every remaining row before confirming
|
||||
// this one. The check above leaves only unconfirmed rows, and
|
||||
// only one row may own an address once this one is confirmed.
|
||||
await this.stores.user.unconfirmOthersByEmail(
|
||||
user.id,
|
||||
user.email as string,
|
||||
cleanEmail,
|
||||
);
|
||||
|
||||
await this.stores.user.update(user.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
|
||||
@@ -300,6 +300,110 @@ describe('OIDCService.createUserFromOIDC', () => {
|
||||
oidcConfig.disable_user_signup = prev;
|
||||
}
|
||||
});
|
||||
|
||||
// Two callbacks for the same brand-new identity — a second tab, a provider
|
||||
// retry — used to each create an account and each link the same sub, since
|
||||
// neither the address nor the sub was constrained. Later sign-ins then
|
||||
// resolved to whichever row came back first.
|
||||
it('creates exactly one account for two simultaneous callbacks', async () => {
|
||||
const email = `race-${crypto.randomBytes(4).toString('hex')}@corp.example`;
|
||||
const sub = `race-sub-${crypto.randomBytes(4).toString('hex')}`;
|
||||
|
||||
const results = await Promise.all([
|
||||
runWithContext({ req }, () =>
|
||||
oidc().createUserFromOIDC('custom-idp', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
),
|
||||
runWithContext({ req }, () =>
|
||||
oidc().createUserFromOIDC('custom-idp', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(results.filter((r) => r.success)).toHaveLength(1);
|
||||
// The loser reports a race, not an error — the caller re-resolves onto
|
||||
// the winner rather than showing the user a failure.
|
||||
const loser = results.find((r) => !r.success)!;
|
||||
expect(loser.raced).toBe(true);
|
||||
expect(loser.error).toBeUndefined();
|
||||
|
||||
const owners = (await server.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `user` WHERE `email` = ?',
|
||||
[email],
|
||||
)) as Array<{ n: number }>;
|
||||
expect(Number(owners[0].n)).toBe(1);
|
||||
|
||||
// And exactly one link, so getByProviderSub cannot flip between
|
||||
// accounts on subsequent sign-ins.
|
||||
const links = (await server.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `user_oidc_providers` WHERE `provider_sub` = ?',
|
||||
[sub],
|
||||
)) as Array<{ n: number }>;
|
||||
expect(Number(links[0].n)).toBe(1);
|
||||
});
|
||||
|
||||
it('reports a race rather than a failure when the address is already taken', async () => {
|
||||
const email = `taken-${crypto.randomBytes(4).toString('hex')}@corp.example`;
|
||||
await server.stores.user.create({
|
||||
username: `taken-${crypto.randomBytes(4).toString('hex')}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: 'hashed',
|
||||
email,
|
||||
clean_email: email,
|
||||
});
|
||||
|
||||
const result = await runWithContext({ req }, () =>
|
||||
oidc().createUserFromOIDC('custom-idp', {
|
||||
sub: `taken-sub-${crypto.randomBytes(4).toString('hex')}`,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.raced).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves no orphan account behind when the identity was linked first', async () => {
|
||||
// The sub is already bound to another account, so `link()` throws after
|
||||
// this call has created its own user. That account can never be signed
|
||||
// in to, so it must not survive.
|
||||
const sub = `orphan-sub-${crypto.randomBytes(4).toString('hex')}`;
|
||||
const incumbent = await server.stores.user.create({
|
||||
username: `incumbent-${crypto.randomBytes(4).toString('hex')}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: null,
|
||||
email: `incumbent-${crypto.randomBytes(4).toString('hex')}@corp.example`,
|
||||
});
|
||||
await server.stores.oidc.link(incumbent.id, 'custom-idp', sub, null);
|
||||
|
||||
const email = `orphan-${crypto.randomBytes(4).toString('hex')}@corp.example`;
|
||||
const before = (await server.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `user`',
|
||||
)) as Array<{ n: number }>;
|
||||
|
||||
const result = await runWithContext({ req }, () =>
|
||||
oidc().createUserFromOIDC('custom-idp', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.raced).toBe(true);
|
||||
const after = (await server.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `user`',
|
||||
)) as Array<{ n: number }>;
|
||||
expect(Number(after[0].n)).toBe(Number(before[0].n));
|
||||
expect(await server.stores.user.getByEmail(email)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDCService.linkProviderToUser', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import type { LayerInstances } from '../../types';
|
||||
import type { puterServices } from '../index';
|
||||
import type { UserRow } from '../../stores/user/UserStore';
|
||||
import { isOwnedEmailConflict } from '../../stores/user/UserStore.js';
|
||||
import { PuterService } from '../types';
|
||||
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
@@ -399,11 +400,11 @@ export class OIDCService extends PuterService {
|
||||
* signed up as `foobar@gmail.com`. Primary email is preferred over a
|
||||
* clean_email collision.
|
||||
*/
|
||||
async findUserByEmail(email: string): Promise<UserRow | null> {
|
||||
if (!email) return null;
|
||||
const direct = await this.stores.user.getByEmail(email);
|
||||
if (direct) return direct;
|
||||
return this.stores.user.getByCleanEmail(cleanEmail(email));
|
||||
async findUserByEmail(
|
||||
email: string,
|
||||
opts: { force?: boolean } = {},
|
||||
): Promise<UserRow | null> {
|
||||
return this.stores.user.findEmailOwner(email, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,6 +452,10 @@ export class OIDCService extends PuterService {
|
||||
/**
|
||||
* Create a new Puter user from OIDC claims and link the provider. Returns
|
||||
* `{ success, user, error? }`.
|
||||
*
|
||||
* `raced` means a concurrent callback got there first and the caller should
|
||||
* re-resolve rather than surface an error — see
|
||||
* `#resolveOrCreateOIDCUser`.
|
||||
*/
|
||||
async createUserFromOIDC(
|
||||
providerId: string,
|
||||
@@ -461,6 +466,7 @@ export class OIDCService extends PuterService {
|
||||
user?: UserRow;
|
||||
error?: string;
|
||||
code?: string;
|
||||
raced?: boolean;
|
||||
/**
|
||||
* Support-correlation id for a vetoed signup (the abuse trail id). Safe
|
||||
* to show the user; the veto reason in `error` is not.
|
||||
@@ -600,45 +606,61 @@ export class OIDCService extends PuterService {
|
||||
Boolean(validateEvent.requires_card_verification) ||
|
||||
Boolean(cfg.always_require_card_verification);
|
||||
|
||||
const created = await this.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
clean_email: cleanEmail(email),
|
||||
free_storage: this.config.storage_capacity ?? null,
|
||||
// Email is provider-verified, so the email step is always skipped;
|
||||
// the phone/card gates still apply when the harness flagged them.
|
||||
requires_email_confirmation: false,
|
||||
requires_phone_verification: force_phone_verification,
|
||||
requires_card_verification: force_card_verification,
|
||||
...(validateEvent.reputation != null
|
||||
? { reputation: validateEvent.reputation }
|
||||
: {}),
|
||||
audit_metadata: {
|
||||
ip: clientIp,
|
||||
ip_fwd: proxyIpChain,
|
||||
user_agent: req?.headers?.['user-agent'],
|
||||
origin: req?.headers?.origin,
|
||||
},
|
||||
signup_ip: clientIp,
|
||||
signup_ip_forwarded: proxyIpChain,
|
||||
signup_user_agent: req?.headers?.['user-agent'] ?? null,
|
||||
signup_origin: req?.headers?.origin,
|
||||
signup_server: this.config.serverId,
|
||||
referrer: referrer ?? null,
|
||||
});
|
||||
// The caller checked this email was free before we got here, but the
|
||||
// validate hook and the blocklist checks above sit in between — long
|
||||
// enough for a second callback (another tab, a provider retry) to have
|
||||
// created the account. Re-check against the primary, and let the unique
|
||||
// index catch anything still in flight.
|
||||
if (await this.stores.user.findEmailOwner(email, { force: true })) {
|
||||
return { success: false, raced: true };
|
||||
}
|
||||
|
||||
let created: UserRow;
|
||||
try {
|
||||
created = await this.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
clean_email: cleanEmail(email),
|
||||
free_storage: this.config.storage_capacity ?? null,
|
||||
// Email is provider-verified, so the email step is always
|
||||
// skipped; the phone/card gates still apply when the harness
|
||||
// flagged them.
|
||||
requires_email_confirmation: false,
|
||||
// Confirmed in the INSERT rather than a follow-up update: an
|
||||
// unconfirmed, password-less row does not own its address, so
|
||||
// deferring this would let two concurrent callbacks both insert
|
||||
// and only collide when they confirm — too late to report as a
|
||||
// race.
|
||||
email_confirmed: true,
|
||||
requires_phone_verification: force_phone_verification,
|
||||
requires_card_verification: force_card_verification,
|
||||
...(validateEvent.reputation != null
|
||||
? { reputation: validateEvent.reputation }
|
||||
: {}),
|
||||
audit_metadata: {
|
||||
ip: clientIp,
|
||||
ip_fwd: proxyIpChain,
|
||||
user_agent: req?.headers?.['user-agent'],
|
||||
origin: req?.headers?.origin,
|
||||
},
|
||||
signup_ip: clientIp,
|
||||
signup_ip_forwarded: proxyIpChain,
|
||||
signup_user_agent: req?.headers?.['user-agent'] ?? null,
|
||||
signup_origin: req?.headers?.origin,
|
||||
signup_server: this.config.serverId,
|
||||
referrer: referrer ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isOwnedEmailConflict(e)) throw e;
|
||||
return { success: false, raced: true };
|
||||
}
|
||||
|
||||
if (!created) {
|
||||
return { success: false, error: 'User creation failed.' };
|
||||
}
|
||||
|
||||
// Mark email as confirmed (OIDC provider already verified it).
|
||||
await this.stores.user.update(created.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
});
|
||||
|
||||
// Default user group — OIDC users skip the temp group entirely since
|
||||
// the email is already verified by the IdP.
|
||||
const defaultGroup = this.config.default_user_group;
|
||||
@@ -665,7 +687,30 @@ export class OIDCService extends PuterService {
|
||||
|
||||
// Link OIDC provider (after provisioning so a failed link doesn't
|
||||
// leave an orphaned user without a home folder).
|
||||
await this.stores.oidc.link(created.id, providerId, claims.sub, null);
|
||||
//
|
||||
// A 409 here means a concurrent callback for the same identity bound the
|
||||
// sub to its own new account while we were provisioning. That leaves us
|
||||
// holding an account nobody can ever sign in to, so tear it down and let
|
||||
// the caller re-resolve onto the winner.
|
||||
try {
|
||||
await this.stores.oidc.link(
|
||||
created.id,
|
||||
providerId,
|
||||
claims.sub,
|
||||
null,
|
||||
);
|
||||
} catch (e) {
|
||||
if ((e as { statusCode?: number })?.statusCode !== 409) throw e;
|
||||
try {
|
||||
await this.services.userAccount.cascadeDelete(created.id);
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
'[oidc] failed to clean up raced account:',
|
||||
cleanupError,
|
||||
);
|
||||
}
|
||||
return { success: false, raced: true };
|
||||
}
|
||||
|
||||
// Re-read so callers see email_confirmed / *_uuid / *_id fields
|
||||
// written above.
|
||||
|
||||
@@ -39,6 +39,7 @@ import { DefaultUserService } from './selfhosted/DefaultUserService';
|
||||
import { SocketService } from './socket/SocketService';
|
||||
import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService';
|
||||
import type { IPuterServiceRegistry } from './types';
|
||||
import { UserAccountService } from './user/UserAccountService';
|
||||
|
||||
/**
|
||||
* Populate `IPuterServiceInstances` (declared in `./types`) with the concrete
|
||||
@@ -69,6 +70,7 @@ declare module './types' {
|
||||
defaultUser: DefaultUserService;
|
||||
homepage: PuterHomepageService;
|
||||
health: ServerHealthService;
|
||||
userAccount: UserAccountService;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +93,9 @@ export const puterServices = {
|
||||
token: TokenService,
|
||||
auth: AuthService,
|
||||
fs: FSService,
|
||||
// Declared after `fs` — account teardown tears the user's filesystem down
|
||||
// first.
|
||||
userAccount: UserAccountService,
|
||||
// AppPermissionService + SubdomainPermissionService register permission
|
||||
// rewriters/implicators only; no runtime state. Placed after fsEntry so
|
||||
// the FS rewriter runs first for `fs:/path` → `fs:<uuid>` before any
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* 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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.ts';
|
||||
|
||||
describe('UserAccountService', () => {
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async (overrides: Record<string, unknown> = {}) => {
|
||||
const slug = Math.random().toString(36).slice(2, 10);
|
||||
return server.stores.user.create({
|
||||
username: `ua_${slug}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashed',
|
||||
email: `ua-${slug}@test.local`,
|
||||
clean_email: `ua-${slug}@test.local`,
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
describe('getUsageSignals', () => {
|
||||
it('reports a freshly created account as unused', async () => {
|
||||
const user = await seedUser();
|
||||
const usage = await server.services.userAccount.getUsageSignals(
|
||||
user.id,
|
||||
);
|
||||
expect(usage.inUse).toBe(false);
|
||||
expect(usage.signals).toEqual([]);
|
||||
});
|
||||
|
||||
it('still reads as unused with only the folders signup provisions', async () => {
|
||||
// The whole delete decision hinges on this: a provisioned account
|
||||
// nobody ever opened must not look like somebody's files.
|
||||
const user = await seedUser();
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
user,
|
||||
);
|
||||
const usage = await server.services.userAccount.getUsageSignals(
|
||||
user.id,
|
||||
);
|
||||
expect(usage.signals).not.toContain('files');
|
||||
expect(usage.inUse).toBe(false);
|
||||
});
|
||||
|
||||
it('reports files once anything beyond the provisioned set exists', async () => {
|
||||
const user = await seedUser();
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
user,
|
||||
);
|
||||
const fresh = (await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `parent_uid`, `user_id`, `name`, `is_dir`, `created`, `modified`) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uuidv4(),
|
||||
fresh.desktop_uuid,
|
||||
user.id,
|
||||
'notes.txt',
|
||||
0,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
const usage = await server.services.userAccount.getUsageSignals(
|
||||
user.id,
|
||||
);
|
||||
expect(usage.signals).toContain('files');
|
||||
expect(usage.inUse).toBe(true);
|
||||
});
|
||||
|
||||
// `stripe_customer_id` is deliberately absent: it is a prod-only column
|
||||
// the self-hosted schema does not carry, which is exactly why the
|
||||
// service reads the row through the store instead of naming columns.
|
||||
it.each([
|
||||
['card_fingerprint', 'fp_123', 'card-verified'],
|
||||
['phone', '+14155550100', 'phone-verified'],
|
||||
])('reports %s as %s', async (column, value, signal) => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.update(user.id, { [column]: value });
|
||||
const usage = await server.services.userAccount.getUsageSignals(
|
||||
user.id,
|
||||
);
|
||||
expect(usage.signals).toContain(signal);
|
||||
expect(usage.inUse).toBe(true);
|
||||
});
|
||||
|
||||
it('reports an external identity link', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.oidc.link(
|
||||
user.id,
|
||||
'custom-idp',
|
||||
`sub-${uuidv4()}`,
|
||||
null,
|
||||
);
|
||||
const usage = await server.services.userAccount.getUsageSignals(
|
||||
user.id,
|
||||
);
|
||||
expect(usage.signals).toContain('oidc-link');
|
||||
});
|
||||
|
||||
it('errs toward in-use when a signal query fails', async () => {
|
||||
// A signal we cannot read is not a signal that is absent. Failing
|
||||
// open here would mean an unreadable table makes accounts look
|
||||
// deletable, and the collapse job deletes what looks unused.
|
||||
const user = await seedUser();
|
||||
const original = server.clients.db.read.bind(server.clients.db);
|
||||
const read = vi
|
||||
.spyOn(server.clients.db, 'read')
|
||||
.mockImplementation(async (sql: string, params?: unknown[]) => {
|
||||
// Only the capped-count probes fail; the row read still
|
||||
// works, which is the realistic "one table is unhappy" case.
|
||||
if (sql.includes('FROM (SELECT 1 FROM')) {
|
||||
throw new Error('table gone');
|
||||
}
|
||||
return original(sql, params);
|
||||
});
|
||||
try {
|
||||
const usage = await server.services.userAccount.getUsageSignals(
|
||||
user.id,
|
||||
);
|
||||
expect(usage.inUse).toBe(true);
|
||||
expect(usage.signals).toContain('sessions');
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cascadeDelete', () => {
|
||||
it('removes the row, its sessions and its cache entries', async () => {
|
||||
const user = await seedUser();
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `sessions` (`uuid`, `user_id`, `created_at`, `last_activity`) VALUES (?, ?, ?, ?)',
|
||||
[uuidv4(), user.id, Date.now(), Date.now()],
|
||||
);
|
||||
// Warm the address-keyed cache entry so a stale hit would show up.
|
||||
expect(
|
||||
(await server.stores.user.getByEmail(user.email as string))?.id,
|
||||
).toBe(user.id);
|
||||
|
||||
await server.services.userAccount.cascadeDelete(user.id);
|
||||
|
||||
expect(await server.stores.user.getById(user.id)).toBeNull();
|
||||
expect(
|
||||
await server.stores.user.getByEmail(user.email as string),
|
||||
).toBeNull();
|
||||
const sessions = (await server.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ?',
|
||||
[user.id],
|
||||
)) as Array<{ n: number }>;
|
||||
expect(Number(sessions[0].n)).toBe(0);
|
||||
});
|
||||
|
||||
it('frees the address for a new account', async () => {
|
||||
const user = await seedUser();
|
||||
const email = user.email as string;
|
||||
await server.services.userAccount.cascadeDelete(user.id);
|
||||
|
||||
// The unique index would reject this if the row survived.
|
||||
await expect(
|
||||
server.stores.user.create({
|
||||
username: `ua_reuse_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashed',
|
||||
email,
|
||||
clean_email: email,
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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 { PuterService } from '../types.js';
|
||||
|
||||
/**
|
||||
* Home directory plus the seven folders `generateDefaultFsentries` creates. An
|
||||
* account with no more than these has never had a file put in it.
|
||||
*/
|
||||
const PROVISIONED_FSENTRY_COUNT = 8;
|
||||
|
||||
/**
|
||||
* Account-lifecycle operations that more than one caller needs: deleting an
|
||||
* account and everything hanging off it, and measuring whether an account has
|
||||
* ever actually been used.
|
||||
*
|
||||
* Most `user_id` foreign keys are `ON DELETE SET NULL` rather than `CASCADE`,
|
||||
* so "delete the row" is never the whole job — anything reaching for that
|
||||
* shortcut leaves orphans behind. Go through `cascadeDelete`.
|
||||
*/
|
||||
export class UserAccountService extends PuterService {
|
||||
/**
|
||||
* Delete a user and the state that belongs to them: their files (S3 objects
|
||||
* included), their sessions, and the row itself.
|
||||
*
|
||||
* Irreversible. Filesystem teardown failures are logged and stepped over —
|
||||
* an orphaned fsentry is a smaller problem than an account that half
|
||||
* survives its own deletion.
|
||||
*/
|
||||
async cascadeDelete(userId: number): Promise<void> {
|
||||
// Capture the identifiers downstream teardown needs before the row is
|
||||
// gone — the marketplace extension cancels the user's Stripe
|
||||
// subscriptions off `user.delete`, keyed by uuid / customer id.
|
||||
let userUuid: string | undefined;
|
||||
let stripeCustomerId: string | null = null;
|
||||
try {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT `uuid`, `stripe_customer_id` FROM `user` WHERE `id` = ?',
|
||||
[userId],
|
||||
)) as Array<{ uuid?: string; stripe_customer_id?: string | null }>;
|
||||
userUuid = rows[0]?.uuid;
|
||||
stripeCustomerId = rows[0]?.stripe_customer_id ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[cascade-delete-user] identifier lookup failed:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.services.fs.removeAllForUser(userId);
|
||||
} catch (e) {
|
||||
// Proceed with user-row delete anyway — orphaned fsentries are
|
||||
// better than a resurrected account.
|
||||
console.warn('[cascade-delete-user] fs cleanup failed:', e);
|
||||
}
|
||||
|
||||
// Sessions FK is SET NULL, so delete explicitly to avoid dangling rows.
|
||||
await this.clients.db.write(
|
||||
'DELETE FROM `sessions` WHERE `user_id` = ?',
|
||||
[userId],
|
||||
);
|
||||
await this.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
|
||||
userId,
|
||||
]);
|
||||
await this.stores.user.invalidateById(userId);
|
||||
|
||||
// Fire-and-forget: let listeners purge external state tied to the
|
||||
// account (Stripe subscriptions are cancelled immediately, without
|
||||
// proration). Emitted after the row delete — listeners key off the
|
||||
// payload, not the DB row.
|
||||
try {
|
||||
this.clients.event?.emit(
|
||||
'user.delete',
|
||||
{
|
||||
user_id: userId,
|
||||
user_uuid: userUuid,
|
||||
stripe_customer_id: stripeCustomerId,
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// ignore — event emission shouldn't block deletion
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evidence that an account has been used for something — the signals that
|
||||
* separate "a row a race created and nobody ever touched" from "somebody's
|
||||
* account".
|
||||
*
|
||||
* Deliberately generous about what counts. A false "in use" costs a row
|
||||
* that sticks around; a false "unused" destroys somebody's files.
|
||||
*
|
||||
* `fsentryCount` is compared against the folders provisioned at signup, so
|
||||
* an account whose Desktop is still empty reads as untouched.
|
||||
*/
|
||||
async getUsageSignals(userId: number): Promise<{
|
||||
userId: number;
|
||||
signals: string[];
|
||||
inUse: boolean;
|
||||
lastActivityTs: string | null;
|
||||
}> {
|
||||
// Each of these is a capped count, not a real one: the subquery stops at
|
||||
// `cap` rows, so a user with a million fsentries costs the same as one
|
||||
// with nine. We only ever compare against a small threshold.
|
||||
const cappedCount = async (
|
||||
table: string,
|
||||
column: string,
|
||||
cap: number,
|
||||
): Promise<number> => {
|
||||
const sql =
|
||||
`SELECT COUNT(*) AS n FROM ` +
|
||||
`(SELECT 1 FROM \`${table}\` WHERE \`${column}\` = ? LIMIT ${cap}) t`;
|
||||
try {
|
||||
const rows = (await this.clients.db.read(sql, [
|
||||
userId,
|
||||
])) as Array<Record<string, unknown>>;
|
||||
return Number(rows[0]?.n ?? 0);
|
||||
} catch (e) {
|
||||
// A signal we cannot read is not a signal that is absent —
|
||||
// report it as present so the caller errs toward keeping.
|
||||
console.warn('[user-usage] signal query failed:', sql, e);
|
||||
return cap;
|
||||
}
|
||||
};
|
||||
|
||||
// Through the store rather than a hand-written column list: several of
|
||||
// the columns read below (`stripe_customer_id`, `card_fingerprint`) are
|
||||
// prod-only additions that a self-hosted schema may not carry, and
|
||||
// naming them in SQL turns their absence into a thrown query instead of
|
||||
// an absent signal.
|
||||
const [
|
||||
row,
|
||||
sessionCount,
|
||||
appCount,
|
||||
subdomainCount,
|
||||
oidcCount,
|
||||
fsentryCount,
|
||||
] = await Promise.all([
|
||||
this.stores.user.getById(userId, { force: true }),
|
||||
cappedCount('sessions', 'user_id', 1),
|
||||
cappedCount('apps', 'owner_user_id', 1),
|
||||
cappedCount('subdomains', 'user_id', 1),
|
||||
cappedCount('user_oidc_providers', 'user_id', 1),
|
||||
cappedCount('fsentries', 'user_id', PROVISIONED_FSENTRY_COUNT + 1),
|
||||
]);
|
||||
|
||||
const signals: string[] = [];
|
||||
if (sessionCount > 0) signals.push('sessions');
|
||||
if (appCount > 0) signals.push('apps');
|
||||
if (subdomainCount > 0) signals.push('subdomains');
|
||||
if (oidcCount > 0) signals.push('oidc-link');
|
||||
if (fsentryCount > PROVISIONED_FSENTRY_COUNT) signals.push('files');
|
||||
if (row?.stripe_customer_id) signals.push('stripe-customer');
|
||||
if (row?.card_fingerprint) signals.push('card-verified');
|
||||
if (row?.phone) signals.push('phone-verified');
|
||||
if (row?.last_activity_ts) signals.push('activity');
|
||||
|
||||
return {
|
||||
userId,
|
||||
signals,
|
||||
inUse: signals.length > 0,
|
||||
lastActivityTs: (row?.last_activity_ts as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,11 @@ export class OIDCStore extends PuterStore {
|
||||
// -- Reads --------------------------------------------------------
|
||||
|
||||
async getByProviderSub(provider, providerSub) {
|
||||
// Ordered so a sub that predates the UNIQUE index (two callbacks for the
|
||||
// same new identity used to be able to both insert) always resolves to
|
||||
// the same link, instead of bouncing the user between two accounts.
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT * FROM `user_oidc_providers` WHERE `provider` = ? AND `provider_sub` = ? LIMIT 1',
|
||||
'SELECT * FROM `user_oidc_providers` WHERE `provider` = ? AND `provider_sub` = ? ORDER BY `id` ASC LIMIT 1',
|
||||
[provider, providerSub],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
|
||||
@@ -21,6 +21,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
import { cleanEmail } from '../../util/email.ts';
|
||||
import { isOwnedEmailConflict } from './UserStore.ts';
|
||||
|
||||
describe('UserStore', () => {
|
||||
let server: PuterServer;
|
||||
@@ -109,19 +111,17 @@ describe('UserStore', () => {
|
||||
const shared = `shared-${Math.random().toString(36).slice(2, 10)}@test.local`;
|
||||
const makeClaimant = async () => {
|
||||
const username = `uc-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const claimant = await server.stores.user.create({
|
||||
return server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: shared,
|
||||
clean_email: shared,
|
||||
});
|
||||
await server.stores.user.update(claimant.id, {
|
||||
email_confirmed: true,
|
||||
});
|
||||
return claimant;
|
||||
};
|
||||
|
||||
// Both start as unconfirmed placeholders — the only shape in which two
|
||||
// rows may hold one address. The winner then confirms it.
|
||||
const loser = await makeClaimant();
|
||||
const winner = await makeClaimant();
|
||||
|
||||
@@ -135,6 +135,7 @@ describe('UserStore', () => {
|
||||
shared,
|
||||
shared,
|
||||
);
|
||||
await server.stores.user.update(winner.id, { email_confirmed: true });
|
||||
|
||||
const strippedLoser = await server.stores.user.getById(loser.id);
|
||||
expect(strippedLoser?.email).toBeNull();
|
||||
@@ -144,6 +145,217 @@ describe('UserStore', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a second account owning the same address', async () => {
|
||||
const shared = `owned-${Math.random().toString(36).slice(2, 10)}@test.local`;
|
||||
const makeOwner = (password: string | null) =>
|
||||
server.stores.user.create({
|
||||
username: `uo-${Math.random().toString(36).slice(2, 10)}`,
|
||||
uuid: uuidv4(),
|
||||
password,
|
||||
email: shared,
|
||||
clean_email: shared,
|
||||
});
|
||||
|
||||
await makeOwner('hashed');
|
||||
|
||||
// A placeholder may still share the address...
|
||||
await expect(makeOwner(null)).resolves.toBeTruthy();
|
||||
|
||||
// ...but a second row that could drive password recovery for that
|
||||
// inbox may not, however the application-level guards behaved.
|
||||
await expect(makeOwner('hashed')).rejects.toThrow(
|
||||
/idx_user_owned_email/,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a shared address to the row that owns it', async () => {
|
||||
const shared = `pref-${Math.random().toString(36).slice(2, 10)}@test.local`;
|
||||
const placeholder = await server.stores.user.create({
|
||||
username: `up-${Math.random().toString(36).slice(2, 10)}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: shared,
|
||||
clean_email: shared,
|
||||
});
|
||||
const owner = await server.stores.user.create({
|
||||
username: `uw-${Math.random().toString(36).slice(2, 10)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashed',
|
||||
email: shared,
|
||||
clean_email: shared,
|
||||
});
|
||||
|
||||
// The placeholder has the lower id, so an unordered LIMIT 1 would be
|
||||
// free to return it — and login and password recovery both resolve
|
||||
// through here.
|
||||
expect(placeholder.id).toBeLessThan(owner.id);
|
||||
expect(
|
||||
(await server.stores.user.getByEmail(shared, { cached: false }))
|
||||
?.id,
|
||||
).toBe(owner.id);
|
||||
expect((await server.stores.user.findEmailOwner(shared))?.id).toBe(
|
||||
owner.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a placeholder from shadowing the owner in the cache', async () => {
|
||||
const shared = `cached-${Math.random().toString(36).slice(2, 10)}@test.local`;
|
||||
const owner = await server.stores.user.create({
|
||||
username: `uk-${Math.random().toString(36).slice(2, 10)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashed',
|
||||
email: shared,
|
||||
clean_email: shared,
|
||||
});
|
||||
// Warm the address-keyed entry, so the placeholder below has something
|
||||
// to overwrite.
|
||||
expect((await server.stores.user.getByEmail(shared))?.id).toBe(
|
||||
owner.id,
|
||||
);
|
||||
|
||||
await server.stores.user.create({
|
||||
username: `ul-${Math.random().toString(36).slice(2, 10)}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: shared,
|
||||
clean_email: shared,
|
||||
});
|
||||
|
||||
// Cached lookups are how login and password recovery resolve an
|
||||
// address, and ordering the SQL does nothing for them — the placeholder
|
||||
// must never be written under the address in the first place.
|
||||
expect((await server.stores.user.getByEmail(shared))?.id).toBe(
|
||||
owner.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets only one of two concurrent signups claim a placeholder row', async () => {
|
||||
const email = `claim-${Math.random().toString(36).slice(2, 10)}@test.local`;
|
||||
const placeholder = await server.stores.user.create({
|
||||
username: `uq-${Math.random().toString(36).slice(2, 10)}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
clean_email: email,
|
||||
});
|
||||
|
||||
// The address never changes hands here — the row already holds it — so
|
||||
// the unique index has nothing to catch. Only the guard stops the
|
||||
// second write from overwriting the first.
|
||||
const claim = (username: string) =>
|
||||
server.stores.user.claimPlaceholder(placeholder.id, {
|
||||
username,
|
||||
password: `hash-${username}`,
|
||||
uuid: uuidv4(),
|
||||
email_confirmed: 0,
|
||||
});
|
||||
|
||||
const first = await claim('winner');
|
||||
const second = await claim('loser');
|
||||
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(false);
|
||||
const fresh = await server.stores.user.getById(placeholder.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(fresh?.username).toBe('winner');
|
||||
expect(fresh?.password).toBe('hash-winner');
|
||||
});
|
||||
|
||||
it('retires the placeholder username once the claim converts the row', async () => {
|
||||
const email = `retire-${Math.random().toString(36).slice(2, 10)}@test.local`;
|
||||
const oldName = `uo-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const newName = `un-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const placeholder = await server.stores.user.create({
|
||||
username: oldName,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
clean_email: email,
|
||||
});
|
||||
// Warm the username-keyed cache entry so a stale hit would surface.
|
||||
expect((await server.stores.user.getByUsername(oldName))?.id).toBe(
|
||||
placeholder.id,
|
||||
);
|
||||
|
||||
expect(
|
||||
await server.stores.user.claimPlaceholder(placeholder.id, {
|
||||
username: newName,
|
||||
password: 'hashed',
|
||||
uuid: uuidv4(),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(await server.stores.user.getByUsername(oldName)).toBeNull();
|
||||
expect((await server.stores.user.getByUsername(newName))?.id).toBe(
|
||||
placeholder.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('tells an address conflict apart from other unique violations', async () => {
|
||||
const username = `udup-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashed',
|
||||
email: `${username}@test.local`,
|
||||
clean_email: `${username}@test.local`,
|
||||
});
|
||||
|
||||
// A duplicate username raises the same unique-violation code. Reading
|
||||
// it as an address conflict would report the wrong error to the user.
|
||||
let usernameError: unknown;
|
||||
try {
|
||||
await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashed',
|
||||
email: `other-${username}@test.local`,
|
||||
clean_email: `other-${username}@test.local`,
|
||||
});
|
||||
} catch (e) {
|
||||
usernameError = e;
|
||||
}
|
||||
expect(usernameError).toBeTruthy();
|
||||
expect(isOwnedEmailConflict(usernameError)).toBe(false);
|
||||
|
||||
let emailError: unknown;
|
||||
try {
|
||||
await server.stores.user.create({
|
||||
username: `${username}-2`,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashed',
|
||||
email: `${username}@test.local`,
|
||||
clean_email: `${username}@test.local`,
|
||||
});
|
||||
} catch (e) {
|
||||
emailError = e;
|
||||
}
|
||||
expect(isOwnedEmailConflict(emailError)).toBe(true);
|
||||
});
|
||||
|
||||
it('finds an address holder through its canonical form', async () => {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
const stored = `first.last.${suffix}+tag@gmail.com`;
|
||||
const owner = await server.stores.user.create({
|
||||
username: `uc-${suffix}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashed',
|
||||
email: stored,
|
||||
clean_email: cleanEmail(stored),
|
||||
});
|
||||
|
||||
// A gmail alias of a stored address has to resolve to the same account,
|
||||
// or signup would happily mint a second one for the same inbox.
|
||||
expect(
|
||||
(
|
||||
await server.stores.user.findEmailOwner(
|
||||
`firstlast${suffix}@gmail.com`,
|
||||
)
|
||||
)?.id,
|
||||
).toBe(owner.id);
|
||||
});
|
||||
|
||||
it('counts other accounts holding the same phone number', async () => {
|
||||
const phone = `+1415555${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
const makeUser = async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { cleanEmail } from '../../util/email.js';
|
||||
import { PuterStore } from '../types';
|
||||
|
||||
// -- Types ------------------------------------------------------------
|
||||
@@ -72,6 +73,36 @@ export type UserIdProperty = (typeof USER_ID_PROPERTIES)[number];
|
||||
|
||||
const CACHE_KEY_PREFIX = 'users';
|
||||
const CACHE_TTL_SECONDS = 15 * 60;
|
||||
// Tie-break for address lookups that can match more than one row. An address is
|
||||
// "owned" by a confirmed account, or failing that by one holding a password;
|
||||
// everything else is an unconfirmed placeholder that anyone may still claim.
|
||||
// Ordering by that precedence (then by age) makes every guard, login and
|
||||
// recovery resolve the same address to the same row.
|
||||
const EMAIL_OWNER_ORDER =
|
||||
'ORDER BY `email_confirmed` DESC, (`password` IS NOT NULL) DESC, `id` ASC';
|
||||
|
||||
/**
|
||||
* Unique index backing "at most one row owns an address". Application checks
|
||||
* race — two signups can both read "address is free" and both insert — so this
|
||||
* is the only thing that actually holds the invariant. Writes that can lose
|
||||
* that race have to recognise the violation and turn it into the same message
|
||||
* the pre-check would have produced.
|
||||
*/
|
||||
export const OWNED_EMAIL_INDEX = 'idx_user_owned_email';
|
||||
|
||||
export const isOwnedEmailConflict = (e: unknown): boolean => {
|
||||
const err = e as { code?: string; message?: string } | null;
|
||||
if (!err) return false;
|
||||
const isUnique =
|
||||
err.code === 'ER_DUP_ENTRY' ||
|
||||
err.code === '23505' ||
|
||||
(typeof err.code === 'string' &&
|
||||
err.code.startsWith('SQLITE_CONSTRAINT'));
|
||||
if (!isUnique) return false;
|
||||
// Other unique columns on `user` (username, uuid, referral_code) raise the
|
||||
// same code and must keep their own error handling.
|
||||
return (err.message ?? '').includes(OWNED_EMAIL_INDEX);
|
||||
};
|
||||
// Cap on placeholders per `IN (?, ?, …)` query. SQLite's default parameter
|
||||
// limit is 999; staying well under that keeps `getByIds` portable across
|
||||
// backends without splitting the cap by driver.
|
||||
@@ -248,16 +279,47 @@ export class UserStore extends PuterStore {
|
||||
* Rehydrates through `getById` so the caller gets a normalized row (and
|
||||
* warms the id-keyed cache for subsequent reads).
|
||||
*/
|
||||
async getByCleanEmail(cleanEmailValue: string): Promise<UserRow | null> {
|
||||
async getByCleanEmail(
|
||||
cleanEmailValue: string,
|
||||
opts: { force?: boolean } = {},
|
||||
): Promise<UserRow | null> {
|
||||
if (!cleanEmailValue) return null;
|
||||
if (!isStorableAsLatin1(cleanEmailValue)) return null;
|
||||
const rows = (await this.clients.db.tryHardRead(
|
||||
'SELECT `id` FROM `user` WHERE `clean_email` = ? LIMIT 1',
|
||||
[cleanEmailValue],
|
||||
)) as Array<{ id: number }>;
|
||||
const sql = `SELECT \`id\` FROM \`user\` WHERE \`clean_email\` = ? ${EMAIL_OWNER_ORDER} LIMIT 1`;
|
||||
const rows = (await (opts.force
|
||||
? this.clients.db.pread(sql, [cleanEmailValue])
|
||||
: this.clients.db.tryHardRead(sql, [cleanEmailValue]))) as Array<{
|
||||
id: number;
|
||||
}>;
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return this.getById(row.id as number);
|
||||
return this.getById(row.id as number, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whoever currently holds an address, matching the raw `email`
|
||||
* column first and falling back to the canonical `clean_email` so
|
||||
* gmail-style aliases (`foo.bar+tag@gmail.com` vs `foobar@gmail.com`)
|
||||
* collapse to the same account.
|
||||
*
|
||||
* This is the single duplicate-detection lookup for every write path that
|
||||
* attaches an address to a row (signup, save-account, change-email, OIDC,
|
||||
* admin provisioning). Callers decide what to do with the hit: a row that
|
||||
* is confirmed or holds a password owns the address and blocks the write;
|
||||
* an unconfirmed password-less row is a placeholder the caller may claim.
|
||||
*
|
||||
* Pass `force` to read the primary. Every caller doing a last-moment
|
||||
* re-check before an insert must, or it re-reads the same stale snapshot
|
||||
* the first check saw.
|
||||
*/
|
||||
async findEmailOwner(
|
||||
email: string,
|
||||
opts: { force?: boolean } = {},
|
||||
): Promise<UserRow | null> {
|
||||
if (!email) return null;
|
||||
const direct = await this.getByEmail(email, { force: opts.force });
|
||||
if (direct) return direct;
|
||||
return this.getByCleanEmail(cleanEmail(email), opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,7 +379,16 @@ export class UserStore extends PuterStore {
|
||||
// (`pread`) to bypass replica lag for hot reads (e.g., immediately
|
||||
// after a signup). Otherwise `tryHardRead` parallels primary +
|
||||
// replica and prefers whichever returns rows.
|
||||
const sql = `SELECT * FROM \`user\` WHERE \`${prop}\` = ? LIMIT 1`;
|
||||
// `id`, `uuid` and `username` are UNIQUE, so at most one row matches and
|
||||
// the optimizer drops the ordering. `email` is not — multiple rows may
|
||||
// legitimately hold the same address while unconfirmed, so without an
|
||||
// explicit order the winner is whatever the storage engine hands back
|
||||
// first, and login / password recovery would resolve the same address to
|
||||
// a different account run to run. Prefer the row that owns the address.
|
||||
const sql =
|
||||
`SELECT * FROM \`user\` WHERE \`${prop}\` = ?` +
|
||||
(prop === 'email' ? ` ${EMAIL_OWNER_ORDER}` : '') +
|
||||
' LIMIT 1';
|
||||
const rows = force
|
||||
? await this.clients.db.pread(sql, [value])
|
||||
: await this.clients.db.tryHardRead(sql, [value]);
|
||||
@@ -353,6 +424,15 @@ export class UserStore extends PuterStore {
|
||||
clean_email?: string | null;
|
||||
free_storage?: number | null;
|
||||
requires_email_confirmation?: boolean;
|
||||
/**
|
||||
* Set this at insert time for accounts that are confirmed from birth
|
||||
* (an identity provider already verified the address). Confirming in a
|
||||
* follow-up `update` instead means the insert does not yet own the
|
||||
* address, so two concurrent creates both succeed and only collide on
|
||||
* the later update — past the point where the caller can cleanly report
|
||||
* a duplicate.
|
||||
*/
|
||||
email_confirmed?: boolean;
|
||||
email_confirm_code?: string | null;
|
||||
email_confirm_token?: string | null;
|
||||
audit_metadata?: Record<string, unknown> | null;
|
||||
@@ -378,6 +458,7 @@ export class UserStore extends PuterStore {
|
||||
uuid,
|
||||
free_storage,
|
||||
requires_email_confirmation,
|
||||
email_confirmed,
|
||||
email_confirm_code,
|
||||
email_confirm_token,
|
||||
audit_metadata,
|
||||
@@ -392,7 +473,7 @@ export class UserStore extends PuterStore {
|
||||
phone,
|
||||
requires_phone_verification,
|
||||
requires_card_verification)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${this.clients.db.returningIdClause()}`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${this.clients.db.returningIdClause()}`,
|
||||
[
|
||||
fields.username,
|
||||
fields.email,
|
||||
@@ -403,6 +484,7 @@ export class UserStore extends PuterStore {
|
||||
this.clients.db.booleanValue(
|
||||
Boolean(fields.requires_email_confirmation),
|
||||
),
|
||||
this.clients.db.booleanValue(Boolean(fields.email_confirmed)),
|
||||
fields.email_confirm_code ?? null,
|
||||
fields.email_confirm_token ?? null,
|
||||
fields.audit_metadata
|
||||
@@ -447,6 +529,44 @@ export class UserStore extends PuterStore {
|
||||
userId: number,
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.#write(userId, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an unconfirmed, password-less placeholder row — the
|
||||
* admin-provisioned pre-registration a signup claims instead of inserting a
|
||||
* new row.
|
||||
*
|
||||
* The guard is the whole point. Two signups can both read the row as
|
||||
* claimable, and an unguarded `UPDATE` lets the second overwrite the first:
|
||||
* the row ends up with the second signup's username and password while the
|
||||
* first was already handed a session for it. The unique index cannot catch
|
||||
* that — the row already existed, so nothing is inserted and no address
|
||||
* changes hands.
|
||||
*
|
||||
* Returns false when someone claimed the row in between, which the caller
|
||||
* reports as a duplicate address.
|
||||
*/
|
||||
async claimPlaceholder(
|
||||
userId: number,
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
const unclaimed =
|
||||
'`password` IS NULL AND `email_confirmed` = ' +
|
||||
this.clients.db.booleanLiteral(false);
|
||||
return this.#write(userId, patch, unclaimed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared write path for `update` / `claimPlaceholder`. `guard` is extra SQL
|
||||
* ANDed into the WHERE clause; the write is reported as lost when it
|
||||
* matches no row.
|
||||
*/
|
||||
async #write(
|
||||
userId: number,
|
||||
patch: Record<string, unknown>,
|
||||
guard?: string,
|
||||
): Promise<boolean> {
|
||||
const dbPatch: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
dbPatch[key] =
|
||||
@@ -458,7 +578,7 @@ export class UserStore extends PuterStore {
|
||||
}
|
||||
|
||||
const keys = Object.keys(dbPatch);
|
||||
if (keys.length === 0) return;
|
||||
if (keys.length === 0) return true;
|
||||
|
||||
assertLatin1Writable(dbPatch);
|
||||
|
||||
@@ -478,15 +598,31 @@ export class UserStore extends PuterStore {
|
||||
? await this.getByProperty('id', userId, { force: true })
|
||||
: null;
|
||||
|
||||
await this.clients.db.write(
|
||||
`UPDATE \`user\` SET ${setClause} WHERE \`id\` = ?`,
|
||||
const result = await this.clients.db.write(
|
||||
`UPDATE \`user\` SET ${setClause} WHERE \`id\` = ?` +
|
||||
(guard ? ` AND ${guard}` : ''),
|
||||
[...values, userId],
|
||||
);
|
||||
|
||||
if (guard) {
|
||||
const affected =
|
||||
(result as { affectedRows?: number; changes?: number })
|
||||
?.affectedRows ??
|
||||
(result as { affectedRows?: number; changes?: number })
|
||||
?.changes ??
|
||||
0;
|
||||
// Nothing was written, so there are no cache keys to retire.
|
||||
if (affected === 0) return false;
|
||||
}
|
||||
|
||||
const fresh = await this.getByProperty('id', userId, { force: true });
|
||||
|
||||
if (before) {
|
||||
const live = new Set(fresh ? this.#cacheKeysForUser(fresh) : []);
|
||||
// Compare against the keys the refresh below will actually write,
|
||||
// not every key the fresh row could be found by: a row that just
|
||||
// stopped owning its address keeps the address in its key list but
|
||||
// no longer gets cached under it, and the old value would survive.
|
||||
const live = new Set(fresh ? this.#cacheKeysToWrite(fresh) : []);
|
||||
const retired = this.#cacheKeysForUser(before).filter(
|
||||
(key) => !live.has(key),
|
||||
);
|
||||
@@ -500,6 +636,7 @@ export class UserStore extends PuterStore {
|
||||
} else {
|
||||
await this.invalidateById(userId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateMetadata(
|
||||
@@ -520,6 +657,32 @@ export class UserStore extends PuterStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account other than `userId` that has already confirmed this address,
|
||||
* if there is one. Matches raw + canonical, exactly like
|
||||
* `unconfirmOthersByEmail`, and is meant to run immediately before it: a
|
||||
* confirmed row proved access to the inbox, so it is refused rather than
|
||||
* demoted. Everything that lookup leaves behind is an unconfirmed row,
|
||||
* which is what `unconfirmOthersByEmail` is for.
|
||||
*
|
||||
* Reads the primary — the confirmation it guards is about to write.
|
||||
*/
|
||||
async findConfirmedOtherByEmail(
|
||||
userId: number,
|
||||
email: string,
|
||||
cleanEmailValue: string,
|
||||
): Promise<UserRow | null> {
|
||||
if (!email) return null;
|
||||
const rows = (await this.clients.db.pread(
|
||||
'SELECT * FROM `user` WHERE `id` != ? AND (`email` = ? OR `clean_email` = ?) ' +
|
||||
`AND \`email_confirmed\` = ${this.clients.db.booleanLiteral(true)} ` +
|
||||
'ORDER BY `id` ASC LIMIT 1',
|
||||
[userId, email, cleanEmailValue],
|
||||
)) as Array<Record<string, unknown>>;
|
||||
const row = rows[0];
|
||||
return row ? this.#normalizeRow(row) : null;
|
||||
}
|
||||
|
||||
async unconfirmOthersByEmail(
|
||||
userId: number,
|
||||
email: string,
|
||||
@@ -585,6 +748,24 @@ export class UserStore extends PuterStore {
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `#cacheKeysForUser` that may point _at_ this row. Same
|
||||
* keys, minus the address when the row doesn't own it: several rows may
|
||||
* hold one address, and `EMAIL_OWNER_ORDER` makes SQL resolve it to the
|
||||
* owner. Caching a placeholder under the address would shadow that for the
|
||||
* whole TTL, and login and password recovery both resolve an address
|
||||
* through the cache.
|
||||
*
|
||||
* Invalidation deliberately keeps using the full set, so a key written
|
||||
* while the row still owned the address is never orphaned.
|
||||
*/
|
||||
#cacheKeysToWrite(user: UserRow): string[] {
|
||||
const keys = this.#cacheKeysForUser(user);
|
||||
if (user.email_confirmed || user.password != null) return keys;
|
||||
const addressKey = this.#cacheKey('email', user.email);
|
||||
return keys.filter((key) => key !== addressKey);
|
||||
}
|
||||
|
||||
async #readCache(
|
||||
prop: UserIdProperty,
|
||||
value: unknown,
|
||||
@@ -603,7 +784,7 @@ export class UserStore extends PuterStore {
|
||||
}
|
||||
|
||||
async #writeCache(user: UserRow): Promise<void> {
|
||||
const keys = this.#cacheKeysForUser(user);
|
||||
const keys = this.#cacheKeysToWrite(user);
|
||||
if (keys.length === 0) return;
|
||||
const serialized = JSON.stringify(user);
|
||||
await Promise.all(
|
||||
@@ -619,7 +800,7 @@ export class UserStore extends PuterStore {
|
||||
}
|
||||
|
||||
async #refreshCache(user: UserRow): Promise<void> {
|
||||
const keys = this.#cacheKeysForUser(user);
|
||||
const keys = this.#cacheKeysToWrite(user);
|
||||
if (keys.length === 0) return;
|
||||
await this.publishCacheKeys({
|
||||
keys,
|
||||
|
||||
Reference in New Issue
Block a user