UNPKG

smc-hub

Version:

CoCalc: Backend webserver component

1,346 lines (1,222 loc) 46.3 kB
/* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ // 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. import { Router } from "express"; import ms from "ms"; import { callback2 as cb2 } from "smc-util/async-utils"; import debug from "debug"; const LOG = debug("hub:auth"); import { join as path_join } from "path"; import { v4 } from "node-uuid"; import passport from "passport"; import * as dot from "dot-object"; import * as _ from "lodash"; import * as misc from "smc-util/misc"; import * as message from "smc-util/message"; // message protocol between front-end and back-end const sign_in = require("./sign-in"); import Cookies from "cookies"; import express_session from "express-session"; import { HELP_EMAIL, DNS } from "smc-util/theme"; import { email_verified_successfully, email_verification_problem, welcome_email, } from "./email"; import { PostgreSQL } from "./postgres/types"; import { PassportStrategy, PRIMARY_SSO, } from "smc-webapp/account/passport-types"; const safeJsonStringify = require("safe-json-stringify"); import base_path from "smc-util-node/base-path"; // primary strategies -- all other ones are "extra" const PRIMARY_STRATEGIES = ["email", "site_conf", ...PRIMARY_SSO]; // root for authentication related endpoints -- will be prefixed with the base_path const AUTH_BASE = "/auth"; type login_info_keys = | "id" | "first_name" | "last_name" | "full_name" | "emails"; export interface PassportStrategyDB extends PassportStrategy { clientID?: string; // Google, Twitter, ... and OAuth2 clientSecret?: string; // Google, Twitter, ... and OAuth2 authorizationURL?: string; // OAuth2 tokenURL?: string; // --*-- userinfoURL?: string; // OAuth2, to get a profile login_info?: { [key in login_info_keys]?: string }; public?: boolean; // if true it's a public SSO. this is only used in the UI, i.e. when there are no public ones, we allow token based email sign up disabled?: boolean; // if true, ignore this entry. default false. exclusive_domains?: string[]; } const { defaults, required } = misc; const API_KEY_COOKIE_NAME = base_path + "get_api_key"; // Nov'19: actually two cookies due to same-site changes. // See https://web.dev/samesite-cookie-recipes/#handling-incompatible-clients export function remember_me_cookie_name(): string { return `${ base_path.length <= 1 ? "" : encodeURIComponent(base_path) }remember_me`; } //####################################### // Password hashing //####################################### const password_hash_library = require("password-hash"); const crypto = require("crypto"); // You can change the parameters at any time and no existing passwords // or cookies should break. This will only impact newly created // passwords and cookies. Old ones can be read just fine (with the old // parameters). const HASH_ALGORITHM = "sha512"; const HASH_ITERATIONS = 1000; const HASH_SALT_LENGTH = 32; // This function is private and burried inside the password-hash // library. To avoid having to fork/modify that library, we've just // copied it here. We need it for remember_me cookies. export function generate_hash(algorithm, salt, iterations, password): string { // there are cases where createHmac throws an error, because "salt" is undefined if (algorithm == null || salt == null) { throw new Error( `undefined arguments: algorithm='${algorithm}' salt='${salt}'` ); } iterations = iterations || 1; let hash = password; for ( let i = 1, end = iterations, asc = 1 <= end; asc ? i <= end : i >= end; asc ? i++ : i-- ) { hash = crypto.createHmac(algorithm, salt).update(hash).digest("hex"); } return algorithm + "$" + salt + "$" + iterations + "$" + hash; } export function password_hash(password): string { // This blocks the server for about 5-9ms. return password_hash_library.generate(password, { algorithm: HASH_ALGORITHM, saltLength: HASH_SALT_LENGTH, iterations: HASH_ITERATIONS, }); } interface PassportLogin { strategy: string; profile: any; // complex object id: string; first_name?: string; last_name?: string; full_name?: string; emails?: string[]; req: any; res: any; host: any; cb: (err) => void; } // maps the full profile object to a string or list of strings (e.g. "first_name") type LoginInfoDerivator<T> = (profile: any) => T; interface StrategyConf { strategy: string; PassportStrategyConstructor: any; extra_opts?: { enableProof?: boolean; // facebook profileFields?: string[]; // facebook includeEmail?: boolean; // twitter }; auth_opts?: passport.AuthenticateOptions; // return type has to partially fit with passport_login login_info: { id: string | LoginInfoDerivator<string>; // id is required! first_name?: string | LoginInfoDerivator<string>; last_name?: string | LoginInfoDerivator<string>; full_name?: string | LoginInfoDerivator<string>; emails?: string | LoginInfoDerivator<string[]>; }; userinfoURL?: string; // OAuth2, to get a profile } // docs for getting these for your app // https://developers.google.com/identity/protocols/oauth2/openid-connect#appsetup // and https://console.developers.google.com/apis/credentials // // You must then put them in the database, via // // require 'c'; db() // db.set_passport_settings(strategy:'google', conf:{clientID:'...',clientSecret:'...'}, cb:console.log) // Scope: // Enabling "profile" below I think required that I explicitly go to Google Developer Console for the project, // then select API&Auth, then API's, then Google+, then explicitly enable it. Otherwise, stuff just mysteriously // didn't work. To figure out that this was the problem, I had to grep the source code of the passport-google-oauth // library and put in print statements to see what the *REAL* errors were, since that // library hid the errors (**WHY**!!?). const GoogleStrategyConf: StrategyConf = { strategy: "google", PassportStrategyConstructor: require("@passport-next/passport-google-oauth2") .Strategy, auth_opts: { scope: "openid email profile" }, login_info: { id: (profile) => profile.id, first_name: (profile) => profile.name.givenName, last_name: (profile) => profile.name.familyName, emails: (profile) => profile.emails.map((x) => x.value as string), }, }; // Get these here: // https://github.com/settings/applications/new // You must then put them in the database, via // db.set_passport_settings(strategy:'github', conf:{clientID:'...',clientSecret:'...'}, cb:console.log) const GithubStrategyConf: StrategyConf = { strategy: "github", PassportStrategyConstructor: require("passport-github2").Strategy, auth_opts: { scope: ["user:email"], }, login_info: { id: (profile) => profile.id, full_name: (profile) => profile.name || profile.displayName || profile.username, emails: (profile) => (profile.emails ?? []).map((x) => x.value), }, }; // Get these by going to https://developers.facebook.com/ and creating a new application. // For that application, set the url to the site CoCalc will be served from. // The Facebook "App ID" and is clientID and the Facebook "App Secret" is the clientSecret // for oauth2, as I discovered by a lucky guess... (sigh). // // You must then put them in the database, via // db.set_passport_settings(strategy:'facebook', conf:{clientID:'...',clientSecret:'...'}, cb:console.log) const FacebookStrategyConf: StrategyConf = { strategy: "facebook", PassportStrategyConstructor: require("passport-facebook").Strategy, extra_opts: { enableProof: false, profileFields: ["id", "email", "name", "displayName"], }, auth_opts: { scope: "email" }, login_info: { id: (profile) => profile.id, full_name: (profile) => profile.displayName, emails: (profile) => (profile.emails ?? []).map((x) => x.value), }, }; // Get these by: // (1) Go to https://apps.twitter.com/ and create a new application. // (2) Click on Keys and Access Tokens // // You must then put them in the database, via // db.set_passport_settings(strategy:'twitter', conf:{clientID:'...',clientSecret:'...'}, cb:console.log) const TwitterWrapper = ( { clientID: consumerKey, clientSecret: consumerSecret, callbackURL }, verify ) => { // cast to any, because otherwies TypeScript complains: // Only a void function can be called with the 'new' keyword. const TwitterStrat = require("passport-twitter").Strategy as any; return new TwitterStrat({ consumerKey, consumerSecret, callbackURL }, verify); }; const TwitterStrategyConf: StrategyConf = { strategy: "twitter", PassportStrategyConstructor: TwitterWrapper, login_info: { id: (profile) => profile.id, full_name: (profile) => profile.displayName, emails: (profile) => (profile.emails ?? []).map((x) => x.value), }, extra_opts: { includeEmail: true, }, }; // generalized OpenID (OAuth2) profile parser for the "userinfo" endpoint // the returned structure matches passport.js's conventions function parse_openid_profile(json: any) { const profile: any = {}; profile.id = json.sub || json.id; profile.displayName = json.name; if (json.family_name || json.given_name) { profile.name = { familyName: json.family_name, givenName: json.given_name, }; // no name? we use the email address } else if (json.email) { // don't include dots, because our "spam protection" rejects domain-like patterns const emailacc = json.email.split("@")[0].split("."); const [first, ...last] = emailacc; // last is always at least [] profile.name = { givenName: first, familyName: last.join(" "), }; } if (json.email) { profile.emails = [ { value: json.email, verified: json.email_verified || json.verified_email, }, ]; } if (json.picture) { profile.photos = [{ value: json.picture }]; } return profile; } interface InitPassport { router: Router; database: PostgreSQL; host: string; cb: (err?) => void; } // singleton let pp_manager: PassportManager | null = null; export function get_passport_manager() { return pp_manager; } export async function init_passport(opts: InitPassport) { 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); } } interface PassportManagerOpts { router: Router; database: PostgreSQL; host: string; } // passport_login state interface PassportLoginLocals { dbg: any; // InstanceType<typeof LOG> -- evidently, broken with current versions of things... account_id: string | undefined; email_address: string | undefined; new_account_created: boolean; has_valid_remember_me: boolean; target: string; cookies: any; remember_me_cookie: string; get_api_key: string; action: "regenerate" | "get" | undefined; api_key: string | undefined; } export class PassportManager { // express js, passed in from hub's main file readonly router: Router; // the database, for various server queries readonly database: PostgreSQL; // set in the hub, passed in -- not used by "site_conf", though readonly host: string; // e.g. 127.0.0.1 // configured strategies private strategies: { [k: string]: PassportStrategyDB } | undefined = undefined; // prefix for those endpoints, where SSO services return back private auth_url: string | undefined = undefined; constructor(opts: PassportManagerOpts) { const { router, database, host } = opts; this.handle_get_api_key.bind(this); this.router = router; this.database = database; this.host = host; } private async init_passport_settings(): Promise<{ [k: string]: PassportStrategyDB; }> { const dbg = LOG.extend("init_passport_settings"); if (this.strategies != null) { dbg("already initialized -- just returning what we have"); return this.strategies; } try { // we always offer email! this.strategies = { email: { name: "email" } }; const settings = await cb2(this.database.get_all_passport_settings); for (const setting of settings) { const name = setting.strategy; const conf = setting.conf as PassportStrategyDB; if (conf.disabled === true) { continue; } conf.name = name; conf.public = setting.conf.public ?? true; // set the default this.strategies[name] = conf; } return this.strategies; } catch (err) { dbg(`error getting passport settings -- ${err}`); throw err; } return {}; } // Define handler for api key cookie setting. private handle_get_api_key(req, res, next) { const dbg = LOG.extend("handle_get_api_key"); dbg(""); if (req.query.get_api_key) { const cookies = new Cookies(req, res); // maxAge: User gets up to 60 minutes to go through the SSO process... cookies.set(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 private strategies_v1(res): void { const data: string[] = []; const known = ["email", ...PRIMARY_SSO]; for (const name in this.strategies) { if (name === "site_conf") continue; if (known.indexOf(name) >= 0) { data.push(name); } } res.json(data); } public get_strategies_v2(): PassportStrategy[] { const data: PassportStrategy[] = []; for (const name in this.strategies) { if (name === "site_conf") continue; // this is sent to the web client → do not include any secret info! const info = _.pick(this.strategies[name], [ "name", "display", "type", "icon", "public", "exclusive_domains", ]); 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. private strategies_v2(res): void { res.json(this.get_strategies_v2()); } async init(): Promise<void> { // Initialize authentication plugins using Passport const dbg = LOG.extend("init"); dbg(""); // initialize use of middleware this.router.use(express_session({ secret: v4() })); // secret is totally random and per-hub session this.router.use(passport.initialize()); this.router.use(passport.session()); // Define user serialization passport.serializeUser((user, done) => done(null, user)); passport.deserializeUser((user: Express.User, done) => done(null, user)); // 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); } }); // email verification this.router.get(`${AUTH_BASE}/verify`, async (req, res) => { const { DOMAIN_URL } = require("smc-util/theme"); const path = require("path").join(base_path, "app"); const url = `${DOMAIN_URL}${path}`; res.header("Content-Type", "text/html"); res.header("Cache-Control", "private, no-cache, must-revalidate"); 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 cb2(this.database.verify_email_check_token, { email_address: email, token, }); res.send(email_verified_successfully(url)); } catch (err) { res.send(email_verification_problem(url, err)); } }); // 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 smc-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(req, res); // to match smc-webapp/client/password-reset const name = encodeURIComponent(`${base_path}PWRESET`); cookies.set(name, token, { maxAge: ms("5 minutes"), secure: true, overwrite: true, httpOnly: false, }); res.redirect("../app"); } }); // prerequisite for setting up any SSO endpoints await this.init_passport_settings(); const settings = await cb2(this.database.get_server_settings_cached); const dns = settings.dns || DNS; this.auth_url = `https://${dns}${path_join(base_path, AUTH_BASE)}`; dbg(`auth_url='${this.auth_url}'`); await Promise.all([ this.init_strategy(GoogleStrategyConf), this.init_strategy(GithubStrategyConf), this.init_strategy(FacebookStrategyConf), this.init_strategy(TwitterStrategyConf), this.init_extra_strategies(), ]); } private extra_strategy_constructor(name: string) { // LDAP via passport-ldapauth: https://github.com/vesse/passport-ldapauth#readme // OAuth2 via @passport-next/passport-oauth2: https://github.com/passport-next/passport-oauth2#readme // ORCID via passport-orcid: https://github.com/hubgit/passport-orcid#readme switch (name) { case "ldap": return require("passport-ldapauth").Strategy; case "oauth1": return require("passport-oauth").OAuthStrategy; case "oauth2": return require("passport-oauth").OAuth2Strategy; case "oauth2next": return require("@passport-next/passport-oauth2").Strategy; case "orcid": return require("passport-orcid").Strategy; case "saml": return require("passport-saml").Strategy; default: throw Error(`hub/auth: unknown extra strategy "${name}"`); } } // 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 ) 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"}, "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! private async init_extra_strategies(): Promise<void> { if (this.strategies == null) throw Error("strategies not initalized!"); const inits: Promise<void>[] = []; for (const [name, strategy] of Object.entries(this.strategies)) { if (PRIMARY_STRATEGIES.indexOf(name) >= 0) { continue; } if (strategy.type == null) { throw new Error( `all "extra" strategies must define their type, in particular also "${name}"` ); } const cons = this.extra_strategy_constructor(strategy.type); // by default, all of these have .id, but we can overwrite them in the configuration's login_info field // the default below works well for OAuth2 const dflt_login_info = { id: "id", first_name: "name.givenName", last_name: "name.familyName", emails: "emails[0].value", }; const config: StrategyConf = { strategy: name, PassportStrategyConstructor: cons, login_info: Object.assign(dflt_login_info, strategy.login_info), userinfoURL: strategy.userinfoURL, // e.g. tokenURL will be extracted here, and then passed to the constructor extra_opts: _.omit(strategy, [ "name", "display", "type", "icon", "clientID", "clientSecret", "userinfoURL", "public", // we don't need that info for initializing them ]) as any, }; inits.push(this.init_strategy(config)); } await Promise.all(inits); } // a generalized strategy initizalier private async init_strategy(strategy_config: StrategyConf): Promise<void> { const { strategy, PassportStrategyConstructor, extra_opts, auth_opts, login_info, userinfoURL, } = strategy_config; const dbg = LOG.extend(`init_strategy ${strategy}`); dbg("start"); if (this.strategies == null) throw Error("strategies not initalized!"); if (strategy == null) { dbg(`strategy is null -- aborting initialization`); return; } const conf = this.strategies[strategy]; if (conf == null) { dbg(`conf is null -- aborting initialization`); return; } const opts = Object.assign( { clientID: conf.clientID, clientSecret: conf.clientSecret, callbackURL: `${this.auth_url}/${strategy}/return`, }, extra_opts ); // attn: this log line shows secrets // dbg(`opts = ${safeJsonStringify(opts)}`); const verify = (_accessToken, _refreshToken, params, profile, done) => { done(undefined, { params, profile }); }; const strategy_instance = new PassportStrategyConstructor(opts, verify); // OAuth2 userinfoURL: next to /authorize // https://github.com/passport-next/passport-oauth2/blob/master/lib/strategy.js#L276 if (userinfoURL != null) { // closure captures "strategy" strategy_instance.userProfile = function userProfile(accessToken, done) { const dbg = LOG.extend("PassportStrategy").extend("userProfile"); dbg(`userinfoURL=${userinfoURL}, accessToken=${accessToken}`); this._oauth2.useAuthorizationHeaderforGET(true); this._oauth2.get(userinfoURL, accessToken, (err, body) => { dbg(`get->body = ${body}`); let json; if (err) { dbg( `InternalOAuthError: Failed to fetch user profile -- ${safeJsonStringify( err )}` ); if (err.data) { try { json = safeJsonStringify(err.data); } catch (_) { json = {}; } } if (json && json.error && json.error_description) { return done( new Error( `UserInfoError: ${json.error_description}, ${json.error}` ) ); } return done( new Error( `InternalOAuthError: Failed to fetch user profile -- ${safeJsonStringify( err )}` ) ); } try { json = JSON.parse(body); } catch (ex) { return done(new Error(`Failed to parse user profile -- ${body}`)); } const profile = parse_openid_profile(json); profile.provider = strategy; profile._raw = body; dbg( `PassportStrategyConstructor.userProfile: profile = ${safeJsonStringify( profile )}` ); return done(null, profile); }); }; } passport.use(strategy, strategy_instance); this.router.get( `${AUTH_BASE}/${strategy}`, this.handle_get_api_key, passport.authenticate(strategy, auth_opts || {}) ); this.router.get( `${AUTH_BASE}/${strategy}/return`, passport.authenticate(strategy), async (req, res) => { const dbg2 = dbg.extend("router.get"); if (req.user == null) { throw Error("req.user == null -- that shouldn't happen"); } dbg2(`${strategy}/return user = ${safeJsonStringify(req.user)}`); const profile = req.user["profile"] as any as passport.Profile; dbg2(`${strategy}/return profile = ${safeJsonStringify(profile)}`); const login_opts = { strategy, profile, // will just get saved in database req, res, host: this.host, }; for (const k in login_info) { const v = login_info[k]; const param: string | string[] = typeof v == "function" ? // v is a LoginInfoDerivator<T> v(profile) : // v is a string for dot-object dot.pick(v, profile); Object.assign(login_opts, { [k]: param }); } // this log line below suddenly produces a lot of output [rub, 2020-05-06] //dbg2( // `login_opts = ${safeJsonStringify(_.omit(login_opts, ["req, res"]))}` //); await this.passport_login(login_opts as PassportLogin); } ); dbg("initialization successful"); } private async passport_login(opts: PassportLogin): Promise<void> { opts = defaults(opts, { strategy: required, // name of the auth strategy, e.g., 'google', 'facebook', etc. profile: required, // will just get saved in database id: required, // unique id given by oauth provider first_name: undefined, last_name: undefined, full_name: undefined, emails: undefined, // string or Array<string> if user not logged in (via remember_me) already, and existing account with same email, and passport not created, then get an error instead of login or account creation. req: required, // request object res: required, // response object host: required, }); const dbg = LOG.extend("passport_login"); const cookies = new Cookies(opts.req, opts.res); const locals: PassportLoginLocals = { dbg, cookies, new_account_created: false, has_valid_remember_me: false, account_id: undefined, email_address: undefined, target: path_join(base_path + "app#login"), remember_me_cookie: cookies.get(remember_me_cookie_name()), get_api_key: cookies.get(API_KEY_COOKIE_NAME), action: undefined, api_key: undefined, }; //# dbg("cookies = '#{opts.req.headers['cookie']}'") # DANGER -- do not uncomment except for debugging due to SECURITY dbg( `strategy=${opts.strategy} id=${opts.id} emails=${ opts.emails } remember_me_cookie = '${ locals.remember_me_cookie }' user=${safeJsonStringify(opts.req.user)}` ); // check if user is just trying to get an api key. if (locals.get_api_key) { dbg("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(API_KEY_COOKIE_NAME); } if ( opts.full_name != null && opts.first_name == null && opts.last_name == null ) { const name = opts.full_name; const i = name.lastIndexOf(" "); if (i === -1) { opts.first_name = ""; opts.last_name = name; } else { opts.first_name = name.slice(0, i).trim(); opts.last_name = name.slice(i).trim(); } } opts.first_name = opts.first_name ?? ""; opts.last_name = opts.last_name ?? ""; if (opts.emails != null) { opts.emails = (() => { const emails = typeof opts.emails == "string" ? [opts.emails] : opts.emails; const result: string[] = []; for (const x of emails) { if (typeof x === "string" && misc.is_valid_email_address(x)) { result.push(x.toLowerCase()); } } return result; })(); } opts.id = `${opts.id}`; // convert to string (id is often a number) try { // do we have a valid remember me cookie for a given account_id already? await this.check_remember_me_cookie(locals); // do we already have a passport? await this.check_passport_exists(opts, locals); // there might be accounts already with that email address await this.check_existing_emails(opts, locals); // if no account yet → create one await this.maybe_create_account(opts, locals); // record a sign-in activity, if we deal with an existing account await this.maybe_record_sign_in(opts, locals); // deal with the case where user wants an API key await this.maybe_provision_api_key(locals); // check if user is banned? await this.is_user_banned(locals.account_id, locals.email_address); // last step: set remember me cookie (for a new sign in) await this.handle_new_sign_in(opts, locals); // no exceptions → we're all good dbg(`redirect the client to '${locals.target}'`); opts.res.redirect(locals.target); } catch (err) { const err_msg = `Error trying to login using ${opts.strategy} -- ${err}`; dbg(`sending error "${err_msg}"`); opts.res.send(err_msg); } } // end passport_login // 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. private async check_remember_me_cookie( locals: PassportLoginLocals ): Promise<void> { if (!locals.remember_me_cookie) return; const dbg = locals.dbg.extend("check_remember_me_cookie"); dbg("check if user has a valid remember_me cookie"); const value = locals.remember_me_cookie; const x: string[] = value.split("$"); if (x.length !== 4) { dbg("badly formatted remember_me cookie"); return; } let hash; try { hash = generate_hash(x[0], x[1], x[2], x[3]); } catch (error) { const err = error; dbg( `unable to generate hash from remember_me cookie = '${locals.remember_me_cookie}' -- ${err}` ); } if (hash != null) { const signed_in_mesg = await cb2(this.database.get_remember_me, { hash, }); if (signed_in_mesg != null) { dbg("user does have valid remember_me token"); locals.account_id = signed_in_mesg.account_id; locals.has_valid_remember_me = true; } else { dbg("no valid remember_me token"); return; } } } private async check_passport_exists( opts: PassportLogin, locals: PassportLoginLocals ): Promise<void> { const dbg = locals.dbg.extend("check_passport_exists"); dbg( "check to see if the passport already exists indexed by the given id -- in that case we will log user in" ); const _account_id = await cb2(this.database.passport_exists, { strategy: opts.strategy, id: opts.id, }); if ( !_account_id && locals.has_valid_remember_me && locals.account_id != null ) { dbg( "passport doesn't exist, but user is authenticated (via remember_me), so we add this passport for them." ); await cb2(this.database.create_passport, { account_id: locals.account_id, strategy: opts.strategy, 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, }); } else { if (locals.has_valid_remember_me && locals.account_id !== _account_id) { dbg("passport exists but is associated with another account already"); throw Error( `Your ${opts.strategy} account is already attached to another CoCalc account. First sign into that account and unlink ${opts.strategy} in account settings if you want to instead associate it with this account.` ); } else { if (locals.has_valid_remember_me) { dbg( "passport already exists and is associated to the currently logged into account" ); } else { dbg( "passport exists and is already associated to a valid account, which we'll log user into" ); locals.account_id = _account_id; } } } } private async check_existing_emails( opts: PassportLogin, locals: PassportLoginLocals ): Promise<void> { // 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; const dbg = locals.dbg.extend("check_existing_emails"); dbg( "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" ); const check_emails = opts.emails.map(async (email) => { if (locals.account_id) { dbg( `already found a match with account_id=${locals.account_id} -- done` ); return; } else { dbg(`checking for account with email ${email}...`); const _account_id = await cb2(this.database.account_exists, { email_address: email.toLowerCase(), }); if (locals.account_id) { // already done, so ignore dbg( `already found a match with account_id=${locals.account_id} -- done` ); return; } else if (!_account_id) { dbg(`check_email: no _account_id for ${email}`); } else { locals.account_id = _account_id; locals.email_address = email.toLowerCase(); dbg( `found matching account ${locals.account_id} for email ${locals.email_address}` ); throw Error( `There is already an account with email address ${locals.email_address}; please sign in using that email account, then link ${opts.strategy} to it in account settings.` ); } } }); await Promise.all(check_emails); } private async set_email_verified( account_id: string, email_address: string ): Promise<void> { return await cb2(this.database._query, { query: "UPDATE accounts", jsonb_set: { email_address_verified: { [email_address]: new Date() } }, where: { "account_id = $::UUID": account_id }, }); } private async create_account(opts, email_address): Promise<string> { return await cb2(this.database.create_account, { first_name: opts.first_name, last_name: opts.last_name, email_address, passport_strategy: opts.strategy, passport_id: opts.id, passport_profile: opts.profile, }); } private async maybe_create_account( opts: PassportLogin, locals: PassportLoginLocals ): Promise<void> { if (locals.account_id) return; const dbg = locals.dbg.extend("maybe_create_account"); dbg( "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]; } dbg("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, // we execute the account creation actions and set the address to be verified if (locals.email_address != null) { const actions = cb2(this.database.do_account_creation_actions, { email_address: locals.email_address, account_id: locals.account_id, }); const verify = this.set_email_verified( locals.account_id, 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, }); } private async maybe_record_sign_in( opts: PassportLogin, locals: PassportLoginLocals ): Promise<void> { if (locals.new_account_created) return; const dbg = locals.dbg.extend("maybe_record_sign_in"); // don't make client wait for this -- it's just a log message for us. dbg(`no new account → record_sign_in: ${opts.req.ip}`); sign_in.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, }); } private async maybe_provision_api_key( locals: PassportLoginLocals ): Promise<void> { if (!locals.get_api_key) return; const dbg = locals.dbg.extend("maybe_provision_api_key"); // Just handle getting api key here. const { api_key_action } = require("./api/manage"); // here, rather than at beginnig of file, due to some circular references... if (locals.new_account_created) { locals.action = "regenerate"; // obvious } else { locals.action = "get"; } locals.api_key = await cb2(api_key_action, { database: this.database, account_id: locals.account_id, passport: true, action: locals.action, }); // if there is no key if (!locals.api_key) { dbg("must generate key, since don't already have it"); locals.api_key = await cb2(api_key_action, { database: this.database, account_id: locals.account_id, passport: true, action: "regenerate", }); } // we got a key ... // NOTE: See also code to generate similar URL in smc-webapp/account/init.ts locals.target = `https://authenticated?api_key=${locals.api_key}`; } private async handle_new_sign_in( opts: PassportLogin, locals: PassportLoginLocals ): Promise<void> { if (locals.has_valid_remember_me) return; const dbg = locals.dbg.extend("handle_new_sign_in"); // make TS happy if (locals.account_id == null) throw new Error("locals.account_id is null"); dbg("passport created: set remember_me cookie, so user gets logged in"); // create and set remember_me cookie, then redirect. // See the remember_me method of client for the algorithm we use. const signed_in_mesg = message.signed_in({ remember_me: true, hub: opts.host, account_id: locals.account_id, first_name: opts.first_name, last_name: opts.last_name, }); dbg("create remember_me cookie"); const session_id = v4(); const hash_session_id = password_hash(session_id); const ttl_s = 24 * 3600 * 30; // 30 days const x: string[] = hash_session_id.split("$"); const remember_me_value = [x[0], x[1], x[2], session_id].join("$"); dbg("save remember_me cookie in database"); await cb2(this.database.save_remember_me, { account_id: locals.account_id, hash: hash_session_id, value: signed_in_mesg, ttl: ttl_s, }); dbg("and also set remember_me cookie in client"); locals.cookies.set(remember_me_cookie_name(), remember_me_value, { maxAge: ttl_s * 1000, }); } private async is_user_banned(account_id, email_address): Promise<boolean> { const is_banned = await cb2(this.database.is_banned_user, { account_id, }); if (is_banned) { const settings = await cb2(this.database.get_server_settings_cached); const email = settings.help_email || HELP_EMAIL; throw Error( `User (account_id=${account_id}, email_address=${email_address}) is BANNED. If this is a mistake, please contact ${email}.` ); } return is_banned; } } interface IsPasswordCorrect { database: PostgreSQL; password: string; password_hash?: string; account_id?: string; email_address?: string; allow_empty_password?: boolean; cb: (err?, correct?: boolean) => void; } // 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. export async function is_password_correct( opts: IsPasswordCorrect ): Promise<void> { 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 = password_hash_library.verify(opts.password, opts.password_hash); opts.cb(undefined, r); } else if (opts.account_id != null || opts.email_address != null) { try { const account = await cb2(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: password_hash(opts.password), invalidate_remember_me: false, cb: (err) => opts.cb(err, true), }); } else { opts.cb(undefined, true); } } else { opts.cb( undefined, password_hash_library.verify(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." ); } } export 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 cb2( opts.database.verify_email_create_token, { account_id: opts.account_id, } ); const settings = await cb2(opts.database.get_server_settings_cached); await cb2(welcome_email, { to: email_address, token, only_verify: opts.only_verify, settings, }); opts.cb(); } catch (err) { opts.cb(err); } }