@cocalc/hub
Version:
CoCalc: Backend webserver component
328 lines (291 loc) • 12.5 kB
text/coffeescript
#########################################################################
# 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