/**
* @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