const db = require("../db");
const bcrypt = require("bcryptjs");
const { contract, is } = require("contractis");
const { v4: uuidv4 } = require("uuid");
const dumbPasswords = require("dumb-passwords");
const validator = require("email-validator");
/**
* User
*/
class User {
constructor(o) {
this.email = o.email;
this.password = o.password;
this.language = o.language;
this._attributes =
typeof o._attributes === "string"
? JSON.parse(o._attributes)
: o._attributes || {};
this.api_token = o.api_token;
this.verification_token = o.verification_token;
this.verified_on = ["string", "number"].includes(typeof o.verified_on)
? new Date(o.verified_on)
: o.verified_on;
this.disabled = !!o.disabled;
this.id = o.id ? +o.id : o.id;
this.reset_password_token = o.reset_password_token || null;
this.reset_password_expiry =
(typeof o.reset_password_expiry === "string" &&
o.reset_password_expiry.length > 0) ||
typeof o.reset_password_expiry === "number"
? new Date(o.reset_password_expiry)
: o.reset_password_expiry || null;
this.role_id = o.role_id ? +o.role_id : 8;
contract.class(this);
}
/**
* Get bcrypt hash for Password
* @param pw - password string
* @returns {Promise<*>}
*/
static async hashPassword(pw) {
return await bcrypt.hash(pw, 10);
}
/**
* Check password
* @param pw - password string
* @returns {*}
*/
checkPassword(pw) {
return bcrypt.compareSync(pw, this.password);
}
/**
* Change password
* @param newpw - new password string
* @param expireToken - if true than force reset password token
* @returns {Promise<void>} no result
*/
async changePasswordTo(newpw, expireToken) {
const password = await User.hashPassword(newpw);
this.password = password;
const upd = { password };
if (expireToken) upd.reset_password_token = null;
await db.update("users", upd, this.id);
}
/**
* Find or Create User
* @param k
* @param v
* @param uo
* @returns {Promise<{session_object: {_attributes: {}}, _attributes: {}}|User|*|boolean|{error: string}|User>}
*/
static async findOrCreateByAttribute(k, v, uo = {}) {
const u = await User.findOne({ _attributes: { json: [k, v] } });
if (u) return u;
else {
const { getState } = require("../db/state");
const email_mask = getState().getConfig("email_mask");
if (email_mask && uo.email) {
const { check_email_mask } = require("./config");
if (!check_email_mask(uo.email)) {
return false;
}
}
const new_user_form = getState().getConfig("new_user_form");
if (new_user_form) {
// cannot create user, return pseudo-user
const pseudoUser = { ...uo, _attributes: { [k]: v } };
return { ...pseudoUser, session_object: pseudoUser };
} else {
const extra = {};
if (!uo.password) extra.password = User.generate_password();
return await User.create({ ...uo, ...extra, _attributes: { [k]: v } });
}
}
}
/**
* Create user
* @param uo - user object
* @returns {Promise<{error: string}|User>}
*/
static async create(uo) {
const { email, password, passwordRepeat, role_id, ...rest } = uo;
const u = new User({ email, password, role_id });
if (User.unacceptable_password_reason(u.password))
return {
error:
"Password not accepted: " +
User.unacceptable_password_reason(u.password),
};
const hashpw = await User.hashPassword(u.password);
const ex = await User.findOne({ email: u.email });
if (ex) return { error: `User with this email already exists` };
const id = await db.insert("users", {
email: u.email,
password: hashpw,
role_id: u.role_id,
...rest,
});
u.id = id;
return u;
}
/**
* Create session object for user
* @returns {{role_id: number, language, id, email, tenant: *}}
*/
get session_object() {
return {
email: this.email,
id: this.id,
role_id: this.role_id,
language: this.language,
tenant: db.getTenantSchema(),
};
}
/**
* Authenticate User
* @param uo - user object
* @returns {Promise<boolean|User>}
*/
static async authenticate(uo) {
const { password, ...uoSearch } = uo;
const urows = await User.find(uoSearch, { limit: 2 });
if (urows.length !== 1) return false;
const [urow] = urows;
if (urow.disabled) return false;
const cmp = urow.checkPassword(password || "");
if (cmp) return new User(urow);
else return false;
}
/**
* Find users list
* @param where - where object
* @param selectopts - select options
* @returns {Promise<*>}
*/
static async find(where, selectopts) {
const us = await db.select("users", where, selectopts);
return us.map((u) => new User(u));
}
/**
* Find one user
* @param where - where object
* @returns {Promise<User|*>}
*/
static async findOne(where) {
const u = await db.selectMaybeOne("users", where);
return u ? new User(u) : u;
}
/**
* Check that user table is not empty in database
* @deprecated use method count()
* @returns {Promise<boolean>} true if there are users in db
*/
static async nonEmpty() {
const res = await db.count("users");
return res > 0;
}
/**
* Delete user based on session object
* @returns {Promise<void>}
*/
async delete() {
const schema = db.getTenantSchemaPrefix();
this.destroy_sessions();
await db.query(`delete FROM ${schema}users WHERE id = $1`, [this.id]);
}
/**
* Set language for User in database
* @param language
* @returns {Promise<void>}
*/
async set_language(language) {
await this.update({ language });
}
/**
* Update User
* @param row
* @returns {Promise<void>}
*/
async update(row) {
await db.update("users", row, this.id);
}
/**
* Get new reset token
* @returns {Promise<*|string>}
*/
async getNewResetToken() {
const reset_password_token_uuid = uuidv4();
const reset_password_expiry = new Date();
reset_password_expiry.setDate(new Date().getDate() + 1);
const reset_password_token = await bcrypt.hash(
reset_password_token_uuid,
10
);
await db.update(
"users",
{ reset_password_token, reset_password_expiry },
this.id
);
return reset_password_token_uuid;
}
/**
* Add new API token to user
* @returns {Promise<*|string>}
*/
async getNewAPIToken() {
const api_token = uuidv4();
await db.update("users", { api_token }, this.id);
this.api_token = api_token;
return api_token;
}
/**
* Remove API token for user
* @returns {Promise<null>}
*/
async removeAPIToken() {
const api_token = null;
await db.update("users", { api_token }, this.id);
this.api_token = api_token;
return api_token;
}
/**
* Validate password
* @param pw
* @returns {string}
*/
static unacceptable_password_reason(pw) {
if (typeof pw !== "string") return "Not a string";
if (pw.length < 8) return "Too short";
if (dumbPasswords.check(pw)) return "Too common";
}
/**
* Validate email
* @param email
* @returns {boolean}
*/
// TBD that validation works
static valid_email(email) {
return validator.validate(email);
}
/**
* Verification with token
* @param email - email sting
* @param verification_token - verification token string
* @returns {Promise<{error: string}|boolean>} true if verification passed, error string if not
*/
static async verifyWithToken({ email, verification_token }) {
if (
typeof verification_token !== "string" ||
typeof email !== "string" ||
verification_token.length < 10 ||
!email
)
return { error: "Invalid token" };
const u = await User.findOne({ email, verification_token });
if (!u) return { error: "Invalid token" };
const upd = { verified_on: new Date() };
const { getState } = require("../db/state");
const elevate_verified = +getState().getConfig("elevate_verified");
if (elevate_verified) upd.role_id = Math.min(elevate_verified, u.role_id);
await db.update("users", upd, u.id);
return true;
}
/**
* Reset password using token
* @param email - email address string
* @param reset_password_token - reset password token string
* @param password
* @returns {Promise<{error: string}|{success: boolean}>}
*/
static async resetPasswordWithToken({
email,
reset_password_token,
password,
}) {
if (
typeof reset_password_token !== "string" ||
typeof email !== "string" ||
reset_password_token.length < 10
)
return { error: "Invalid token or invalid token length or incorrect email" };
const u = await User.findOne({ email });
if (u && new Date() < u.reset_password_expiry && u.reset_password_token) {
const match = bcrypt.compareSync(
reset_password_token,
u.reset_password_token
);
if (match) {
if (User.unacceptable_password_reason(password))
return {
error:
"Password not accepted: " +
User.unacceptable_password_reason(password),
};
await u.changePasswordTo(password, true);
return { success: true };
} else return { error: "User not found or expired token" };
} else {
return { error: "User not found or expired token" };
}
}
/**
* Count users in database
* @param where
* @returns {Promise<number>}
*/
// TBD I think that method is simular to notEmppty() but more powerfull.
// TBD use some rules for naming of methods - e.g. this method will have name count_users or countUsers because of methods relay on roles in this class
static async count(where) {
return await db.count("users", where || {});
}
/**
* Get available roles
* @returns {Promise<*>}
*/
static async get_roles() {
const rs = await db.select("_sc_roles", {}, { orderBy: "id" });
return rs;
}
/**
* Generate password
* @returns {*}
*/
static generate_password() {
const candidate = is.str.generate().split(" ").join("");
// TBD low performance impact - un
if (candidate.length < 10) return User.generate_password();
else return candidate;
}
async destroy_sessions() {
if (!db.isSQLite) {
const schema = db.getTenantSchema();
await db.query(
`delete from _sc_session
where sess->'passport'->'user'->>'id' = $1
and sess->'passport'->'user'->>'tenant' = $2`,
[`${this.id}`, schema]
);
}
}
relogin(req) {
req.login(this.session_object, function (err) {
if (err) req.flash("danger", err);
});
}
}
User.contract = {
variables: {
id: is.maybe(is.posint),
email: is.str,
//password: is.str,
disabled: is.bool,
language: is.maybe(is.str),
_attributes: is.maybe(is.obj({})),
role_id: is.posint,
reset_password_token: is.maybe(
is.and(
is.str,
is.sat((s) => s.length > 10)
)
),
reset_password_expiry: is.maybe(is.class("Date")),
},
methods: {
delete: is.fun([], is.promise(is.undefined)),
destroy_sessions: is.fun([], is.promise(is.undefined)),
changePasswordTo: is.fun(is.str, is.promise(is.undefined)),
checkPassword: is.fun(is.str, is.bool),
},
static_methods: {
find: is.fun(is.maybe(is.obj()), is.promise(is.array(is.class("User")))),
findOne: is.fun(is.obj(), is.promise(is.maybe(is.class("User")))),
nonEmpty: is.fun([], is.promise(is.bool)),
hashPassword: is.fun(is.str, is.promise(is.str)),
authenticate: is.fun(
is.objVals(is.str),
is.promise(is.or(is.class("User"), is.eq(false)))
),
verifyWithToken: is.fun(
is.obj({ email: is.str, verification_token: is.str }),
is.promise(is.any)
),
resetPasswordWithToken: is.fun(
is.obj({ email: is.str, reset_password_token: is.str, password: is.str }),
is.promise(is.any)
),
create: is.fun(
is.obj({ email: is.str }),
is.promise(is.or(is.obj({ error: is.str }), is.class("User")))
),
get_roles: is.fun(
[],
is.promise(is.array(is.obj({ id: is.posint, role: is.str })))
),
},
};
module.exports = User;