UNPKG

@magic.batua/account

Version:

The Account modules powers the user account management features of the Magic Batua platform.

397 lines (354 loc) 13.1 kB
/** * @module Account * @overview This module defines the `Account` class that models a Magic Batua account. * * @author Animesh Mishra <hello@animesh.ltd> * @copyright © 2018 Animesh Ltd. All Rights Reserved. */ import * as Crypto from "crypto" import * as Token from "jsonwebtoken" import { ObjectId } from "bson" import { Source } from "./Source" import * as Points from "@magic.batua/points" /** * The `Account` class models the data and functions supported by Magic Batua * accounts. Besides the standard profile information — name, email, phone, etc. — * it also takes care of things such as referrals and loyalty points. * * Business logic used for password resets, salt generation, hashing and other * such operations are also defined by the `Account` class. */ export class Account { /** Unique ID generated by the database at the time of record creation */ public _id: ObjectId /** User's full name */ public name: string /** Account email */ public email: string /** Email verification status */ public isEmailVerified?: string /** An Indian mobile number without the prefixed zero or international dialling code */ public phone: string /** Phone verification status */ public isPhoneVerified?: boolean /** Invite code to bring friends and family on to the platform. */ public referralCode: string /** Account `_id` of the referrer */ public referredBy?: string | null /** List of account IDs who have signed up using this account's `referralCode` */ public referrals?: Array<string> | null /** * A ledger listing the points earmarked for this account, their status and any associated * transactions. */ public pointsLedger: Points.Ledger /** * Password policies aren't enforced server-side for now. The system expects valid * passwords from the client. */ public password: string /** The random bytes used to "salt" the `password` before hashing. */ public salt?: string /** * Flag used to soft-delete an account. Accounts are never deleted permanently. This * is for administrative and data analytics reasons. When a user submits a account * deletion request, this flag is set to true and the `recoverBy` date is set to 14 * days ahead. * * If a user logs into their account within those 14 days, the deletion hold is lifted * and this flag is set to `false` again. */ public isDeleted: boolean = false /** * Number of milliseconds since Unix epoch after which the account will be permanently * archived. */ public recoverBy?: number /** * Some API operations, such as mobile number change, will require an additional level * of security check. We use two-factor authentication for such operations. The OTP here * is the pin sent to the registered mobile number of this account for verification purposes. */ public otp?: number /** * In a server-side application, there are two instantiation scenarios: one where the input * is provided by the client, and the other where the input is provided by the database. * * Client-side input is always lighter than database-side input, since auto-generated * properties such as `_id` are missing in the client-side variant. Moreover, client-side * input may contain less information than database-side input as some sensitive properties * such as password, salt, etc. are never revealed to the client side. * * As such, we need a way to track the source of input and proceed with instantiation * accordingly. This `source` parameter serves exactly this purpose. * * @param {any} json Input JSON * @param {Source} source Input source. See `Source/Source.ts` for definition of `Source`. * * @class */ public constructor(json: any, source: Source = Source.Database) { this.name = json.name this.phone = json.phone this.email = json.email // Source is client only for signup requests. So we will need to // hash the password and generate a referral code for the new // account. if (source == Source.Client) { this._id = new ObjectId() this.password = this.hash(json.password) this.referralCode = this.generateReferralCode() // Issue points for signup and initialise the ledger let signup: Points.InitTransaction = { points: Points.Points.ToSelfFor("Signup"), type: "Issue", notes: "Signup" } this.pointsLedger = new Points.Ledger([signup]) } // If source is database, we merely need to feed in the db response else { this._id = json._id this.isEmailVerified = json.isEmailVerified this.isPhoneVerified = json.isPhoneVerified this.referralCode = json.referralCode this.referredBy = json.referredBy this.referrals = json.referrals this.pointsLedger = new Points.Ledger(json.pointsLedger.transactions) this.password = json.password this.salt = json.salt this.isDeleted = json.isDeleted this.recoverBy = json.recoverBy this.otp = json.otp } } /** * Salts the given `password` using the `salt` used at account creation, * and then compares the hash with the hashed password stored in the * database. * * @param {string} password Client-provided password * * @returns {boolean} `true` if the password is correct, otherwise `false` * * @function CanAuthenticateUsing * @memberof Account * @instance */ public CanAuthenticateUsing(password: string): boolean { return this.password == this.hash(password, this.salt) } /** * Sets `referredBy` to the `_id` of the user who referred this user to * Magic Batua. * * @param {string} id User `_id` of the referrer * * @function SetReferrer * @memberof Account * @instance */ public SetReferrer(id: string | null) { this.referredBy = id } /** * Adds a referral to the account's referrals list * * @param {string} id User `_id` of the referral * * @function AddReferral * @memberof Account * @instance */ public AddReferral(id: string) { if(!this.referrals) { this.referrals = new Array<string>() } this.referrals.push(id) } /** * Adds loyalty points to the account. * * @param {number} points Number of points to be awarded * @param {string} reason Reason for the generosity * * @function AddPoints * @memberof Account * @instance */ public AddPoints(points: number, reason: string) { this.pointsLedger.Issue(points, reason) } /** * Redeems the given number of points. Throws an error if available * points are fewer than the `points` requested. * * @param {number} points Numbe of points to be redeemed * @param {string} reason Purpose of redemption * * @function RedeemPoints * @memberof Account * @instance */ public RedeemPoints(points: number, reason: string) { this.pointsLedger.Redeem(points, reason) } /** * Removes a referral from the account's referral list. This is used * only when a referral decides to permanently delete their account. * * @param {string} id User `_id` of the referral to be removed * * @function RemoveReferral * @memberof Account * @instance */ public RemoveReferral(id: string) { if(this.referrals) { this.referrals = this.referrals.filter(element => element != id) } } /** * Changes the existing account password to `newPass`. This also changes the * `salt` used before hashing. * * @param {string} newPass New password * @returns A tuple of the form (salt, newPassword) * * @function ResetPassword * @memberof Account * @instance */ public ResetPassword(newPass: string) { this.password = this.hash(newPass) } /** * Generates a random 6-digit number and stores it in database as a verification code. * * @function SetOTP * @memberof Account * @instance */ public SetOTP() { this.otp = Math.floor(Math.random() * 900000) + 100000 } /** * Sets the verification code `otp` to `undefined` after a successful verification * * @function UnsetOTP * @memberof Account * @instance */ public UnsetOTP() { this.otp = undefined } /** * Puts the account into a 14-day deletion hold by setting `isDeleted` to `true` * and setting the account `recoverBy` date to 14-days from now. * * @function Delete * @memberof Account * @instance */ public Delete() { this.isDeleted = true const millisecondsInAFortnight = 1209600000 this.recoverBy = Date.now() + millisecondsInAFortnight } /** * Removes the deletion hold on the account by setting `isDeleted` to `false` and * setting the account `recoverBy` date to `undefined`. * * @function Undelete * @memberof Account * @instance */ public Undelete() { this.isDeleted = false this.recoverBy = undefined } /** * Exports the `Account` object to a shareable JSON string. Used to compose * HTTP response bodies. Prevents sensitive information such as password, salt etc. * from leaking onto client-side. * * @returns {string} A stringified, sanitised version of the `Account` instance * * @function Export * @memberof Account * @instance */ public Export(): string { // Copy the object so changes made here don't affect `this` object let exportable = JSON.parse(JSON.stringify(this)) // Convert ObjectId to string exportable._id = exportable._id.toString() // Remove sensitive information delete exportable.otp delete exportable.password delete exportable.salt delete exportable.isDeleted delete exportable.recoverBy return JSON.stringify(exportable) } // // Private methods // /** * Storing plain-text password is poor security practice. When client sends a plain-text password, * it must be salted, i.e. a random bytes must be attached to it, and then hashed to make it harder * for hackers to hack into someone's account. This method takes in a plaintext UTF-8 password and * returns a salted and hashed Base64 string which is written to the database. * * @param {string} password Plaintext to be hashed * @param {string} salt A Base64 string denoting the random data to be attached to the password. * If a value is not provided, one is generated randomly at runtime using * Node's `Crypto.randomBytes()`. * * @returns {string} A salted and hashed Base64 string * * @function hash * @memberof Account * @private * @instance */ private hash(password: string, salt: string = Crypto.randomBytes(128).toString("base64")): string { this.salt = salt return Crypto.pbkdf2Sync(password, salt, 5000, 512, "sha512").toString("base64") } /** * Generates a unique referral code using Node's `crypto` module. * * @returns {string} The referral code * * @function generateReferralCode * @memberof Account * @private * @instance * */ private generateReferralCode(): string { return Crypto.randomBytes(5).toString("hex").toLowerCase() } // // Static methods // /** * Changes user's password. The newPassword uses a unique salt and the salted * string is hashed for better security. * * @param {string} password The new password * * @returns {any} A tuple of the form (salt, newPassword) * * @function ResetPassword * @memberof Account * @private * @static */ public static ResetPassword(newPassword: string): [string, string] { let salt = Crypto.randomBytes(128).toString("base64") let hashed = Crypto.pbkdf2Sync(newPassword, salt, 5000, 512, "sha512").toString("base64") return [salt, hashed] } }