UNPKG

@cocalc/server

Version:

CoCalc server functionality: functions used by either the hub and the next.js server

490 lines 25.1 kB
"use strict"; /* * This file is part of CoCalc: Copyright © 2022 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PassportLogin = void 0; /** This is called by a passport strategy endpoint (which is already setup) to actually * authenticate a user. This checks if there already exists an account, if not creates it, etc. * Checks if user info needs to be updated, and even checks if this is actually about * getting an API key. * * There are various details to consider as well. * 1. If you're signed in already and you're trying to sign in with a different account provided * via an SSO strategy, we link this passport to your exsiting account. There is just one exception, * which are SSO strategies which "exclusively" manage a domain. * 2. If you're not signed in and try to sign in, this checks if there is already an account – and creates it if not. * 3. If you sign in and the SSO strategy is set to "update_on_login", it will reset the name of the user to the * data from the SSO provider. However, the user can still modify the name. * 4. If you already have an email address belonging to a newly introduced exclusive domain, it will start to be controlled by it. */ const base_path_1 = __importDefault(require("@cocalc/backend/base-path")); const logger_1 = __importDefault(require("@cocalc/backend/logger")); const manage_1 = __importDefault(require("@cocalc/server/api/manage")); const hash_1 = __importDefault(require("@cocalc/server/auth/hash")); const remember_me_1 = require("@cocalc/server/auth/remember-me"); const sanitize_id_1 = require("@cocalc/server/auth/sso/sanitize-id"); const async_utils_1 = require("@cocalc/util/async-utils"); const theme_1 = require("@cocalc/util/theme"); const cookies_1 = __importDefault(require("cookies")); const _ = __importStar(require("lodash")); //import Saml2js from "saml2js"; const account_queries_1 = require("@cocalc/database/postgres/account-queries"); const sanitize_profile_1 = require("@cocalc/server/auth/sso/sanitize-profile"); const consts_1 = require("./consts"); const check_required_sso_1 = require("./check-required-sso"); const lodash_1 = require("lodash"); const get_email_address_1 = __importDefault(require("../../accounts/get-email-address")); const logger = (0, logger_1.default)("server:auth:sso:passport-login"); class PassportLogin { constructor(opts) { this.passports = {}; const L = logger.extend("constructor").debug; this.passports = opts.passports; //this.exclusiveDomains = this.mapExclusiveDomains(); this.database = opts.database; this.record_sign_in = opts.record_sign_in; // TODO: untangle this, make each param a field in this object this.opts = opts; L({ strategyName: opts.strategyName, profile: opts.profile, id: opts.id, first_name: opts.first_name, last_name: opts.last_name, full_name: opts.full_name, emails: opts.emails, update_on_login: opts.update_on_login, // FIXME: host field is probably not needed anywhere – kept for now to be compatible with old code host: opts.host, }); } async login() { const L = logger.extend("login").debug; // sanity checks if (this.opts.strategyName == null) { throw new Error("opts.strategyName must be defined"); } if (this.passports?.[this.opts.strategyName] == null) { throw new Error(`passport strategy '${this.opts.strategyName}' does not exist`); } if (!_.isPlainObject(this.opts.profile)) { throw new Error("opts.profile must be an object"); } (0, sanitize_id_1.sanitizeID)(this.opts); const cookies = new cookies_1.default(this.opts.req, this.opts.res); // TODO: once this is settled, refactor this.opts and locals to be attributes of this short-living class. const locals = { cookies, new_account_created: false, has_valid_remember_me: false, account_id: undefined, email_address: undefined, target: base_path_1.default, remember_me_cookie: cookies.get(remember_me_1.COOKIE_NAME), get_api_key: cookies.get(consts_1.API_KEY_COOKIE_NAME), action: undefined, api_key: undefined, }; // L( {remember_me_cookie : locals.remember_me_cookie}) // DANGER -- do not uncomment except for debugging due to SECURITY L(`remember_me_cookie is set: ${locals.remember_me_cookie?.length > 0}`); // check if user is just trying to get an api key. if (locals.get_api_key) { L("user is just trying to get api_key"); // Set with no value **deletes** the cookie when the response is set. It's very important // to delete this cookie ASAP, since otherwise the user can't sign in normally. locals.cookies.set(consts_1.API_KEY_COOKIE_NAME); } (0, sanitize_profile_1.sanitizeProfile)(this.opts); // L({ locals, opts }); // DANGER -- do not uncomment except for debugging due to SECURITY try { // do we have a valid remember me cookie for a given account_id already? await this.checkRememberMeCookie(locals); // do we already have a passport? await this.checkPassportExists(this.opts, locals); // there might be accounts already with that email address await this.checkExistingEmails(this.opts, locals); // if no account yet → create one await this.maybeCreateAccount(this.opts, locals); // record a sign-in activity, if we deal with an existing account await this.maybeRecordSignIn(this.opts, locals); // if update_on_login is true, update the account with the new profile data await this.maybeUpdateAccountAndPassport(this.opts, locals); // deal with the case where user wants an API key await this.maybeProvisionAPIKey(locals); // check if user is banned? await this.isUserBanned(locals.account_id, locals.email_address); // last step: set remember me cookie (for a new sign in) await this.handleNewSignIn(this.opts, locals); // no exceptions → we're all good L(`redirect the client to '${locals.target}'`); this.opts.res.redirect(locals.target); } catch (err) { // this error is used to signal that the user has done something wrong (in a general sense) // and it shouldn't be the code or how it handles the returned data. // this is used to improve the feedback sent back to the user if there is a problem... err.name = "PassportLoginError"; throw err; } } // end passport_login // retrieve the support help email address from the server settings async getHelpEmail() { const settings = await (0, async_utils_1.callback2)(this.database.get_server_settings_cached); return settings.help_email || theme_1.HELP_EMAIL; } // Check for a valid remember me cookie. If there is one, set // the account_id and has_valid_remember_me fields of locals. // If not, do NOTHING except log some debugging messages. Does // not raise an exception. See // https://github.com/sagemathinc/cocalc/issues/4767 // where this was failing the sign in if the remmeber me was // invalid in any way, which is overkill... since rememember_me // not being valid should just not entitle the user to having a // a specific account_id. async checkRememberMeCookie(locals) { const L = logger.extend("check_remember_me_cookie").debug; if (!locals.remember_me_cookie) return; L("check if user has a valid remember_me cookie"); const value = locals.remember_me_cookie; const x = value.split("$"); if (x.length !== 4) { L("badly formatted remember_me cookie"); return; } let hash; try { hash = (0, hash_1.default)(x[0], x[1], parseInt(x[2]), x[3]); } catch (error) { const err = error; L(`unable to generate hash from remember_me cookie = '${locals.remember_me_cookie}' -- ${err}`); } if (hash != null) { const signed_in_mesg = await (0, async_utils_1.callback2)(this.database.get_remember_me, { hash, }); if (signed_in_mesg != null) { L("user does have valid remember_me token"); locals.account_id = signed_in_mesg.account_id; locals.has_valid_remember_me = true; } else { L("no valid remember_me token"); return; } } } // this adds a passport to an existing account async createPassport(opts, locals) { if (locals.account_id == null) { throw new Error("createPassport: account_id is null"); } await this.database.create_passport({ account_id: locals.account_id, strategy: opts.strategyName, id: opts.id, profile: opts.profile, email_address: opts.emails != null ? opts.emails[0] : undefined, first_name: opts.first_name, last_name: opts.last_name, }); } // this checks if the login info contains an email address, which belongs to an exclusive SSO strategy checkExclusiveSSO(opts) { const strategy = opts.passports[opts.strategyName]; const exclusiveDomains = strategy.info?.exclusive_domains ?? []; if (!(0, lodash_1.isEmpty)(exclusiveDomains)) { for (const email of opts.emails ?? []) { const emailDomain = (0, check_required_sso_1.getEmailDomain)(email.toLocaleLowerCase()); for (const ssoDomain of exclusiveDomains) { if ((0, check_required_sso_1.emailBelongsToDomain)(emailDomain, ssoDomain)) { return true; } } } } return false; } // similar to the above, for a specific email address checkEmailExclusiveSSO(email_address) { const emailDomain = (0, check_required_sso_1.getEmailDomain)(email_address.toLocaleLowerCase()); for (const strategyName in this.opts.passports) { const strategy = this.opts.passports[strategyName]; for (const ssoDomain of strategy.info?.exclusive_domains ?? []) { if ((0, check_required_sso_1.emailBelongsToDomain)(emailDomain, ssoDomain)) { return true; } } } return false; } // check, if depending on the strategy name and provided ID, we already know about that particular passport // this is in particular important, if e.g. a user A is signed in, but attempts to link to a passport X, // which is already associated with a user B. passports across all users are unique! // Exceptions apply to exclusive SSO strategies, which excert more control over the associated account. async checkPassportExists(opts, locals) { const L = logger.extend("check_passport_exists").debug; L("check to see if the passport already exists indexed by the given id -- in that case we will log user in"); const passport_account_id = await this.database.passport_exists({ strategy: opts.strategyName, id: opts.id, }); if (!passport_account_id && locals.has_valid_remember_me && locals.account_id != null) { L("passport doesn't exist, but user is authenticated (via remember_me), so we add this passport for them."); // check if the email address of the passport is exclusive (which means we do not link to an existing account) if (this.checkExclusiveSSO(opts)) { throw new Error(`It is not possible to link this SSO ${opts.passports[opts.strategyName].info?.display ?? opts.strategyName} account to the account your're current logged in with. Please sign out first and then try signin in using this SSO account again.`); } // we also check if the currently signed in user is goverend by an exclusive SSO domain // and prevent linking *another* SSO accoount (because this bypasses the exclusivity) const account_email_address = await (0, get_email_address_1.default)(locals.account_id); if (account_email_address != null) { if (this.checkEmailExclusiveSSO(account_email_address)) { throw new Error(`It is not possible to link any other SSO accounts to the account your're current logged in with.`); } } // user authenticated, passport not known, adding to the user's account await this.createPassport(opts, locals); } else { if (locals.has_valid_remember_me && locals.account_id !== passport_account_id) { L("passport exists but is associated with another account already"); throw Error(`Your ${opts.strategyName} account is already attached to another CoCalc account. First sign into that account and unlink ${opts.strategyName} in account settings, if you want to instead associate it with this account.`); } else { if (locals.has_valid_remember_me) { L("passport already exists and is associated to the currently logged in account"); } else { L("passport exists and is already associated to a valid account, which we'll log user into"); locals.account_id = passport_account_id; } } } } // If the SSO strategy provides one or more email addresses, we check if we already know these addresses! // This means a user can't "grab" some elses account, but has to sign in first (knowing the password) // and then link to the account. An exception are "exclusive" SSO strategies, which are all set to // control email addresses of their associated accounts (and well, users can only sign in using that SSO // strategy) async checkExistingEmails(opts, locals) { const L = logger.extend("check_existing_emails").debug; // handle case where passport doesn't exist, but we know one or more email addresses → check for matching email if (locals.account_id != null || opts.emails == null) return; L("passport doesn't exist but emails are available -- therefore check for existing account with a matching email -- if we find one it's an error, unless it's an 'exclusive' strategy, where we take over that account"); const strategy = opts.passports[opts.strategyName]; // there is usually just one email in opts.emails, or an empty array for (const email of opts.emails) { const email_address = email.toLowerCase().trim(); L(`checking for account with email ${email_address}...`); const existing_account_id = await (0, async_utils_1.callback2)(this.database.account_exists, { email_address, }); if (!existing_account_id) { L(`check_email: no existing_account_id for ${email}`); } else { locals.account_id = existing_account_id; locals.email_address = email_address; L(`found matching account ${locals.account_id} for email ${locals.email_address}`); if (this.checkExclusiveSSO(opts)) { L(`email ${email_address} belongs to SSO strategy ${strategy.info?.display ?? opts.strategyName}, which exclusively manages all emails with domain in ${JSON.stringify(strategy.info?.exclusive_domains ?? [])}`); await this.createPassport(opts, locals); return; } // if there is no SSO mechanism with an exclusive email domain, we throw an error: throw Error(`There is already an account with email address ${locals.email_address}; please sign in using that email account, then link ${opts.strategyName} to it in account settings.`); } } } // This calls the DB methods to create a new account, including the SSO passport configuration async create_account(opts, email_address) { return await (0, async_utils_1.callback2)(this.database.create_account, { first_name: opts.first_name, last_name: opts.last_name, email_address, passport_strategy: opts.strategyName, passport_id: opts.id, passport_profile: opts.profile, }); } // This calls the above, as long as we do not already have an account_id async maybeCreateAccount(opts, locals) { if (locals.account_id) return; const L = logger.extend("maybe_create_account").debug; L("no existing account to link, so create new account that can be accessed using this passport"); if (opts.emails != null) { locals.email_address = opts.emails[0]; } L(`emails=${opts.emails} email_address=${locals.email_address}`); locals.account_id = await this.create_account(opts, locals.email_address); locals.new_account_created = true; // if we know the email address provided by the SSO strategy, // we execute the account creation actions and set the address to be verified if (locals.email_address != null) { const actions = (0, async_utils_1.callback2)(this.database.do_account_creation_actions, { email_address: locals.email_address, account_id: locals.account_id, }); const verify = (0, account_queries_1.set_email_address_verified)({ db: this.database, account_id: locals.account_id, email_address: locals.email_address, }); await Promise.all([actions, verify]); } // log the newly created account const data = { account_id: locals.account_id, first_name: opts.first_name, last_name: opts.last_name, email_address: locals.email_address != null ? locals.email_address : null, created_by: opts.req.ip, }; // no await -- don't let client wait for *logging* the fact that we created an account // failure wouldn't matter. this.database.log({ event: "create_account", value: data, }); } // if the above created no new account (and hence we had an account_id before that) // we record that we signed in a user async maybeRecordSignIn(opts, locals) { if (locals.new_account_created) return; const L = logger.extend("maybe_record_sign_in").debug; // don't make client wait for this -- it's just a log message for us. L(`no new account → record_sign_in: ${opts.req.ip}`); this.record_sign_in({ ip_address: opts.req.ip, successful: true, remember_me: locals.has_valid_remember_me, email_address: locals.email_address, account_id: locals.account_id, database: this.database, }); } // optionally, SSO strategies can be configured to always update fields of the user // with the data they provide. right now that's first and last name. // email address is a bit more tricky and not implemented. async maybeUpdateAccountAndPassport(opts, locals) { // we only update if explicitly configured to do so if (!opts.update_on_login) return; if (locals.new_account_created || locals.account_id == null) return; const L = logger.extend("maybe_update_account_profile").debug; // if (opts.emails != null) { // locals.email_address = opts.emails[0]; // } L(`account exists and we update name of user based on SSO`); await this.database.update_account_and_passport({ account_id: locals.account_id, first_name: opts.first_name, last_name: opts.last_name, strategy: opts.strategyName, id: opts.id, profile: opts.profile, // but not the email address, at least for now // email_address: locals.email_address, passport_profile: opts.profile, }); } // There is a special case, where an api_key was requested. // This is chekced here, key created, and the client is redirected to a special (local) URL async maybeProvisionAPIKey(locals) { if (!locals.get_api_key) return; if (!locals.account_id) return; // typescript cares about this. const L = logger.extend("maybe_provision_api_key").debug; // Just handle getting api key here. if (locals.new_account_created) { locals.action = "regenerate"; // obvious } else { locals.action = "get"; } locals.api_key = await (0, manage_1.default)({ account_id: locals.account_id, action: locals.action, }); // if there is no key if (!locals.api_key) { L("must generate key, since don't already have it"); locals.api_key = await (0, manage_1.default)({ account_id: locals.account_id, action: "regenerate", }); } // we got a key ... // NOTE: See also code to generate similar URL in @cocalc/frontend/account/init.ts locals.target = `https://authenticated?api_key=${locals.api_key}`; } // ebfore recording the sign-in below, we check if a user is banned async isUserBanned(account_id, email_address) { const is_banned = await (0, async_utils_1.callback2)(this.database.is_banned_user, { account_id, }); if (is_banned) { const helpEmail = await this.getHelpEmail(); throw Error(`User (account_id=${account_id}, email_address=${email_address}) is BANNED. If this is a mistake, please contact ${helpEmail}.`); } return is_banned; } // If we did end up here, and there wasn't already a valid remember me cookie, // we signed in a user. We record that and set the remember me cookie // SSO strategies can configure the expiration of that cookie – e.g. super paranoid ones can set this to 1 day. async handleNewSignIn(opts, locals) { if (locals.has_valid_remember_me) return; const L = logger.extend("handle_new_sign_in").debug; // make TS happy if (locals.account_id == null) throw new Error("locals.account_id is null"); L("passport created: set remember_me cookie, so user gets logged in"); L(`create remember_me cookie in database. ttl=${opts.cookie_ttl_s}s`); const { value, ttl_s } = await (0, remember_me_1.createRememberMeCookie)(locals.account_id, opts.cookie_ttl_s); L(`set remember_me cookie in client. ttl=${ttl_s}s`); locals.cookies.set(remember_me_1.COOKIE_NAME, value, { maxAge: ttl_s * 1000, }); } } exports.PassportLogin = PassportLogin; //# sourceMappingURL=passport-login.js.map