@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
text/typescript
/**
* @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]
}
}