@cocalc/server
Version:
CoCalc server functionality: functions used by either the hub and the next.js server
490 lines • 25.1 kB
JavaScript
;
/*
* 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