Asymmetrik/node-rest-starter

View on GitHub
src/app/core/user/user.model.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import crypto, { BinaryLike } from 'crypto';

import _ from 'lodash';
import mongoose, {
    HydratedDocument,
    model,
    Model,
    Schema,
    Types
} from 'mongoose';
import uniqueValidator from 'mongoose-unique-validator';

import { config, utilService as util } from '../../../dependencies';
import {
    ContainsSearchable,
    containsSearchPlugin
} from '../../common/mongoose/contains-search.plugin';
import getterPlugin from '../../common/mongoose/getter.plugin';
import {
    Paginateable,
    paginatePlugin
} from '../../common/mongoose/paginate.plugin';
import {
    TextSearchable,
    textSearchPlugin
} from '../../common/mongoose/text-search.plugin';
import { TeamRoles, TeamRoleSchema } from '../teams/team-role.model';

/**
 * Validation
 */

// Validate the password
const validatePassword = function (user: UserDocument, password: string) {
    let toReturn = true;

    // only care if it's local
    if (user.provider === 'local') {
        toReturn = password?.length >= 6;
    }

    return toReturn;
};
const passwordMessage = 'Password must be at least 6 characters long';

/**
 * User Roles
 */
export const Roles = config.get<string[]>('auth.roles');

const roleObject = Roles.reduce(
    (obj, role) => {
        obj[role] = {
            type: Boolean,
            default:
                config.get<Record<string, boolean>>('auth.defaultRoles')[role] ?? false
        };
        return obj;
    },
    { _id: false }
);

const roleSchemaDef = new mongoose.Schema(roleObject);

type UserRoles = {
    user?: boolean;
    editor?: boolean;
    auditor?: boolean;
    admin?: boolean;
    machine?: boolean;
};

type UserTeam = { _id: Types.ObjectId; role: TeamRoles };

export interface IUser {
    _id: Types.ObjectId;
    name: string;
    organization: string;
    organizationLevels: Record<string, unknown>;
    email: string;
    phone: string;
    username: string;
    password: string;
    provider: string;
    providerData: Record<string, unknown>;
    additionalProvidersData: Record<string, unknown>;
    roles: UserRoles;
    localRoles?: UserRoles;
    canProxy: boolean;
    canMasquerade: boolean;
    externalGroups: string[];
    externalRoles: string[];
    bypassAccessCheck: boolean;
    updated: Date;
    created: Date;
    messagesAcknowledged: Date;
    alertsViewed: Date;
    resetPasswordToken: string;
    resetPasswordExpires: Date;
    acceptedEua: Date;
    lastLogin: Date;
    lastLoginWithAccess: Date;
    newFeatureDismissed: Date;
    preferences: Record<string, unknown>;
    salt: BinaryLike;
    teams: UserTeam[];
}

interface IUserMethods {
    fullCopy(): IUser;
    filteredCopy(): Record<string, unknown>;
    auditCopy(userIP?: string): Record<string, unknown>;

    authenticate(password: string): boolean;
    hashPassword(password: string): string;
}

export type UserDocument = HydratedDocument<
    IUser,
    IUserMethods,
    IUserQueryHelpers
>;

type IUserQueryHelpers = ContainsSearchable &
    TextSearchable &
    Paginateable<UserDocument>;

export interface UserModel
    extends Model<IUser, IUserQueryHelpers, IUserMethods> {
    createCopy(user: Partial<IUser>): Partial<IUser>;
}

const UserSchema = new Schema<
    IUser,
    UserModel,
    IUserMethods,
    IUserQueryHelpers
>(
    {
        name: {
            type: String,
            trim: true,
            required: [true, 'Name is required']
        },
        organization: {
            type: String,
            trim: true,
            required: [true, 'Organization is required']
        },
        organizationLevels: {
            type: Object
        },
        email: {
            type: String,
            trim: true,
            required: [true, 'Email is required'],
            match: [util.emailMatcher, 'A valid email address is required']
        },
        phone: {
            type: String,
            trim: true,
            default: '',
            match: [
                /.+@.+\..+/,
                'A valid phone number and cellular provider is required'
            ],
            required: false
        },
        username: {
            type: String,
            trim: true,
            unique: true,
            required: [true, 'Username is required']
        },
        password: {
            type: String,
            default: '',
            validate: [validatePassword, passwordMessage]
        },
        salt: {
            type: String
        },
        provider: {
            type: String,
            required: [true, 'Provider is required']
        },
        providerData: {},
        additionalProvidersData: {},
        roles: {
            type: roleSchemaDef,
            default: () => ({})
        },
        canProxy: {
            type: Boolean,
            default: false
        },
        canMasquerade: {
            type: Boolean,
            default: false
        },
        externalGroups: {
            type: [],
            default: []
        },
        externalRoles: {
            type: [],
            default: []
        },
        bypassAccessCheck: {
            type: Boolean,
            default: false
        },
        messagesAcknowledged: {
            type: Date,
            default: null
        },
        alertsViewed: {
            type: Date,
            default: () => Date.now()
        },
        /* For reset password */
        resetPasswordToken: {
            type: String
        },
        resetPasswordExpires: {
            type: Date
        },
        acceptedEua: {
            type: Date,
            default: null
        },
        lastLogin: {
            type: Date,
            default: null
        },
        lastLoginWithAccess: {
            type: Date,
            default: null
        },
        newFeatureDismissed: {
            type: Date,
            default: null
        },
        preferences: {
            type: {}
        },
        teams: [TeamRoleSchema]
    },
    {
        timestamps: {
            createdAt: 'created',
            updatedAt: 'updated'
        }
    }
);

/**
 * Plugin declarations
 */
UserSchema.plugin(getterPlugin);
UserSchema.plugin(uniqueValidator);
UserSchema.plugin(paginatePlugin);
UserSchema.plugin(containsSearchPlugin, {
    fields: ['name', 'username']
});
UserSchema.plugin(textSearchPlugin);

/**
 * Index declarations
 */
// Text-search index
UserSchema.index({ name: 'text', email: 'text', username: 'text' });

/**
 * Lifecycle Hooks
 */

const preSave = function (this: UserDocument, next) {
    // If the password is modified and it is valid, then re- salt/hash it
    if (this.isModified('password') && validatePassword(this, this.password)) {
        this.salt = Buffer.from(
            crypto.randomBytes(16).toString('base64'),
            'base64'
        );
        this.password = this.hashPassword(this.password);
    }

    next();
};
UserSchema.pre('save', preSave);

/**
 * Instance Methods
 */

/**
 * Hash Password
 * @returns {string} An SHA1 hash of the password.
 */
UserSchema.methods.hashPassword = function (password: string) {
    if (this.salt && password) {
        return crypto
            .pbkdf2Sync(password, this.salt, 10000, 64, 'SHA1')
            .toString('base64');
    }
    return password;
};

/**
 * Authenticate a password against the user
 * @param password Password attempt.
 * @returns Whether or not the password is correct.
 */
UserSchema.methods.authenticate = function (password: string): boolean {
    return this.password === this.hashPassword(password);
};

// Copy a user for audit logging
UserSchema.methods.auditCopy = function (userIP?: string) {
    const toReturn: Record<string, unknown> = {};

    const asObject = this.toObject();

    toReturn._id = this._id;
    toReturn.name = this.name;
    toReturn.username = this.username;
    toReturn.organization = this.organization;
    toReturn.organizationLevels = this.organizationLevels;
    toReturn.email = this.email;
    toReturn.phone = this.phone;
    toReturn.messagesAcknowledged = this.messagesAcknowledged;
    toReturn.alertsViewed = this.alertsViewed;
    toReturn.newFeatureDismissed = this.newFeatureDismissed;
    toReturn.canProxy = this.canProxy;
    toReturn.canMasquerade = this.canMasquerade;
    toReturn.teams = asObject.teams;
    toReturn.roles = asObject.roles;
    toReturn.bypassAccessCheck = this.bypassAccessCheck;

    if (userIP) {
        toReturn.ip = userIP;
    }
    if (this.providerData?.dn) {
        toReturn.dn = this.providerData.dn;
    }
    if (this.preferences) {
        toReturn.preferences = this.preferences;
    }

    return toReturn;
};

// Full Copy of a User (admin)
UserSchema.methods.fullCopy = function () {
    const toReturn: IUser = this.toObject();
    if (_.has(toReturn, 'password')) {
        delete toReturn.password;
    }
    if (_.has(toReturn, 'salt')) {
        delete toReturn.salt;
    }
    return toReturn;
};

// Filtered Copy of a User (public)
UserSchema.methods.filteredCopy = function () {
    const toReturn: Record<string, unknown> = {};

    toReturn._id = this._id;
    toReturn.name = this.name;
    toReturn.username = this.username;
    toReturn.organizationLevels = this.organizationLevels;
    toReturn.lastLogin = this.lastLogin;

    // The below fields (and other) are available, but shouldn't
    // necessarily be exposed to other users.

    // toReturn.created = this.created;
    // toReturn.messagesAcknowledged = this.messagesAcknowledged;
    // toReturn.alertsViewed = this.alertsViewed;
    // toReturn.newFeatureDismissed = this.newFeatureDismissed;

    if (this.providerData) {
        toReturn.providerData = {
            dn: this.providerData.dn
        };
    }

    if (this.preferences) {
        toReturn.preferences = this.preferences;
    }

    return toReturn;
};

/**
 * Static Methods
 */
// Copy User for creation
UserSchema.statics.createCopy = function (
    user: Partial<IUser>
): Partial<IUser> {
    const toReturn: Partial<IUser> = {};

    toReturn.name = user.name;
    toReturn.organization = user.organization;
    toReturn.organizationLevels = user.organizationLevels;
    toReturn.email = user.email;
    toReturn.phone = user.phone;
    toReturn.username = user.username;
    toReturn.password = user.password;
    toReturn.messagesAcknowledged = user.messagesAcknowledged;
    toReturn.alertsViewed = user.alertsViewed;
    toReturn.newFeatureDismissed = user.newFeatureDismissed;

    if (user.preferences) {
        toReturn.preferences = user.preferences;
    }

    return toReturn;
};

/**
 * Model Registration
 */
export const User = model<IUser, UserModel>('User', UserSchema);