smc-hub
Version:
CoCalc: Backend webserver component
1,346 lines (1,222 loc) • 46.3 kB
text/typescript
/*
* 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);
}
}