UNPKG

@cocalc/hub

Version:
328 lines (291 loc) 12.5 kB
######################################################################### # This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. # License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details ######################################################################### ### User sign in Throttling policy: It basically like this, except we reset the counters each minute and hour, so a crafty attacker could get twice as many tries by finding the reset interval and hitting us right before and after. This is an acceptable tradeoff for making the data structure trivial. * POLICY 1: A given email address is allowed at most 3 failed login attempts per minute. * POLICY 2: A given email address is allowed at most 30 failed login attempts per hour. * POLICY 3: A given ip address is allowed at most 10 failed login attempts per minute. * POLICY 4: A given ip address is allowed at most 50 failed login attempts per hour. ### async = require('async') message = require('@cocalc/util/message') misc = require('@cocalc/util/misc') {required, defaults} = misc auth = require('./auth') {process_env_int} = require("@cocalc/backend/misc") throttle = require("@cocalc/server/auth/throttle") Bottleneck = require("bottleneck") apiKeyAction = require("@cocalc/server/api/manage").default; # these parameters are per group and per hub! bottleneck_opts = minTime : process_env_int('THROTTLE_SIGN_IN_MINTIME', 100) maxConcurrent : process_env_int('THROTTLE_SIGN_IN_CONCURRENT', 10) limit_group = new Bottleneck.Group(bottleneck_opts) record_sign_in_fail = (opts) -> {email, ip, logger} = defaults opts, email : required ip : required logger : undefined throttle.recordFail(email, ip) logger?("WARNING: record_sign_in_fail(#{email}, #{ip})") sign_in_check = (opts) -> {email, ip, auth_token} = defaults opts, email : required ip : required auth_token : undefined return throttle.signInCheck(email, ip, auth_token) exports.sign_in = (opts) -> opts = defaults opts, client : required mesg : required logger : undefined database : required host : undefined port : undefined cb : undefined key = opts.client.ip_address done_cb = () -> opts.logger?.debug("sign_in(group='#{key}'): done") limit_group.key(key).submit(_sign_in, opts, done_cb) _sign_in = (opts, done) -> {client, mesg} = opts if opts.logger? dbg = (m) -> opts.logger.debug("_sign_in(#{mesg.email_address}): #{m}") dbg() else dbg = -> tm = misc.walltime() sign_in_error = (error) -> dbg("sign_in_error -- #{error}") exports.record_sign_in database : opts.database ip_address : client.ip_address successful : false email_address : mesg.email_address account_id : account?.account_id client.push_to_client(message.sign_in_failed(id:mesg.id, email_address:mesg.email_address, reason:error)) opts.cb?(error) if not mesg.email_address sign_in_error("Empty email address.") return if not mesg.password sign_in_error("Empty password.") return mesg.email_address = misc.lower_email_address(mesg.email_address) m = await sign_in_check email : mesg.email_address ip : client.ip_address auth_token : false if m sign_in_error("sign_in_check failure: #{m}") return signed_in_mesg = undefined account = undefined async.series([ (cb) -> dbg("get account and check credentials") # NOTE: Despite people complaining, we do give away info about whether # the e-mail address is for a valid user or not. # There is no security in not doing this, since the same information # can be determined via the invite collaborators feature. opts.database.get_account email_address : mesg.email_address columns : ['password_hash', 'account_id', 'passports', 'banned'] cb : (err, _account) -> if err cb(err) return account = _account dbg("account = #{JSON.stringify(account)}") if account.banned dbg("banned account!") cb("This account is BANNED. Contact help@cocalc.com if you believe this is a mistake.") return cb() (cb) -> dbg("got account; now checking if password is correct...") auth.is_password_correct database : opts.database account_id : account.account_id password : mesg.password password_hash : account.password_hash cb : (err, is_correct) -> if err cb("Error checking correctness of password -- #{err}") return if not is_correct if not account.password_hash cb("The account #{mesg.email_address} exists but doesn't have a password. Either set your password by clicking 'Forgot Password?' or log in using #{misc.keys(account.passports).join(', ')}. If that doesn't work, email help@cocalc.com and we will sort this out.") else cb("Incorrect password for #{mesg.email_address}. You can reset your password by clicking the 'Forgot Password?' link. If that doesn't work, email help@cocalc.com and we will sort this out.") else cb() # remember me (cb) -> if not mesg.remember_me # do not set cookie if already set (and message known) cb(); return dbg("remember_me -- setting the remember_me cookie") signed_in_mesg = message.signed_in id : mesg.id account_id : account.account_id email_address : mesg.email_address remember_me : false hub : opts.host + ':' + opts.port client.remember_me account_id : signed_in_mesg.account_id cb : cb (cb) -> if not mesg.get_api_key cb(); return dbg("get_api_key -- also get_api_key") try signed_in_mesg.api_key = await apiKeyAction({account_id:account.account_id, password:mesg.password, action:'get'}) cb() catch err cb(err) (cb) -> if not mesg.get_api_key or signed_in_mesg.api_key cb(); return dbg("get_api_key -- must generate key since don't already have it") try signed_in_mesg.api_key = await apiKeyAction({account_id:account.account_id, password:mesg.password, action:'regenerate'}) cb() catch err cb(err) ], (err) -> if err dbg("send error to user (in #{misc.walltime(tm)}seconds) -- #{err}") sign_in_error(err) opts.cb?(err) else dbg("user got signed in fine (in #{misc.walltime(tm)}seconds) -- sending them a message") client.signed_in(signed_in_mesg) client.push_to_client(signed_in_mesg) opts.cb?() # final callback for bottleneck group done() ) exports.sign_in_using_auth_token = (opts) -> opts = defaults opts, client : required mesg : required logger : undefined database : required host : undefined port : undefined cb : undefined key = opts.client.ip_address done_cb = () -> opts.logger?.debug("sign_in_using_auth_token(group='#{key}'): done") limit_group.key(key).submit(_sign_in_using_auth_token, opts, done_cb) _sign_in_using_auth_token = (opts, done) -> {client, mesg} = opts if opts.logger? dbg = (m) -> opts.logger.debug("_sign_in_using_auth_token(#{mesg.email_address}): #{m}") dbg() else dbg = -> tm = misc.walltime() sign_in_error = (error) -> dbg("sign_in_using_auth_token_error -- #{error}") exports.record_sign_in database : opts.database ip_address : client.ip_address successful : false email_address : mesg.auth_token # yes, we abuse the email_address field account_id : account?.account_id client.push_to_client(message.error(id:mesg.id, error:error)) opts.cb?(error) if not mesg.auth_token sign_in_error("missing auth_token.") return if mesg.auth_token?.length != 24 sign_in_error("auth_token must be exactly 24 characters long") return m = await sign_in_check email : mesg.auth_token ip : client.ip_address auth_token : true if m sign_in_error("sign_in_check fail(ip=#{client.ip_address}): #{m}") return signed_in_mesg = undefined account = account_id = undefined async.series([ (cb) -> dbg("get account and check credentials") opts.database.get_auth_token_account_id auth_token : mesg.auth_token cb : (err, _account_id) -> if not err and not _account_id err = 'auth_token is not valid' account_id = _account_id; cb(err) (cb) -> dbg("successly got account_id; now getting more information about the account") opts.database.get_account account_id : account_id columns : ['email_address', 'lti_id'] cb : (err, _account) -> # for LTI accounts, we set the email address at least to an empty string if !!_account.lti_id _account.email_address ?= '' account = _account; cb(err) # remember me (cb) -> dbg("remember_me -- setting the remember_me cookie") signed_in_mesg = message.signed_in id : mesg.id account_id : account_id email_address : account.email_address lti_id : account.lti_id remember_me : false hub : opts.host + ':' + opts.port client.remember_me account_id : signed_in_mesg.account_id lti_id : signed_in_mesg.lti_id ttl : 12*3600 cb : cb ], (err) -> if err dbg("send error to user (in #{misc.walltime(tm)}seconds) -- #{err}") sign_in_error(err) opts.cb?(err) else dbg("user got signed in fine (in #{misc.walltime(tm)}seconds) -- sending them a message") client.signed_in(signed_in_mesg) client.push_to_client(signed_in_mesg) opts.cb?() # final callback for bottleneck group done() ) # Record to the database a failed and/or successful login attempt. exports.record_sign_in = (opts) -> opts = defaults opts, ip_address : required successful : required database : required email_address : undefined account_id : undefined remember_me : false if not opts.successful record_sign_in_fail email : opts.email_address ip : opts.ip_address else data = ip_address : opts.ip_address email_address : opts.email_address ? null remember_me : opts.remember_me account_id : opts.account_id opts.database.log event : 'successful_sign_in' value : data