auth/ldap/LdapProvider.js

/**
 * @module flitter-auth/ldap/LdapProvider
 */

const Provider = require('../Provider')
const AsyncLdapConnection = require('./AsyncLdapConnection')
const ldap = require('ldap')

/**
 * LDAP authentication provider for flitter-auth.
 * @extends {module:flitter-auth/Provider~Provider}
 * @type {module:flitter-auth/Provider~Provider}
 */
class LdapProvider extends Provider {

    constructor(app, config) {
        super(app, config)

        // Create the LDAP connection
        /**
         * The LDAP connection string in the format 'ldap://...'.
         * @type {string}
         */
        this.connect_string = `ldap${config.secure ? 's' : ''}://${config.host}${config.port ? ':'+config.port : ''}`

        const client = ldap.createClient({url: this.connect_string})

        /**
         * The async LDAP connection.
         * @type {module:flitter-auth/ldap/AsyncLdapConnection~AsyncLdapConnection}
         */
        this.connection = new AsyncLdapConnection(client)
    }

    /**
     * Get an LDAP connection bound to the configured DN.
     * @returns {Promise<ldap/Client>}
     */
    async ldap(){
        return this.connection.bind(this.config.bind_dn, this.config.bind_secret, [])
    }

    /**
     * Build the user search filter string. Replaces all instances of '%u' with uid.
     * @param {string} uid - uid to be interpolated
     * @returns {string}
     */
    user_filter(uid){
        let user_filter = this.config.user_filter
        if ( !user_filter.startsWith('(') ) user_filter = `(${userfilter}`
        if ( !user_filter.endsWith(')') ) user_filter = `${userfilter})`

        return user_filter.replace(/%u/g, uid)
    }

    /**
     * Get an array of user data records matched by the configured filter from the LDAP server.
     * @returns {Promise<Array<Object>>}
     */
    async get_users(){
        const ldap = await this.ldap()
        return ldap.search(this.config.user_search_base, this.user_filter('*'), Object.values(this.config.attributes));
    }

    /**
     * Get the user data record for the specified user uid matched by the configured filter from the LDAP server.
     * @param {string} uid - the user's username
     * @returns {Promise<Object|undefined>} - undefined if no user is found with uid
     */
    async get_user(uid){
        const ldap = await this.ldap()
        const matches = await ldap.search(this.config.user_search_base, this.user_filter(uid), Object.values(this.config.attributes))
        if ( matches.length > 0 ) return matches[0]
    }

    /**
     * Given the user data record from the LDAP server, either look up or create an instance of this.User.
     * Store the raw LDAP data in User.data.ldap (as JSON), and update roles where necessary.
     * @param {object} data - the data from the LDAP server
     * @returns {Promise<module:flitter-auth/model/User~User>}
     */
    async get_user_object(data){
        const User = this.User
        const uid = data[this.config.attributes.uid]

        const match = await User.findOne({ uid, provider: this.config.name })
        if ( match ){
            const json = JSON.parse(match.data)
            json.ldap = data
            match.data = JSON.stringify(json)
            await this.set_user_roles(data, match)
            await this.set_user_data(data, match)
            return match
        }

        const user = new User({
            uid,
            provider: this.config.name,
            data: JSON.stringify({
                ldap: data,
            }),
        })

        await this.set_user_roles(data, user)
        await this.set_user_data(data, user)
        return user
    }

    /**
     * Update user data from the LDAP record based on model-attribute to ldap-attribute
     * mappings in the config (config key: attributes).
     * @param {object} data - the user's LDAP data
     * @param {module:flitter-auth/model/User~User} user - the user to be updated
     * @returns {Promise<void>}
     */
    async set_user_data(data, user) {
        const exclude_attributes = ['group_membership']

        for ( const attr in this.config.attributes ) {
            if ( Object.keys(data).includes(this.config.attributes[attr]) && !(exclude_attributes.includes(attr)) ) {
                user[attr] = data[this.config.attributes[attr]]
            }
        }

        await user.save()
    }

    /**
     * Update the user's auth roles based on the role/group mappings from config. Uses the configured group_membership attribute.
     * @param {object} data - user's data record from the LDAP server
     * @param {module:flitter-auth/model/User~User} user - the user to be updated
     * @returns {Promise<void>}
     */
    async set_user_roles(data, user) {
        let user_roles
        if ( !data[this.config.attributes.group_membership] ) user_roles = []
        else if ( Array.isArray(data[this.config.attributes.group_membership]) ) user_roles = data[this.config.attributes.group_membership]
        else user_roles = [data[this.config.attributes.group_membership]]

        for ( let role in this.config.role_groups ){
            if ( user_roles.includes(this.config.role_groups[role]) ) user.promote(role)
            else user.demote(role)
        }

        await user.save()
    }

    /**
     * Convert a uid string to a fully qualified DN based on the configured user search base.
     * @param {string} uid
     * @returns {string} - fully qualified DN of the user
     */
    uid_to_dn(uid){
        return `uid=${uid},${this.config.user_search_base}`
    }

    /**
     * Register a new user with the specified username and attributes.
     * Attributes object should contain a 'password' key, which will be removed and used to set the user's LDAP password.
     * @param {string} username - uid of the new user
     * @param {object} attrs - additional attributes of the user
     * @param {object} [data] - additional data to be stored in the user's JSON
     * @returns {Promise<module:flitter-auth/model/User~User>}
     */
    async register(username, attrs, data) {
        const entry = {
            uid: username,
        }

        if ( this.config.registration_merge_attributes ){
            Object.keys(this.config.registration_merge_attributes).forEach((attr) => {
                if ( typeof this.config.registration_merge_attributes[attr] === 'string' ){
                    entry[attr] = this.config.registration_merge_attributes[attr].replace(/%u/g, username)
                }
                else entry[attr] = this.config.registration_merge_attributes[attr]
            })
        }

        await this.connection.add(this.uid_to_dn(username), entry)
        await this.connection.password_reset(this.uid_to_dn(username), attrs.password)

        const user_data = await this.get_user(username)
        const user = await this.get_user_object(user_data)
        if ( data ) user.data = JSON.stringify({...JSON.parse(user.data), ...data})

        Object.keys(attrs).filter(i => i !== 'password').forEach(key => user[key] = attrs[key])
        await user.save()
        return user
    }

    /**
     * Ensure that registration form_data is valid. Checks for password and unique username.
     * @param {object} form_data
     * @returns {Promise<Array<string>>} - array of string errors. If empty array, no errors.
     */
    async validate_registration(form_data){
        const errors = await super.validate_registration(form_data)
        const min_pw_length = this.config.min_password_length

        if ( !Object.keys(form_data).includes('password') || !form_data.password ){
            errors.push('Password field is required.')
        }
        else if ( form_data.password.length < min_pw_length ){
            errors.push(`Password must be at least ${min_pw_length} characters long.`)
        }

        if ( form_data.username ) {
            const user = await this.get_user(form_data.username)
            if ( user ) errors.push('User already exists with that username.')
        }

        return errors
    }

    /**
     * From the form data, get the formatted arguments to be passed into the registration function.
     * Should create the username and {password} objects.
     * @param {object} form_data
     * @returns {Promise<Array<*>>}
     */
    async get_registration_args(form_data){
        return [
            form_data.username,
            {
                password: form_data.password,
            }
        ]
    }

    /**
     * Attempt to authenticate a user with the provided credentials. If it succeeds, return their User object.
     * @param {string} username
     * @param {string} password
     * @param [args] - not required
     * @returns {Promise<boolean|module:flitter-auth/model/User~User>} - false if the auth is unsuccessful, a User instance if it is
     */
    async login(username, password, args = {}){
        const data = await this.get_user(username)
        if ( !data ) return false

        const success = await this.connection.password_check(data.dn, password)
        if ( !success ) return false

        let user = await this.get_user_object(data)
        if ( user.block_login ) return false

        user.last_auth = new Date()
        await user.save()
        return user
    }

    /**
     * Check the validity of the provided credentials.
     * @param {string} user
     * @param {string} password
     * @returns {Promise<boolean>} - true if the credentials succeed, false otherwise
     */
    async check_user_auth(user, password){
        const data = await this.get_user(user)
        const ldap = await this.ldap()
        return await ldap.password_check(data.dn, password)
    }

    /**
     * Clean up resources used by this provider. Unbinds all open LDAP connections.
     * @param {module:libflitter/app/FlitterApp~FlitterApp} app - the current app
     * @returns {Promise<void>}
     */
    async cleanup(app){
        this.connection.connection.unbind()
    }

}

module.exports = exports = LdapProvider