#########################################################################
# This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
# License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
#########################################################################

###
Password reset and change functionality.
###

async                = require('async')
misc                 = require('@cocalc/util/misc')
message              = require('@cocalc/util/message')     # message protocol between front-end and back-end
email                = require('./email')
{defaults, required} = misc
{is_valid_password}  = require('./client/create-account')
auth                 = require('./auth')
base_path   = require('@cocalc/backend/base-path').default
passwordHash = require("@cocalc/backend/auth/password-hash").default;

exports.PW_RESET_ENDPOINT = PW_RESET_ENDPOINT = '/auth/password_reset'
exports.PW_RESET_KEY = PW_RESET_KEY = 'token'

exports.forgot_password = (opts) ->
    opts = defaults opts,
        mesg       : required
        database   : required
        ip_address : required
        cb         : required
    ###
    Send an email message to the given email address with a code that
    can be used to reset the password for a certain account.

    Anti-spam/DOS throttling policies:
      * a given email address can be sent at most 30 password resets per hour
      * a given ip address can send at most 100 password reset request per minute
      * a given ip can send at most 250 per hour
    ###
    if opts.mesg.event != 'forgot_password'
        opts.cb("Incorrect message event type: #{opts.mesg.event}")
        return

    # This is an easy check to save work and also avoid empty email_address, which causes trouble below
    if not misc.is_valid_email_address(opts.mesg.email_address)
        opts.cb("Invalid email address.")
        return

    opts.mesg.email_address = misc.lower_email_address(opts.mesg.email_address)

    id = null
    locals =
        settings : undefined

    async.series([
        (cb) ->
            # Record this password reset attempt in our database
            opts.database.record_password_reset_attempt
                email_address : opts.mesg.email_address
                ip_address    : opts.ip_address
                ttl           : 24*60*60 # 1 day, enough to check, we only look at one hour in the past
                cb            : cb
        (cb) ->
            # POLICY 1: We limit the number of password resets that an email address can receive
            opts.database.count_password_reset_attempts
                email_address : opts.mesg.email_address
                age_s         : 60*60  # 1 hour
                cb            : (err, count) ->
                    if err
                        cb(err)
                    else if count >= 31
                        cb("Too many password resets for this email per hour; try again later.")
                    else
                        cb()

        (cb) ->
            # POLICY 2: a given ip address can send at most 10 password reset requests per minute
            opts.database.count_password_reset_attempts
                ip_address : opts.ip_address
                age_s      : 60  # 1 minute
                cb         : (err, count) ->
                    if err
                        cb(err)
                    else if count > 10
                        cb("Too many password resets per minute; try again later.")
                    else
                        cb()
        (cb) ->
            # POLICY 3: a given ip can send at most 60 per hour
            opts.database.count_password_reset_attempts
                ip_address : opts.ip_address
                age_s      : 60*60  # 1 hour
                cb         : (err, count) ->
                    if err
                        cb(err)
                    else if count > 60
                        cb("Too many password resets per hour; try again later.")
                    else
                        cb()
        (cb) ->
            opts.database.account_exists
                email_address : opts.mesg.email_address
                cb : (err, exists) ->
                    if err
                        cb(err)
                    else if not exists
                        cb("No account with e-mail address #{opts.mesg.email_address}")
                    else
                        cb()
        (cb) ->
            # We now know that there is an account with this email address.
            # put entry in the password_reset uuid:value table with ttl of
            # 1 hour, and send an email
            opts.database.set_password_reset
                email_address : opts.mesg.email_address
                ttl           : 60*60
                cb            : (err, _id) ->
                    id = _id; cb(err)

        (cb) =>
            opts.database.get_server_settings_cached
                cb: (err, settings) =>
                    if err
                        cb(err)
                    else
                        locals.settings = settings
                        cb()

        (cb) ->
            # send an email to opts.mesg.email_address that has a password reset link
            theme = require('@cocalc/util/theme')

            dns         = locals.settings.dns or theme.DNS
            DOMAIN_URL = "https://#{dns}"
            HELP_EMAIL  = locals.settings.help_email ? theme.HELP_EMAIL
            SITE_NAME   = locals.settings.site_name  ? theme.SITE_NAME

            path          = require('path').join(base_path, PW_RESET_ENDPOINT)
            RESET_URL     = "#{DOMAIN_URL}#{path}?#{PW_RESET_KEY}=#{id}"

            body = """
                <div>Hello,</div>
                <div>&nbsp;</div>
                <div>
                Somebody just requested to change the password of your #{SITE_NAME} account.
                If you requested this password change, please click this link:</div>
                <div>&nbsp;</div>
                <div style="text-align: center; font-size: 120%;">
                  <b><a href="#{RESET_URL}">#{RESET_URL}</a></b>
                </div>
                <div>&nbsp;</div>
                <div>If you don't want to change your password, ignore this message.</div>
                <div>&nbsp;</div>
                <div>In case of problems, email
                <a href="mailto:#{HELP_EMAIL}">#{HELP_EMAIL}</a> immediately!
                <div>&nbsp;</div>
                """

            email.send_email
                subject  : "#{SITE_NAME} Password Reset"
                body     : body
                from     : "CoCalc Help <#{HELP_EMAIL}>"
                to       : opts.mesg.email_address
                category : "password_reset"
                settings : locals.settings
                cb       : cb
    ], opts.cb)

exports.reset_forgot_password = (opts) ->
    opts = defaults opts,
        mesg       : required
        database   : required
        cb         : required
    if opts.mesg.event != 'reset_forgot_password'
        opts.cb("incorrect message event type: #{opts.mesg.event}")
        return

    email_address = account_id = db = null

    async.series([
        (cb) ->
            # Verify password is valid and compute its hash.
            [valid, reason] = is_valid_password(opts.mesg.new_password)
            if not valid
                cb(reason); return
            # Check that request is still valid
            opts.database.get_password_reset
                id : opts.mesg.reset_code
                cb   : (err, x) ->
                    if err
                        cb(err)
                    else if not x
                        cb("Password reset request is no longer valid.")
                    else
                        email_address = x
                        cb()
        (cb) ->
            # Get the account_id.
            opts.database.get_account
                email_address : email_address
                columns       : ['account_id']
                cb            : (err, account) ->
                    account_id = account?.account_id; cb(err)
        (cb) ->
            # Make the change
            opts.database.change_password
                account_id    : account_id
                password_hash : passwordHash(opts.mesg.new_password)
                cb            : (err, account) ->
                    if err
                        cb(err)
                    else
                        # only allow successful use of this reset token once
                        opts.database.delete_password_reset
                            id : opts.mesg.reset_code
                            cb : cb
    ], opts.cb)

# DEPRECATED -- see packages/server/accounts/set-password.ts
# except this is still used by client.coffee, etc.
exports.change_password = (opts) ->
    opts = defaults opts,
        mesg       : required
        account_id : required   # user they are auth'd as
        database   : required
        ip_address : required
        cb         : required
    account = null
    async.series([
        (cb) ->
            # get account and validate the password (if they have one)
            opts.database.get_account
              account_id : opts.account_id
              columns    : ['password_hash']
              cb : (error, result) ->
                if error
                    cb({other:error})
                    return
                account = result
                auth.is_password_correct
                    database             : opts.database
                    account_id           : opts.account_id
                    password             : opts.mesg.old_password
                    password_hash        : account.password_hash
                    allow_empty_password : true
                    cb                   : (err, is_correct) ->
                        if err
                            cb(err)
                        else
                            if not is_correct
                                err = "invalid old password"
                                opts.database.log
                                    event : 'change_password'
                                    value : {email_address:opts.mesg.email_address, client_ip_address:opts.ip_address, message:err}
                                cb(err)
                            else
                                cb()
        (cb) ->
            # check that new password is valid
            [valid, reason] = is_valid_password(opts.mesg.new_password)
            if not valid
                cb({new_password:reason})
            else
                cb()

        (cb) ->
            # record current password hash (just in case?) and that we
            # are changing password and set new password
            opts.database.log
                event : "change_password"
                value :
                    account_id             : opts.account_id
                    client_ip_address      : opts.ip_address
                    previous_password_hash : account.password_hash

            opts.database.change_password
                account_id    : opts.account_id
                password_hash : passwordHash(opts.mesg.new_password),
                cb            : cb
    ], opts.cb)

# DEPRECATED -- see packages/server/accounts/set-email-address.ts
# except this is still used by client.coffee, etc.  It's just that
# I've also rewritten it.
exports.change_email_address = (opts) ->
    opts = defaults opts,
        mesg       : required
        database   : required
        account_id : required
        ip_address : required
        logger     : undefined
        cb         : required

    if opts.logger?
        dbg = (m...) -> opts.logger?.debug("change_email_address(#{opts.mesg.account_id}): ", m...)
        dbg()
    else
        dbg = ->

    opts.mesg.new_email_address = misc.lower_email_address(opts.mesg.new_email_address)

    if not misc.is_valid_email_address(opts.mesg.new_email_address)
        dbg("invalid email address")
        opts.cb('email_invalid')
        return

    if opts.mesg.account_id != opts.account_id
        opts.cb("account_id in mesg is not what user is signed in as")
        return

    async.series([
        (cb) ->
            auth.is_password_correct
                database             : opts.database
                account_id           : opts.mesg.account_id
                password             : opts.mesg.password
                allow_empty_password : true  # in case account created using a linked passport only
                cb                   : (err, is_correct) ->
                    if err
                        cb("Error checking password -- please try again in a minute -- #{err}.")
                    else if not is_correct
                        cb("invalid_password")
                    else
                        cb()

        (cb) ->
            # Record current email address (just in case?) and that we are
            # changing email address to the new one.  This will make it
            # easy to implement a "change your email address back" feature
            # if I need to at some point.
            dbg("log change to db")
            opts.database.log
                event : 'change_email_address'
                value :
                    client_ip_address : opts.ip_address
                    new_email_address : opts.mesg.new_email_address

            dbg("actually make change in db")
            opts.database.change_email_address
                account_id    : opts.mesg.account_id
                email_address : opts.mesg.new_email_address
                cb            : cb
        (cb) ->
            # If they just changed email to an address that has some actions, carry those out...
            # TODO: move to hook this only after validation of the email address?
            # TODO: NO -- instead this should get completely removed and these actions
            #       should be replaced by special URL's (e.g., a URL that when visited
            #       makes it so you get added to a project, or a code you enter on the page).
            #       That would be way more secure *and* flexible.
            opts.database.do_account_creation_actions
                email_address : opts.mesg.new_email_address
                account_id    : opts.mesg.account_id
                cb            : cb
    ], opts.cb)
