/*
* 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);
});
}