Files
puter/src/backend/services/selfhosted/DefaultUserService.ts
Daniel Salazar d162549c1d refactor(controllers): one signup, login, confirm and window.user path; userProtected/internalAuth route options (#4189)
* refactor(auth): one signup path for form, temp, OIDC and team accounts

Signup steps (validate hook, phone/card forcing, bonus verdict, provisioning,
signup events) move from AuthController and OIDCService into SignupService.
Username rules, availability and generation move to util/username.ts, and
user-row + group + default-folder creation to provisionUser, which
TeamService and DefaultUserService now share.

Behaviour fixes: a provisioned team seat joins default_user_group like every
other account, and generated temp usernames skip names whose home path is
occupied.

* refactor(auth): one web-session start for password and OIDC sign-in

startWebSession creates the session with the request's IP, user agent, origin
and host and sets the session cookie. OIDC sign-ins used to create their
session without that metadata, so they showed up blank in the session list.

* refactor(auth): one confirmEmail for the code and link confirmation routes

Both routes now re-run the email checks, refuse an address another account
confirmed, strip it from other rows, promote the group and push
user.email_confirmed to the account's sockets (with the originating socket id
on the code route). The link route's full-table UPDATE clearing other
accounts' pending change-email requests is dropped: /change_email/confirm
already refuses an address a confirmed account owns.

* refactor(auth): build window.user once for whoami and sign-in

buildUserDetails and scrubSensitive move from the whoami extension into
util/userDetails.ts. /whoami calls them; sign-in responses now return the same
user-actor payload, scrubbed, so the login copy no longer ships the phone
number and a no-reload login gets oidc_only/otp/directories up front. Taskbar
resolution is best-effort on both.

* refactor(http): userProtected route option replaces AuthController's route walker copy

The user-protected gate is materialized from a userProtected RouteOption
(implies requireUserActor), so AuthController no longer re-implements the
decorator walker to wire it per instance; those routes and the session
routes are plain decorators now. AuthRequired also counts requireVerified
and userProtected.

* refactor(http): internalAuth route option and a shared bodyRecord

EventsController and PeerController each checked x-puter-internal-auth by
hand with different compares; an internalAuth RouteOption now picks the
secret from config and one constant-time gate checks it. ShareController and
EventsController share bodyRecord instead of private copies.

* fix(fs): cap legacy /delete at 500 paths per request

The bulk form resolved, ACL-checked and removed an unbounded paths[] list one
by one. It now refuses more than FS_BATCH_WRITE_MAX_ITEMS (500), the cap the
batch write routes already use, before touching any entry.

* refactor(controllers): user and subdomain writes and reads through their stores

AuthController's 2FA setup/enable/disable and recovery-code writes go through
UserStore.update instead of raw UPDATEs plus a manual invalidate, and
/readdir-subdomains pages SubdomainStore.listByUserId instead of an unbounded
SELECT; the response shape is unchanged.

* refactor(auth): note SignupService's call-time peers

* fix(auth): type userDetails' taskbar deps independently of TaskbarDeps.clients
2026-10-11 22:00:53 -07:00

135 lines
5.3 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 crypto from 'node:crypto';
import { v4 as uuidv4 } from 'uuid';
import { PuterService } from '../types.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import { provisionUser } from '../../util/userProvisioning.js';
import { LOCAL_UNLIMITED_USER } from '../../data/subPolicies/localUnlimitedUserPolicy.js';
import { UNLIMITED_SUBSCRIPTION } from '../metering/consts.js';
const USERNAME = 'admin';
export const ADMIN_GROUP_UID = 'ca342a5e-b13d-4dee-9048-58b11a57cc55';
const ADMIN_STORAGE_BYTES = 10 * 1024 * 1024 * 1024;
/**
* Bootstraps the `admin` user on first boot for self-hosted deployments.
*
* If no admin exists, creates one with a random 8-char hex password, places
* them in the admin group, and stashes the plaintext under
* `metadata.tmp_password` so we can detect on later boots whether the operator
* has rotated it yet.
*
* Each boot where the current password hash still matches the stashed
* plaintext, the credentials are re-printed to stdout (CI scrapes this line to
* extract the default password). Once rotation is detected the stash is deleted
* so the plaintext isn't retained longer than needed.
*/
export class DefaultUserService extends PuterService {
override async onServerStart(): Promise<void> {
// Dev convenience: grant the bootstrap `admin` user unlimited metering.
// Gated to env === 'dev' so prod deployments never get a free-usage
// actor. Registering the policy makes its `'unlimited'` id resolvable
// (extraPolicies is always in the available set); the resolver returns
// null for everyone else, so all other users keep their normal tier.
if (this.config.env === 'dev') {
this.services.metering.registerPolicy(LOCAL_UNLIMITED_USER);
this.services.metering.registerSubscriptionResolver((actor) =>
actor.user?.username === USERNAME
? UNLIMITED_SUBSCRIPTION
: null,
);
}
if (this.config.no_default_user) return;
let user = await this.stores.user.getByUsername(USERNAME);
let tmpPassword: string;
if (!user) {
// 16 bytes (128 bits) — the old 4 bytes was only 32 bits,
// brute-forceable if the box is reachable before the admin
// rotates the printed bootstrap password.
tmpPassword = crypto.randomBytes(16).toString('hex');
user = await this.#createAdminUser(tmpPassword);
} else {
const metadata = (user.metadata ?? {}) as Record<string, unknown>;
const stashed = metadata.tmp_password;
if (typeof stashed !== 'string' || stashed === '') return;
tmpPassword = stashed;
}
if (!user.password) return;
const isDefault = await bcrypt.compare(
tmpPassword,
String(user.password),
);
if (!isDefault) {
// Password was rotated — stop retaining the plaintext stash.
await this.stores.user.updateMetadata(user.id, {
tmp_password: null,
});
return;
}
this.#printCredentials(tmpPassword);
}
async #createAdminUser(tmpPassword: string): Promise<UserRow> {
const passwordHash = await bcrypt.hash(tmpPassword, 8);
const created = await provisionUser(
{
db: this.clients.db,
userStore: this.stores.user,
groupStore: this.stores.group,
},
{
username: USERNAME,
uuid: uuidv4(),
password: passwordHash,
email: null,
free_storage: ADMIN_STORAGE_BYTES,
requires_email_confirmation: false,
},
ADMIN_GROUP_UID,
);
await this.stores.user.updateMetadata(created.id, {
tmp_password: tmpPassword,
});
return (await this.stores.user.getById(created.id)) ?? created;
}
#printCredentials(tmpPassword: string): void {
console.log(`password for admin is: ${tmpPassword}`);
console.log(
'\n************************************************************',
);
console.log('* Your default login credentials are:');
console.log('* Username: admin');
console.log(`* Password: ${tmpPassword}`);
console.log('* (change the password to remove this message)');
console.log(
'************************************************************\n',
);
}
}