mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-13 00:36:02 +00:00
Review of the previous change found three of its claims unmet.
The image-generation crash it reported fixed is still reachable. The assert was
scattered across three helpers, and Gemini and OpenAI call `isHttpUrl` directly
on `input_images` without going through any of them — so two of seven providers
still 500 on a non-string. `isHttpUrl` now refuses a non-string itself, and the
shape is settled once in `ImageGenerationDriver.generate`, where the driver call
arrives, rather than per helper. That also covers `input_images` that isn't an
array, which produced a different crash per provider.
The sixth case in the ticket, previously unlocated, is
`Messages.js` reading `tool_call.function.name` with no guard — reachable with
`{"messages":[{"role":"assistant","tool_calls":[{"id":"x"}]}]}`. Guarded, along
with the same shape in `make_claude_tools`: a TypeError there carries no status,
so the retry loop reads it as a provider failure and marks the route unhealthy
for every caller.
`#hardExpiryFromExpiresIn` returning null for a bad type moved the failure past
the session INSERT, leaving an orphaned non-expiring row and still answering
500. Reverted; the controller guard is the fix, now covering fractions,
negatives and unparseable durations rather than only wrong types.
Also: the batch write handlers check that the body is an array but not what is
in it, so a null element 500s the same way; `#requireObjectBody` accepted an
array despite its name; `handleCreateAccessToken` destructured a body that may
be absent; and two AGPL notices had been rewrapped with a Markdown link.
4675 lines
175 KiB
TypeScript
4675 lines
175 KiB
TypeScript
/*
|
|
* 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 bcrypt from 'bcrypt';
|
|
import type { Request, RequestHandler, Response } from 'express';
|
|
import crypto from 'node:crypto';
|
|
import { v4 as uuidv4, validate as validateUuid } from 'uuid';
|
|
import validator from 'validator';
|
|
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
|
import type { HttpErrorOptions } from '../../core/http/HttpError.js';
|
|
import { HttpError } from '../../core/http/HttpError.js';
|
|
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
|
|
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
|
|
import type { Actor } from '../../core/actor.js';
|
|
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
|
|
import {
|
|
signStepUpToken,
|
|
STEP_UP_COOKIE_NAME,
|
|
stepUpCookieOptions,
|
|
} from '../../core/http/middleware/stepUpSession.js';
|
|
import {
|
|
createUserProtectedGate,
|
|
createWebSessionActorGate,
|
|
} from '../../core/http/middleware/userProtected.js';
|
|
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
|
import {
|
|
ROUTES_METADATA_KEY,
|
|
type CollectedRoute,
|
|
type RouteMethod,
|
|
type RouteOptions,
|
|
type RoutePath,
|
|
} from '../../core/http/types.js';
|
|
import {
|
|
createRecoveryCode,
|
|
hashRecoveryCode,
|
|
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 type { CardFallbackDeps } from '../../util/cardFallback.js';
|
|
import {
|
|
CARD_FALLBACK_OPEN_TTL_SECONDS,
|
|
SEND_PHONE_RATE_LIMIT,
|
|
SEND_PHONE_RATE_WINDOW_MS,
|
|
cardFallbackAfterAttempts,
|
|
cardFallbackFlagKey,
|
|
isCardFallbackEligible,
|
|
isCardFallbackEnabled,
|
|
phoneAttemptsKey,
|
|
} from '../../util/cardFallback.js';
|
|
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
|
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
|
import { generate_identifier } from '../../util/identifier.js';
|
|
import { parsePhone } from '../../util/phone.js';
|
|
import { getTaskbarItems } from '../../util/taskbarItems.js';
|
|
import {
|
|
generateDefaultFsentries,
|
|
promoteToVerifiedGroup,
|
|
} from '../../util/userProvisioning.js';
|
|
import {
|
|
APP_DATA_PERMISSION_PREFIX,
|
|
appDataSharingAllowed,
|
|
parseAppDataPermission,
|
|
} from '../../services/permission/appDataScopes.js';
|
|
import { PuterController } from '../types.js';
|
|
|
|
const USERNAME_REGEX = /^\w{1,}$/;
|
|
const USERNAME_MAX_LENGTH = 45;
|
|
const FINGERPRINT_MAX_LENGTH = 128;
|
|
// One consent prompt covers a handful of scopes at most. The cap keeps a
|
|
// crafted request from turning a single grant call into a bulk write.
|
|
const MAX_PERMISSIONS_PER_REQUEST = 16;
|
|
const DISPATCH_ID_MAX_LENGTH = 128;
|
|
|
|
// -- Post-login route limits -----------------------------------------
|
|
//
|
|
// The credential legs above (login, signup, recovery, confirmation) each
|
|
// carry their own limit. Everything a session can reach *after* signing
|
|
// in shares the four shapes below, keyed on the actor rather than the
|
|
// network — a per-account ceiling is the meaningful one once we know who
|
|
// is calling.
|
|
|
|
/**
|
|
* Mints or reconfigures a credential. Deliberately an hour-scale window: these
|
|
* are human actions taken a handful of times, and an unbounded rate turns one
|
|
* compromised session into a durable foothold.
|
|
*/
|
|
const CREDENTIAL_MINT_LIMIT = {
|
|
scope: 'auth-credential-mint',
|
|
limit: 20,
|
|
window: 60 * 60_000,
|
|
key: 'user',
|
|
} as const;
|
|
|
|
/**
|
|
* Second-factor configuration, including the verify leg. Shorter window than
|
|
* the mint limit because enabling 2FA legitimately involves a few attempts in a
|
|
* row, but unbounded verification is a TOTP brute force.
|
|
*/
|
|
const TWO_FACTOR_LIMIT = {
|
|
scope: 'auth-2fa-configure',
|
|
limit: 30,
|
|
window: 15 * 60_000,
|
|
key: 'user',
|
|
} as const;
|
|
|
|
/** Permission writes. Never called in a loop by a client. */
|
|
const GRANT_LIMIT = {
|
|
scope: 'auth-grant',
|
|
limit: 60,
|
|
window: 60_000,
|
|
key: 'user',
|
|
} as const;
|
|
|
|
/**
|
|
* Read-only checks the GUI makes on nearly every interaction. The ceiling is
|
|
* high enough that only a runaway loop reaches it.
|
|
*/
|
|
const AUTH_CHECK_LIMIT = {
|
|
scope: 'auth-check',
|
|
limit: 300,
|
|
window: 60_000,
|
|
key: 'user',
|
|
} as const;
|
|
|
|
/**
|
|
* Anti-CSRF token issuance. Clients mint a fresh token per protected mutation
|
|
* and cache nothing, so this ceiling has to clear the SUM of the budgets that
|
|
* spend tokens — matching any single one of them guarantees the gate fires
|
|
* before the mutation it guards does.
|
|
*
|
|
* What spends them: the session-authenticated download path, one token per
|
|
* file, at the read budget of 600/min — a multi-selection download burns tokens
|
|
* exactly the way a bulk delete burns its own budget, so this tracks the bulk
|
|
* figure used for filesystem mutations; logout at 60/min; and the
|
|
* session-management writes (revoke, rename), which a person triggers a handful
|
|
* of times. Call it ~700/min of real demand, and leave enough on top that a
|
|
* bulk operation runs out of files before it runs out of tokens.
|
|
*/
|
|
const ANTI_CSRF_MINT_LIMIT = {
|
|
scope: 'anticsrf',
|
|
limit: 1200,
|
|
window: 60_000,
|
|
key: 'user',
|
|
} as const;
|
|
|
|
/** Settings-page reads — enumerating sessions and permissions. */
|
|
const AUTH_LIST_LIMIT = {
|
|
scope: 'auth-list',
|
|
limit: 120,
|
|
window: 60_000,
|
|
key: 'user',
|
|
} as const;
|
|
|
|
/** Session plumbing: logout, GUI token, cookie sync. */
|
|
const SESSION_LIMIT = {
|
|
scope: 'auth-session',
|
|
limit: 60,
|
|
window: 60_000,
|
|
key: 'user',
|
|
} as const;
|
|
// How long a failed-SMS-send record stays readable by its error_id — long
|
|
// enough to cover the typical support round-trip.
|
|
const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
|
|
/**
|
|
* Whether this account has already been through the card flow. Both halves
|
|
* matter: the pending flag being clear only says nobody asked, while the
|
|
* fingerprint is the artifact a completed check leaves behind. A route that
|
|
* requires a verified card (`requireCardVerified`) sends users here with the
|
|
* flag clear and no card on file, and answering "already verified" on the flag
|
|
* alone would bounce them between a dialog that reports success and a route
|
|
* that keeps refusing.
|
|
*/
|
|
const cardAlreadyVerified = (user: {
|
|
card_fingerprint?: string | null;
|
|
requires_card_verification?: boolean;
|
|
}): boolean =>
|
|
Boolean(user.card_fingerprint) && !user.requires_card_verification;
|
|
const RESERVED_USERNAMES = new Set([
|
|
'admin',
|
|
'administrator',
|
|
'root',
|
|
'system',
|
|
'puter',
|
|
'www',
|
|
'api',
|
|
'support',
|
|
'help',
|
|
'info',
|
|
'contact',
|
|
'mail',
|
|
'email',
|
|
'null',
|
|
'undefined',
|
|
'test',
|
|
'guest',
|
|
'anonymous',
|
|
'user',
|
|
'users',
|
|
]);
|
|
|
|
/**
|
|
* Auth controller — login/logout, permission grants/revokes, session
|
|
* management, OTP, and permission checks.
|
|
*
|
|
* Routes are declared via decorators (@Get/@Post on each handler). The five
|
|
* `/user-protected/*` and `/user-protected/delete-own-user` routes also need a
|
|
* per-instance `createUserProtectedGate(...)` middleware built from
|
|
* `this.config / this.stores / this.services`, which can't live in a static
|
|
* decorator literal — those are wired imperatively in the `registerRoutes`
|
|
* override below. The override also re-runs the default decorator-walker logic
|
|
* so the rest of the routes register normally.
|
|
*/
|
|
@Controller('')
|
|
export class AuthController extends PuterController {
|
|
@Post('/login/wait', {
|
|
subdomain: ['api'],
|
|
rateLimit: [
|
|
// A client will make a request to this every 10 seconds while waiting for the login to complete, so we allow a higher limit than the main /login endpoint.
|
|
{ scope: 'login-wait', limit: 100, window: 15 * 60_000, key: 'ip' },
|
|
],
|
|
})
|
|
async loginWait(req: Request, res: Response) {
|
|
// Destructuring an absent body is a TypeError, not the 400 a request
|
|
// with no session id deserves.
|
|
const session = (req.body as { session?: unknown } | undefined)
|
|
?.session;
|
|
// validate uuid to prevent ultra long key or listening on pubsub.login.*
|
|
if (typeof session !== 'string' || !validateUuid(session)) {
|
|
throw new HttpError(400, 'session is required.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
// Browser-only gate. The session id is client-chosen and travels in a
|
|
// link, so it is not a secret — the `Origin` header is what actually
|
|
// says who is asking, and only a browser is prevented from lying about
|
|
// it. A caller with no `Origin` (curl, a server-side fetch) could
|
|
// otherwise collect a token minted for someone else's app just by
|
|
// knowing the id.
|
|
//
|
|
// `"null"` is rejected too: sandboxed iframes and `file://` documents
|
|
// serialise their opaque origin that way, and two *unrelated* opaque
|
|
// origins would compare equal to each other.
|
|
const reqOrigin = req.headers.origin;
|
|
if (!reqOrigin || reqOrigin === 'null') {
|
|
throw new HttpError(403, 'Origin not allowed', {
|
|
legacyCode: 'forbidden',
|
|
});
|
|
}
|
|
|
|
// The app identity this caller is allowed to collect a token for,
|
|
// derived from the browser-attested header rather than anything in
|
|
// the request body — so no client, honest or not, can influence the
|
|
// comparison made after the token arrives.
|
|
const expectedAppUid =
|
|
await this.services.auth.appUidFromOrigin(reqOrigin);
|
|
|
|
const { resolve, promise } = Promise.withResolvers<void>();
|
|
|
|
let token: string | null = null;
|
|
const listener = (_key: string, value: { authtoken: string }) => {
|
|
token = value.authtoken;
|
|
resolve();
|
|
};
|
|
this.clients.event.on(`pubsub.login.${session}`, listener);
|
|
|
|
const timeout = new Promise<void>((resolve) =>
|
|
setTimeout(resolve, 10000),
|
|
);
|
|
await Promise.race([promise, timeout]);
|
|
this.clients.event.off(`pubsub.login.${session}`, listener);
|
|
if (!token) {
|
|
throw new HttpError(408, 'Request timeout.', {
|
|
legacyCode: 'request_timeout',
|
|
});
|
|
}
|
|
|
|
// Audience check. The postMessage hand-off this relay stands in for
|
|
// is origin-bound for free — it posts with `targetOrigin`, so a page
|
|
// can only ever receive a token minted for *itself*. Delivering
|
|
// server-side dropped that binding; this restores it. Without it a
|
|
// popup talked into minting for app X (see `trustsOpenerOriginParam`
|
|
// in the GUI) hands X's token to whoever holds the session id.
|
|
if (!this.#tokenIsForApp(token, expectedAppUid)) {
|
|
// Deliberately the same 408 the no-token path returns: a caller
|
|
// learns only that nothing arrived for them, not that a token
|
|
// for a different app went past.
|
|
throw new HttpError(408, 'Request timeout.', {
|
|
legacyCode: 'request_timeout',
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
auth_token: token,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether a relayed token is an app-under-user token minted for
|
|
* `expectedAppUid`. Verifies the signature — an unverified decode would let
|
|
* a caller relay a token whose claims it wrote itself.
|
|
*/
|
|
#tokenIsForApp(token: string, expectedAppUid: string): boolean {
|
|
try {
|
|
const payload = this.services.token.verify<{
|
|
type?: string;
|
|
app_uid?: string;
|
|
}>('auth', token);
|
|
return (
|
|
payload.type === 'app-under-user' &&
|
|
!!payload.app_uid &&
|
|
payload.app_uid === expectedAppUid
|
|
);
|
|
} catch {
|
|
// Malformed, expired, or signed with a key we don't hold.
|
|
return false;
|
|
}
|
|
}
|
|
@Post('/login/set', {
|
|
subdomain: ['api'],
|
|
// Unauthenticated fan-out to every `/login/wait` listener on the
|
|
// session id. A legitimate popup posts here exactly once per sign-in,
|
|
// so a generous per-IP cap costs honest traffic nothing while denying
|
|
// an attacker unbounded attempts to land a token on a guessed id.
|
|
rateLimit: [
|
|
{ scope: 'login-set', limit: 60, window: 15 * 60_000, key: 'ip' },
|
|
],
|
|
})
|
|
async loginSet(req: Request, res: Response) {
|
|
const { session, auth_token } = req.body;
|
|
if (!session || !auth_token || !validateUuid(session)) {
|
|
throw new HttpError(400, 'session and auth_token are required.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
this.clients.event.emit(
|
|
`pubsub.login.${session}`,
|
|
{
|
|
authtoken: auth_token,
|
|
},
|
|
{},
|
|
);
|
|
|
|
res.json({ success: true });
|
|
}
|
|
|
|
// -- Login -------------------------------------------------------
|
|
|
|
@Post('/login', {
|
|
// Returns a full session token in the response body. Reflected CORS
|
|
// would otherwise let any page trade a password for that token and
|
|
// read it — third-party sign-in goes through `puter.auth.signIn()`,
|
|
// whose popup runs on this origin and yields an app-scoped token.
|
|
guiOriginOnly: true,
|
|
captcha: true,
|
|
// Two limits: per-fingerprint keeps users behind a shared IP
|
|
// (offices, campuses) from throttling each other, while the
|
|
// coarser per-IP backstop stops an attacker from minting fresh
|
|
// fingerprint buckets by rotating client-controlled headers
|
|
// (User-Agent etc.). Same pattern on the other unauthenticated
|
|
// credential endpoints below.
|
|
rateLimit: [
|
|
{ scope: 'login', limit: 10, window: 15 * 60_000 },
|
|
{ scope: 'login-ip', limit: 50, window: 15 * 60_000, key: 'ip' },
|
|
],
|
|
})
|
|
async handleLogin(req: Request, res: Response): Promise<void> {
|
|
const { username, email, password } = req.body;
|
|
|
|
if (!username && !email) {
|
|
throw new HttpError(400, 'Username or email is required.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (!password || typeof password !== 'string') {
|
|
throw new HttpError(400, 'Password is required.', {
|
|
legacyCode: 'password_required',
|
|
});
|
|
}
|
|
if (password.length < (this.config.min_pass_length || 6)) {
|
|
throw new HttpError(400, 'Invalid password.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
// Look up user
|
|
let user;
|
|
if (username) {
|
|
if (typeof username !== 'string')
|
|
throw new HttpError(400, 'username must be a string.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
user = await this.stores.user.getByUsername(username);
|
|
} else {
|
|
user = await this.stores.user.getByEmail(email);
|
|
}
|
|
|
|
if (!user) {
|
|
throw new HttpError(
|
|
404,
|
|
username ? 'Username not found.' : 'Email not found.',
|
|
{ legacyCode: 'not_found' },
|
|
);
|
|
}
|
|
if (
|
|
user.username === 'system' &&
|
|
!(this.config as { allow_system_login?: boolean })
|
|
.allow_system_login
|
|
) {
|
|
throw new HttpError(
|
|
404,
|
|
username ? 'Username not found.' : 'Email not found.',
|
|
{ legacyCode: 'not_found' },
|
|
);
|
|
}
|
|
if (user.suspended) {
|
|
throw new HttpError(401, 'This account is suspended.', {
|
|
legacyCode: 'account_suspended',
|
|
});
|
|
}
|
|
if (user.password === null) {
|
|
throw new HttpError(401, 'Incorrect password.', {
|
|
legacyCode: 'unauthorized',
|
|
});
|
|
}
|
|
|
|
// Verify password
|
|
const passwordMatch = await bcrypt.compare(
|
|
password,
|
|
user.password as string,
|
|
);
|
|
if (!passwordMatch) {
|
|
throw new HttpError(401, 'Incorrect password.', {
|
|
legacyCode: 'password_mismatch',
|
|
});
|
|
}
|
|
|
|
const reauthAuthId = this.#extractAuthIdFromReauthToken(
|
|
req.body.reauth_token,
|
|
);
|
|
await this.#enforceAuthIdMatch(req, user, reauthAuthId);
|
|
|
|
// OTP branching — if 2FA enabled, return a short-lived OTP JWT.
|
|
// Re-bind the verified `auth_id` into the JWT so the follow-up
|
|
// OTP/recovery call can re-enforce the match without re-trusting
|
|
// a free-form claim from the client.
|
|
if (user.otp_enabled) {
|
|
const otpClaims: Record<string, unknown> = {
|
|
user_uid: user.uuid,
|
|
purpose: 'otp-login',
|
|
};
|
|
if (reauthAuthId) otpClaims.auth_id = reauthAuthId;
|
|
const otp_jwt_token = this.services.token.sign('otp', otpClaims, {
|
|
expiresIn: '5m',
|
|
});
|
|
|
|
res.status(202).json({
|
|
proceed: true,
|
|
next_step: 'otp',
|
|
otp_jwt_token,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await this.#completeLogin(req, res, user);
|
|
}
|
|
|
|
// -- Login: OTP verification -------------------------------------
|
|
|
|
@Post('/login/otp', {
|
|
// Second leg of `/login` — also completes into `#completeLogin`.
|
|
guiOriginOnly: true,
|
|
captcha: true,
|
|
rateLimit: [
|
|
{ scope: 'login-otp', limit: 15, window: 30 * 60_000 },
|
|
{
|
|
scope: 'login-otp-ip',
|
|
limit: 60,
|
|
window: 30 * 60_000,
|
|
key: 'ip',
|
|
},
|
|
],
|
|
})
|
|
async handleLoginOtp(req: Request, res: Response): Promise<void> {
|
|
const { token, code } = req.body;
|
|
if (!token)
|
|
throw new HttpError(400, 'token is required.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
if (!code)
|
|
throw new HttpError(400, 'code is required.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
|
|
let decoded;
|
|
try {
|
|
decoded = this.services.token.verify<{
|
|
user_uid: string;
|
|
purpose: string;
|
|
auth_id?: string;
|
|
}>('otp', token);
|
|
} catch {
|
|
throw new HttpError(400, 'Invalid token.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (!decoded.user_uid || decoded.purpose !== 'otp-login') {
|
|
throw new HttpError(400, 'Invalid token.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
const user = await this.stores.user.getByUuid(decoded.user_uid);
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found.', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
if (user.suspended) {
|
|
throw new HttpError(401, 'This account is suspended.', {
|
|
legacyCode: 'account_suspended',
|
|
});
|
|
}
|
|
|
|
if (!verifyOtp(user.username, user.otp_secret, code)) {
|
|
res.json({ proceed: false });
|
|
return;
|
|
}
|
|
|
|
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
|
|
|
await this.#completeLogin(req, res, user);
|
|
}
|
|
|
|
// -- Login: recovery code ----------------------------------------
|
|
|
|
@Post('/login/recovery-code', {
|
|
// Second leg of `/login` — also completes into `#completeLogin`.
|
|
guiOriginOnly: true,
|
|
captcha: true,
|
|
rateLimit: [
|
|
{ scope: 'login-recovery', limit: 10, window: 60 * 60_000 },
|
|
{
|
|
scope: 'login-recovery-ip',
|
|
limit: 40,
|
|
window: 60 * 60_000,
|
|
key: 'ip',
|
|
},
|
|
],
|
|
})
|
|
async handleLoginRecoveryCode(req: Request, res: Response): Promise<void> {
|
|
const { token, code } = req.body;
|
|
if (!token)
|
|
throw new HttpError(400, 'token is required.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
if (!code)
|
|
throw new HttpError(400, 'code is required.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
|
|
let decoded;
|
|
try {
|
|
decoded = this.services.token.verify<{
|
|
user_uid: string;
|
|
purpose: string;
|
|
auth_id?: string;
|
|
}>('otp', token);
|
|
} catch {
|
|
throw new HttpError(400, 'Invalid token.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (!decoded.user_uid || decoded.purpose !== 'otp-login') {
|
|
throw new HttpError(400, 'Invalid token.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
const user = await this.stores.user.getByUuid(decoded.user_uid);
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found.', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
if (user.suspended) {
|
|
throw new HttpError(401, 'This account is suspended.', {
|
|
legacyCode: 'account_suspended',
|
|
});
|
|
}
|
|
|
|
const hashed = hashRecoveryCode(code);
|
|
const codes = ((user.otp_recovery_codes as string) || '')
|
|
.split(',')
|
|
.filter(Boolean);
|
|
const idx = codes.indexOf(hashed);
|
|
if (idx === -1) {
|
|
res.json({ proceed: false });
|
|
return;
|
|
}
|
|
|
|
// Consume the recovery code
|
|
codes.splice(idx, 1);
|
|
await this.clients.db.write(
|
|
'UPDATE `user` SET `otp_recovery_codes` = ? WHERE `uuid` = ?',
|
|
[codes.join(','), user.uuid],
|
|
);
|
|
await this.stores.user.invalidateById(user.id);
|
|
|
|
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
|
|
|
await this.#completeLogin(req, res, user);
|
|
}
|
|
|
|
// -- Signup ------------------------------------------------------
|
|
|
|
@Post('/signup', {
|
|
// Completes into `#completeLogin`, so it hands back a session token
|
|
// exactly like `/login`. Same reasoning.
|
|
guiOriginOnly: true,
|
|
captcha: true,
|
|
rateLimit: [
|
|
{ scope: 'signup', limit: 10, window: 15 * 60_000 },
|
|
{ scope: 'signup-ip', limit: 50, window: 15 * 60_000, key: 'ip' },
|
|
],
|
|
})
|
|
async handleSignup(req: Request, res: Response): Promise<void> {
|
|
const body = req.body ?? {};
|
|
const is_temp = Boolean(body.is_temp);
|
|
|
|
// Bot honeypot — only applies to non-temp signups
|
|
if (
|
|
!is_temp &&
|
|
body.p102xyzname !== '' &&
|
|
body.p102xyzname !== undefined
|
|
) {
|
|
res.json({});
|
|
return;
|
|
}
|
|
|
|
// Optional device signal (browser fingerprint hash). Core only enforces
|
|
// shape and forwards the value verbatim — signup-abuse policy built on it
|
|
// lives in extensions. Checked before the reauth short-circuit so a
|
|
// malformed value is rejected on every /signup path.
|
|
if (body.fingerprint !== undefined && body.fingerprint !== null) {
|
|
if (typeof body.fingerprint !== 'string')
|
|
throw new HttpError(400, 'fingerprint must be a string.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
if (body.fingerprint.length > FINGERPRINT_MAX_LENGTH)
|
|
throw new HttpError(
|
|
400,
|
|
`fingerprint cannot be longer than ${FINGERPRINT_MAX_LENGTH} characters.`,
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
// Empty strings are treated as absent — a signal that wasn't
|
|
// collected, not a malformed request.
|
|
const fingerprint: string | null = body.fingerprint || null;
|
|
|
|
// Temp-user reauth short-circuit: when an existing temp user is
|
|
// forced through the reauth flow, the GUI re-submits /signup with
|
|
// is_temp=true plus the server-signed reauth_token from the 401.
|
|
// Verifying the token (not a raw auth_id) means a leaked uuid alone
|
|
// can't re-attach a session to someone else's temp account.
|
|
// Permanent users must go through /login (they have credentials),
|
|
// so we reject that path here.
|
|
if (
|
|
is_temp &&
|
|
body.reauth_token !== undefined &&
|
|
body.reauth_token !== null
|
|
) {
|
|
const reauthAuthId = this.#extractAuthIdFromReauthToken(
|
|
body.reauth_token,
|
|
);
|
|
if (!reauthAuthId) {
|
|
throw new HttpError(400, 'Invalid `reauth_token`.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
await this.#checkAuthIdRateLimit(req);
|
|
const existing = await this.stores.user.getByUuid(reauthAuthId);
|
|
if (!existing) {
|
|
throw new HttpError(404, 'auth_id not found.', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
}
|
|
if (existing.password !== null || existing.email !== null) {
|
|
throw new HttpError(
|
|
400,
|
|
'auth_id resolves to a non-temp account; use /login instead.',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
if (existing.suspended) {
|
|
throw new HttpError(401, 'This account is suspended.', {
|
|
legacyCode: 'account_suspended',
|
|
});
|
|
}
|
|
await this.#completeLogin(req, res, existing);
|
|
return;
|
|
}
|
|
|
|
// Fill in temp user defaults
|
|
if (is_temp) {
|
|
body.username ??= await this.#generateRandomUsername();
|
|
body.email ??= `${body.username}@gmail.com`;
|
|
body.password ??= uuidv4();
|
|
}
|
|
|
|
// Validation
|
|
if (!body.username)
|
|
throw new HttpError(400, 'Username is required', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
if (typeof body.username !== 'string')
|
|
throw new HttpError(400, 'username must be a string.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
if (!USERNAME_REGEX.test(body.username)) {
|
|
throw new HttpError(
|
|
400,
|
|
'Username can only contain letters, numbers and underscore (_).',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
if (body.username.length > USERNAME_MAX_LENGTH) {
|
|
throw new HttpError(
|
|
400,
|
|
`Username cannot be longer than ${USERNAME_MAX_LENGTH} characters.`,
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
if (RESERVED_USERNAMES.has(body.username.toLowerCase())) {
|
|
throw new HttpError(400, 'This username is not available.', {
|
|
legacyCode: 'username_already_in_use',
|
|
});
|
|
}
|
|
if (!is_temp) {
|
|
if (!body.email)
|
|
throw new HttpError(400, 'Email is required', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
if (typeof body.email !== 'string')
|
|
throw new HttpError(400, 'email must be a string.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
if (!validator.isEmail(body.email))
|
|
throw new HttpError(
|
|
400,
|
|
'Please enter a valid email address.',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
await this.#validateEmail(body.email);
|
|
if (!body.password)
|
|
throw new HttpError(400, 'Password is required', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
if (typeof body.password !== 'string')
|
|
throw new HttpError(400, 'password must be a string.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
const minLen = this.config.min_pass_length || 6;
|
|
if (body.password.length < minLen) {
|
|
throw new HttpError(
|
|
400,
|
|
`Password must be at least ${minLen} characters long.`,
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Signup-disabled gate. Runs before the duplicate checks so a
|
|
// disabled endpoint doesn't reveal which usernames or emails
|
|
// exist. Claiming a pre-existing placeholder row is still
|
|
// allowed, so permanent signups look the email up first.
|
|
if (this.config.disable_user_signup) {
|
|
let claimable = false;
|
|
if (!is_temp) {
|
|
const existing = await this.stores.user.findEmailOwner(
|
|
body.email,
|
|
);
|
|
claimable = Boolean(
|
|
existing &&
|
|
!existing.email_confirmed &&
|
|
existing.password === null,
|
|
);
|
|
}
|
|
if (!claimable) {
|
|
throw new HttpError(403, 'User registration is disabled.', {
|
|
legacyCode: 'signup_disabled',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Duplicate username check
|
|
if (await this.stores.user.getByUsername(body.username)) {
|
|
throw new HttpError(
|
|
400,
|
|
'This username already exists in our database. Please use another one.',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
|
|
// Duplicate confirmed-email check. A confirmed account (any
|
|
// credential type — password OR OIDC) on this email → reject.
|
|
//
|
|
// A pseudo-user is an UNCONFIRMED placeholder row: email
|
|
// present, password null, email_confirmed = 0. Those rows
|
|
// (e.g. admin-created pre-provisioning) are NOT a block —
|
|
// signup claims them: the INSERT becomes an UPDATE on the
|
|
// pseudo row.
|
|
//
|
|
// OIDC-created accounts have password null but email_confirmed
|
|
// = 1, so they fall in the reject branch — signup can't hijack
|
|
// someone's OIDC account by knowing their email. To add a
|
|
// password to an OIDC account, the owner logs in via OIDC and
|
|
// uses the authenticated change-password flow.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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.
|
|
const clientIp: string | null =
|
|
req.ip || req.socket?.remoteAddress || null;
|
|
const proxyIpChain = req.headers['x-forwarded-for'];
|
|
|
|
let pseudo_user = is_temp
|
|
? null
|
|
: await this.#resolveSignupEmailClaim(body.email);
|
|
|
|
// Extension-level validation gate. Abuse-prevention extensions
|
|
// inspect the incoming signup and can:
|
|
// - block it outright via `event.allow = false`
|
|
// - force email confirmation via `event.requires_email_confirmation = true`
|
|
// - skip temp-user creation via `event.no_temp_user = true`
|
|
// Listeners run sequentially so multi-signal checks (rate limit +
|
|
// IP reputation + domain reputation) can short-circuit cleanly.
|
|
const validateEvent = {
|
|
req,
|
|
data: body,
|
|
// `req.ip` honors `trust proxy`; reading x-forwarded-for directly
|
|
// would let a client pick its own per-IP abuse bucket.
|
|
ip: clientIp,
|
|
email: body.email,
|
|
// The same canonical form `email.validate` was given, so a check
|
|
// in the abuse harness can look up the verdict that hook cached
|
|
// for this address. Without it an alias (`a+tag@outlook.com`,
|
|
// `a.b@icloud.com`) reaches the two hooks under two different keys.
|
|
clean_email: cleanEmail(body.email),
|
|
// Temp signups carry a synthetic `<username>@gmail.com` and skip
|
|
// #validateEmail entirely, so an email check must know not to
|
|
// reason about the address at all.
|
|
is_temp,
|
|
allow: true,
|
|
no_temp_user: false,
|
|
requires_email_confirmation: false,
|
|
// Set by the abuse harness for low-reputation signups: the account is
|
|
// created + logged in but gated behind SMS phone verification (in
|
|
// addition to email confirmation) instead of being blocked.
|
|
requires_phone_verification: false,
|
|
// Same idea, one rung up the ladder: gate the account behind
|
|
// credit-card verification (a $0 auth handled by an extension).
|
|
requires_card_verification: false,
|
|
message: null,
|
|
code: null,
|
|
user_agent: req?.headers?.['user-agent'] ?? null,
|
|
fingerprint,
|
|
// Populated by the abuse extension's v2 harness; persisted to the
|
|
// user row below so the signup-time reputation is referable later.
|
|
reputation: null as number | null,
|
|
// Stamped by the abuse harness for flagged signups — the id keying
|
|
// the `abuse:trail:<id>` decision trail (carrying both the live and
|
|
// shadow trails). Surfaced to a blocked user as the Request Code so
|
|
// the code they quote support leads straight to their trail.
|
|
trail_id: undefined as string | undefined,
|
|
};
|
|
try {
|
|
await this.clients.event?.emitAndWait(
|
|
'puter.signup.validate',
|
|
validateEvent,
|
|
{},
|
|
);
|
|
} catch (e) {
|
|
console.warn('[signup] validate hook failed:', e);
|
|
}
|
|
if (!validateEvent.allow) {
|
|
// Pass the trail id back to a blocked user as the Request Code (when
|
|
// the harness stamped one), embedded in the message so the existing
|
|
// signup-block UI surfaces it without a GUI change.
|
|
const requestCode = validateEvent.trail_id;
|
|
throw new HttpError(
|
|
403,
|
|
(validateEvent.message ?? 'Signup blocked') +
|
|
(requestCode ? ` Request Code: ${requestCode}` : ''),
|
|
{
|
|
...(validateEvent.code
|
|
? { legacyCode: validateEvent.code as never }
|
|
: {}),
|
|
},
|
|
);
|
|
}
|
|
if (is_temp && validateEvent.no_temp_user) {
|
|
throw new HttpError(
|
|
403,
|
|
validateEvent.message ?? 'Temporary accounts are disabled',
|
|
{
|
|
legacyCode: 'must_login_or_signup',
|
|
...(validateEvent.code
|
|
? { legacyCode: validateEvent.code as never }
|
|
: {}),
|
|
},
|
|
);
|
|
}
|
|
const force_email_confirmation = Boolean(
|
|
validateEvent.requires_email_confirmation,
|
|
);
|
|
const force_phone_verification =
|
|
Boolean(validateEvent.requires_phone_verification) ||
|
|
// Test/QA switch: force the SMS gate on every signup regardless of
|
|
// reputation (see config.always_require_phone_verification).
|
|
Boolean(this.config.always_require_phone_verification);
|
|
const force_card_verification = Boolean(
|
|
validateEvent.requires_card_verification ||
|
|
// Test/QA switch: force the card gate on every signup regardless of
|
|
// reputation (see config.always_require_card_verification).
|
|
this.config.always_require_card_verification,
|
|
);
|
|
|
|
// Prepare shared fields
|
|
const user_uuid = uuidv4();
|
|
const email_confirm_code = String(crypto.randomInt(100000, 1000000));
|
|
const email_confirm_token = uuidv4();
|
|
const password_hash = is_temp
|
|
? null
|
|
: await bcrypt.hash(body.password, 8);
|
|
|
|
const signupSqlTs = new Date()
|
|
.toISOString()
|
|
.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) --
|
|
//
|
|
// 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) {
|
|
try {
|
|
await this.stores.group.removeUsers(
|
|
this.config.default_temp_group,
|
|
[body.username],
|
|
);
|
|
} catch {
|
|
// Best-effort — missing membership shouldn't block signup
|
|
}
|
|
}
|
|
if (this.config.default_user_group) {
|
|
try {
|
|
await this.stores.group.addUsers(
|
|
this.config.default_user_group,
|
|
[body.username],
|
|
);
|
|
} catch (e) {
|
|
console.warn('[signup] group assignment failed:', e);
|
|
}
|
|
}
|
|
|
|
user = await this.stores.user.getById(pseudo_user.id, {
|
|
force: true,
|
|
});
|
|
} else {
|
|
// -- New user ----------------------------------------
|
|
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,
|
|
// The abuse harness and the admin IP lookup both key on
|
|
// this column, so it holds the trusted client address; the
|
|
// raw forwarded chain stays in `audit_metadata.ip_fwd`.
|
|
signup_ip_forwarded: clientIp,
|
|
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
|
|
? this.config.default_temp_group
|
|
: this.config.default_user_group;
|
|
if (defaultGroup) {
|
|
try {
|
|
await this.stores.group.addUsers(defaultGroup, [
|
|
user.username,
|
|
]);
|
|
} catch (e) {
|
|
console.warn('[signup] group assignment failed:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- Provision FS home + default folders -----------------
|
|
// Idempotent — skips if `user.trash_uuid` is already set (pseudo
|
|
// users who went through a prior signup won't double-create).
|
|
try {
|
|
await generateDefaultFsentries(
|
|
this.clients.db,
|
|
this.stores.user,
|
|
user!,
|
|
);
|
|
} catch (e) {
|
|
console.warn('[signup] generateDefaultFsentries failed:', e);
|
|
}
|
|
|
|
// -- Send email confirmation -----------------------------
|
|
if (
|
|
!is_temp &&
|
|
user!.requires_email_confirmation &&
|
|
this.clients.email
|
|
) {
|
|
const sendCode = body.send_confirmation_code ?? true;
|
|
try {
|
|
let sent;
|
|
if (sendCode) {
|
|
sent = await this.clients.email.send(
|
|
user!.email!,
|
|
'email_verification_code',
|
|
{
|
|
code: email_confirm_code,
|
|
},
|
|
);
|
|
} else {
|
|
const link = `${this.config.origin ?? ''}/confirm-email-by-token?token=${email_confirm_token}&user_uuid=${user!.uuid}`;
|
|
sent = await this.clients.email.send(
|
|
user!.email!,
|
|
'email_verification_link',
|
|
{ link },
|
|
);
|
|
}
|
|
// `null` = dropped for want of a transport; silent otherwise.
|
|
if (sent === null) {
|
|
this.#confirmationEmailFailed('signup', user!, null);
|
|
}
|
|
} catch (e) {
|
|
this.#confirmationEmailFailed('signup', user!, e);
|
|
}
|
|
}
|
|
|
|
// Fire signup events (best-effort). `user.save_account` is fired
|
|
// for every non-temp signup (fresh or pseudo-claim) — downstream
|
|
// consumers (mailchimp sync, welcome email, etc.) key off it.
|
|
try {
|
|
this.clients.event?.emit(
|
|
'puter.signup.success' as never,
|
|
{
|
|
user_id: user!.id,
|
|
user_uuid: user!.uuid,
|
|
email: user!.email,
|
|
username: user!.username,
|
|
fingerprint,
|
|
// Reflects the row that was actually created/claimed —
|
|
// a pseudo-user claim ends up with credentials, so it
|
|
// reports false here. Same signal completeLogin uses.
|
|
is_temp: user!.password === null && user!.email === null,
|
|
// Same derivation as the validate event above — the two
|
|
// have to agree or per-IP counters are written under one
|
|
// key and read under another.
|
|
ip: clientIp,
|
|
} as never,
|
|
{},
|
|
);
|
|
} catch {
|
|
// ignore — event emission shouldn't block signup
|
|
}
|
|
if (!is_temp) {
|
|
try {
|
|
this.clients.event?.emit(
|
|
'user.save_account' as never,
|
|
{ user_id: user!.id } as never,
|
|
{},
|
|
);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
await this.#completeLogin(req, res, user!);
|
|
}
|
|
|
|
// -- Logout ------------------------------------------------------
|
|
|
|
@Post('/logout', {
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
antiCsrf: true,
|
|
rateLimit: SESSION_LIMIT,
|
|
})
|
|
async handleLogout(req: Request, res: Response): Promise<void> {
|
|
// Clear the session cookie + `puter_token_v2`. Nothing issues the
|
|
// latter any more (it came from the retired token migration), but
|
|
// authProbe still reads it as a fallback, so a value left in a
|
|
// browser would re-authenticate the next request.
|
|
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
|
res.clearCookie('puter_token_v2');
|
|
// Drop any step-up elevation too, so it can't reactivate on a shared
|
|
// machine.
|
|
res.clearCookie(STEP_UP_COOKIE_NAME, {
|
|
...(this.config.domain ? { domain: this.config.domain } : {}),
|
|
});
|
|
|
|
// Remove the session (fire-and-forget)
|
|
if (req.token) {
|
|
this.services.auth.removeSessionByToken(req.token).catch(() => {});
|
|
}
|
|
|
|
// Delete temp users (no password + no email). Full cascade —
|
|
// same path as /user-protected/delete-own-user — so we don't
|
|
// orphan fsentries/sessions/permissions.
|
|
if (req.actor?.user && !req.actor.user.email) {
|
|
const user = await this.stores.user.getByUuid(
|
|
req.actor.user.uuid as string,
|
|
);
|
|
if (user && user.password === null && user.email === null) {
|
|
this.#cascadeDeleteUser(user.id).catch((e) => {
|
|
console.warn('[logout] temp-user cleanup failed:', e);
|
|
});
|
|
}
|
|
}
|
|
|
|
res.send('logged out');
|
|
}
|
|
|
|
// -- Email confirmation ------------------------------------------
|
|
|
|
@Post('/send-confirm-email', {
|
|
subdomain: ['api', ''],
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
rateLimit: {
|
|
scope: 'send-confirm-email',
|
|
limit: 10,
|
|
window: 60 * 60_000,
|
|
key: 'user',
|
|
},
|
|
})
|
|
async handleSendConfirmEmail(req: Request, res: Response): Promise<void> {
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found.', {
|
|
legacyCode: 'user_not_found' as never,
|
|
});
|
|
if (user.suspended)
|
|
throw new HttpError(403, 'Account suspended.', {
|
|
legacyCode: 'account_suspended',
|
|
});
|
|
if (!user.email)
|
|
throw new HttpError(400, 'No email on file.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
|
|
const code = String(crypto.randomInt(100000, 1000000));
|
|
await this.stores.user.update(user.id, {
|
|
email_confirm_code: code,
|
|
});
|
|
|
|
if (this.clients.email) {
|
|
try {
|
|
const sent = await this.clients.email.send(
|
|
user.email,
|
|
'email_verification_code',
|
|
{ code },
|
|
);
|
|
if (sent === null) {
|
|
this.#confirmationEmailFailed('resend', user, null);
|
|
}
|
|
} catch (e) {
|
|
this.#confirmationEmailFailed('resend', user, e);
|
|
}
|
|
}
|
|
res.json({});
|
|
}
|
|
|
|
@Post('/confirm-email', {
|
|
subdomain: ['api', ''],
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
rateLimit: {
|
|
scope: 'confirm-email',
|
|
limit: 10,
|
|
window: 10 * 60_000,
|
|
key: 'user',
|
|
},
|
|
})
|
|
async handleConfirmEmail(req: Request, res: Response): Promise<void> {
|
|
const { code, original_client_socket_id } = req.body ?? {};
|
|
if (!code)
|
|
throw new HttpError(400, 'Missing `code`.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found.', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
if (user.email_confirmed) {
|
|
res.json({
|
|
email_confirmed: true,
|
|
original_client_socket_id,
|
|
});
|
|
return;
|
|
}
|
|
// Reject before comparing when no code is stored: `String(null)` would
|
|
// otherwise equal a submitted `"null"` and confirm the email without
|
|
// the real code.
|
|
if (
|
|
!user.email_confirm_code ||
|
|
String(user.email_confirm_code) !== String(code)
|
|
) {
|
|
res.json({
|
|
email_confirmed: false,
|
|
original_client_socket_id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Re-validate the email at confirmation time — the address may
|
|
// have been added to the blocklist (or flagged by an extension)
|
|
// 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,
|
|
email_confirm_code: null,
|
|
email_confirm_token: null,
|
|
});
|
|
|
|
await promoteToVerifiedGroup(this.stores.group, this.config, user);
|
|
|
|
try {
|
|
this.clients.event?.emit(
|
|
'user.email-confirmed' as never,
|
|
{
|
|
user_id: user.id,
|
|
user_uid: user.uuid,
|
|
email: user.email,
|
|
} as never,
|
|
{},
|
|
);
|
|
} catch {
|
|
// ignore — event is a side-channel signal, not load-bearing
|
|
}
|
|
|
|
res.json({ email_confirmed: true, original_client_socket_id });
|
|
}
|
|
|
|
/**
|
|
* Alarm on a confirmation email that did not reach the recipient. A
|
|
* `requires_email_confirmation` account is refused by
|
|
* `requireVerifiedAccount` everywhere, so a lost code leaves an account
|
|
* that cannot be used. `cause === null` is the silent case: `sendRaw` drops
|
|
* the message rather than throwing when no transport is configured.
|
|
*
|
|
* `sole_gate` reports that no phone/card gate is outstanding either, so
|
|
* this user is stuck on the email alone. `dedup` because one broken mail
|
|
* path fails once per signup and is still one thing to fix.
|
|
*/
|
|
#confirmationEmailFailed(
|
|
stage: 'signup' | 'resend',
|
|
user: {
|
|
uuid?: string | null;
|
|
username?: string | null;
|
|
email?: string | null;
|
|
requires_phone_verification?: unknown;
|
|
requires_card_verification?: unknown;
|
|
},
|
|
cause: unknown,
|
|
): void {
|
|
const email = user.email ?? null;
|
|
const detail =
|
|
cause instanceof Error
|
|
? cause.message
|
|
: cause === null
|
|
? 'no transport configured (message dropped)'
|
|
: String(cause);
|
|
console.warn(
|
|
`[${stage === 'signup' ? 'signup' : 'send-confirm-email'}] ` +
|
|
`confirmation email not delivered: ${detail}`,
|
|
);
|
|
// Best-effort: failing to alarm must not fail the signup.
|
|
try {
|
|
this.clients.alarm?.create(
|
|
`auth:confirmation-email-send-failed:${stage}`,
|
|
'Confirmation email could not be sent — gated accounts cannot be used until it arrives',
|
|
{
|
|
stage,
|
|
user_uid: user.uuid ?? null,
|
|
username: user.username ?? null,
|
|
email,
|
|
email_domain: email?.split('@')[1] ?? null,
|
|
sole_gate:
|
|
!user.requires_phone_verification &&
|
|
!user.requires_card_verification,
|
|
detail,
|
|
...(cause instanceof Error ? { error: cause } : {}),
|
|
},
|
|
'warning',
|
|
{ dedup: true },
|
|
);
|
|
} catch (e) {
|
|
console.warn(`[${stage}] confirmation-email alarm failed:`, e);
|
|
}
|
|
}
|
|
|
|
// -- Phone verification (SMS via Prelude) ------------------------
|
|
|
|
/**
|
|
* Build the error thrown when a verification SMS can't be sent (a delivery
|
|
* failure, or a refused/blocked send). Mints a short `error_id`, writes a
|
|
* single greppable line tying that id to the real reason (so support can
|
|
* look it up in CloudWatch with the id the user quotes), stores the same
|
|
* record in KV under `sms-send-error:<error_id>` for a week (the admin
|
|
* abuse page looks it up there without needing log access), and returns the
|
|
* `HttpError` with the id attached as `error_id` for the GUI to surface.
|
|
* The phone number is deliberately omitted from the log line and the KV
|
|
* record (PII); the user + country are enough to correlate.
|
|
*/
|
|
private async smsSendError(
|
|
statusCode: number,
|
|
clientMessage: string,
|
|
reason: string,
|
|
ctx: {
|
|
userId?: number;
|
|
userUid?: string;
|
|
country?: string;
|
|
detail?: unknown;
|
|
},
|
|
options: HttpErrorOptions = {},
|
|
): Promise<HttpError> {
|
|
const errorId = uuidv4();
|
|
const detail =
|
|
ctx.detail instanceof Error ? ctx.detail.message : ctx.detail;
|
|
console.warn(
|
|
`[send-confirm-phone] send_failed error_id=${errorId} ` +
|
|
`reason=${reason} status=${statusCode} ` +
|
|
`user_id=${ctx.userId ?? ''} user_uid=${ctx.userUid ?? ''} ` +
|
|
`country=${ctx.country ?? ''}` +
|
|
(detail ? ` detail=${JSON.stringify(String(detail))}` : ''),
|
|
);
|
|
// Best-effort: the record backs a support lookup, so a KV failure
|
|
// must never mask the error actually being reported.
|
|
try {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
await this.stores.kv.set({
|
|
key: `sms-send-error:${errorId}`,
|
|
value: {
|
|
reason,
|
|
status: statusCode,
|
|
user_id: ctx.userId ?? null,
|
|
user_uid: ctx.userUid ?? null,
|
|
country: ctx.country ?? null,
|
|
detail: detail != null ? String(detail) : null,
|
|
t: now,
|
|
},
|
|
expireAt: now + SMS_SEND_ERROR_TTL_SECONDS,
|
|
});
|
|
} catch (e) {
|
|
console.warn('[send-confirm-phone] error-record store failed:', e);
|
|
}
|
|
return new HttpError(statusCode, clientMessage, {
|
|
...options,
|
|
fields: { ...options.fields, error_id: errorId },
|
|
});
|
|
}
|
|
|
|
// -- SMS-to-card fallback -----------------------------------------
|
|
//
|
|
// Once a user has used up their SMS send attempts for the window without
|
|
// getting through, they can verify a card instead to clear the phone gate.
|
|
// On wherever both gates work unless config opts out. The rule itself lives
|
|
// in ../../util/cardFallback.ts, because /whoami answers the same question.
|
|
//
|
|
// Two KV keys: a short-lived counter tied to the send rate-limit window
|
|
// triggers the fallback, and a longer-lived "open" flag holds eligibility
|
|
// once the threshold is crossed. The card endpoints check only the flag —
|
|
// deriving eligibility from the raw counter would let it expire while the
|
|
// user is mid-way through the card flow. Every KV failure fails closed
|
|
// (fallback unavailable), never open.
|
|
|
|
// TTL ties the counter to the send rate-limit window, so it resets with it.
|
|
private async bumpPhoneAttempts(userId: number): Promise<number> {
|
|
try {
|
|
const { res } = await this.stores.kv.incr({
|
|
key: phoneAttemptsKey(userId),
|
|
pathAndAmountMap: { attempts: 1 },
|
|
expireAt:
|
|
Math.floor(Date.now() / 1000) +
|
|
SEND_PHONE_RATE_WINDOW_MS / 1000,
|
|
});
|
|
const count = (res as { attempts?: number } | null)?.attempts;
|
|
return typeof count === 'number' ? count : 0;
|
|
} catch (e) {
|
|
console.warn('[send-confirm-phone] attempt-count bump failed:', e);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Count a send attempt and, once the threshold is crossed, stamp the
|
|
* eligibility flag the card endpoints check. Returns whether the fallback
|
|
* is open so send responses (success or 429) can advertise it.
|
|
*/
|
|
private async recordPhoneAttemptForFallback(user: {
|
|
id: number;
|
|
requires_phone_verification?: boolean | number | null;
|
|
}): Promise<boolean> {
|
|
const attempts = await this.bumpPhoneAttempts(user.id);
|
|
const open =
|
|
Boolean(user.requires_phone_verification) &&
|
|
attempts >= cardFallbackAfterAttempts(this.config) &&
|
|
(await isCardFallbackEnabled(this.config, this.cardFallbackDeps()));
|
|
if (open) {
|
|
try {
|
|
// Plain set, so each eligible attempt refreshes the window.
|
|
await this.stores.kv.set({
|
|
key: cardFallbackFlagKey(user.id),
|
|
value: true,
|
|
expireAt:
|
|
Math.floor(Date.now() / 1000) +
|
|
CARD_FALLBACK_OPEN_TTL_SECONDS,
|
|
});
|
|
} catch (e) {
|
|
console.warn(
|
|
'[send-confirm-phone] fallback flag stamp failed:',
|
|
e,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
return open;
|
|
}
|
|
|
|
private async isCardFallbackEligible(user: {
|
|
id: number;
|
|
requires_phone_verification?: boolean | number | null;
|
|
}): Promise<boolean> {
|
|
return isCardFallbackEligible(
|
|
this.config,
|
|
user,
|
|
async (key) => (await this.stores.kv.get({ key })).res,
|
|
this.cardFallbackDeps(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The two facts the fallback's default rests on: SMS can only work with a
|
|
* provider configured, and the card gate belongs to an extension, so the
|
|
* only honest way to ask whether it is on is to ask that extension. Nothing
|
|
* is listening on a stock build, which reads as "no card gate".
|
|
*/
|
|
private cardFallbackDeps(): CardFallbackDeps {
|
|
return {
|
|
smsConfigured: () => Boolean(this.clients.prelude?.isConfigured()),
|
|
probeCardVerification: async () => {
|
|
const statusEvent = { enabled: null as boolean | null };
|
|
await this.clients.event?.emitAndWait(
|
|
'puter.card-verification.status',
|
|
statusEvent,
|
|
{},
|
|
);
|
|
return statusEvent.enabled;
|
|
},
|
|
};
|
|
}
|
|
|
|
@Post('/send-confirm-phone', {
|
|
subdomain: ['api', ''],
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
rateLimit: {
|
|
scope: 'send-confirm-phone',
|
|
limit: SEND_PHONE_RATE_LIMIT,
|
|
window: SEND_PHONE_RATE_WINDOW_MS,
|
|
key: 'user',
|
|
},
|
|
})
|
|
async handleSendConfirmPhone(req: Request, res: Response): Promise<void> {
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found.', {
|
|
legacyCode: 'user_not_found' as never,
|
|
});
|
|
if (user.suspended)
|
|
throw new HttpError(403, 'Account suspended.', {
|
|
legacyCode: 'account_suspended',
|
|
});
|
|
if (!this.clients.prelude?.isConfigured())
|
|
throw await this.smsSendError(
|
|
503,
|
|
'Phone verification is unavailable.',
|
|
'prelude_not_configured',
|
|
{ userId: user.id, userUid: user.uuid },
|
|
{ legacyCode: 'service_unavailable' as never },
|
|
);
|
|
|
|
// Parse to E.164 (Prelude's required form + the stored form) and the
|
|
// country, so we can apply the per-country cost cap.
|
|
const parsed = parsePhone(
|
|
req.body?.phone,
|
|
this.clients.prelude.defaultCountry,
|
|
);
|
|
if (!parsed)
|
|
throw new HttpError(400, 'Invalid phone number.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
|
|
// Optional Prelude dispatch id (browser signals gathered by the JS
|
|
// Signals SDK on the number-entry page). Shape-checked and forwarded
|
|
// verbatim to Prelude; an empty / oversized / non-string value is just
|
|
// dropped so a bad client signal never blocks a real verification.
|
|
const rawDispatchId = req.body?.dispatch_id;
|
|
const dispatchId =
|
|
typeof rawDispatchId === 'string' &&
|
|
rawDispatchId.length > 0 &&
|
|
rawDispatchId.length <= DISPATCH_ID_MAX_LENGTH
|
|
? rawDispatchId
|
|
: undefined;
|
|
|
|
// Cost cap: skip countries with no SMS channel or rates above the cap
|
|
// (see PreludeClient / countries.ts). Avoids paying exorbitant per-SMS
|
|
// rates in low-revenue, high-fraud geographies.
|
|
if (!this.clients.prelude.isCountrySupported(parsed.country))
|
|
throw await this.smsSendError(
|
|
400,
|
|
'Phone verification is not available for this country.',
|
|
'country_not_supported',
|
|
{
|
|
userId: user.id,
|
|
userUid: user.uuid,
|
|
country: parsed.country,
|
|
},
|
|
{ legacyCode: 'phone_country_not_supported' as never },
|
|
);
|
|
|
|
// Counted before the abuse / Prelude checks so a blocked attempt still
|
|
// counts toward the fallback threshold.
|
|
const fallbackAvailable =
|
|
await this.recordPhoneAttemptForFallback(user);
|
|
const fallbackFields = fallbackAvailable
|
|
? { card_fallback_available: true }
|
|
: {};
|
|
|
|
// Abuse caps live ENTIRELY in a listening abuse extension, consulted
|
|
// via `puter.phone-verification.check`. The backend ships no thresholds
|
|
// or detection of its own (so none of it is readable in the open-source
|
|
// repo): it forwards the user / number / ip, and the extension decides
|
|
// `allowed` plus an opaque `reason` (per-account + per-number send
|
|
// velocity, cross-account reuse, …). With no extension listening
|
|
// `allowed` stays true. Fail-open on a hook error — this is abuse/cost
|
|
// control, not a security boundary (the route rate limit and the country
|
|
// cost cap remain), so a flaky hook must not lock signups out.
|
|
const abuseCheck = {
|
|
user_id: user.id,
|
|
user_uid: user.uuid,
|
|
phone: parsed.e164,
|
|
device_fingerprint: req.deviceFingerprint ?? null,
|
|
allowed: true,
|
|
reason: null as string | null,
|
|
};
|
|
try {
|
|
await this.clients.event?.emitAndWait(
|
|
'puter.phone-verification.check',
|
|
abuseCheck,
|
|
{},
|
|
);
|
|
} catch (e) {
|
|
console.warn('[send-confirm-phone] abuse-check hook failed:', e);
|
|
}
|
|
// Forward the verdict verbatim: a generic 429 plus the opaque reason for
|
|
// the client to message on. The backend never interprets the reason —
|
|
// its meaning lives in the extension (which sets it) and the GUI (which
|
|
// displays it), so no abuse semantics leak into the OSS repo.
|
|
if (abuseCheck.allowed === false)
|
|
throw await this.smsSendError(
|
|
429,
|
|
'Phone verification is unavailable for this number right now.',
|
|
`not_allowed:${abuseCheck.reason ?? 'unspecified'}`,
|
|
{
|
|
userId: user.id,
|
|
userUid: user.uuid,
|
|
country: parsed.country,
|
|
},
|
|
{
|
|
legacyCode: 'phone_verification_unavailable' as never,
|
|
fields: {
|
|
...fallbackFields,
|
|
...(abuseCheck.reason
|
|
? { reason: abuseCheck.reason }
|
|
: {}),
|
|
},
|
|
},
|
|
);
|
|
|
|
// Stage the parsed number as pending in KV (NOT on the user row) so a
|
|
// never-confirmed number is never written to the indexed `phone`
|
|
// column. /confirm-phone reads it back and persists it to the row only
|
|
// once Prelude confirms the code. ~1h TTL covers the code's lifetime.
|
|
// Stored before the send so we never dispatch an SMS we couldn't later
|
|
// confirm against.
|
|
const pendingPhoneKey = `phone-verify-pending:${user.id}`;
|
|
try {
|
|
await this.stores.kv.set({
|
|
key: pendingPhoneKey,
|
|
value: parsed.e164,
|
|
expireAt: Math.floor(Date.now() / 1000) + 60 * 60,
|
|
});
|
|
} catch (e) {
|
|
throw await this.smsSendError(
|
|
503,
|
|
'Could not start phone verification.',
|
|
'pending_store_failed',
|
|
{
|
|
userId: user.id,
|
|
userUid: user.uuid,
|
|
country: parsed.country,
|
|
detail: e,
|
|
},
|
|
{ legacyCode: 'service_unavailable' as never },
|
|
);
|
|
}
|
|
|
|
const ip = req.ip || req.socket?.remoteAddress || undefined;
|
|
const userAgent =
|
|
typeof req.headers['user-agent'] === 'string'
|
|
? req.headers['user-agent']
|
|
: undefined;
|
|
// First entry of Prelude's delivery sequence — where the code actually
|
|
// went. Returned to the client so it can point the user at the right
|
|
// app (e.g. "check WhatsApp" instead of "check your texts").
|
|
let deliveryChannel: string | undefined;
|
|
try {
|
|
const result = await this.clients.prelude.createVerification(
|
|
parsed.e164,
|
|
{
|
|
ip,
|
|
device_id: req.deviceFingerprint ?? undefined,
|
|
user_agent: userAgent,
|
|
dispatch_id: dispatchId,
|
|
},
|
|
);
|
|
deliveryChannel = result.channels?.[0];
|
|
// Prelude rejected the attempt as abusive — surface as rate-limit.
|
|
if (
|
|
result.status === 'blocked' ||
|
|
result.status === 'shadow_blocked'
|
|
) {
|
|
throw await this.smsSendError(
|
|
429,
|
|
'Phone verification is temporarily unavailable for this number.',
|
|
`prelude_${result.status}`,
|
|
{
|
|
userId: user.id,
|
|
userUid: user.uuid,
|
|
country: parsed.country,
|
|
},
|
|
{
|
|
legacyCode: 'too_many_requests' as never,
|
|
fields: fallbackFields,
|
|
},
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof HttpError) throw e;
|
|
throw await this.smsSendError(
|
|
502,
|
|
'Could not send verification code.',
|
|
'prelude_request_failed',
|
|
{
|
|
userId: user.id,
|
|
userUid: user.uuid,
|
|
country: parsed.country,
|
|
detail: e,
|
|
},
|
|
{ legacyCode: 'upstream_error' as never },
|
|
);
|
|
}
|
|
|
|
// Tell the abuse extension a code was actually sent, so it can bump its
|
|
// send-velocity counters (per number + per account). Fire-and-forget;
|
|
// the backend keeps no send counts of its own. Only reached after a
|
|
// successful send, so an upstream error never burns quota.
|
|
try {
|
|
this.clients.event?.emit(
|
|
'puter.phone-verification.sent' as never,
|
|
{
|
|
user_id: user.id,
|
|
user_uid: user.uuid,
|
|
phone: parsed.e164,
|
|
device_fingerprint: req.deviceFingerprint ?? null,
|
|
} as never,
|
|
{},
|
|
);
|
|
} catch {
|
|
// ignore — best-effort velocity signal
|
|
}
|
|
res.json({
|
|
...fallbackFields,
|
|
...(deliveryChannel ? { channel: deliveryChannel } : {}),
|
|
});
|
|
}
|
|
|
|
@Post('/confirm-phone', {
|
|
subdomain: ['api', ''],
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
rateLimit: {
|
|
scope: 'confirm-phone',
|
|
limit: 10,
|
|
window: 10 * 60_000,
|
|
key: 'user',
|
|
},
|
|
})
|
|
async handleConfirmPhone(req: Request, res: Response): Promise<void> {
|
|
const { code, original_client_socket_id } = req.body ?? {};
|
|
if (!code)
|
|
throw new HttpError(400, 'Missing `code`.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found.', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
if (!user.requires_phone_verification) {
|
|
res.json({ phone_verified: true, original_client_socket_id });
|
|
return;
|
|
}
|
|
// The number being verified is the one staged at send time (KV
|
|
// pending), not the user row — we don't persist an unverified number.
|
|
// Fall back to a row value for accounts that already have one on file
|
|
// (legacy / a number persisted by a prior verified flow).
|
|
const pendingPhoneKey = `phone-verify-pending:${user.id}`;
|
|
let pendingPhone: string | null = null;
|
|
try {
|
|
const { res: staged } = await this.stores.kv.get({
|
|
key: pendingPhoneKey,
|
|
});
|
|
if (typeof staged === 'string' && staged) pendingPhone = staged;
|
|
} catch (e) {
|
|
console.warn('[confirm-phone] pending read failed:', e);
|
|
}
|
|
if (!pendingPhone) pendingPhone = user.phone ?? null;
|
|
if (!pendingPhone)
|
|
throw new HttpError(
|
|
400,
|
|
'No phone number on file. Request a code first.',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
if (!this.clients.prelude?.isConfigured())
|
|
throw new HttpError(503, 'Phone verification is unavailable.', {
|
|
legacyCode: 'service_unavailable' as never,
|
|
});
|
|
|
|
let status;
|
|
try {
|
|
({ status } = await this.clients.prelude.checkVerification(
|
|
pendingPhone,
|
|
String(code),
|
|
));
|
|
} catch (e) {
|
|
console.warn('[confirm-phone] checkVerification failed:', e);
|
|
throw new HttpError(502, 'Could not verify code.', {
|
|
legacyCode: 'upstream_error' as never,
|
|
});
|
|
}
|
|
|
|
if (status !== 'success') {
|
|
res.json({ phone_verified: false, original_client_socket_id });
|
|
return;
|
|
}
|
|
|
|
// Verified — persist the number now (and only now) and clear the gate.
|
|
await this.stores.user.update(user.id, {
|
|
requires_phone_verification: 0,
|
|
phone: pendingPhone,
|
|
});
|
|
|
|
// Run the verified-event listeners synchronously (emitAndWait, not
|
|
// fire-and-forget emit) so a carrier-based card-verification waiver in
|
|
// the abuse extension lands BEFORE we broadcast "refresh" and respond —
|
|
// otherwise the client re-fetches and still sees the card gate. Load-
|
|
// bearing now; emitAndWait swallows listener errors, so this stays
|
|
// best-effort and never blocks confirm on a listener.
|
|
try {
|
|
await this.clients.event?.emitAndWait(
|
|
'user.phone-verified' as never,
|
|
{
|
|
user_id: user.id,
|
|
user_uid: user.uuid,
|
|
phone: pendingPhone,
|
|
} as never,
|
|
{},
|
|
);
|
|
} catch {
|
|
// ignore — listeners are best-effort
|
|
}
|
|
// Notify other tabs/devices for this user so they refresh + drop the gate.
|
|
try {
|
|
await this.services.socket?.send(
|
|
{ room: user.id },
|
|
'user.phone_verified',
|
|
{ original_client_socket_id },
|
|
);
|
|
} catch {
|
|
// ignore — best-effort
|
|
}
|
|
|
|
res.json({ phone_verified: true, original_client_socket_id });
|
|
}
|
|
|
|
// -- Card verification ($0 auth via a payments extension) --------
|
|
|
|
/**
|
|
* Start card verification for the calling user. Pure mechanism: the
|
|
* endpoint emits `puter.card-verification.setup` and a payments extension
|
|
* fills in the client credentials — the OSS backend holds no provider
|
|
* knowledge or config. Phone verification (when required) must be completed
|
|
* first; the ordering is enforced here so a client can't skip the cheaper
|
|
* gate.
|
|
*/
|
|
@Post('/card-verification/setup', {
|
|
subdomain: ['api', ''],
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
rateLimit: {
|
|
scope: 'card-verification-setup',
|
|
limit: 5,
|
|
window: 60 * 60_000,
|
|
key: 'user',
|
|
},
|
|
})
|
|
async handleCardVerificationSetup(
|
|
req: Request,
|
|
res: Response,
|
|
): Promise<void> {
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found.', {
|
|
legacyCode: 'user_not_found' as never,
|
|
});
|
|
if (user.suspended)
|
|
throw new HttpError(403, 'Account suspended.', {
|
|
legacyCode: 'account_suspended',
|
|
});
|
|
// Phone normally comes first, but the fallback lets a phone-gated user
|
|
// in once they've exhausted SMS attempts.
|
|
const fallbackEligible = await this.isCardFallbackEligible(user);
|
|
if (cardAlreadyVerified(user) && !fallbackEligible) {
|
|
res.json({ card_verified: true });
|
|
return;
|
|
}
|
|
if (user.requires_phone_verification && !fallbackEligible)
|
|
throw new HttpError(
|
|
409,
|
|
'Phone verification must be completed first.',
|
|
{ legacyCode: 'conflict' },
|
|
);
|
|
|
|
// `enabled` stays null when no extension is listening; an installed
|
|
// extension always sets it (true/false) before doing any work.
|
|
const setupEvent = {
|
|
user_id: user.id,
|
|
user_uid: user.uuid,
|
|
ip: (req.ip || req.socket?.remoteAddress || null) as string | null,
|
|
device_fingerprint: req.deviceFingerprint ?? null,
|
|
enabled: null as boolean | null,
|
|
allowed: true,
|
|
reason: null as string | null,
|
|
client_secret: null as string | null,
|
|
publishable_key: null as string | null,
|
|
};
|
|
try {
|
|
await this.clients.event?.emitAndWait(
|
|
'puter.card-verification.setup',
|
|
setupEvent,
|
|
{},
|
|
);
|
|
} catch (e) {
|
|
console.warn('[card-verification/setup] setup hook failed:', e);
|
|
}
|
|
|
|
// Abuse veto (e.g. per-device setup-velocity cap): the extension refused
|
|
// before any Stripe work. Forward the opaque reason verbatim as a 429,
|
|
// same as the phone gate — the backend never interprets it.
|
|
if (setupEvent.allowed === false)
|
|
throw new HttpError(
|
|
429,
|
|
'Card verification is unavailable right now.',
|
|
{
|
|
legacyCode: 'too_many_requests' as never,
|
|
fields: setupEvent.reason
|
|
? { reason: setupEvent.reason }
|
|
: {},
|
|
},
|
|
);
|
|
|
|
// Kill switch: the extension reports the feature disabled — unstick
|
|
// any user still carrying the flag instead of dead-ending them.
|
|
if (setupEvent.enabled === false) {
|
|
// A fallback user is here BECAUSE SMS isn't working for them, and
|
|
// now the card path is off too — they stay phone-gated with no
|
|
// way through. Surface it; don't clear a gate with nothing
|
|
// verified.
|
|
if (fallbackEligible)
|
|
console.warn(
|
|
'[card-verification/setup] card verification disabled;' +
|
|
` fallback-eligible user ${user.uuid} remains` +
|
|
' phone-gated with no working verification path',
|
|
);
|
|
await this.stores.user.update(user.id, {
|
|
requires_card_verification: 0,
|
|
});
|
|
res.json({ card_verified: true, disabled: true });
|
|
return;
|
|
}
|
|
if (!setupEvent.client_secret || !setupEvent.publishable_key)
|
|
throw new HttpError(503, 'Card verification is not available.', {
|
|
legacyCode: 'service_unavailable' as never,
|
|
});
|
|
|
|
res.json({
|
|
client_secret: setupEvent.client_secret,
|
|
publishable_key: setupEvent.publishable_key,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Complete card verification. The client confirms the setup intent with the
|
|
* payment provider directly, then posts the resulting id here; the payments
|
|
* extension checks it (and applies its own abuse limits) via
|
|
* `puter.card-verification.confirm`. On success the gate clears exactly
|
|
* like `/confirm-phone` clears the phone gate.
|
|
*/
|
|
@Post('/card-verification/confirm', {
|
|
subdomain: ['api', ''],
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
rateLimit: {
|
|
scope: 'card-verification-confirm',
|
|
limit: 10,
|
|
window: 10 * 60_000,
|
|
key: 'user',
|
|
},
|
|
})
|
|
async handleCardVerificationConfirm(
|
|
req: Request,
|
|
res: Response,
|
|
): Promise<void> {
|
|
const { setup_intent_id, original_client_socket_id } = req.body ?? {};
|
|
if (
|
|
typeof setup_intent_id !== 'string' ||
|
|
setup_intent_id.length === 0 ||
|
|
setup_intent_id.length > 255
|
|
)
|
|
throw new HttpError(400, 'Invalid `setup_intent_id`.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found.', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
// Same fallback exception as setup: card may come before phone.
|
|
const fallbackEligible = await this.isCardFallbackEligible(user);
|
|
if (cardAlreadyVerified(user) && !fallbackEligible) {
|
|
res.json({ card_verified: true });
|
|
return;
|
|
}
|
|
if (user.requires_phone_verification && !fallbackEligible)
|
|
throw new HttpError(
|
|
409,
|
|
'Phone verification must be completed first.',
|
|
{ legacyCode: 'conflict' },
|
|
);
|
|
|
|
const confirmEvent = {
|
|
user_id: user.id,
|
|
user_uid: user.uuid,
|
|
setup_intent_id,
|
|
enabled: null as boolean | null,
|
|
verified: false,
|
|
reason: null as string | null,
|
|
fingerprint: null as string | null,
|
|
funding: null as string | null,
|
|
country: null as string | null,
|
|
customer_id: null as string | null,
|
|
};
|
|
try {
|
|
await this.clients.event?.emitAndWait(
|
|
'puter.card-verification.confirm',
|
|
confirmEvent,
|
|
{},
|
|
);
|
|
} catch (e) {
|
|
console.warn('[card-verification/confirm] confirm hook failed:', e);
|
|
}
|
|
|
|
// Kill switch — same semantics as /card-verification/setup.
|
|
if (confirmEvent.enabled === false) {
|
|
if (fallbackEligible)
|
|
console.warn(
|
|
'[card-verification/confirm] card verification disabled;' +
|
|
` fallback-eligible user ${user.uuid} remains` +
|
|
' phone-gated with no working verification path',
|
|
);
|
|
await this.stores.user.update(user.id, {
|
|
requires_card_verification: 0,
|
|
});
|
|
res.json({ card_verified: true, disabled: true });
|
|
return;
|
|
}
|
|
// No extension listening — nothing could have verified anything.
|
|
if (confirmEvent.enabled === null)
|
|
throw new HttpError(503, 'Card verification is not available.', {
|
|
legacyCode: 'service_unavailable' as never,
|
|
});
|
|
|
|
if (confirmEvent.verified !== true) {
|
|
res.json({ card_verified: false, reason: confirmEvent.reason });
|
|
return;
|
|
}
|
|
|
|
// A fallback card clears the phone gate too — the point of the
|
|
// fallback. `fallbackEligible &&` makes the invariant local instead
|
|
// of leaning on the 409 guard above: only a fallback user's card can
|
|
// ever clear a phone gate.
|
|
const clearedPhoneGate =
|
|
fallbackEligible && Boolean(user.requires_phone_verification);
|
|
await this.stores.user.update(user.id, {
|
|
requires_card_verification: 0,
|
|
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
|
|
});
|
|
|
|
try {
|
|
this.clients.event?.emit(
|
|
'user.card-verified' as never,
|
|
{
|
|
user_id: user.id,
|
|
user_uid: user.uuid,
|
|
fingerprint: confirmEvent.fingerprint,
|
|
funding: confirmEvent.funding,
|
|
country: confirmEvent.country,
|
|
customer_id: confirmEvent.customer_id,
|
|
} as never,
|
|
{},
|
|
);
|
|
} catch {
|
|
// ignore — event is a side-channel signal, not load-bearing
|
|
}
|
|
// Notify other tabs/devices for this user so they refresh + drop the gate.
|
|
try {
|
|
await this.services.socket?.send(
|
|
{ room: user.id },
|
|
'user.card_verified',
|
|
{ original_client_socket_id },
|
|
);
|
|
// The fallback cleared the phone gate too — tell phone-gate UIs.
|
|
if (clearedPhoneGate)
|
|
await this.services.socket?.send(
|
|
{ room: user.id },
|
|
'user.phone_verified',
|
|
{ original_client_socket_id },
|
|
);
|
|
} catch {
|
|
// ignore — best-effort
|
|
}
|
|
|
|
res.json({
|
|
card_verified: true,
|
|
...(clearedPhoneGate ? { phone_verified: true } : {}),
|
|
});
|
|
}
|
|
|
|
// -- Password recovery -------------------------------------------
|
|
|
|
@Post('/send-pass-recovery-email', {
|
|
subdomain: ['api', ''],
|
|
rateLimit: {
|
|
scope: 'send-pass-recovery-email',
|
|
limit: 10,
|
|
window: 60 * 60_000,
|
|
},
|
|
})
|
|
async handleSendPassRecoveryEmail(
|
|
req: Request,
|
|
res: Response,
|
|
): Promise<void> {
|
|
const { username, email } = req.body ?? {};
|
|
if (!username && !email) {
|
|
throw new HttpError(400, 'username or email is required.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
const genericMessage =
|
|
'If that account exists, a password recovery email was sent.';
|
|
|
|
let user;
|
|
if (username) {
|
|
user = await this.stores.user.getByUsername(username);
|
|
} else {
|
|
if (!validator.isEmail(email))
|
|
throw new HttpError(400, 'Invalid email.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
user = await this.stores.user.getByEmail(email);
|
|
}
|
|
|
|
if (!user || user.suspended || !user.email) {
|
|
res.json({ message: genericMessage });
|
|
return;
|
|
}
|
|
|
|
const pass_recovery_token = uuidv4();
|
|
await this.stores.user.update(user.id, { pass_recovery_token });
|
|
|
|
const jwt = this.services.token.sign(
|
|
'otp',
|
|
{
|
|
token: pass_recovery_token,
|
|
user_uid: user.uuid,
|
|
email: user.email,
|
|
purpose: 'pass-recovery',
|
|
},
|
|
{ expiresIn: '1h' },
|
|
);
|
|
|
|
const origin = this.config.origin ?? '';
|
|
const link = `${origin}/action/set-new-password?token=${encodeURIComponent(jwt)}`;
|
|
|
|
if (this.clients.email) {
|
|
try {
|
|
await this.clients.email.send(
|
|
user.email,
|
|
'email_password_recovery',
|
|
{ link },
|
|
);
|
|
} catch (e) {
|
|
console.warn('[send-pass-recovery-email] send failed:', e);
|
|
}
|
|
}
|
|
|
|
res.json({ message: genericMessage });
|
|
}
|
|
|
|
@Post('/verify-pass-recovery-token', {
|
|
subdomain: ['api', ''],
|
|
rateLimit: {
|
|
scope: 'verify-pass-recovery-token',
|
|
limit: 10,
|
|
window: 15 * 60_000,
|
|
},
|
|
})
|
|
async handleVerifyPassRecoveryToken(
|
|
req: Request,
|
|
res: Response,
|
|
): Promise<void> {
|
|
const { token } = req.body ?? {};
|
|
if (!token)
|
|
throw new HttpError(400, 'Missing `token`.', {
|
|
legacyCode: 'token_missing' as never,
|
|
});
|
|
|
|
let decoded;
|
|
try {
|
|
decoded = this.services.token.verify<{
|
|
user_uid: string;
|
|
email: string;
|
|
exp: number;
|
|
purpose: string;
|
|
}>('otp', token);
|
|
} catch {
|
|
throw new HttpError(400, 'Invalid or expired token.', {
|
|
legacyCode: 'token_expired' as never,
|
|
});
|
|
}
|
|
if (decoded.purpose !== 'pass-recovery') {
|
|
throw new HttpError(400, 'Invalid or expired token.', {
|
|
legacyCode: 'token_expired' as never,
|
|
});
|
|
}
|
|
|
|
const user = await this.stores.user.getByUuid(decoded?.user_uid);
|
|
if (!user || user.email !== decoded.email) {
|
|
throw new HttpError(400, 'Token is no longer valid.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (user.suspended) {
|
|
throw new HttpError(401, 'This account is suspended.', {
|
|
legacyCode: 'account_suspended',
|
|
});
|
|
}
|
|
|
|
const exp = decoded.exp as number;
|
|
const time_remaining = exp
|
|
? Math.max(0, exp - Math.floor(Date.now() / 1000))
|
|
: 0;
|
|
res.json({ time_remaining });
|
|
}
|
|
|
|
@Post('/set-pass-using-token', {
|
|
subdomain: ['api', ''],
|
|
rateLimit: {
|
|
scope: 'set-pass-using-token',
|
|
limit: 10,
|
|
window: 60 * 60_000,
|
|
},
|
|
})
|
|
async handleSetPassUsingToken(req: Request, res: Response): Promise<void> {
|
|
const { token, password } = req.body ?? {};
|
|
if (!token || !password) {
|
|
throw new HttpError(400, 'Missing `token` or `password`.', {
|
|
legacyCode: 'token_missing' as never,
|
|
});
|
|
}
|
|
const minLen = this.config.min_pass_length || 6;
|
|
if (password.length < minLen) {
|
|
throw new HttpError(
|
|
400,
|
|
`Password must be at least ${minLen} characters long.`,
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
|
|
let decoded;
|
|
try {
|
|
decoded = this.services.token.verify<{
|
|
user_uid: string;
|
|
email: string;
|
|
token: string;
|
|
purpose: string;
|
|
}>('otp', token);
|
|
} catch {
|
|
throw new HttpError(400, 'Invalid or expired token.', {
|
|
legacyCode: 'token_expired' as never,
|
|
});
|
|
}
|
|
if (decoded.purpose !== 'pass-recovery') {
|
|
throw new HttpError(400, 'Invalid or expired token.', {
|
|
legacyCode: 'token_expired' as never,
|
|
});
|
|
}
|
|
|
|
const user = await this.stores.user.getByUuid(decoded.user_uid);
|
|
if (!user || user.email !== decoded.email) {
|
|
throw new HttpError(400, 'Token is no longer valid.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (user.suspended) {
|
|
throw new HttpError(401, 'This account is suspended.', {
|
|
legacyCode: 'account_suspended',
|
|
});
|
|
}
|
|
|
|
// Atomic check: only update if the recovery token still matches
|
|
const password_hash = await bcrypt.hash(password, 8);
|
|
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 ??
|
|
(result as { affectedRows?: number; changes?: number })?.changes ??
|
|
0;
|
|
if (affected === 0) {
|
|
throw new HttpError(400, 'Token has already been used.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
await this.stores.user.invalidateById(user.id);
|
|
|
|
// A password reset is the "I think someone else has access" flow —
|
|
// evict every interactive session so a hijacked one doesn't survive.
|
|
await this.services.auth.revokeInteractiveSessionsForUserId(
|
|
user.id as number,
|
|
);
|
|
|
|
res.send('Password successfully updated.');
|
|
}
|
|
|
|
// -- User-protected mutations ------------------------------------
|
|
//
|
|
// The five `/user-protected/*` and `/user-protected/delete-own-user`
|
|
// routes are wired in the `registerRoutes` override below because
|
|
// their `middleware: createUserProtectedGate(...)` argument depends
|
|
// on `this.config / this.stores / this.services` and so can't live
|
|
// in a static decorator literal. The handler bodies stay here as
|
|
// ordinary methods so tests can call them directly.
|
|
|
|
async handleChangePassword(req: Request, res: Response): Promise<void> {
|
|
const { new_pass } = req.body ?? {};
|
|
if (!new_pass)
|
|
throw new HttpError(400, 'Missing `new_pass`.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
const minLen = this.config.min_pass_length || 6;
|
|
if (new_pass.length < minLen) {
|
|
throw new HttpError(
|
|
400,
|
|
`Password must be at least ${minLen} characters long.`,
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
|
|
const user = req.userProtected!.user;
|
|
|
|
const password_hash = await bcrypt.hash(new_pass, 8);
|
|
await this.stores.user.update(user.id, {
|
|
password: password_hash,
|
|
pass_recovery_token: null,
|
|
change_email_confirm_token: null,
|
|
});
|
|
|
|
// Sign out every other web session (cascading to their derived
|
|
// rows); only the session that changed the password survives.
|
|
await this.services.auth.revokeAllSessions(req.actor!);
|
|
|
|
if (this.clients.email && user.email) {
|
|
try {
|
|
await this.clients.email.send(
|
|
user.email,
|
|
'password_change_notification',
|
|
{
|
|
username: user.username,
|
|
},
|
|
);
|
|
} catch (e) {
|
|
console.warn('[change-password] notification send failed:', e);
|
|
}
|
|
}
|
|
|
|
res.send('Password successfully updated.');
|
|
}
|
|
|
|
async handleChangeUsername(req: Request, res: Response): Promise<void> {
|
|
const { new_username } = req.body ?? {};
|
|
if (!new_username || typeof new_username !== 'string') {
|
|
throw new HttpError(400, '`new_username` is required', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (!USERNAME_REGEX.test(new_username)) {
|
|
throw new HttpError(
|
|
400,
|
|
'Username can only contain letters, numbers and underscore (_).',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
if (new_username.length > USERNAME_MAX_LENGTH) {
|
|
throw new HttpError(
|
|
400,
|
|
`Username cannot be longer than ${USERNAME_MAX_LENGTH} characters.`,
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
if (RESERVED_USERNAMES.has(new_username.toLowerCase())) {
|
|
throw new HttpError(400, 'This username is not available.', {
|
|
legacyCode: 'username_already_in_use',
|
|
});
|
|
}
|
|
if (await this.stores.user.getByUsername(new_username)) {
|
|
throw new HttpError(400, 'This username is already taken.', {
|
|
legacyCode: 'username_already_in_use',
|
|
});
|
|
}
|
|
|
|
await this.stores.user.update(req.actor!.user.id!, {
|
|
username: new_username,
|
|
});
|
|
|
|
// Rename the user's FS home from `/<old>` to `/<new>` and
|
|
// cascade the prefix to all descendants. Without this, any
|
|
// path-based lookup (stat/readdir/write) would 404 after
|
|
// rename because the fsentries still reference `/<old>`.
|
|
try {
|
|
await this.stores.fsEntry.renameUserHome(
|
|
req.actor!.user.id!,
|
|
new_username,
|
|
);
|
|
} catch (e) {
|
|
console.warn('[change-username] fs home rename failed:', e);
|
|
}
|
|
|
|
try {
|
|
this.clients.event?.emit(
|
|
'user.username-changed' as never,
|
|
{
|
|
user_id: req.actor!.user.id,
|
|
old_username: req.actor!.user.username,
|
|
new_username,
|
|
} as never,
|
|
{},
|
|
);
|
|
} catch {
|
|
// event emission best-effort
|
|
}
|
|
|
|
res.json({ username: new_username });
|
|
}
|
|
|
|
async handleChangeEmail(req: Request, res: Response): Promise<void> {
|
|
const { new_email } = req.body ?? {};
|
|
if (!new_email || typeof new_email !== 'string') {
|
|
throw new HttpError(400, '`new_email` is required', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (!validator.isEmail(new_email)) {
|
|
throw new HttpError(400, 'Please enter a valid email address.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
await this.#validateEmail(new_email);
|
|
|
|
// Block if any OTHER confirmed account (password or OIDC) already
|
|
// owns that email. Match raw + canonical to collapse gmail
|
|
// 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 existing = await this.stores.user.findEmailOwner(new_email);
|
|
if (
|
|
existing &&
|
|
existing.id !== req.actor!.user.id &&
|
|
(existing.email_confirmed || existing.password !== null)
|
|
) {
|
|
throw new HttpError(400, 'This email is already in use.', {
|
|
legacyCode: 'email_already_in_use' as never,
|
|
});
|
|
}
|
|
|
|
const confirm_token = uuidv4();
|
|
await this.stores.user.update(req.actor!.user.id!, {
|
|
unconfirmed_change_email: new_email,
|
|
change_email_confirm_token: confirm_token,
|
|
});
|
|
|
|
const linkJwt = this.services.token.sign(
|
|
'otp',
|
|
{
|
|
token: confirm_token,
|
|
user_id: req.actor!.user.id,
|
|
purpose: 'change-email',
|
|
},
|
|
{ expiresIn: '1h' },
|
|
);
|
|
|
|
if (this.clients.email) {
|
|
const origin = this.config.origin ?? '';
|
|
const link = `${origin}/change_email/confirm?token=${encodeURIComponent(linkJwt)}`;
|
|
try {
|
|
await this.clients.email.send(
|
|
new_email,
|
|
'email_verification_link',
|
|
{ link },
|
|
);
|
|
} catch (e) {
|
|
console.warn('[change-email] new-address email failed:', e);
|
|
}
|
|
// Notify the old address too
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (user?.email) {
|
|
try {
|
|
await (
|
|
this.clients.email as unknown as {
|
|
sendRaw: (opts: {
|
|
to: string;
|
|
subject: string;
|
|
text: string;
|
|
}) => Promise<unknown>;
|
|
}
|
|
).sendRaw({
|
|
to: user.email,
|
|
subject: 'Your Puter email change was requested',
|
|
text: `A change to ${new_email} was requested on your account. If this wasn't you, please contact support.`,
|
|
});
|
|
} catch (e) {
|
|
console.warn(
|
|
'[change-email] old-address notice failed:',
|
|
e,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({});
|
|
}
|
|
|
|
@Get('/change_email/confirm', {
|
|
subdomain: ['api', ''],
|
|
rateLimit: {
|
|
scope: 'change-email-confirm',
|
|
limit: 10,
|
|
window: 60 * 60_000,
|
|
},
|
|
})
|
|
async handleChangeEmailConfirm(req: Request, res: Response): Promise<void> {
|
|
const jwtToken = req.query?.token;
|
|
if (!jwtToken || typeof jwtToken !== 'string') {
|
|
throw new HttpError(400, 'Missing `token`', {
|
|
legacyCode: 'token_missing' as never,
|
|
});
|
|
}
|
|
|
|
let decoded;
|
|
try {
|
|
decoded = this.services.token.verify('otp', jwtToken);
|
|
} catch {
|
|
throw new HttpError(400, 'Invalid or expired token.', {
|
|
legacyCode: 'token_expired' as never,
|
|
});
|
|
}
|
|
if (decoded.purpose !== 'change-email' || !decoded.token) {
|
|
throw new HttpError(400, 'Invalid or expired token.', {
|
|
legacyCode: 'token_expired' as never,
|
|
});
|
|
}
|
|
|
|
const rows = (await this.clients.db.read(
|
|
'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
|
|
| {
|
|
id: number;
|
|
email_confirmed?: number | boolean;
|
|
password?: string | null;
|
|
unconfirmed_change_email?: string;
|
|
}
|
|
| undefined;
|
|
if (!user || !user.unconfirmed_change_email) {
|
|
throw new HttpError(400, 'Invalid or expired token.', {
|
|
legacyCode: 'token_expired' as never,
|
|
});
|
|
}
|
|
|
|
const newEmail = user.unconfirmed_change_email;
|
|
|
|
// Re-check nobody claimed the new email meanwhile. Match raw +
|
|
// canonical; block if any real account (confirmed OR
|
|
// 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.findEmailOwner(newEmail, {
|
|
force: true,
|
|
});
|
|
if (
|
|
owner &&
|
|
owner.id !== user.id &&
|
|
(owner.email_confirmed || owner.password !== null)
|
|
) {
|
|
throw new HttpError(400, 'This email is already in use.', {
|
|
legacyCode: 'email_already_in_use' as never,
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
|
|
try {
|
|
this.clients.event?.emit(
|
|
'user.email-changed' as never,
|
|
{
|
|
user_id: user.id,
|
|
new_email: newEmail,
|
|
} as never,
|
|
{},
|
|
);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
|
|
res.send('Email changed successfully. You may close this window.');
|
|
}
|
|
|
|
// -- Save account (convert temp user to permanent) ---------------
|
|
|
|
@Post('/save_account', {
|
|
subdomain: ['api', ''],
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
captcha: true,
|
|
rateLimit: {
|
|
scope: 'save-account',
|
|
limit: 10,
|
|
window: 60 * 60_000,
|
|
key: 'user',
|
|
},
|
|
})
|
|
async handleSaveAccount(req: Request, res: Response): Promise<void> {
|
|
const { username, email, password } = req.body ?? {};
|
|
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
if (user.password !== null || user.email !== null) {
|
|
throw new HttpError(400, 'This is not a temporary account.', {
|
|
legacyCode: 'temporary_accounts_not_allowed' as never,
|
|
});
|
|
}
|
|
|
|
// Validation
|
|
if (
|
|
!username ||
|
|
typeof username !== 'string' ||
|
|
!USERNAME_REGEX.test(username)
|
|
) {
|
|
throw new HttpError(400, 'Invalid username.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (username.length > USERNAME_MAX_LENGTH) {
|
|
throw new HttpError(
|
|
400,
|
|
`Username cannot be longer than ${USERNAME_MAX_LENGTH} characters.`,
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
if (RESERVED_USERNAMES.has(username.toLowerCase())) {
|
|
throw new HttpError(400, 'This username is not available.', {
|
|
legacyCode: 'username_already_in_use',
|
|
});
|
|
}
|
|
if (!email || !validator.isEmail(email)) {
|
|
throw new HttpError(400, 'Please enter a valid email address.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
await this.#validateEmail(email);
|
|
if (!password || typeof password !== 'string') {
|
|
throw new HttpError(400, 'Password is required.', {
|
|
legacyCode: 'password_required',
|
|
});
|
|
}
|
|
const minLen = this.config.min_pass_length || 6;
|
|
if (password.length < minLen) {
|
|
throw new HttpError(
|
|
400,
|
|
`Password must be at least ${minLen} characters long.`,
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
|
|
// Duplicate checks
|
|
const existingUsername = await this.stores.user.getByUsername(username);
|
|
if (existingUsername && existingUsername.id !== user.id) {
|
|
throw new HttpError(400, 'This username is already taken.', {
|
|
legacyCode: 'username_already_in_use',
|
|
});
|
|
}
|
|
// Match raw + canonical to catch gmail-alias collisions, and
|
|
// 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.findEmailOwner(email);
|
|
if (
|
|
existingEmail &&
|
|
existingEmail.id !== user.id &&
|
|
(existingEmail.email_confirmed || existingEmail.password !== null)
|
|
) {
|
|
throw new HttpError(400, 'This email is already in use.', {
|
|
legacyCode: 'email_already_in_use' as never,
|
|
});
|
|
}
|
|
|
|
// Promote: set username/email/password on the existing row
|
|
const password_hash = await bcrypt.hash(password, 8);
|
|
const email_confirm_code = String(crypto.randomInt(100000, 1000000));
|
|
const email_confirm_token = uuidv4();
|
|
|
|
// 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
|
|
// subsequent path-based FS lookup against the new
|
|
// username would 404.
|
|
if (username !== user.username) {
|
|
try {
|
|
await this.stores.fsEntry.renameUserHome(user.id, username);
|
|
} catch (e) {
|
|
console.warn('[save-account] fs home rename failed:', e);
|
|
}
|
|
}
|
|
|
|
// Move from temp group to user group
|
|
if (this.config.default_temp_group) {
|
|
try {
|
|
await this.stores.group.removeUsers(
|
|
this.config.default_temp_group,
|
|
[username],
|
|
);
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
}
|
|
if (this.config.default_user_group) {
|
|
try {
|
|
await this.stores.group.addUsers(
|
|
this.config.default_user_group,
|
|
[username],
|
|
);
|
|
} catch (e) {
|
|
console.warn('[save-account] group add failed:', e);
|
|
}
|
|
}
|
|
|
|
// Send confirmation email
|
|
if (this.clients.email) {
|
|
try {
|
|
await this.clients.email.send(
|
|
email,
|
|
'email_verification_code',
|
|
{ code: email_confirm_code },
|
|
);
|
|
} catch (e) {
|
|
console.warn('[save-account] confirmation email failed:', e);
|
|
}
|
|
}
|
|
|
|
try {
|
|
this.clients.event?.emit(
|
|
'user.save_account' as never,
|
|
{
|
|
user_id: user.id,
|
|
old_username: user.username,
|
|
new_username: username,
|
|
email,
|
|
} as never,
|
|
{},
|
|
);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
|
|
const updatedUser = await this.stores.user.getById(user.id, {
|
|
force: true,
|
|
});
|
|
res.json({
|
|
user: {
|
|
username: updatedUser!.username,
|
|
uuid: updatedUser!.uuid,
|
|
email: updatedUser!.email,
|
|
email_confirmed: updatedUser!.email_confirmed,
|
|
requires_email_confirmation:
|
|
updatedUser!.requires_email_confirmation,
|
|
is_temp: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
// -- Captcha generation -------------------------------------------
|
|
|
|
@Get('/api/captcha/generate', {
|
|
subdomain: '*',
|
|
// Unauthenticated, renders an image per call, and is the gate
|
|
// protecting /login and /signup — so bulk pre-generation is
|
|
// directly useful to an attacker. Per-fingerprint for fairness on
|
|
// shared IPs, plus a per-IP backstop against header rotation.
|
|
//
|
|
// The fingerprint bucket is the one sized for a person: a handful of
|
|
// refreshes while getting a captcha right. The IP bucket is not — one
|
|
// address is a whole office, campus or carrier gateway, and everyone
|
|
// behind it is signing in through the same counter, so sizing it for
|
|
// a browser would deny the captcha to a network rather than to an
|
|
// attacker. It stays wide enough for that population and narrow
|
|
// enough that header rotation still runs out.
|
|
rateLimit: [
|
|
{ scope: 'captcha', limit: 30, window: 60_000 },
|
|
{ scope: 'captcha-ip', limit: 3_000, window: 60_000, key: 'ip' },
|
|
],
|
|
})
|
|
async handleCaptchaGenerate(_req: Request, res: Response): Promise<void> {
|
|
const difficulty =
|
|
(this.config as { captcha?: { difficulty?: string } }).captcha
|
|
?.difficulty || 'medium';
|
|
const { token, image } = await generateCaptcha(difficulty);
|
|
res.json({ token, image });
|
|
}
|
|
|
|
// -- Anti-CSRF token generation ----------------------------------
|
|
|
|
@Get('/get-anticsrf-token', {
|
|
rateLimit: ANTI_CSRF_MINT_LIMIT,
|
|
// Anti-CSRF tokens are only consumed by `requireUserActor` routes,
|
|
// so issuance is scoped to the same actor kind for consistency.
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
})
|
|
async handleGetAntiCsrfToken(req: Request, res: Response): Promise<void> {
|
|
const sessionId = req.actor?.user?.uuid;
|
|
if (!sessionId)
|
|
throw new HttpError(401, 'Authentication required.', {
|
|
legacyCode: 'unauthorized',
|
|
});
|
|
const token = await antiCsrf.createToken(sessionId);
|
|
res.json({ token });
|
|
}
|
|
|
|
// -- Permission grants -------------------------------------------
|
|
|
|
/**
|
|
* Shared input validation for the user-app grant/revoke handlers, which
|
|
* accept a caller-supplied `origin` as an alternative to `app_uid`. All
|
|
* parameters are optional-but-typed: presence is enforced by the handlers'
|
|
* own `app_uid`/`permission` checks after origin resolution.
|
|
*
|
|
* These are bounded only against absurd input. The width of the column a
|
|
* permission lands in is enforced by the permission service instead, on the
|
|
* _rewritten_ string: `fs:/path:mode` is rewritten to `fs:<uuid>:mode`
|
|
* before it is stored, so a deep path is a ~45-character row and must not
|
|
* be rejected for the length of the path the caller typed.
|
|
*/
|
|
#validateAppPermissionParams(params: {
|
|
app_uid?: unknown;
|
|
origin?: unknown;
|
|
permission?: unknown;
|
|
extra?: unknown;
|
|
meta?: unknown;
|
|
}): void {
|
|
const MAX_LEN = 4096;
|
|
for (const key of ['app_uid', 'origin', 'permission'] as const) {
|
|
const value = params[key];
|
|
if (value === undefined || value === null) continue;
|
|
if (typeof value !== 'string' || value.length > MAX_LEN) {
|
|
throw new HttpError(400, `Invalid \`${key}\``, {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
}
|
|
// `extra` and `meta` are forwarded into the audit row and read as
|
|
// objects downstream. A non-object would fault *after* the grant is
|
|
// committed, so reject it up front. `null` is treated as absent, the
|
|
// same as the string parameters above.
|
|
for (const key of ['extra', 'meta'] as const) {
|
|
const value = params[key];
|
|
if (value === undefined || value === null) continue;
|
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new HttpError(400, `Invalid \`${key}\``, {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves a caller-supplied `origin` to the uid of a _registered_ app.
|
|
*
|
|
* `appUidFromOrigin` synthesises a deterministic `app-<uuidv5>` uid for an
|
|
* origin that has no app row yet, and the permission services resolve their
|
|
* identifier as uid-_or-name_. Passing a synthetic uid straight through
|
|
* would therefore let whoever registered an app under that literal name
|
|
* collect a grant the user made to the origin — the uid is derived from a
|
|
* published namespace constant, so it can be computed and squatted offline.
|
|
* Only a uid that names an existing app row is accepted.
|
|
*
|
|
* An `origin` supplied alongside an `app_uid` takes precedence over it (see
|
|
* the grant/revoke handlers). The origin is what a consent prompt shows the
|
|
* user, so it — not a uid travelling beside it — has to decide who receives
|
|
* the grant; otherwise a caller could name one app on screen and grant to
|
|
* another. No caller sends both with different intent.
|
|
*/
|
|
async #registeredAppUidFromOrigin(origin: string): Promise<string> {
|
|
const uid = await this.services.auth.appUidFromOrigin(origin);
|
|
const app = await this.stores.app.getByUid(uid);
|
|
if (!app) {
|
|
throw new HttpError(404, `entity_not_found: app:${uid}`, {
|
|
legacyCode: 'subject_does_not_exist',
|
|
});
|
|
}
|
|
return app.uid;
|
|
}
|
|
|
|
/**
|
|
* Resolve the `permission` / `permissions` pair into the list to act on.
|
|
*
|
|
* One consent prompt can cover several scopes (read a store, write
|
|
* another), and a client looping the single form would have to invent its
|
|
* own partial-failure and rollback handling. Accepting the array keeps that
|
|
* in one request.
|
|
*/
|
|
#appPermissionList(body: {
|
|
permission?: unknown;
|
|
permissions?: unknown;
|
|
}): string[] {
|
|
const { permission, permissions } = body;
|
|
if (permissions !== undefined && permissions !== null) {
|
|
if (permission !== undefined && permission !== null) {
|
|
throw new HttpError(
|
|
400,
|
|
'Pass `permission` or `permissions`, not both',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
if (!Array.isArray(permissions) || permissions.length === 0) {
|
|
throw new HttpError(400, 'Invalid `permissions`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (permissions.length > MAX_PERMISSIONS_PER_REQUEST) {
|
|
throw new HttpError(400, 'Too many `permissions`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
for (const entry of permissions) {
|
|
this.#validateAppPermissionParams({ permission: entry });
|
|
// `*` means "revoke everything" in the scalar form only —
|
|
// inside a list it would silently widen a targeted request.
|
|
if (!entry || entry === '*') {
|
|
throw new HttpError(400, 'Invalid `permissions`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
}
|
|
return [...new Set(permissions as string[])];
|
|
}
|
|
return typeof permission === 'string' && permission ? [permission] : [];
|
|
}
|
|
|
|
/**
|
|
* Gate a cross-app data grant: the target must exist, must not have opted
|
|
* out of sharing, and must be named. Also creates the target's AppData
|
|
* directory for an `fs` scope, since it is only created lazily when the app
|
|
* first runs — without this a valid grant would 404 until then.
|
|
*/
|
|
async #prepareAppDataGrant(
|
|
actor: Actor,
|
|
permission: string,
|
|
): Promise<void> {
|
|
const parsed = parseAppDataPermission(permission);
|
|
if (!parsed) {
|
|
// A bare `app-data` (or one with an empty target) would cover every
|
|
// app the user has by prefix implication, which no prompt can
|
|
// describe. Reject rather than treat it as an unrelated permission.
|
|
if (
|
|
permission === APP_DATA_PERMISSION_PREFIX ||
|
|
permission.startsWith(`${APP_DATA_PERMISSION_PREFIX}:`)
|
|
) {
|
|
throw new HttpError(
|
|
400,
|
|
'Invalid `app-data` permission: missing target app',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const target = await this.stores.app.getByUid(parsed.targetAppUid);
|
|
if (!target) {
|
|
throw new HttpError(
|
|
404,
|
|
`entity_not_found: app:${parsed.targetAppUid}`,
|
|
{ legacyCode: 'subject_does_not_exist' },
|
|
);
|
|
}
|
|
if (!appDataSharingAllowed(target)) {
|
|
throw new HttpError(
|
|
403,
|
|
'This app does not share its data with other apps',
|
|
{ legacyCode: 'forbidden' },
|
|
);
|
|
}
|
|
|
|
const username = actor.user?.username;
|
|
const userId = actor.user?.id;
|
|
if ((parsed.store === 'fs' || !parsed.store) && username && userId) {
|
|
await this.services.fs.mkdir(userId, {
|
|
path: `/${username}/AppData/${parsed.targetAppUid}`,
|
|
createMissingParents: true,
|
|
} as never);
|
|
}
|
|
}
|
|
|
|
@Post('/auth/grant-user-app', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
rateLimit: GRANT_LIMIT,
|
|
})
|
|
async handleGrantUserApp(req: Request, res: Response): Promise<void> {
|
|
let { app_uid } = req.body;
|
|
const { origin, permission, permissions, extra, meta } = req.body;
|
|
this.#validateAppPermissionParams({
|
|
app_uid,
|
|
origin,
|
|
permission,
|
|
extra,
|
|
meta,
|
|
});
|
|
const list = this.#appPermissionList({ permission, permissions });
|
|
if (origin) {
|
|
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
|
}
|
|
if (!app_uid || list.length === 0) {
|
|
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
// Validate every entry before writing any, so a bad one in the list
|
|
// cannot leave a partially-granted set behind: the dialog reads a 4xx as
|
|
// "nothing was written" and skips its withdrawal, so a partial commit
|
|
// leaves live access the user was told they refused. The rewrite running
|
|
// twice is cheaper than splitting the grant into prepare/commit.
|
|
for (const entry of list) {
|
|
await this.services.permission.assertUserAppPermissionWritable(
|
|
entry,
|
|
);
|
|
await this.#prepareAppDataGrant(req.actor!, entry);
|
|
}
|
|
for (const entry of list) {
|
|
await this.services.permission.grantUserAppPermission(
|
|
req.actor!,
|
|
app_uid,
|
|
entry,
|
|
extra ?? undefined,
|
|
meta ?? undefined,
|
|
);
|
|
}
|
|
res.json({});
|
|
}
|
|
|
|
// -- Permission revokes ------------------------------------------
|
|
|
|
/**
|
|
* @deprecated Use `puter.fs.unshare()`. Kept for direct HTTP callers:
|
|
* access left behind by an older client has to stay withdrawable.
|
|
*/
|
|
@Post('/auth/revoke-user-user', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
rateLimit: GRANT_LIMIT,
|
|
})
|
|
async handleRevokeUserUser(req: Request, res: Response): Promise<void> {
|
|
const { target_username, permission, meta } = req.body;
|
|
if (!target_username || !permission) {
|
|
throw new HttpError(
|
|
400,
|
|
'Missing `target_username` or `permission`',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
await this.services.permission.revokeUserUserPermission(
|
|
req.actor!,
|
|
target_username,
|
|
permission,
|
|
meta,
|
|
);
|
|
// The share index is what listings read, so it has to go with the
|
|
// grant — otherwise the item keeps reporting itself as shared.
|
|
await this.services.share.onGrantRevoked(
|
|
req.actor!,
|
|
target_username,
|
|
permission,
|
|
);
|
|
res.json({});
|
|
}
|
|
|
|
@Post('/auth/revoke-user-app', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
rateLimit: GRANT_LIMIT,
|
|
})
|
|
async handleRevokeUserApp(req: Request, res: Response): Promise<void> {
|
|
let { app_uid } = req.body;
|
|
const { origin, permission, permissions, meta } = req.body;
|
|
this.#validateAppPermissionParams({
|
|
app_uid,
|
|
origin,
|
|
permission,
|
|
meta,
|
|
});
|
|
const list = this.#appPermissionList({ permission, permissions });
|
|
if (origin) {
|
|
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
|
}
|
|
if (!app_uid || list.length === 0) {
|
|
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
// Deliberately not gated by the target's sharing flag: a user must
|
|
// always be able to withdraw a grant, whatever the target now says.
|
|
if (permission === '*') {
|
|
await this.services.permission.revokeUserAppAll(
|
|
req.actor!,
|
|
app_uid,
|
|
meta ?? undefined,
|
|
);
|
|
} else {
|
|
for (const entry of list) {
|
|
await this.services.permission.revokeUserAppPermission(
|
|
req.actor!,
|
|
app_uid,
|
|
entry,
|
|
meta ?? undefined,
|
|
);
|
|
}
|
|
}
|
|
res.json({});
|
|
}
|
|
|
|
// -- Permission checks -------------------------------------------
|
|
|
|
@Post('/auth/check-permissions', {
|
|
subdomain: 'api',
|
|
requireAuth: true,
|
|
rateLimit: AUTH_CHECK_LIMIT,
|
|
})
|
|
async handleCheckPermissions(req: Request, res: Response): Promise<void> {
|
|
const { permissions } = req.body;
|
|
if (!Array.isArray(permissions)) {
|
|
throw new HttpError(400, 'Missing or invalid `permissions` array', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
const unique = [...new Set(permissions)] as string[];
|
|
const result: Record<string, boolean> = {};
|
|
let granted: Map<string, boolean>;
|
|
try {
|
|
granted = await this.services.permission.checkMany(
|
|
req.actor!,
|
|
unique,
|
|
);
|
|
} catch {
|
|
granted = new Map<string, boolean>();
|
|
}
|
|
for (const perm of unique) {
|
|
result[perm] = granted.get(perm) ?? false;
|
|
}
|
|
res.json({ permissions: result });
|
|
}
|
|
|
|
// -- Session management ------------------------------------------
|
|
|
|
@Get('/auth/list-sessions', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
rateLimit: AUTH_LIST_LIMIT,
|
|
})
|
|
async handleListSessions(req: Request, res: Response): Promise<void> {
|
|
const sessions = await this.services.auth.listSessions(req.actor!);
|
|
res.json(sessions);
|
|
}
|
|
|
|
// Wired imperatively in `registerRoutes` so the cookie-only gate
|
|
// (built from `this.config`) can be composed in. Cookie-only is
|
|
// mandatory: an access token must not be able to revoke its own
|
|
// issuing web session.
|
|
async handleRevokeSession(req: Request, res: Response): Promise<void> {
|
|
const { uuid } = req.body;
|
|
if (!uuid || typeof uuid !== 'string') {
|
|
throw new HttpError(400, 'Missing or invalid `uuid`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
// The caller's own session row must go through /logout, not a
|
|
// self-revoke — otherwise the response can't write fresh auth
|
|
// state and the client ends up with an ambiguous post-revoke
|
|
// identity. /auth/revoke-all-sessions still supports a separate
|
|
// `include_current` opt-in for the nuclear case.
|
|
if (uuid === req.actor!.session?.uid) {
|
|
throw new HttpError(
|
|
400,
|
|
'Cannot revoke your current session — use /logout instead',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
// `getByUuid` returns null when the row is missing, already
|
|
// soft-revoked, or past `expires_at` — surface as 404 so a stale
|
|
// manage-sessions UI doesn't 500 when it clicks revoke on a row
|
|
// that already went away.
|
|
const session = await this.stores.session.getByUuid(uuid);
|
|
if (!session) {
|
|
throw new HttpError(404, 'Session not found', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
}
|
|
if (session.user_id !== req.actor!.user.id) {
|
|
throw new HttpError(403, 'Can only revoke your own sessions', {
|
|
legacyCode: 'unauthorized',
|
|
});
|
|
}
|
|
await this.services.auth.revokeSession(uuid);
|
|
const sessions = await this.services.auth.listSessions(req.actor!);
|
|
res.json({ sessions });
|
|
}
|
|
|
|
async handleRevokeAllSessions(req: Request, res: Response): Promise<void> {
|
|
const { include_current, include_apps } = req.body ?? {};
|
|
await this.services.auth.revokeAllSessions(req.actor!, {
|
|
includeCurrent: !!include_current,
|
|
includeApps: !!include_apps,
|
|
});
|
|
const sessions = await this.services.auth.listSessions(req.actor!);
|
|
res.json({ sessions });
|
|
}
|
|
|
|
async handleRenameSession(req: Request, res: Response): Promise<void> {
|
|
const uuid = req.params.uuid;
|
|
const { label } = (req.body ?? {}) as { label?: unknown };
|
|
if (!uuid || typeof uuid !== 'string') {
|
|
throw new HttpError(400, 'Missing or invalid `uuid`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (label !== null && typeof label !== 'string') {
|
|
throw new HttpError(400, '`label` must be a string or null', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
await this.services.auth.setSessionLabel(
|
|
req.actor!,
|
|
uuid,
|
|
label ?? null,
|
|
);
|
|
res.json({});
|
|
}
|
|
|
|
// -- Dev app permissions -----------------------------------------
|
|
|
|
@Post('/auth/grant-dev-app', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
rateLimit: GRANT_LIMIT,
|
|
})
|
|
async handleGrantDevApp(req: Request, res: Response): Promise<void> {
|
|
let { app_uid } = req.body;
|
|
const { origin, permission, extra, meta } = req.body;
|
|
if (origin && !app_uid) {
|
|
// Registered apps only, for the same reason the user-app handlers
|
|
// insist on it: a synthesised `app-<uuidv5(origin)>` is resolved
|
|
// downstream as uid-*or-name*, so it would land on whoever
|
|
// registered an app under that literal name. A dev-app grant is
|
|
// scanned with the issuer's authority for anyone running as that
|
|
// app, so that hands this user's permission to the squatter.
|
|
// Without a squatter the synthetic uid resolves to nothing and
|
|
// this 404s regardless, so nothing legitimate changes.
|
|
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
|
}
|
|
if (!app_uid || !permission) {
|
|
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
await this.services.permission.grantDevAppPermission(
|
|
req.actor!,
|
|
app_uid,
|
|
permission,
|
|
extra,
|
|
meta,
|
|
);
|
|
res.json({});
|
|
}
|
|
|
|
@Post('/auth/revoke-dev-app', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
rateLimit: GRANT_LIMIT,
|
|
})
|
|
async handleRevokeDevApp(req: Request, res: Response): Promise<void> {
|
|
let { app_uid } = req.body;
|
|
const { origin, permission, meta } = req.body;
|
|
if (origin && !app_uid) {
|
|
// Registered apps only — see handleGrantDevApp.
|
|
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
|
}
|
|
if (!app_uid || !permission) {
|
|
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
if (permission === '*') {
|
|
await this.services.permission.revokeDevAppAll(
|
|
req.actor!,
|
|
app_uid,
|
|
meta,
|
|
);
|
|
} else {
|
|
await this.services.permission.revokeDevAppPermission(
|
|
req.actor!,
|
|
app_uid,
|
|
permission,
|
|
meta,
|
|
);
|
|
}
|
|
res.json({});
|
|
}
|
|
|
|
// -- Permission listing ------------------------------------------
|
|
|
|
@Get('/auth/list-permissions', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
rateLimit: AUTH_LIST_LIMIT,
|
|
})
|
|
async handleListPermissions(req: Request, res: Response): Promise<void> {
|
|
const userId = req.actor!.user.id;
|
|
const db = this.clients.db;
|
|
|
|
const [appPerms, userPermsOut, userPermsIn] = await Promise.all([
|
|
db.read(
|
|
// The permissions table stores the numeric `app_id` FK; the
|
|
// public shape exposes the app's `uid`.
|
|
'SELECT a.`uid` AS app_uid, p.`permission`, p.`extra` ' +
|
|
'FROM `user_to_app_permissions` p ' +
|
|
'JOIN `apps` a ON a.`id` = p.`app_id` ' +
|
|
'WHERE p.`user_id` = ?',
|
|
[userId],
|
|
),
|
|
db.read(
|
|
'SELECT u.`username`, p.`permission`, p.`extra` FROM `user_to_user_permissions` p ' +
|
|
'JOIN `user` u ON u.`id` = p.`holder_user_id` WHERE p.`issuer_user_id` = ?',
|
|
[userId],
|
|
),
|
|
db.read(
|
|
'SELECT u.`username`, p.`permission`, p.`extra` FROM `user_to_user_permissions` p ' +
|
|
'JOIN `user` u ON u.`id` = p.`issuer_user_id` WHERE p.`holder_user_id` = ?',
|
|
[userId],
|
|
),
|
|
]);
|
|
|
|
type Row = {
|
|
app_uid?: string;
|
|
username?: string;
|
|
permission: string;
|
|
extra?: string | Record<string, unknown> | null;
|
|
};
|
|
|
|
res.json({
|
|
myself_to_app: (appPerms as Row[]).map((r) => ({
|
|
app_uid: r.app_uid,
|
|
permission: r.permission,
|
|
extra:
|
|
typeof r.extra === 'string'
|
|
? JSON.parse(r.extra)
|
|
: (r.extra ?? {}),
|
|
})),
|
|
myself_to_user: (userPermsOut as Row[]).map((r) => ({
|
|
user: r.username,
|
|
permission: r.permission,
|
|
extra:
|
|
typeof r.extra === 'string'
|
|
? JSON.parse(r.extra)
|
|
: (r.extra ?? {}),
|
|
})),
|
|
user_to_myself: (userPermsIn as Row[]).map((r) => ({
|
|
user: r.username,
|
|
permission: r.permission,
|
|
extra:
|
|
typeof r.extra === 'string'
|
|
? JSON.parse(r.extra)
|
|
: (r.extra ?? {}),
|
|
})),
|
|
});
|
|
}
|
|
|
|
// -- App origin resolution ---------------------------------------
|
|
|
|
@Post('/auth/app-uid-from-origin', {
|
|
subdomain: 'api',
|
|
requireAuth: true,
|
|
rateLimit: AUTH_CHECK_LIMIT,
|
|
})
|
|
async handleAppUidFromOrigin(req: Request, res: Response): Promise<void> {
|
|
const origin = req.body?.origin || req.query?.origin;
|
|
if (!origin)
|
|
throw new HttpError(400, 'Missing `origin`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
const uid = await this.services.auth.appUidFromOrigin(origin as string);
|
|
res.json({ uid });
|
|
}
|
|
|
|
// -- App token + check -------------------------------------------
|
|
|
|
@Post('/auth/get-user-app-token', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
// Called once per app launch, and the GUI can legitimately launch
|
|
// several in quick succession.
|
|
rateLimit: { ...AUTH_CHECK_LIMIT, scope: 'app-token', limit: 120 },
|
|
})
|
|
async handleGetUserAppToken(req: Request, res: Response): Promise<void> {
|
|
let { app_uid } = req.body;
|
|
const { origin } = req.body;
|
|
const resolvedFromOrigin = !app_uid && !!origin;
|
|
if (!app_uid && origin) {
|
|
app_uid = await this.services.auth.appUidFromOrigin(origin);
|
|
}
|
|
if (!app_uid) {
|
|
throw new HttpError(400, 'Missing `app_uid` or `origin`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
let app = await this.stores.app.getByUid(app_uid);
|
|
if (!app && resolvedFromOrigin) {
|
|
// Hosted-subdomain origins get the site owner stamped as the
|
|
// app's creator at bootstrap; external origins stay unowned.
|
|
const ownerUserId =
|
|
await this.services.auth.subdomainOwnerIdFromOrigin(origin);
|
|
app = await this.stores.app.createFromOrigin(app_uid, origin, {
|
|
ownerUserId,
|
|
});
|
|
// An origin's uid is a deterministic uuidv5, so a deleted app
|
|
// reappears here under the identical uid. Withdraw any cross-app
|
|
// data grants left pointing at it before this new row can inherit
|
|
// consent the user gave its predecessor. Only *this* path can reuse
|
|
// a uid: `AppStore.create` mints a random uuid4, which no deleted
|
|
// app can ever hold again.
|
|
//
|
|
// Called directly rather than through `app.changed`: the token is
|
|
// issued below, so this has to be able to stop that, and
|
|
// `emitAndWait` swallows listener errors. Letting it throw is the
|
|
// point — a sweep that failed leaves the old grants live against an
|
|
// app whoever controls the origin now has just claimed.
|
|
await this.services.appPermission.withdrawAppDataGrants(
|
|
app_uid,
|
|
'uid reused by a new app',
|
|
);
|
|
}
|
|
if (!app) {
|
|
throw new HttpError(404, `App ${app_uid} does not exist`, {
|
|
legacyCode: 'not_found',
|
|
});
|
|
}
|
|
|
|
const userPermGrantPromise =
|
|
this.services.permission.grantUserAppPermission(
|
|
req.actor!,
|
|
app_uid,
|
|
'flag:app-is-authenticated',
|
|
{},
|
|
{},
|
|
);
|
|
|
|
const tokenPromise = this.services.auth.getUserAppToken(
|
|
req.actor!,
|
|
app_uid,
|
|
);
|
|
|
|
const missingFSPathPromise = (async () => {
|
|
// Ensure the app's per-user AppData directory exists.
|
|
// v1 did this in LLMkdir with the app icon as thumbnail
|
|
// on first app open. mkdir is idempotent (returns
|
|
// existing dir without rewriting), and
|
|
// createMissingParents seeds `/<username>/AppData` if
|
|
// the user never had one. Path lookups in FSEntryStore
|
|
// have a recursive-CTE fallback (mirrors v1's
|
|
// `convert_path_to_fsentry` walk-down) so legacy rows
|
|
// with a NULL `path` column still resolve and get
|
|
// backfilled on first read.
|
|
const username = req.actor!.user?.username;
|
|
const userId = req.actor!.user?.id;
|
|
if (username && userId) {
|
|
await this.services.fs.mkdir(userId, {
|
|
path: `/${username}/AppData/${app_uid}`,
|
|
createMissingParents: true,
|
|
thumbnail: (app as { icon?: string | null }).icon ?? null,
|
|
} as never);
|
|
}
|
|
})();
|
|
|
|
const [, token] = await Promise.all([
|
|
userPermGrantPromise,
|
|
tokenPromise,
|
|
missingFSPathPromise,
|
|
]);
|
|
|
|
try {
|
|
const a = app as {
|
|
id?: number;
|
|
uid?: string;
|
|
index_url?: string | null;
|
|
owner_user_id?: number | null;
|
|
name?: string | null;
|
|
};
|
|
this.clients.event?.emit(
|
|
'puter.app.authenticated' as never,
|
|
{
|
|
app_uid,
|
|
app: {
|
|
id: a.id,
|
|
uid: a.uid,
|
|
index_url: a.index_url ?? null,
|
|
owner_user_id: a.owner_user_id ?? null,
|
|
name: a.name ?? null,
|
|
},
|
|
user_id: req.actor!.user?.id ?? null,
|
|
} as never,
|
|
{},
|
|
);
|
|
} catch {
|
|
// Fine if failed
|
|
}
|
|
|
|
res.json({ token, app_uid });
|
|
}
|
|
|
|
@Post('/auth/check-app', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
rateLimit: AUTH_CHECK_LIMIT,
|
|
})
|
|
async handleCheckApp(req: Request, res: Response): Promise<void> {
|
|
let { app_uid } = req.body;
|
|
const { origin } = req.body;
|
|
if (!app_uid && origin) {
|
|
app_uid = await this.services.auth.appUidFromOrigin(origin);
|
|
}
|
|
if (!app_uid)
|
|
throw new HttpError(400, 'Missing `app_uid` or `origin`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
|
|
// Check if the app is authenticated for this user
|
|
const authenticated = await this.services.permission
|
|
.check(
|
|
req.actor!,
|
|
`service:${app_uid}:ii:flag:app-is-authenticated`,
|
|
)
|
|
.catch(() => false);
|
|
|
|
const result: {
|
|
app_uid: string;
|
|
authenticated: boolean;
|
|
token?: string;
|
|
} = { app_uid, authenticated };
|
|
if (authenticated) {
|
|
result.token = await this.services.auth.getUserAppToken(
|
|
req.actor!,
|
|
app_uid,
|
|
);
|
|
}
|
|
res.json(result);
|
|
}
|
|
|
|
// -- Access tokens -----------------------------------------------
|
|
|
|
@Post('/auth/create-access-token', {
|
|
subdomain: 'api',
|
|
requireAuth: true,
|
|
rateLimit: CREDENTIAL_MINT_LIMIT,
|
|
})
|
|
async handleCreateAccessToken(req: Request, res: Response): Promise<void> {
|
|
const { permissions, expiresIn, label } = req.body ?? {};
|
|
if (!Array.isArray(permissions) || permissions.length === 0) {
|
|
throw new HttpError(400, 'Missing or empty `permissions` array', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
// Optional user-facing name for the manage-sessions UI. Trim and clamp
|
|
// to the same 64-char limit the rename endpoint enforces.
|
|
let normalizedLabel: string | null = null;
|
|
if (label !== undefined && label !== null) {
|
|
if (typeof label !== 'string') {
|
|
throw new HttpError(400, '`label` must be a string', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
normalizedLabel = label.trim().slice(0, 64) || null;
|
|
}
|
|
|
|
// Whole seconds or a duration string ('30d'). A wrong type, a
|
|
// fraction, or an unparseable unit all reach the signer, which throws
|
|
// after the session row is already inserted.
|
|
if (expiresIn !== undefined && expiresIn !== null) {
|
|
const usable =
|
|
typeof expiresIn === 'number'
|
|
? Number.isInteger(expiresIn) && expiresIn > 0
|
|
: typeof expiresIn === 'string' &&
|
|
/^\d+\s*[smhdwy]?$/.test(expiresIn.trim());
|
|
if (!usable) {
|
|
throw new HttpError(
|
|
400,
|
|
'`expiresIn` must be a whole number of seconds or a duration string like `30d`',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Normalize specs: string → [string], [string] → [string, {}], [string, extra] → as-is
|
|
const normalized = permissions.map((spec) => {
|
|
if (typeof spec === 'string') return [spec];
|
|
if (Array.isArray(spec)) return spec;
|
|
throw new HttpError(
|
|
400,
|
|
'Each permission must be a string or [string, extra?]',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
});
|
|
|
|
const token = await this.services.auth.createAccessToken(
|
|
req.actor!,
|
|
normalized as never,
|
|
{
|
|
...(expiresIn ? { expiresIn } : {}),
|
|
...(normalizedLabel ? { label: normalizedLabel } : {}),
|
|
},
|
|
);
|
|
res.json({ token });
|
|
}
|
|
|
|
// Wired imperatively in `registerRoutes` so the cookie-only gate
|
|
// (built from `this.config`) can be composed in. Cookie-only is
|
|
// mandatory: a leaked access token must not be able to silently
|
|
// revoke its own siblings.
|
|
async handleRevokeAccessToken(req: Request, res: Response): Promise<void> {
|
|
let { tokenOrUuid } = req.body;
|
|
if (!tokenOrUuid || typeof tokenOrUuid !== 'string') {
|
|
throw new HttpError(400, 'Missing `tokenOrUuid`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
// Extract JWT from /token-read URLs if needed
|
|
if (tokenOrUuid.includes('/token-read')) {
|
|
const match = tokenOrUuid.match(/\/token-read\/([^\s/?]+)/);
|
|
if (match) tokenOrUuid = match[1];
|
|
}
|
|
await this.services.auth.revokeAccessToken(req.actor!, tokenOrUuid);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
// -- 2FA: configure ----------------------------------------------
|
|
|
|
@Post('/auth/configure-2fa/:action', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
rateLimit: TWO_FACTOR_LIMIT,
|
|
})
|
|
async handleConfigure2fa(req: Request, res: Response): Promise<void> {
|
|
const action = req.params.action;
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
|
|
if (action === 'setup') {
|
|
if (user.otp_enabled) {
|
|
throw new HttpError(409, '2FA is already enabled.', {
|
|
legacyCode: 'conflict',
|
|
});
|
|
}
|
|
|
|
const result = otpCreateSecret(user.username);
|
|
|
|
// Generate 10 recovery codes
|
|
const codes: string[] = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
codes.push(createRecoveryCode());
|
|
}
|
|
const hashedCodes = codes.map((c) => hashRecoveryCode(c));
|
|
|
|
await this.clients.db.write(
|
|
'UPDATE `user` SET `otp_secret` = ?, `otp_recovery_codes` = ? WHERE `uuid` = ?',
|
|
[result.secret, hashedCodes.join(','), user.uuid],
|
|
);
|
|
await this.stores.user.invalidateById(user.id);
|
|
|
|
res.json({
|
|
url: result.url,
|
|
secret: result.secret,
|
|
codes,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (action === 'test') {
|
|
const { code } = req.body ?? {};
|
|
if (!code)
|
|
throw new HttpError(400, 'Missing `code`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
const ok = verifyOtp(user.username, user.otp_secret, code);
|
|
res.json({ ok });
|
|
return;
|
|
}
|
|
|
|
if (action === 'enable') {
|
|
if (!user.email_confirmed) {
|
|
throw new HttpError(
|
|
403,
|
|
'Email must be confirmed before enabling 2FA.',
|
|
{ legacyCode: 'forbidden' },
|
|
);
|
|
}
|
|
if (user.otp_enabled) {
|
|
throw new HttpError(409, '2FA is already enabled.', {
|
|
legacyCode: 'conflict',
|
|
});
|
|
}
|
|
if (!user.otp_secret) {
|
|
throw new HttpError(
|
|
409,
|
|
'2FA has not been configured. Call setup first.',
|
|
{ legacyCode: 'conflict' },
|
|
);
|
|
}
|
|
|
|
await this.clients.db.write(
|
|
'UPDATE `user` SET `otp_enabled` = ? WHERE `uuid` = ?',
|
|
[this.clients.db.booleanValue(true), user.uuid],
|
|
);
|
|
await this.stores.user.invalidateById(user.id);
|
|
|
|
if (this.clients.email && user.email) {
|
|
try {
|
|
await this.clients.email.send(user.email, 'enabled_2fa', {
|
|
username: user.username,
|
|
});
|
|
} catch (e) {
|
|
console.warn('[configure-2fa] email send failed:', e);
|
|
}
|
|
}
|
|
|
|
res.json({});
|
|
return;
|
|
}
|
|
|
|
throw new HttpError(400, `Invalid action: ${action}`, {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
// -- 2FA: disable (user-protected, wired in registerRoutes below) -
|
|
|
|
async handleDisable2fa(req: Request, res: Response): Promise<void> {
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
|
|
await this.clients.db.write(
|
|
'UPDATE `user` SET `otp_enabled` = ?, `otp_recovery_codes` = NULL, `otp_secret` = NULL WHERE `uuid` = ?',
|
|
[this.clients.db.booleanValue(false), user.uuid],
|
|
);
|
|
await this.stores.user.invalidateById(user.id);
|
|
|
|
if (this.clients.email && user.email) {
|
|
try {
|
|
await this.clients.email.send(user.email, 'disabled_2fa', {
|
|
username: user.username,
|
|
});
|
|
} catch (e) {
|
|
console.warn('[disable-2fa] email send failed:', e);
|
|
}
|
|
}
|
|
|
|
res.json({ success: true });
|
|
}
|
|
|
|
// -- Developer profile -------------------------------------------
|
|
|
|
@Get('/get-dev-profile', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
rateLimit: AUTH_LIST_LIMIT,
|
|
})
|
|
async handleGetDevProfile(req: Request, res: Response): Promise<void> {
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
|
|
const u = user as unknown as {
|
|
first_name?: string | null;
|
|
last_name?: string | null;
|
|
approved_for_incentive_program?: number | boolean;
|
|
joined_incentive_program?: number | boolean;
|
|
paypal?: string | null;
|
|
};
|
|
res.json({
|
|
first_name: u.first_name ?? null,
|
|
last_name: u.last_name ?? null,
|
|
approved_for_incentive_program: Boolean(
|
|
u.approved_for_incentive_program,
|
|
),
|
|
joined_incentive_program: Boolean(u.joined_incentive_program),
|
|
paypal: u.paypal ?? null,
|
|
});
|
|
}
|
|
|
|
// -- Session helpers ---------------------------------------------
|
|
|
|
@Get('/get-gui-token', {
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
rateLimit: SESSION_LIMIT,
|
|
})
|
|
async handleGetGuiToken(req: Request, res: Response): Promise<void> {
|
|
if (!req.actor?.session?.uid)
|
|
throw new HttpError(400, 'No session bound to this actor', {
|
|
legacyCode: 'session_required' as never,
|
|
});
|
|
const user = await this.stores.user.getById(req.actor.user.id!);
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
const guiToken = this.services.auth.createGuiToken(
|
|
user,
|
|
req.actor.session.uid,
|
|
);
|
|
res.json({ token: guiToken });
|
|
}
|
|
|
|
@Get('/session/sync-cookie', {
|
|
rateLimit: SESSION_LIMIT,
|
|
// Installs the session cookie. Only page script on our own origin
|
|
// should be able to ask for that (the `tokenSource` check below is the
|
|
// companion rule: the token has to come from an Authorization header,
|
|
// not a URL).
|
|
guiOriginOnly: true,
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
})
|
|
async handleSessionSyncCookie(req: Request, res: Response): Promise<void> {
|
|
// This route installs a session cookie, so the token has to come from
|
|
// page script on our own origin rather than from the URL.
|
|
if (req.tokenSource !== 'header') {
|
|
throw new HttpError(
|
|
401,
|
|
'This endpoint requires an Authorization header.',
|
|
{ legacyCode: 'token_auth_failed' },
|
|
);
|
|
}
|
|
if (!req.actor?.session?.uid) {
|
|
res.status(400).end();
|
|
return;
|
|
}
|
|
const user = await this.stores.user.getById(req.actor.user.id!);
|
|
if (!user) {
|
|
res.status(404).end();
|
|
return;
|
|
}
|
|
const sessionToken = this.services.auth.createSessionTokenForSession(
|
|
user,
|
|
req.actor.session.uid,
|
|
);
|
|
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
|
|
...sessionCookieFlags(this.config),
|
|
httpOnly: true,
|
|
});
|
|
res.status(204).end();
|
|
}
|
|
|
|
// -- Step-up ("elevation"), wired below --------------------------
|
|
//
|
|
// Mints the second-factor cookie for a session that re-proves identity: a
|
|
// fresh TOTP code when 2FA is enabled, otherwise the account password.
|
|
// Privileged endpoints require it on top of the session, so a leaked session
|
|
// alone can't exercise them. Accounts with neither credential (no password
|
|
// and 2FA disabled) can't elevate.
|
|
|
|
async handleElevate(req: Request, res: Response): Promise<void> {
|
|
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
|
force: true,
|
|
});
|
|
if (!user)
|
|
throw new HttpError(404, 'User not found.', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
if (user.suspended)
|
|
throw new HttpError(403, 'Account suspended.', {
|
|
legacyCode: 'account_suspended',
|
|
});
|
|
|
|
if (user.otp_enabled) {
|
|
const code = req.body?.code;
|
|
if (!code)
|
|
throw new HttpError(400, 'code is required.', {
|
|
legacyCode: 'bad_request',
|
|
fields: { factor: 'otp' },
|
|
});
|
|
if (
|
|
!verifyOtp(
|
|
user.username,
|
|
user.otp_secret as string,
|
|
String(code),
|
|
)
|
|
)
|
|
throw new HttpError(401, 'Incorrect code.', {
|
|
legacyCode: 'code_mismatch' as never,
|
|
fields: { factor: 'otp' },
|
|
});
|
|
} else if (user.password) {
|
|
const password = req.body?.password;
|
|
if (!password || typeof password !== 'string')
|
|
throw new HttpError(400, 'Password is required.', {
|
|
legacyCode: 'password_required',
|
|
fields: { factor: 'password' },
|
|
});
|
|
const match = await bcrypt.compare(
|
|
password,
|
|
user.password as string,
|
|
);
|
|
if (!match)
|
|
throw new HttpError(401, 'Incorrect password.', {
|
|
legacyCode: 'password_mismatch',
|
|
fields: { factor: 'password' },
|
|
});
|
|
} else {
|
|
// Neither credential on file (e.g. an account that only ever
|
|
// authenticated through an external identity provider).
|
|
throw new HttpError(
|
|
403,
|
|
'This account has no credential to re-authenticate with. Set a password or enable two-factor authentication first.',
|
|
{ legacyCode: 'elevation_unavailable' as never },
|
|
);
|
|
}
|
|
|
|
const token = signStepUpToken(this.services.token, user as never);
|
|
res.cookie(
|
|
STEP_UP_COOKIE_NAME,
|
|
token,
|
|
stepUpCookieOptions(this.config),
|
|
);
|
|
|
|
// A browser reads its elevation back from the httpOnly cookie and never
|
|
// needs the raw value; handing it to page JS would put the second factor
|
|
// within reach of an XSS. API clients have no cookie jar, so they get the
|
|
// token to send back as `x-puter-elevation`. Both paths proved the same
|
|
// password/TOTP — this only avoids needless exposure, it isn't a gate.
|
|
const cookieName = this.config.cookie_name ?? 'puter_token';
|
|
const usedSessionCookie =
|
|
!!req.token && req.token === req.cookies?.[cookieName];
|
|
res.json(
|
|
usedSessionCookie ? { elevated: true } : { elevated: true, token },
|
|
);
|
|
}
|
|
|
|
// -- Delete own account (user-protected, wired below) ------------
|
|
//
|
|
// Purge S3 objects + fsentries first, then the user row. FK
|
|
// cascades on most related tables are `ON DELETE SET NULL` (not
|
|
// CASCADE), so anything holding tightly to user_id (sessions) we
|
|
// clear explicitly to avoid orphan rows.
|
|
|
|
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
|
|
const userId = req.actor!.user.id!;
|
|
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
|
res.clearCookie('puter_token_v2');
|
|
res.clearCookie('puter_revalidation');
|
|
res.clearCookie(STEP_UP_COOKIE_NAME, {
|
|
...(this.config.domain ? { domain: this.config.domain } : {}),
|
|
});
|
|
await this.#cascadeDeleteUser(userId);
|
|
res.json({ success: true });
|
|
}
|
|
|
|
// -- registerRoutes override -------------------------------------
|
|
//
|
|
// The `@Controller('')` decorator would normally install a default
|
|
// `registerRoutes` walker that iterates `prototype[__puterRoutes]`.
|
|
// We override it here so we can ALSO wire the five
|
|
// `/user-protected/*` (and `/user-protected/delete-own-user`) routes
|
|
// whose `middleware: createUserProtectedGate(...)` argument is
|
|
// built from instance state — not expressible inside a static
|
|
// decorator literal.
|
|
//
|
|
// The first half of this method is a transcription of the default
|
|
// walker (see core/http/decorators.ts → Controller). The second
|
|
// half adds the imperative routes that need the per-instance gate.
|
|
override registerRoutes(router: PuterRouter): void {
|
|
const proto = Object.getPrototypeOf(this) as {
|
|
[ROUTES_METADATA_KEY]?: CollectedRoute[];
|
|
};
|
|
const routes = (proto[ROUTES_METADATA_KEY] ?? []) as CollectedRoute[];
|
|
for (const r of routes) {
|
|
const bound = r.handler.bind(this) as RequestHandler;
|
|
if (r.method === 'use') {
|
|
if (r.path !== undefined) {
|
|
router.use(r.path, r.options, bound);
|
|
} else {
|
|
router.use(r.options, bound);
|
|
}
|
|
continue;
|
|
}
|
|
if (r.path === undefined) {
|
|
throw new Error(
|
|
`@${r.method.toUpperCase()} decorator missing path`,
|
|
);
|
|
}
|
|
const routerMethod = router[
|
|
r.method as Exclude<RouteMethod, 'use'>
|
|
] as (
|
|
path: RoutePath,
|
|
options: RouteOptions,
|
|
handler: RequestHandler,
|
|
) => PuterRouter;
|
|
routerMethod.call(router, r.path, r.options, bound);
|
|
}
|
|
|
|
// -- User-protected routes (per-instance middleware) ----------
|
|
const userProtectedDeps = {
|
|
config: this.config,
|
|
userStore: this.stores.user,
|
|
oidcService: this.services.oidc,
|
|
tokenService: this.services.token,
|
|
};
|
|
|
|
router.post(
|
|
'/user-protected/change-password',
|
|
{
|
|
requireUserActor: true,
|
|
rateLimit: {
|
|
scope: 'passwd',
|
|
limit: 10,
|
|
window: 60 * 60_000,
|
|
key: 'user',
|
|
},
|
|
middleware: [
|
|
createUserProtectedGate(
|
|
userProtectedDeps as never,
|
|
) as unknown as RequestHandler,
|
|
],
|
|
},
|
|
(req, res) => this.handleChangePassword(req, res),
|
|
);
|
|
|
|
router.post(
|
|
'/user-protected/change-username',
|
|
{
|
|
requireUserActor: true,
|
|
requireVerified: true,
|
|
rateLimit: {
|
|
scope: 'change-username',
|
|
limit: 2,
|
|
window: 30 * 24 * 60 * 60_000,
|
|
key: 'user',
|
|
},
|
|
middleware: [
|
|
createUserProtectedGate(
|
|
userProtectedDeps as never,
|
|
) as unknown as RequestHandler,
|
|
],
|
|
},
|
|
(req, res) => this.handleChangeUsername(req, res),
|
|
);
|
|
|
|
router.post(
|
|
'/user-protected/change-email',
|
|
{
|
|
requireUserActor: true,
|
|
rateLimit: {
|
|
scope: 'change-email-start',
|
|
limit: 10,
|
|
window: 60 * 60_000,
|
|
key: 'user',
|
|
},
|
|
middleware: [
|
|
createUserProtectedGate(
|
|
userProtectedDeps as never,
|
|
) as unknown as RequestHandler,
|
|
],
|
|
},
|
|
(req, res) => this.handleChangeEmail(req, res),
|
|
);
|
|
|
|
router.post(
|
|
'/user-protected/disable-2fa',
|
|
{
|
|
requireUserActor: true,
|
|
rateLimit: {
|
|
scope: 'disable-2fa',
|
|
limit: 10,
|
|
window: 60 * 60_000,
|
|
key: 'user',
|
|
},
|
|
middleware: [
|
|
createUserProtectedGate(
|
|
userProtectedDeps as never,
|
|
) as unknown as RequestHandler,
|
|
],
|
|
},
|
|
(req, res) => this.handleDisable2fa(req, res),
|
|
);
|
|
|
|
router.post(
|
|
'/user-protected/delete-own-user',
|
|
{
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
middleware: [
|
|
createUserProtectedGate(userProtectedDeps as never, {
|
|
allowTempUsers: true,
|
|
}) as unknown as RequestHandler,
|
|
],
|
|
},
|
|
(req, res) => this.handleDeleteOwnUser(req, res),
|
|
);
|
|
|
|
// Step-up. Served on the root origin (browser form posts same-origin)
|
|
// and on `api` (SDK/script clients, which have no cookie jar and send a
|
|
// bearer). Deliberately NOT cookie-gated: the password/TOTP in the body
|
|
// is the control — a stolen token alone can't satisfy it, and it's also
|
|
// what makes CSRF a non-issue. `requireUserActor` still keeps app and
|
|
// access-token actors out, so an access token can never mint an
|
|
// elevation for its issuer.
|
|
router.post(
|
|
'/auth/elevate',
|
|
{
|
|
subdomain: ['api', ''],
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
rateLimit: [
|
|
{
|
|
scope: 'elevate',
|
|
limit: 10,
|
|
window: 15 * 60_000,
|
|
key: 'user',
|
|
},
|
|
{
|
|
scope: 'elevate-ip',
|
|
limit: 40,
|
|
window: 15 * 60_000,
|
|
key: 'ip',
|
|
},
|
|
],
|
|
},
|
|
(req, res) => this.handleElevate(req, res),
|
|
);
|
|
|
|
const webSessionGate = createWebSessionActorGate();
|
|
|
|
router.post(
|
|
'/auth/revoke-session',
|
|
{
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
antiCsrf: true,
|
|
middleware: [webSessionGate],
|
|
},
|
|
(req, res) => this.handleRevokeSession(req, res),
|
|
);
|
|
|
|
router.post(
|
|
'/auth/revoke-all-sessions',
|
|
{
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
antiCsrf: true,
|
|
rateLimit: {
|
|
scope: 'revoke-all-sessions',
|
|
limit: 10,
|
|
window: 60 * 60_000,
|
|
key: 'user',
|
|
},
|
|
middleware: [webSessionGate],
|
|
},
|
|
(req, res) => this.handleRevokeAllSessions(req, res),
|
|
);
|
|
|
|
router.post(
|
|
'/auth/revoke-access-token',
|
|
{
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
antiCsrf: true,
|
|
middleware: [webSessionGate],
|
|
},
|
|
(req, res) => this.handleRevokeAccessToken(req, res),
|
|
);
|
|
|
|
router.patch(
|
|
'/auth/sessions/:uuid/label',
|
|
{
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
allowUnconfirmed: true,
|
|
antiCsrf: true,
|
|
middleware: [webSessionGate],
|
|
},
|
|
(req, res) => this.handleRenameSession(req, res),
|
|
);
|
|
}
|
|
|
|
// -- Private helpers ----------------------------------------------
|
|
|
|
async #cascadeDeleteUser(userId: number): Promise<void> {
|
|
await this.services.userAccount.cascadeDelete(userId);
|
|
}
|
|
|
|
async #generateRandomUsername(): Promise<string> {
|
|
let username: string;
|
|
let attempts = 0;
|
|
do {
|
|
username = generate_identifier();
|
|
attempts++;
|
|
if (attempts > 20)
|
|
throw new HttpError(
|
|
409,
|
|
'Failed to generate unique username. Try again later.',
|
|
{ legacyCode: 'conflict' },
|
|
);
|
|
} while (await this.stores.user.getByUsername(username));
|
|
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`
|
|
* event lets extensions (abuse) reject. Throws HttpError(400) on
|
|
* rejection.
|
|
*/
|
|
async #validateEmail(email: string): Promise<void> {
|
|
if (
|
|
isBlockedEmail(
|
|
email,
|
|
(this.config as { blockedEmailDomains?: string[] })
|
|
.blockedEmailDomains,
|
|
)
|
|
) {
|
|
throw new HttpError(400, 'This email is not allowed.', {
|
|
legacyCode: 'email_not_allowed' as never,
|
|
});
|
|
}
|
|
|
|
const validateEvent: {
|
|
email: string;
|
|
allow: boolean;
|
|
message: string | null;
|
|
} = {
|
|
email: cleanEmail(email),
|
|
allow: true,
|
|
message: null,
|
|
};
|
|
try {
|
|
await this.clients.event?.emitAndWait(
|
|
'email.validate' as never,
|
|
validateEvent as never,
|
|
{},
|
|
);
|
|
} catch (e) {
|
|
console.warn('[email-validate] hook failed:', e);
|
|
}
|
|
if (!validateEvent.allow) {
|
|
throw new HttpError(
|
|
400,
|
|
validateEvent.message ??
|
|
'This email cannot be used. Please try a different email address.',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-IP enumeration clamp on `auth_id`-bearing auth requests. Separate
|
|
* from the route-level rate limit so a tighter ceiling applies only to the
|
|
* path that takes a uuid hint from the body — the normal login path stays
|
|
* at its more generous limit.
|
|
*/
|
|
async #checkAuthIdRateLimit(req: Request): Promise<void> {
|
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
const ok = await checkRateLimit(
|
|
`login-with-auth-id:${ip}`,
|
|
5,
|
|
15 * 60_000,
|
|
);
|
|
if (!ok) {
|
|
throw new HttpError(429, 'Too many auth_id login attempts.', {
|
|
legacyCode: 'too_many_requests',
|
|
fields: { 'retry-after': 900 },
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract the `auth_id` claim from a client-supplied reauth_token. Returns
|
|
* null when no token was supplied. Throws on invalid/expired tokens. The
|
|
* reauth_token is a server-signed JWT minted by the authProbe at 401 time —
|
|
* accepting only the signed envelope (vs. a raw UUID) means a leaked
|
|
* auth_id alone can't attach a session to an existing account.
|
|
*/
|
|
#extractAuthIdFromReauthToken(suppliedToken: unknown): string | null {
|
|
if (suppliedToken === undefined || suppliedToken === null) return null;
|
|
if (typeof suppliedToken !== 'string' || !suppliedToken) {
|
|
throw new HttpError(400, 'Invalid `reauth_token`.', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
const { authId } = this.services.auth.verifyReauthToken(suppliedToken);
|
|
return authId;
|
|
}
|
|
|
|
/**
|
|
* Enforce a verified `auth_id` against the user the credential flow has
|
|
* resolved. Caller has already extracted `auth_id` from the server-signed
|
|
* reauth_token (or from an OTP-flow JWT). When the GUI is forced through
|
|
* reauth, the 401 response embeds the reauth_token; the client echoes it
|
|
* back so we can confirm the second login lands on the same user row —
|
|
* critical for temp users, where a fresh signup would otherwise mint a new
|
|
* account and strand their files.
|
|
*
|
|
* No `authId` supplied → no-op (normal login). Unknown `authId` → 404
|
|
* (mirrors username-not-found, avoids being an enumeration oracle).
|
|
* Mismatch against the resolved user → 409 (`auth_id_mismatch`).
|
|
*/
|
|
async #enforceAuthIdMatch(
|
|
req: Request,
|
|
resolvedUser: { id: number; uuid: string },
|
|
authId: string | null,
|
|
): Promise<void> {
|
|
if (!authId) return;
|
|
|
|
await this.#checkAuthIdRateLimit(req);
|
|
|
|
// Common path: auth_id maps to the same uuid the credential flow
|
|
// resolved. Skip the user-table read entirely. The DB lookup is
|
|
// only needed to disambiguate 404 (unknown auth_id) from 409
|
|
// (known but mismatched) on the error path.
|
|
if (authId === resolvedUser.uuid) return;
|
|
|
|
const authIdUser = await this.stores.user.getByUuid(authId);
|
|
if (!authIdUser) {
|
|
throw new HttpError(404, 'auth_id not found.', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
}
|
|
if (authIdUser.id !== resolvedUser.id) {
|
|
throw new HttpError(409, 'auth_id does not match credentials.', {
|
|
legacyCode: 'bad_request',
|
|
fields: { code: 'auth_id_mismatch' },
|
|
});
|
|
}
|
|
}
|
|
|
|
async #completeLogin(
|
|
req: Request,
|
|
res: Response,
|
|
user: {
|
|
id: number;
|
|
uuid: string;
|
|
username: string;
|
|
email?: string | null;
|
|
password?: string | null;
|
|
email_confirmed?: number | boolean;
|
|
requires_email_confirmation?: number | boolean;
|
|
phone?: string | null;
|
|
requires_phone_verification?: number | boolean;
|
|
requires_card_verification?: number | boolean;
|
|
},
|
|
): Promise<void> {
|
|
const meta = {
|
|
ip: req.ip || req.socket?.remoteAddress,
|
|
user_agent: req.headers?.['user-agent'],
|
|
origin: req.headers?.origin,
|
|
host: req.headers?.host,
|
|
};
|
|
|
|
const { token: sessionToken, gui_token } =
|
|
await this.services.auth.createSessionToken(user as never, meta);
|
|
|
|
// HTTP-only cookie gets the session token
|
|
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
|
|
...sessionCookieFlags(this.config),
|
|
httpOnly: true,
|
|
});
|
|
|
|
// Resolve taskbar items up-front so the GUI doesn't need a second
|
|
// round-trip on first paint. Best-effort: a failure here shouldn't
|
|
// block login (the client can still fetch them via /whoami later).
|
|
let taskbar_items: unknown[] = [];
|
|
try {
|
|
taskbar_items = await getTaskbarItems(
|
|
user as never,
|
|
{
|
|
clients: this.clients,
|
|
stores: this.stores,
|
|
services: this.services,
|
|
apiBaseUrl: (this.config as { api_base_url?: string })
|
|
.api_base_url,
|
|
} as never,
|
|
);
|
|
} catch (e) {
|
|
console.warn('[auth] taskbar_items resolution failed:', e);
|
|
}
|
|
|
|
// Response body gets the GUI token (client never sees session token)
|
|
res.json({
|
|
proceed: true,
|
|
next_step: 'complete',
|
|
token: gui_token,
|
|
user: {
|
|
username: user.username,
|
|
uuid: user.uuid,
|
|
email: user.email,
|
|
email_confirmed: user.email_confirmed,
|
|
requires_email_confirmation: user.requires_email_confirmation,
|
|
phone: user.phone,
|
|
requires_phone_verification: user.requires_phone_verification,
|
|
requires_card_verification: user.requires_card_verification,
|
|
is_temp: user.password === null && user.email === null,
|
|
taskbar_items,
|
|
},
|
|
});
|
|
}
|
|
}
|