UNPKG

@cocalc/hub

Version:
716 lines 32.2 kB
"use strict"; /* * This file is part of CoCalc: Copyright © 2020 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.verify_email_send_token = exports.is_password_correct = exports.PassportManager = exports.init_passport = exports.get_passport_manager = void 0; // Passport Authentication (oauth, etc.) // // Server-side setup // ----------------- // // In order to get this running, you have to manually setup each service. // That requires to register with the authentication provider, telling them about CoCalc, // the domain you use, the return path for the response, and adding the client identification // and corresponding secret keys to the database. // Then, the service is active and will be presented to the user on the sign up page. // The following is an example for setting up google oauth. // The other services are similar. // // 1. background: https://developers.google.com/identity/sign-in/web/devconsole-project // 2. https://console.cloud.google.com/apis/credentials/consent // 3. https://console.developers.google.com/apis/credentials → create credentials → oauth, ... // 4. The return path for google is https://{DOMAIN_NAME}/auth/google/return // 5. When done, there should be an entry under "OAuth 2.0 client IDs" // 6. ... and you have your ID and secret! // // Now, connect to the database, where the setup is in the passports_settings table: // // In older code, there was a "site_conf". We fix it to be $base_path/auth. There is no need to configure it, and existing configurations are ignored. Besides that, it wasn't properly used for all SSO strategies anyways … // // What's important is to configure the individual passport settings: // // 2. insert into passport_settings (strategy , conf ) VALUES ( 'google', '{"clientID": "....apps.googleusercontent.com", "clientSecret": "..."}'::JSONB ) // // Then restart the hubs. const password_hash_1 = __importStar(require("@cocalc/backend/auth/password-hash")); const base_path_1 = __importDefault(require("@cocalc/backend/base-path")); const logger_1 = require("@cocalc/hub/logger"); const extra_strategies_1 = require("@cocalc/server/auth/sso/extra-strategies"); const load_sso_conf_1 = require("@cocalc/database/postgres/load-sso-conf"); const oauth2_user_profile_callback_1 = require("@cocalc/server/auth/sso/oauth2-user-profile-callback"); const passport_login_1 = require("@cocalc/server/auth/sso/passport-login"); const types_1 = require("@cocalc/server/auth/sso/types"); const async_utils_1 = require("@cocalc/util/async-utils"); const misc = __importStar(require("@cocalc/util/misc")); const theme_1 = require("@cocalc/util/theme"); const passport_types_1 = require("@cocalc/util/types/passport-types"); const cookies_1 = __importDefault(require("cookies")); const dot = __importStar(require("dot-object")); const express = __importStar(require("express")); const express_session_1 = __importDefault(require("express-session")); const _ = __importStar(require("lodash")); const ms_1 = __importDefault(require("ms")); const passport_1 = __importDefault(require("passport")); const path_1 = require("path"); const uuid_1 = require("uuid"); const email_1 = require("./email"); //import Saml2js from "saml2js"; const passport_store_1 = require("@cocalc/database/postgres/passport-store"); const consts_1 = require("@cocalc/server/auth/sso/consts"); const public_strategies_1 = require("@cocalc/server/auth/sso/public-strategies"); const sign_in = require("./sign-in"); const safeJsonStringify = require("safe-json-stringify"); const logger = (0, logger_1.getLogger)("hub:auth"); // primary strategies -- all other ones are "extra" const PRIMARY_STRATEGIES = ["email", "site_conf", ...passport_types_1.PRIMARY_SSO]; // root for authentication related endpoints -- will be prefixed with the base_path const AUTH_BASE = "/auth"; const { defaults, required } = misc; // singleton let pp_manager = null; function get_passport_manager() { return pp_manager; } exports.get_passport_manager = get_passport_manager; async function init_passport(opts) { opts = defaults(opts, { router: required, database: required, host: required, cb: required, }); try { if (pp_manager == null) { pp_manager = new PassportManager(opts); await pp_manager.init(); } opts.cb(); } catch (err) { opts.cb(err); } } exports.init_passport = init_passport; class PassportManager { constructor(opts) { // configured strategies this.passports = undefined; // prefix for those endpoints, where SSO services return back this.auth_url = undefined; const { router, database, host } = opts; this.handle_get_api_key.bind(this); this.router = router; this.database = database; this.host = host; } async init_passport_settings() { if (this.passports != null) { logger.debug("already initialized -- just returning what we have"); return this.passports; } try { // email is always included, if even email singup is disabled // use "register tokens" to restrict this method this.passports = { email: { strategy: "email", conf: { type: "email" }, info: { public: true }, }, }; const settings = await this.database.get_all_passport_settings(); for (const setting of settings) { const name = setting.strategy; if (consts_1.BLACKLISTED_STRATEGIES.includes(name)) { throw new Error(`It is not allowed to name a strategy endpoint "${name}", because it is used by the next.js /auth/* endpoint. See next/pages/auth/ROUTING.md for more information.`); } // backwards compatibility const conf = setting.conf; setting.info = setting.info ?? {}; if (setting.info.disabled ?? conf?.disabled ?? false) { continue; } for (const deprecated of [ "public", "display", "icon", "exclusive_domains", ]) { if (setting.info[deprecated] == null) { setting.info[deprecated] = conf?.[deprecated]; } } this.passports[setting.strategy] = setting; } return this.passports; } catch (err) { logger.debug(`error getting passport settings -- ${err}`); throw err; } return {}; } // Define handler for api key cookie setting. handle_get_api_key(req, res, next) { if (req.query.get_api_key) { logger.debug("handle_get_api_key"); const cookies = new cookies_1.default(req, res); // maxAge: User gets up to 60 minutes to go through the SSO process... cookies.set(consts_1.API_KEY_COOKIE_NAME, req.query.get_api_key, { maxAge: 30 * 60 * 1000, }); } next(); } // this is for pure backwards compatibility. at some point remove this! // it only returns a string[] array of the legacy authentication strategies strategies_v1(res) { const data = []; const known = ["email", ...passport_types_1.PRIMARY_SSO]; for (const name in this.passports) { if (name === "site_conf") continue; if (known.indexOf(name) >= 0) { data.push(name); } } res.json(data); } get_strategies_v2() { const data = []; // we cast the result of _.pick to get more type saftey const keys = [ "display", "type", "icon", "public", "exclusive_domains", "do_not_hide", ]; for (const name in this.passports) { if (name === "site_conf") continue; // this is sent to the web client → do not include any secret info! const info = { name, ..._.pick(this.passports[name].info, keys), }; data.push(info); } return data; } // version 2 tells the web client a little bit more. // the additional info is used to render customizeable SSO icons. strategies_v2(res) { res.json(this.get_strategies_v2()); } async init() { // Initialize authentication plugins using Passport logger.debug("init"); // initialize use of middleware this.router.use((0, express_session_1.default)({ secret: (0, uuid_1.v4)() })); // secret is totally random and per-hub session this.router.use(passport_1.default.initialize()); this.router.use(passport_1.default.session()); // Define user serialization passport_1.default.serializeUser((user, done) => done(null, user)); passport_1.default.deserializeUser((user, done) => done(null, user)); await (0, load_sso_conf_1.loadSSOConf)(this.database); // this.router endpoints setup this.init_strategies_endpoint(); this.init_email_verification(); this.init_password_reset_token(); // prerequisite for setting up any SSO endpoints await this.init_passport_settings(); this.check_exclusive_domains_unique(); const settings = await (0, async_utils_1.callback2)(this.database.get_server_settings_cached); const dns = settings.dns || theme_1.DNS; this.auth_url = `https://${dns}${(0, path_1.join)(base_path_1.default, AUTH_BASE)}`; logger.debug(`auth_url='${this.auth_url}'`); await Promise.all([ this.initStrategy(public_strategies_1.GoogleStrategyConf), this.initStrategy(public_strategies_1.GithubStrategyConf), this.initStrategy(public_strategies_1.FacebookStrategyConf), this.initStrategy(public_strategies_1.TwitterStrategyConf), this.init_extra_strategies(), ]); } // check if exclusive domains are unique check_exclusive_domains_unique() { const ret = {}; for (const k in this.passports) { const v = this.passports[k]; for (const domain of v.info?.exclusive_domains ?? []) { if (ret[domain] != null) { throw new Error(`exclusive domain '${domain}' defined by ${ret[domain]} and ${k}: they must be unique`); } ret[domain] = k; } } } init_strategies_endpoint() { // Return the configured and supported authentication strategies. this.router.get(`${AUTH_BASE}/strategies`, (req, res) => { if (req.query.v === "2") { this.strategies_v2(res); } else { this.strategies_v1(res); } }); } async init_email_verification() { // email verification this.router.get(`${AUTH_BASE}/verify`, async (req, res) => { const { DOMAIN_URL } = require("@cocalc/util/theme"); const path = require("path").join(base_path_1.default, "app"); const url = `${DOMAIN_URL}${path}`; res.header("Content-Type", "text/html"); res.header("Cache-Control", "no-cache, no-store"); if (!(req.query.token && req.query.email) || typeof req.query.email !== "string" || typeof req.query.token !== "string") { res.send("ERROR: I need the email address and the corresponding token data"); return; } const email = decodeURIComponent(req.query.email); // .toLowerCase() on purpose: some crazy MTAs transform everything to uppercase! const token = req.query.token.toLowerCase(); try { await (0, async_utils_1.callback2)(this.database.verify_email_check_token, { email_address: email, token, }); res.send((0, email_1.email_verified_successfully)(url)); } catch (err) { res.send((0, email_1.email_verification_problem)(url, err)); } }); } init_password_reset_token() { // reset password: user email link contains a token, which we store in a session cookie. // this prevents leaking that token to 3rd parties as a referrer // endpoint has to match with @cocalc/hub/password this.router.get(`${AUTH_BASE}/password_reset`, (req, res) => { if (typeof req.query.token !== "string") { res.send("ERROR: reset token must be set"); } else { const token = req.query.token.toLowerCase(); const cookies = new cookies_1.default(req, res); // to match @cocalc/frontend/client/password-reset const name = encodeURIComponent(`${base_path_1.default}PWRESET`); const secure = req.protocol === "https"; cookies.set(name, token, { maxAge: (0, ms_1.default)("5 minutes"), secure: secure, overwrite: true, httpOnly: false, }); res.redirect("../app"); } }); } get_extra_default_opts({ name, type, }) { switch (type) { case "saml": // see https://github.com/node-saml/passport-saml#config-parameter-details const cachedMS = (0, ms_1.default)("8 hours"); return { issuer: this.auth_url, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", wantAssertionsSigned: true, acceptedClockSkewMs: (0, ms_1.default)("5 minutes"), // if "*:persistent" doesn't work, use *:emailAddress identifierFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", requestIdExpirationPeriodMs: cachedMS, validateInResponseTo: true, cacheProvider: (0, passport_store_1.getPassportCache)(name, cachedMS), }; } } get_extra_opts(name, conf) { // "extra_opts" is passed to the passport.js "Strategy" constructor! // e.g. arbitrary fields like a tokenURL will be extracted here, and then passed to the constructor const extracted = _.omit(conf, [ "name", "display", "type", "icon", "login_info", "clientID", "clientSecret", "userinfoURL", "public", "auth_opts", // we pass them as a separate parameter ]); return { ...this.get_extra_default_opts({ name, type: conf.type }), ...extracted, }; } // this maps additional strategy configurations to a list of StrategyConf objects // the overall goal is to support custom OAuth2 and LDAP endpoints, where additional // info is sent to the webapp client to properly present them. Google&co are "primary" configurations. // // here is one example what can be saved in the DB to make this work for a general OAuth2 // if this SSO is not public (e.g. uni campus, company specific, ...) mark it as {"public":false}! // // insert into passport_settings (strategy, conf, info ) VALUES ( '[unique, e.g. "wowtech"]', '{"type": "oauth2next", "clientID": "CoCalc_Client", "scope": ["email", "cocalc", "profile", ... depends on the config], "clientSecret": "[a password]", "authorizationURL": "https://domain.edu/.../oauth2/authorize", "userinfoURL" :"https://domain.edu/.../oauth2/userinfo", "tokenURL":"https://domain.edu/.../oauth2/...extras.../access_token", "login_info" : {"emails" :"emails[0].value"}}'::JSONB, {"display": "[user visible, e.g. "WOW Tech"]", "icon": "https://storage.googleapis.com/square.svg", "public": false}::JSONB); // // note, the login_info.emails string extracts from the profile object constructed by parse_openid_profile, // which is only triggered if there is such a "userinfoURL", which is OAuth2 specific. // other auth mechanisms might already provide the profile in passport.js's structure! async init_extra_strategies() { if (this.passports == null) throw Error("strategies not initalized!"); const inits = []; for (const [name, strategy] of Object.entries(this.passports)) { if (PRIMARY_STRATEGIES.indexOf(name) >= 0) { continue; } if (strategy.conf.type == null) { throw new Error(`all "extra" strategies must define their type, in particular also "${name}"`); } const type = strategy.conf.type; // the constructor const PassportStrategyConstructor = (0, extra_strategies_1.getExtraStrategyConstructor)(type); const config = { name, type, PassportStrategyConstructor, login_info: { ...consts_1.DEFAULT_LOGIN_INFO, ...strategy.conf.login_info }, userinfoURL: strategy.conf.userinfoURL, extra_opts: this.get_extra_opts(name, strategy.conf), update_on_login: strategy.info?.update_on_login ?? false, cookie_ttl_s: strategy.info?.cookie_ttl_s, auth_opts: strategy.conf.auth_opts ?? {}, }; inits.push(this.initStrategy(config)); } await Promise.all(inits); } // this is the 2nd entry for the strategy, just a basic callback getVerify(type) { switch (type) { case "saml": return (profile, done) => { done(undefined, profile); }; case "azuread": return (_iss, _sub, profile, _accessToken, _refreshToken, done) => { if (!profile.oid) { return done(new Error("No oid found"), null); } done(undefined, profile); }; case "oidc": return (_issuer, profile, done) => { return done(undefined, profile); }; default: return (_accessToken, _refreshToken, params, profile, done) => { done(undefined, { params, profile }); }; } } getStrategyInstance(args) { const { type, opts, userinfoURL, PassportStrategyConstructor } = args; const L1 = logger.extend("get_strategy_instance"); const L2 = L1.extend("userProfile").debug; const verify = this.getVerify(type); const strategy_instance = new PassportStrategyConstructor(opts, verify); // for OAuth2, set the userinfoURL to get the profile if (userinfoURL != null) { (0, oauth2_user_profile_callback_1.addUserProfileCallback)({ strategy_instance, userinfoURL, L2, type }); } return strategy_instance; } getHandleReturn({ Linit, name, type, update_on_login, cookie_ttl_s, login_info, }) { return async (req, res) => { if (req.user == null) { throw Error("req.user == null -- that shouldn't happen"); } const Lret = Linit.extend(`${name}/return`).debug; // usually, we pick the "profile", but in some cases like SAML this is in "attributes". // finally, as a fallback, we just take the ".user" // technically, req.user should never be undefined, though. const profile = (req.user.profile != null ? req.user.profile : req.user.attributes != null ? req.user.attributes : req.user); if (type === "saml") { // the nameID is set via the conf.identifierFormat parameter – even if we set it to // persistent, we might still just get an email address, though Lret(`nameID format we actually got is ${req.user.nameIDFormat}`); profile.id = req.user.nameID; } Lret(`profile = ${safeJsonStringify(profile)}`); const login_opts = { passports: this.passports ?? {}, database: this.database, host: this.host, record_sign_in: sign_in.record_sign_in, id: profile.id, strategyName: name, profile, update_on_login, cookie_ttl_s, req, res, }; for (const k in login_info) { const v = login_info[k]; const param = typeof v == "function" ? // v is a LoginInfoDerivator<T> v(profile) : // v is a string for dot-object dot.pick(v, profile); login_opts[k] = param; } const passportLogin = new passport_login_1.PassportLogin(login_opts); try { await passportLogin.login(); } catch (err) { let err_msg = ""; // due to https://github.com/Microsoft/TypeScript/issues/13965 we have to check on name and can't use instanceof if (err.name === "PassportLoginError") { const signInUrl = (0, path_1.join)(base_path_1.default, "auth", "sign-in"); err_msg = `Problem signing in using '${name}:<br/><strong>${err.message ?? `${err}`}</strong><br/><a href="${signInUrl}">Sign-in again</a>`; } else { const helpEmail = await passportLogin.getHelpEmail(); err_msg = `Error trying to login using '${name}' -- if this problem persists please contact ${helpEmail} -- ${err}<br/><pre>${err.stack}</pre>`; } Lret(`sending error "${err_msg}"`); res.send(err_msg); } }; } // right now, we only set this for OAauth2 (SAML knows what to do on its own) // This does not encode any information for now. setState(name, type, auth_opts) { return async (_req, _res, next) => { if ((0, types_1.isOAuth2)(type)) { const oauthcache = (0, passport_store_1.getOauthCache)(name); const state = (0, uuid_1.v4)(); await oauthcache.saveAsync(state, `${Date.now()}`); auth_opts.state = state; logger.debug("session: " + auth_opts.state); } next(); }; } // corresponding check to the above. basically checks if the state data is still available. checkState(name, type) { return async (req, _res, next) => { if ((0, types_1.isOAuth2)(type)) { const oauthcache = (0, passport_store_1.getOauthCache)(name); const state = req.query.state; const saved_state = await oauthcache.getAsync(state); if (saved_state == null) { throw Error(`Invalid state: ${state}`); } await oauthcache.removeAsync(state); } next(); }; } // a generalized strategy initizalier async initStrategy(strategy_config) { const { name, // our "name" of the strategy, set in the DB type, // the "type", which is the key in the k PassportStrategyConstructor, extra_opts, auth_opts = {}, login_info, userinfoURL, cookie_ttl_s, update_on_login = false, } = strategy_config; const Linit = logger.extend("init_strategy"); const L = Linit.debug; L(`init_strategy ${name}`); if (this.passports == null) throw Error("strategies not initalized!"); if (name == null) { L(`strategy name is null -- aborting initialization`); return; } const confDB = this.passports[name]; if (confDB == null) { L(`no conf for strategy='${name}' in DB -- aborting initialization`); return; } // under the same name, we make it accessible const strategyUrl = `${AUTH_BASE}/${name}`; const returnUrl = `${strategyUrl}/return`; if (confDB.conf == null) { // This happened on *all* of my dev servers, etc. -- William L(`strategy='${name}' is not properly configured -- aborting initialization`); return; } const opts = { clientID: confDB.conf.clientID, clientSecret: confDB.conf.clientSecret, callbackURL: returnUrl, ...extra_opts, }; // attn: this log line shows secrets // logger.debug(`opts = ${safeJsonStringify(opts)}`); const strategy_instance = this.getStrategyInstance({ type, opts, userinfoURL, PassportStrategyConstructor, }); // this ties the name (our name set in the DB) to the strategy instance passport_1.default.use(name, strategy_instance); this.router.get(strategyUrl, this.handle_get_api_key, this.setState(name, type, auth_opts), passport_1.default.authenticate(name, auth_opts)); // this will hopefully do new PassportLogin().login() const handleReturn = this.getHandleReturn({ Linit, name, type, update_on_login, cookie_ttl_s, login_info, }); if (type === "saml") { this.router.post(returnUrl, // the body-parser package is deprecated, using express directly express.urlencoded({ extended: false }), express.json(), passport_1.default.authenticate(name), async (req, res) => { // block below: boilerplate-code to parse the response from the SAML provider – could become helpful some day! //const xmlResponse = req.body.SAMLResponse; //if (xmlResponse == null) { // throw new Error("SAML xmlResponse is null"); //} //const samlRes = new Saml2js(xmlResponse); //if (req.user == null) req.user = {}; //req.user["profile"] = samlRes.toObject(); await handleReturn(req, res); }); } else if ((0, types_1.isOAuth2)(type)) { this.router.get(returnUrl, this.checkState(name, type), passport_1.default.authenticate(name), handleReturn); } else { this.router.get(returnUrl, passport_1.default.authenticate(name), handleReturn); } L(`initialization of '${name}' at '${strategyUrl}' successful`); } } exports.PassportManager = PassportManager; // NOTE: simpler clean replacement for this is in packages/server/auth/is-password-correct.ts // // Password checking. opts.cb(undefined, true) if the // password is correct, opts.cb(error) on error (e.g., loading from // database), and opts.cb(undefined, false) if password is wrong. You must // specify exactly one of password_hash, account_id, or email_address. // In case you specify password_hash, in addition to calling the // callback (if specified), this function also returns true if the // password is correct, and false otherwise; it can do this because // there is no async IO when the password_hash is specified. async function is_password_correct(opts) { opts = defaults(opts, { database: required, password: required, password_hash: undefined, account_id: undefined, email_address: undefined, // If true and no password set in account, it matches anything. // this is only used when first changing the email address or password // in passport-only accounts. allow_empty_password: false, // cb(err, true or false) cb: required, }); if (opts.password_hash != null) { const r = (0, password_hash_1.verifyPassword)(opts.password, opts.password_hash); opts.cb(undefined, r); } else if (opts.account_id != null || opts.email_address != null) { try { const account = await (0, async_utils_1.callback2)(opts.database.get_account, { account_id: opts.account_id, email_address: opts.email_address, columns: ["password_hash"], }); if (opts.allow_empty_password && !account.password_hash) { if (opts.password && opts.account_id) { // Set opts.password as the password, since we're actually // setting the email address and password at the same time. opts.database.change_password({ account_id: opts.account_id, password_hash: (0, password_hash_1.default)(opts.password), invalidate_remember_me: false, cb: (err) => opts.cb(err, true), }); } else { opts.cb(undefined, true); } } else { opts.cb(undefined, (0, password_hash_1.verifyPassword)(opts.password, account.password_hash)); } } catch (error) { opts.cb(error); } } else { opts.cb("One of password_hash, account_id, or email_address must be specified."); } } exports.is_password_correct = is_password_correct; async function verify_email_send_token(opts) { opts = defaults(opts, { database: required, account_id: required, only_verify: false, cb: required, }); try { const { token, email_address } = await (0, async_utils_1.callback2)(opts.database.verify_email_create_token, { account_id: opts.account_id, }); const settings = await (0, async_utils_1.callback2)(opts.database.get_server_settings_cached); await (0, async_utils_1.callback2)(email_1.welcome_email, { to: email_address, token, only_verify: opts.only_verify, settings, }); opts.cb(); } catch (err) { opts.cb(err); } } exports.verify_email_send_token = verify_email_send_token; //# sourceMappingURL=auth.js.map