/* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ /* Client account creation and deletion */ const MAX_ACCOUNTS_PER_30MIN = 150; const MAX_ACCOUNTS_PER_30MIN_GOLD = 1500; const auth = require("@cocalc/hub/auth"); import passwordHash from "@cocalc/backend/auth/password-hash"; import { get_server_settings } from "@cocalc/database/postgres/server-settings"; import type { PostgreSQL } from "@cocalc/database/postgres/types"; import { getLogger } from "@cocalc/hub/logger"; import apiKeyAction from "@cocalc/server/api/manage"; import { callback2 } from "@cocalc/util/async-utils"; import * as message from "@cocalc/util/message"; import { CreateAccount } from "@cocalc/util/message-types"; import { defaults, is_valid_email_address, len, lower_email_address, required, walltime, } from "@cocalc/util/misc"; import { delay } from "awaiting"; import { parseDomain, ParseResultType } from "parse-domain"; import { get_passports, have_active_registration_tokens } from "../utils"; const winston = getLogger("create-account"); export function is_valid_password(password: string) { if (typeof password !== "string") { return [false, "Password must be specified."]; } if (password.length >= 6 && password.length <= 64) { return [true, ""]; } else { return [false, "Password must be between 6 and 64 characters in length."]; } } function issues_with_create_account(mesg) { const issues: any = {}; if (mesg.email_address && !is_valid_email_address(mesg.email_address)) { issues.email_address = "Email address does not appear to be valid."; } if (mesg.password) { const [valid, reason] = is_valid_password(mesg.password); if (!valid) { issues.password = reason; } } return issues; } async function get_db_client(db: PostgreSQL) { const t0 = new Date().getTime(); while (new Date().getTime() - t0 < 10 * 1000) { const client = db._client(); if (client != null) { return client; } else { await delay(100); } } throw new Error("Unable to get a database client"); } // if the email address's domain should go through SSO, return the domain name // THIS HAS BEEN REWRITTEN AT @cocalc/server/auth/is-domain-exclusive-sso async function is_domain_exclusive_sso( db: PostgreSQL, email?: string ): Promise { if (email == null) return undefined; const raw_domain = email.split("@")[1]?.trim().toLowerCase(); if (raw_domain == null) return; const parsed = parseDomain(raw_domain); const passports = await get_passports(db); const blocked = new Set([]); for (const pp of passports) { for (const domain of pp.info?.exclusive_domains ?? []) { blocked.add(domain); } } if (parsed.type == ParseResultType.Listed) { const { domain, topLevelDomains } = parsed; const canonical = [domain ?? "", ...topLevelDomains].join("."); if (blocked.has(canonical)) { return canonical; } } } // return true if allowed to continue creating an account (either no token required or token matches) // NOTE: completely rewritten in src/packages/server/auth/tokens/redeem.ts // This is not really used (except for the settings app sign in page, which is going to get deleted). // Also it has a bug: Basically, you have a big try/catch and you rollback the transaction if there // is an exception. However, if something goes wrong your code actually just does return "error message.". // Hence if ever anybody enters an incorrect token, the transaction just gets left opened -- it is // never committed or rolled back. async function check_registration_token( db: PostgreSQL, token: string | undefined ): Promise { const have_tokens = await have_active_registration_tokens(db); // if there are no tokens set, it's ok if (!have_tokens) return; if (token == null || token == "") { return "No registration token provided"; } // since we check the counter against the limit, we have to wrap this in a transaction // otherwise there is a chance to increase the counter above the limit const client = await get_db_client(db); try { await client.query("BEGIN"); // overview: first, we check if the token matches. // → check if it is disabled? // → check expiration date → abort if expired // → if counter, check counter vs. limit // → true: increase the counter → ok // → false: ok const q_match = `SELECT "expires", "counter", "limit", "disabled" FROM registration_tokens WHERE token = $1::TEXT FOR UPDATE`; const match = await client.query(q_match, [token]); if (match.rows.length != 1) { return "Registration token is wrong."; } // e.g. { expires: 2020-12-04T11:54:52.889Z, counter: null, limit: 10, disabled: ... } const { expires, counter: counter_raw, limit, disabled: disabled_raw, } = match.rows[0]; const counter = counter_raw ?? 0; const disabled = disabled_raw ?? false; if (disabled) { return "Registration token disabled."; } if (expires != null && expires.getTime() < new Date().getTime()) { return "Registration token no longer valid."; } // we count in any case, but only enforce the limit if there is actually a limit set if (limit != null && limit <= counter) { return "Registration token used up."; } else { // increase counter const q_inc = `UPDATE registration_tokens SET "counter" = coalesce("counter", 0) + 1 WHERE token = $1::TEXT`; await client.query(q_inc, [token]); } // all good, let's commit await client.query("COMMIT"); } catch (e) { await client.query("ROLLBACK"); throw e; } } interface AccountCreationOptions { client: any; mesg: CreateAccount; database: PostgreSQL; host?: string; port?: number; sign_in?: boolean; // if true, the newly created user will also be signed in; only makes sense for browser clients! } interface CreateAccountData { account_id: string; first_name: string; last_name: string; email_address?: string; created_by: string; analytics_token?: string; } // This should not actually throw in case of trouble, but instead send // error directly to the client. export async function create_account( opts: AccountCreationOptions ): Promise { // we still use defaults/required due to coffeescript client. opts = defaults(opts, { client: required, mesg: required, database: required, host: undefined, port: undefined, sign_in: false, // if true, the newly created user will also be signed in; only makes sense for browser clients! }); const id: string = opts.mesg.id; let mesg1: { [key: string]: any }; winston.info( `create_account ${opts.mesg.first_name} ${opts.mesg.last_name} ${opts.mesg.email_address}` ); function dbg(m): void { winston.debug(`create_account (${opts.mesg.email_address}): ${m}`); } const tm = walltime(); if (opts.mesg.email_address != null) { opts.mesg.email_address = lower_email_address(opts.mesg.email_address); } let account_id: string = ""; async function createAccount() { dbg("run tests on generic validity of input"); // check if we even allow account creating via email/password const settings = await get_server_settings(opts.database); if (!settings.email_signup) { return { other: "Signing up via email/password is disabled." }; } // issues_with_create_account also does check is_valid_password! const issues = issues_with_create_account(opts.mesg); // TODO -- only uncomment this for easy testing to allow any password choice. // the client test suite will then fail, which is good, so we are reminded // to comment this out before release! // delete issues['password'] if (len(issues) > 0) { return issues; } // Make sure this ip address hasn't requested too many accounts recently, // just to avoid really nasty abuse, but still allow for demo registration // behind a single router. dbg("make sure not too many accounts were created from the given ip"); const n = await callback2(opts.database.count_accounts_created_by, { ip_address: opts.client.ip_address, age_s: 60 * 30, }); if (n >= MAX_ACCOUNTS_PER_30MIN) { let m = MAX_ACCOUNTS_PER_30MIN; // Check if account is being created via API by a "gold" user. if ( opts.client.account_id != null && (await callback2(opts.database.user_is_in_group, { account_id: opts.client.account_id, group: "gold", })) ) { m = MAX_ACCOUNTS_PER_30MIN_GOLD; } if (n >= m) { return { other: `Too many accounts are being created from the ip address ${opts.client.ip_address}; try again later. By default at most ${m} accounts can be created every 30 minutes from one IP; if you're using the API and need a higher limit, contact us.`, }; } } if (opts.mesg.email_address) { dbg("query database to determine whether the email address is available"); const not_available = await callback2(opts.database.account_exists, { email_address: opts.mesg.email_address, }); if (not_available) { return { email_address: "This e-mail address is already taken." }; } dbg("check that account is not banned"); const is_banned = await callback2(opts.database.is_banned_user, { email_address: opts.mesg.email_address, }); if (is_banned) { return { email_address: "This e-mail address is banned." }; } } dbg("check if a registration token is required"); const check_token = await check_registration_token( opts.database, opts.mesg.token ); if (check_token) { return { token: check_token }; } dbg("check if email domain has to go through an SSO mechanism"); const check_domain = await is_domain_exclusive_sso( opts.database, opts.mesg.email_address ); if (check_domain != null) { return { email_address: `To sign up with "@${check_domain}", you have to use the corresponding SSO connect mechanism listed above!`, }; } dbg("create new account"); account_id = await callback2(opts.database.create_account, { first_name: opts.mesg.first_name, last_name: opts.mesg.last_name, email_address: opts.mesg.email_address, password_hash: opts.mesg.password ? passwordHash(opts.mesg.password) : undefined, created_by: opts.client.ip_address, usage_intent: opts.mesg.usage_intent, }); if (opts.mesg.token != null) { // we also record that we used a registration token ... await callback2(opts.database.log, { event: "create_account_registration_token", value: { token: opts.mesg.token, account_id }, }); } // log to database const data: CreateAccountData = { account_id, first_name: opts.mesg.first_name, last_name: opts.mesg.last_name, email_address: opts.mesg.email_address, created_by: opts.client.ip_address, }; await callback2(opts.database.log, { event: "create_account", value: data, }); if (opts.mesg.email_address) { dbg("check for any account creation actions"); // do not block await callback2(opts.database.do_account_creation_actions, { email_address: opts.mesg.email_address, account_id, }); } if (opts.sign_in) { dbg("set remember_me cookie..."); // so that proxy server will allow user to connect and // download images, etc., the very first time right after they make a new account. await callback2(opts.client.remember_me, { account_id, }); dbg( `send message back to user that they are logged in as the new user (in ${walltime( tm )}seconds)` ); // no analytics token is logged, because it is already done in the create_account entry above. mesg1 = message.signed_in({ id, account_id, email_address: opts.mesg.email_address, first_name: opts.mesg.first_name, last_name: opts.mesg.last_name, remember_me: false, hub: opts.host + ":" + opts.port, }); opts.client.signed_in(mesg1); // records this creation in database... } else { mesg1 = message.account_created({ id, account_id }); } if (opts.mesg.email_address != null) { try { dbg("send email verification request"); await callback2(auth.verify_email_send_token, { account_id, database: opts.database, }); } catch (err) { // We make this nonfatal since email might just be misconfigured, // and we don't want that to completely break account creation. dbg(`WARNING -- error sending email verification (non-fatal): ${err}`); } } if (opts.mesg.get_api_key) { dbg("get_api_key -- generate key and include in response message"); mesg1.api_key = await apiKeyAction({ account_id, password: opts.mesg.password, action: "regenerate", }); } opts.client.push_to_client(mesg1); } let reason: any = undefined; try { reason = await createAccount(); } catch (err) { // This can happen, e.g., the call to opts.database.create_account above // is not wrapped in try/catch and if the first name is wstein.org, // then there is an exception saying the first name is a URL. (ASIDE: This is // really minimal security, since as of writing this you can just change your // name to anything after you make an account.) reason = { other: `${err}` }; } if (reason) { // IMPORTANT: There are various settings where the user simply never sees // this, since they aren't setup to listen for it... dbg( `send message to user that there was an error (in ${walltime( tm )}seconds) -- ${JSON.stringify(reason)}` ); opts.client.push_to_client(message.account_creation_failed({ id, reason })); } } interface DeleteAccountOptions { client?: any; mesg?: any; database: PostgreSQL; } // This should not actually throw in case of trouble, but instead send // error directly to the client. export async function delete_account( opts: DeleteAccountOptions ): Promise { opts = defaults(opts, { client: undefined, mesg: required, database: required, }); winston.info(`delete_account("${opts.mesg.account_id}")`); let error: any = undefined; try { await callback2(opts.database.mark_account_deleted, { account_id: opts.mesg.account_id, }); } catch (err) { error = err; } if (opts.client != null) { opts.client.push_to_client( message.account_deleted({ id: opts.mesg.id, error }) ); } }