api/users.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */

const fs = require("fs");
const lib = require(__dirname + '/../lib');
const logger = require(__dirname + '/../logger');
const db = require(__dirname + '/../db');
const api = require(__dirname + '/../api');

/**
 * User instance of {@link DbTableColumn}
 * @typedef {object} DbUser
 * @property {string} login - primary key, user email, name or other unique identifier
 * @property {string} id - unique auto-generated UUID
 * @property {string} name - full user name
 * @property {string[]} roles - list of roles for access
 * @property {string[]} flags - custom tags
 * @property {bigint} ctime - create time in milliseconds
 * @property {bigint} mtime - last modified time, auto saved
 * @property {string} secret - hashed user password
 * @property {gibint} [expires] - if set access will be defined if beyond this time
 * @property {string} [pushkey] - can be used for push notifications
 * @property {string} [passkey] - can be used for passkey verifications
 *
 * @example <caption>Default schema</caption>
 * {
 * login: {
 *   primary: 1,
 *   keyword: 1,
 *   length: 140,
 *   check: { max: 140 }
 * },
 * id: {
 *   type: 'uuid',
 *   prefix: 'u_',
 *   unique: 1,
 *   keyword: 1,
 *   dynamodb: { projections: 'ALL' },
 *   api: { pub: 1 }
 * },
 * name: {
 *   type: 'text',
 *   notempty: 1,
 *   length: 140,
 *   check: { max: 140 },
 *   api: { pub: 1 }
 * },
 * roles: {
 *   type: 'list',
 *   keyword: 1,
 *   convert: { list: 1, lower: 1 },
 *   api: { internal: 1 }
 * },
 * flags: {
 *   type: 'list',
 *   keyword: 1,
 *   length: 140,
 *   check: { max: 140 },
 *   convert: { list: 1 }
 * },
 * ctime: { type: 'now', readonly: 1 },
 * mtime: { type: 'now' },
 * secret: { type: 'text', check: { max: 140 }, api: { priv: 1 } },
 * expires: { type: 'bigint', api: { internal: 1, priv: 1 } },
 * pushkey: { type: 'text', api: { priv: 1 }, check: { max: 4096 } },
 * passkey: { type: 'text', api: { internal: 1, priv: 1 }, check: { max: 4096 } }
 *}
 */

/**
  * @module api/users
  */

const mod =

/**
 * ## User management and authentication API
 *
 * ### POST __/auth__
 *
 *  This API request returns the current user record from the __bk_user__ table if the request is verified and the signature provided
 *  is valid. If no signature or it is invalid the result will be an error with the corresponding error code and message.
 *
 *  By default this endpoint is secured, i.e. requires a valid signature.
 *
 *  On successful login, the result contains full user record
 *
 * ### POST __/login__
 *
 *  Same as the /auth but it uses secret for user authentication, this request does not need a signature, just simple
 *  login and secret query parameters to be sent to the backend. This must be sent over SSL.
 *
 *  Parameters:
 *
 *    - login - user login
 *    - secret - user secret
 *
 *  On successful login, the result contains full user record
 *
 *  Example:
 *
 *```javascript
 *   var res = await fetch("/login", { method: "POST", body: "login=test123&secret=test123" });
 *   await res.json()
 *
 *   > { id: "XXXX...", name: "Test User", login: "test123", ...}
 *```
 *
 * ### POST __/logout__
 *
 *  Logout the current user, clear session cookies if exist. For pure API access with the signature this will not do anything on the backend side.
 *
 * To disable default endpoints set in bkjs.conf:
 *
 * `api-users-cap-disabled=1`
 */
module.exports = {
    name: "api.users",
    args: [
        { name: "table", descr: "Table to use for users" },
        { name: "err-(.+)", descr: "Error messages for various cases" },
        { name: "cap-(.+)", type: "int", strip: "cap-", descr: "Capability parameters" },
        { name: "max-length", type: "int", descr: "Max login and name length" },
        { name: "users", obj: "users", type: "json", merge: 1, logger: "error", descr: "An object with users" },
        { name: "file", descr: "A JSON file with users" },
        { name: "endpoint", descr: "Root endpoint for the api routes to remount under differnet top path" },
    ],

    /**
     * Table to use for users
     * @var {string}
     * @default
     */
    table: "bk_user",
    maxLength: 140,

    /**
     * Router base endpoint
     * @var {string}
     * @default
     */
    endpoint: "/",

    /** @var {object} - users loaded from a file */
    users: {},

    noweb: 0,

    errInvalidUser: "The username is required",
    errInvalidPasswd: "The password is required",
    errInvalidName: "The name is required",
    errInvalidParams: "No username or id provided",
    errInvalidId: "Invalid id provided",
    errInvalidLogin: "No username or password provided",
};

mod.configure = function(options, callback)
{
    this.tables = {
        [this.table]: {
            login: {                                                     // User login/username
                primary: 1,
                keyword: 1,
                length: mod.maxLength,
                check: {
                    max: mod.maxLength
                },
            },
            id: {                                                        // Autogenerated ID
                type: "uuid",
                prefix: "u_",
                unique: 1,
                keyword: 1,
                dynamodb: {
                    projections: "ALL",
                },
                api: {
                    pub: 1,
                }
            },
            name: {                                                      // User name
                type: "text",
                notempty: 1,
                length: mod.maxLength,
                check: {
                    max: mod.maxLength
                },
                api: {
                    pub: 1,
                }
            },
            roles: {                                                      // Permission roles: admin, ....
                type: "list",
                keyword: 1,
                convert: {
                    list: 1,
                    lower: 1,
                },
                api: {
                    internal: 1,
                }
            },
            flags: {                                                      // Tags/flags
                type: "list",
                keyword: 1,
                length: mod.maxLength,
                check: {
                    max: mod.maxLength
                },
                convert: {
                    list: 1,
                }
            },
            ctime: { type: "now", readonly: 1 },                         // Create time
            mtime: { type: "now" },                                      // Modified time
            secret: {                                                    // Signature secret or password
                type: "text",
                check: { max: mod.maxLength },
                api: {
                    priv: 1
                },
            },
            expires: {                                                   // Deny access if this value is before current date, ms
                type: "bigint",
                api: {
                    internal: 1,
                    priv: 1
                },
            },
            pushkey: {                                                  // Push notifications tokens: [service://]token[@appname]
                type: "text",
                api: {
                    priv: 1,
                },
                check: {
                    max: 4096
                }
            },
            passkey: {                                                  // List of registered passkeys in json format
                type: "text",
                api: {
                    internal: 1,
                    priv: 1,
                },
                check: {
                    max: 4096
                }
            },
        },
    };

    if (this.file) {
        this.loadFile(this.file, (err) => {
            if (err) return;
            this._watcher = fs.watch(this.file, () => {
                clearTimeout(this._timer);
                this._timer = setTimeout(this.loadFile.bind(this, this.file), lib.randomInt(1000, 5000));
            });
        });
    }

    callback();
}

mod.shutdown = function(options, callback)
{
    clearTimeout(this._timer);
    delete this._timer;
    if (this._watcher?.close) {
        this._watcher.close();
        delete this._watcher;
    }
    lib.tryCall(callback);
}

mod.configureWeb = function(options, callback)
{
    if (this.disabled) return callback();

    var endpoint = mod.endpoint;
    if (!endpoint.endsWith("/")) endpoint += "/";

    // Allow routes without any config if enabled
    api.hooks.add('access', '', `${endpoint}login`, (req, status, cb) => { cb({ status: 200 }) });
    api.hooks.add('pre', '', `${endpoint}(auth|logout)`, (req, status, cb) => { cb({ status: 200 }) });

    api.app.use(mod.endpoint,
        api.express.Router().
            post("/auth", mod.auth).
            post("/login", mod.login).
            post("/logout", mod.logout));

    callback();
}

/**
 * Authentication check with signature/session, endpoint middleware for /auth
 * @param {http.IncomingMessage} req
 * @param {http.ServerResponse} res
 * @memberof module:api/users
 * @method auth
 */
mod.auth = function(req, res)
{
    if (!req.user?.id) {
        return api.sendReply(res, { status: 417, message: mod.errInvalidLogin, code: "NOLOGIN" });
    }
    api.session.setup(req, () => {
        req.options.cleanup = mod.table;
        req.options.cleanup_strict = 1;
        api.sendJSON(req, null, req.user);
    });
}

/**
 * Login with just the secret without signature, endpoint middleware for /login
 * @param {http.IncomingMessage} req
 * @param {http.ServerResponse} res
 * @memberof module:api/users
 * @method login
 */
mod.login = function(req, res)
{
    if (!req.body.login || !req.body.secret) {
        return api.sendReply(res, { status: 417, message: mod.errInvalidLogin, code: "NOLOGIN" });
    }
    // Create internal signature from the login data
    req.signature = api.signature.fromRequest(req, { version: -1, source: "l", login: req.body.login, secret: req.body.secret });
    delete req.body.login;
    delete req.body.secret;

    api.access.authenticate(req, (err) => {
        if (!req.user?.id) {
            return api.sendJSON(req, err || { status: 417, message: mod.errInvalidLogin, code: "NOLOGIN" });
        }
        api.session.setup(req, () => {
            req.options.cleanup = mod.table;
            req.options.cleanup_strict = 1;
            api.sendJSON(req, null, req.user);
        });
    });
}

/**
 * Clear sessions and access tokens, logout endpoint middleware for /logout
 * @param {http.IncomingMessage} req
 * @param {http.ServerResponse} res
 * @memberof module:api/users
 * @method logout
 */
mod.logout = function(req, res)
{
    api.signature.get(req);
    api.session.clear(req);
    api.csrf.clear(req);
    api.sendJSON(req);
}

/**
 * Returns a user record by login or id, to make use of a cache add to the config
 * @param {object|string} query - user id or login or { id, login }
 * @param {object} [options]
 * @param {function} callback as function(err, user)
 * @memberof module:api/users
 * @method get
 */
mod.get = function(query, options, callback)
{
    if (typeof options == "function") callback = options, options = null;
    if (typeof query == "string") {
        query = { [lib.isUuid(query) ? "id" : "login"]: query };
    }
    if (query?.login) {
        var user = this.users[query.login];
        if (user) return callback(null, Object.assign({}, user));

        db.get(this.table, { login: query.login }, callback);
    } else
    if (query?.id) {
        for (const p in this.users) {
            if (this.users[p].id == query.id) return callback(null, this.users[p]);
        }
        var opts = { noscan: 1, sort: "id", ops: { id: "eq" }, count: 1, first: 1 };
        db.select(this.table, { id: query.id }, opts, callback);
    } else {
        callback();
    }
}

/**
 * Async version of the {@link module:api/users.get} method
 * @param {object|string} query
 * @param {object} [options]
 * @returns {Promise}
 * @example
 * const { err, data } = await api.users.aget("john@mail.com");
 * @memberof module:api/users
 * @method aget
 * @async
 */
mod.aget = function(query, options)
{
    return new Promise((resolve, reject) => {
        mod.get(query, options, (err, data, info) => {
            resolve({ err, data, info });
        });
    });
}

/**
 * Registers a new user, returns new record in the callback,
 * @param {object} query - user record
 * @param {object} [options]
 * @param {boolean} [options.isInternal] if true then allow to set all properties
 * @param {object} [options.internalQuery] can be used to add restricted properties if not in isInternal mode
 * otherwise internal properties will not be added
 * @param {function} callback as function(err, user)
 * @memberof module:api/users
 * @method add
 */
mod.add = function(query, options, callback)
{
    if (typeof options == "function") callback = options, options = null;
    if (!query.login) return lib.tryCall(callback, { status: 400, message: mod.errInvalidUser });
    if (!query.secret) return lib.tryCall(callback, { status: 400, message: mod.errInvalidPasswd });
    if (!query.name) return lib.tryCall(callback, { status: 400, message: mod.errInvalidName });

    var opts = { result_query: 1, first: 1 };
    query = Object.assign({}, query);

    mod.prepareSecret(query, options, (err) => {
        if (err) return lib.tryCall(callback, err);

        if (!options.isInternal) {
            Object.entries(db.tables[mod.table]).filter(x => (x[1].api?.internal)).forEach(x => { delete query[x[0]] });
        }
        Object.assign(query, options?.internalQuery);
        delete query.id;

        db.add(mod.table, query, opts, (err, row, info) => {
            if (!err) {
                Object.assign(query, row);
            }
            lib.tryCall(callback, err, query, info);
        });
    });
}

/**
 * Async version of the {@link module:api/users.add} method
 * @param {object|string} query
 * @param {object} [options]
 * @returns {Promise}
 * @example
 * const { err, data } = await api.users.aadd({ login: "john@mail.com", name: "John" });
 * @memberof module:api/users
 * @method aadd
 * @async
 */
mod.aadd = function(query, options)
{
    return new Promise((resolve, reject) => {
        mod.add(query, options, (err, data, info) => {
            resolve({ err, data, info });
        });
    });
}

/**
 * Updates an existing user by login or id,
 * @param {object} query
 * @param {object} [options]
 * @param {boolean} [options.isInternal] - if true then allow to update all properties, otherwise all
 * columns with __api.interal__ will be ignored
 * @param {object} [options.internalQuery] - can be used to add restricted properties if not in isInternal mode
 * returns a new record in the callback
 * @param {function} callback as function(err, user)
 * @memberof module:api/users
 * @method update
 */
mod.update = function(query, options, callback)
{
    if (typeof options == "function") callback = options, options = null;

    var opts = { returning: "*", first: 1 };
    query = Object.assign({}, query);

    this.prepareSecret(query, options, (err) => {
        if (err) return lib.tryCall(callback, err);

        if (!options?.isInternal) {
            Object.entries(db.tables[this.table]).filter(x => (x[1].api?.internal)).forEach(x => { delete query[x[0]] });
            if (query.login) delete query.id;
        }
        Object.assign(query, options?.internalQuery);

        if (!query.name) delete query.name;
        if (!this.isUid(query.id)) delete query.id;

        if (query.login) {
            db.update(this.table, query, opts, callback);
        } else
        if (query.id) {
            db.select(this.table, { id: query.id }, { sort: "id", count: 1, first: 1 }, (err, row) => {
                if (!row) return callback(err, { status: 404, message: this.errInvalidId });

                query.login = row.login;
                db.update(this.table, query, opts, callback);
            });
        } else {
            lib.tryCall(callback, { status: 400, message: this.errInvalidParams });
        }
    });
}

/**
 * Async version of the {@link module:api/users.update} method
 * @param {object|string} query
 * @param {object} [options]
 * @returns {Promise}
 * @example
 * const { err, data } = await api.users.aupdate({ login: "john@mail.com", name: "John" });
 * @memberof module:api/users
 * @method aupdate
 * @async
 */

mod.aupdate = function(query, options)
{
    return new Promise((resolve, reject) => {
        mod.update(query, options, (err, data, info) => {
            resolve({ err, data, info });
        });
    });
}

/**
 * Deletes an existing user by login or id, no admin checks, returns the old record in the callback
 * @param {object|string} query - user id or login or { id, login }
 * @param {object} [options]
 * @param {object} [options.query] - additional query making it conditional delete
 * @param {function} callback as function(err, user)
 * @memberof module:api/users
 * @method del
 */
mod.del = function(query, options, callback)
{
    if (typeof options == "function") callback = options, options = null;
    if (typeof query == "string") {
        query = { [this.isUid(query) ? "id" : "login"]: query };
    }
    var opts = { returning: "old", first: 1, query: options?.query };

    if (query.login) {
        db.del(this.table, query, opts, callback);
    } else
    if (query.id) {
        db.select(this.table, { id: query.id }, { sort: "id", count: 1, first: 1 }, (err, row) => {
            if (!row) return callback(err, { status: 404, message: this.errInvalidId });

            query.login = row.login;
            db.del(this.table, query, opts, callback);
        });
    } else {
        lib.tryCall(callback, { status: 400, message: this.errInvalidParams });
    }
}

/**
 * Async version of the {@link module:api/users.del} method
 * @param {object|string} query
 * @param {object} [options]
 * @returns {Promise}
 * @example
 * const { err, data } = await api.users.adel({ login: "john@mail.com" });
 * @memberof module:api/users
 * @method adel
 * @async
 */

mod.adel = function(query, options)
{
    return new Promise((resolve, reject) => {
        mod.del(query, options, (err, data, info) => {
            resolve({ err, data, info });
        });
    });
}

/**
 * Returns true of the given id is a valid user uuid
 * @param {string} id
 * @returns {boolean}
 */
mod.isUid = function(id)
{
    return lib.isUuid(id, this.tables[this.table].id.prefix);
}

/**
 * If specified in the options, prepare credentials to be stored in the db, if no error occurred return null, otherwise an error object
 * @param {object} query
 * @param {string} query.secret - plain text secret to be converted into scrypt hash in place
 * @param {object} [options]
 * @param {function} [callback]
 */
mod.prepareSecret = function(query, options, callback)
{
    if (typeof options == "function") callback = options, options = null;

    if (!query.secret) delete query.secret;

    if (!query.secret) {
        return lib.tryCall(callback);
    }
    lib.prepareSecret(query.secret, (err, secret) => {
        if (!err) query.secret = secret;
        lib.tryCall(callback, err);
    });
}

/**
 * Load users from a JSON file, only add or update records
 */
mod.loadFile = function(file, callback)
{
    lib.readFile(file, { json: 1, logger: "error" }, (err, users) => {
        if (!err) {
            for (const p in users) {
                if (users[p].login && users[p].id && users[p].secret && users[p].name) {
                    this.users[users[p].login] = users[p];
                    logger.debug("loadFile:", mod.name, users[p]);
                }
            }
        }
        lib.tryCall(callback, err);
    });
}