UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

736 lines (707 loc) • 37 kB
const { Client } = require('ldapts') const { Roles, TeamRoles } = require('../../../lib/roles') const { completeUserSignup, generatePassword } = require('../../../lib/userTeam') module.exports.init = async function (app) { // Set the SSO feature flag app.config.features.register('sso', true, true) app.postoffice.registerTemplate('SSOCertsExpiring', require('./emailTemplates/SSOCertsExpiring')) app.housekeeper.registerTask(require('./tasks/saml-cert-check')) async function getProviderOptions (id) { const provider = await app.db.models.SAMLProvider.byId(id) if (provider) { const result = { ...provider.getOptions() } // @node-saml@4 renamed `cert` to `idpCert` result.idpCert = result.cert delete result.cert return result } return null } async function getProviderForEmail (email) { const provider = await app.db.models.SAMLProvider.forEmail(email) if (provider) { return provider.hashid } return null } async function isSSOEnabledForEmail (email) { return !!(await getProviderForEmail(email)) } /** * Handle a request POST /account/login to see if SSO should be triggered * @returns whether the request has been handled or not */ async function handleLoginRequest (request, reply) { const user = await app.db.models.User.byUsernameOrEmail(request.body.username) if (user) { const providerConfig = await app.db.models.SAMLProvider.forEmail(user.email) if (providerConfig) { if (providerConfig.type === 'saml') { if (request.body.username.toLowerCase() !== user.email.toLowerCase() || request.body.password) { // A SSO enabled user has tried to login with their username, or have provided a password. // If they are an admin, allow them to continue - we need to let admins bypass SSO so they // cannot be locked out. if (user.admin) { return false } // We need them to provide just their email address to avoid // us exposing their email domain reply.code(401).send({ code: 'sso_required', error: 'Please login with your email address' }) } else { reply.code(401).send({ code: 'sso_required', redirect: `/ee/sso/login?u=${user.email}` }) } return true } else if (providerConfig.type === 'ldap') { if (!request.body.password) { reply.code(401).send({ code: 'password_required', error: 'Password required' }) return true } const userInfo = app.auditLog.formatters.userObject(request.body) const ldapVerified = await verifyLDAPUser(providerConfig, user, request.body.password) if (ldapVerified) { const sessionInfo = await app.createSessionCookie(request.body.username, providerConfig.options.sessionExpiry, providerConfig.options.sessionIdle) if (sessionInfo) { userInfo.id = sessionInfo.session.UserId user.sso_enabled = true user.email_verified = true if (user.mfa_enabled) { // They are mfa_enabled - but have authenticated via SSO // so we will let them in without further challenge sessionInfo.session.mfa_verified = true await sessionInfo.session.save() } await user.save() reply.setCookie('sid', sessionInfo.session.sid, sessionInfo.cookieOptions) if (sessionInfo.session.User.mfa_enabled && !sessionInfo.mfa_verified) { reply.code(403).send({ code: 'mfa_required', error: 'MFA required' }) return true } await app.auditLog.User.account.login(userInfo, null) reply.send() return true } else { const resp = { code: 'user_suspended', error: 'User Suspended' } await app.auditLog.User.account.login(userInfo, resp, userInfo) reply.code(403).send(resp) return true } } else if (user.admin) { // If they are an admin, fallback to checking their local password // so they won't be locked out due to an LDAP error return false } else { const resp = { code: 'unauthorized', error: 'unauthorized' } await app.auditLog.User.account.login(userInfo, resp, userInfo) reply.code(401).send(resp) return true } } } } else { // should check if username is email address? const providerConfig = await app.db.models.SAMLProvider.forEmail(request.body.username) if (providerConfig?.options?.provisionNewUsers) { if (providerConfig.type === 'saml') { reply.code(401).send({ code: 'sso_required', redirect: `/ee/sso/login?u=${request.body.username}` }) return true } else if (providerConfig.type === 'ldap') { if (!request.body.password) { reply.code(401).send({ code: 'password_required', error: 'Password required' }) return true } const tempUser = { username: request.body.username, email: request.body.username } const userInfo = app.auditLog.formatters.userObject(request.body) if (verifyLDAPUser(providerConfig, tempUser, request.body.password)) { // TODO create user const newUserProperties = await lookupLDAPUser(providerConfig, request.body.username) if (newUserProperties) { const newUser = await app.db.models.User.create(newUserProperties) const sessionInfo = await app.createSessionCookie(newUser.username, providerConfig.options.sessionExpiry, providerConfig.options.sessionIdle) if (sessionInfo) { userInfo.id = sessionInfo.session.UserId newUser.sso_enabled = true newUser.email_verified = true await newUser.save() // Complete user signup - create team and accept invites await completeUserSignup(app, newUser) reply.setCookie('sid', sessionInfo.session.sid, sessionInfo.cookieOptions) await app.auditLog.User.account.login(userInfo, null) reply.send() return true } } } return false } } } return false } /** * Look up ldap user * @param {*} providerConfig the ldap confi * @param {String} username the User to lookup * @returns User properties */ async function lookupLDAPUser (providerConfig, username) { let url = providerConfig.options.server if (!/^ldaps?:\/\//.test(url)) { if (providerConfig.options.tls) { url = 'ldaps://' + url } else { url = 'ldap://' + url } } const clientOptions = { url } if (providerConfig.options.tls) { if (!providerConfig.options.tlsVerifyServer) { clientOptions.tlsOptions = { rejectUnauthorized: false } } } const adminClient = new Client(clientOptions) try { await adminClient.bind(providerConfig.options.username, providerConfig.options.password) } catch (err) { app.log.error(`Failed to bind LDAP client: Provider '${providerConfig.name}' ${url} ${err}`) return null } // Any errors from here must unbind the adminClient - so wrap all in try/catch try { const filter = providerConfig.options.userFilter.replace(/\${username}/g, username).replace(/\${email}/g, username) const { searchEntries } = await adminClient.search(providerConfig.options.baseDN, { filter }) if (searchEntries.length === 1) { const userProperties = { username: username.replace('@', '-').replaceAll('.', '_').toLowerCase(), email: username, password: generatePassword() } // may want to extend this list at some point if (searchEntries[0].displayName) { userProperties.name = searchEntries[0].displayName } else if (searchEntries[0].givenName) { if (searchEntries[0].sn) { userProperties.name = `${searchEntries[0].givenName} ${searchEntries[0].sn}` } else { userProperties.name = `${searchEntries[0].givenName}` } } else { userProperties.name = username.split('@')[0] } return userProperties } } catch (err) { app.log.error(`Error looking up new LDAP User '${username}' Provider '${providerConfig.name}' ${url} ${err}`) } finally { try { if (adminClient) { await adminClient.unbind() } } catch (err) {} } return null } /** * Verifies a user's ldap credentials * @param {*} providerConfig the ldap config * @param {User} user the User object to verify * @param {String} password the password * @returns True if it verifies, false if not */ async function verifyLDAPUser (providerConfig, user, password) { let userClient let url = providerConfig.options.server if (!/^ldaps?:\/\//.test(url)) { if (providerConfig.options.tls) { url = 'ldaps://' + url } else { url = 'ldap://' + url } } const clientOptions = { url } if (providerConfig.options.tls) { if (!providerConfig.options.tlsVerifyServer) { clientOptions.tlsOptions = { rejectUnauthorized: false } } } const adminClient = new Client(clientOptions) try { await adminClient.bind(providerConfig.options.username, providerConfig.options.password) } catch (err) { app.log.error(`Failed to bind LDAP client: Provider '${providerConfig.name}' ${url} ${err}`) return null } try { // Any errors from here must unbind the adminClient - so wrap all in try/catch const filter = providerConfig.options.userFilter.replace(/\${username}/g, user.username).replace(/\${email}/g, user.email) const { searchEntries } = await adminClient.search(providerConfig.options.baseDN, { filter }) if (searchEntries.length === 1) { const userDN = searchEntries[0].dn userClient = new Client(clientOptions) try { await userClient.bind(userDN, password) // if ldap group support enabled if (providerConfig.options.groupMapping) { await updateTeamMembershipLDAP(adminClient, user, userDN, providerConfig.options) } return true } catch (err) { // Failed to bind user return false } } } catch (err) { app.log.error(`Error validating LDAP User '${user.username}' Provider '${providerConfig.name}' ${url} ${err}`) } finally { try { if (adminClient) { await adminClient.unbind() } } catch (err) {} try { if (userClient) { await userClient.unbind() } } catch (err) {} } return false } /** * Checks to see if the user's membership of the specified team is managed by SSO\ * @param {User} user The user object * @param {Team} team The team object * @returns {boolean} True if the membership is managed by SSO */ async function isUserMembershipManaged (user, team) { // Checks to see if the user's membership of the specified team is managed by SSO // 1. check if the user's email is managed by SSO const provider = await app.db.models.SAMLProvider.forEmail(user.email) if (provider) { const options = provider.getOptions() if (options.groupAllTeams || (options.groupTeams || []).includes(team.slug)) { return true } } return false } function getUserGroups (samlUser, user, providerOpts) { let groupAssertions = samlUser[providerOpts.groupAssertionName] if (groupAssertions) { if (!Array.isArray(groupAssertions)) { groupAssertions = [groupAssertions] } } return groupAssertions } /** * Given a group-name match (from the `ff-SLUG-ROLE` regex), check if it is an * Application override group of the form `ff-<team>[<application>]-<role>` and, * if so, record the desired application role in `desiredTeamApplicationroles`. * * @param {object} desiredTeamApplicationroles Accumulator keyed by team hashid -> { applicationHashid: role } * @param {RegExpExecArray} match The result of running the `ff-(.+)-([^-]+)$` regex on the group name * @param {object} providerOpts The SSO Provider configuration object * @returns {Promise<boolean>} true if the group was an Application override group (and should not * be treated as a plain team-role group), false otherwise. */ async function recordApplicationOverride (desiredTeamApplicationroles, match, providerOpts) { const applicationRolesMatch = /(.+)\[(.+)\]/.exec(match[1]) if (!applicationRolesMatch) { // Not an Application override group return false } const teamSlug = applicationRolesMatch[1] const applicationId = applicationRolesMatch[2] const applicationRole = Roles[match[2]] // From here on this is an Application override group - return true even if // we cannot apply it, so the caller does not treat it as a team-role group. if (applicationRole === undefined) { // Unrecognised role name - ignore return true } // Check if this team is allowed to be managed for this SSO provider before // doing any database lookups. if (!(providerOpts.groupAllTeams || (providerOpts.groupTeams || []).includes(teamSlug))) { return true } const team = await app.db.models.Team.bySlug(teamSlug) if (!team) { // Unrecognised team - ignore return true } const application = await team.hasApplication(applicationId) if (!application) { // Unrecognised application - ignore return true } if (!desiredTeamApplicationroles[team.hashid]) { desiredTeamApplicationroles[team.hashid] = {} } // In case we have multiple assertions for a single application, // ensure we keep the highest level of access desiredTeamApplicationroles[team.hashid][application.hashid] = Math.max( desiredTeamApplicationroles[team.hashid][application.hashid] || 0, applicationRole ) return true } /** * Apply the desired per-application role overrides to a user's team memberships * and clear any SSO-managed overrides that are no longer desired. * * This is shared between the SAML and LDAP flows. * * @param {*} user The FF User object who is logging in * @param {object} desiredTeamApplicationroles Accumulator keyed by team hashid -> { applicationHashid: role } * @param {object} providerOpts The SSO Provider configuration object */ async function applyApplicationOverrides (user, desiredTeamApplicationroles, providerOpts) { app.log.debug(`Desired Application Roles for ${user.username} ${JSON.stringify(desiredTeamApplicationroles)}`) // Work out which teams currently have SSO-managed overrides so we can clear // any that are no longer desired. Only teams in scope of this provider are // considered. const existingOverrideTeamIds = new Set( ((await app.db.models.TeamMember.getTeamsForUser(user.id, true)) || []) .filter(teamMembership => { if (!teamMembership.permissions) { return false } // If the user is allowed to be a member of teams outside of SSO // control, only manage overrides on teams this provider controls. if (providerOpts.groupMapping && providerOpts.groupOtherTeams) { return (providerOpts.groupTeams || []).includes(teamMembership.Team.slug) } return true }) .map(teamMembership => teamMembership.Team.hashid) ) app.log.debug(`Existing Application Roles for ${user.username} ${JSON.stringify([...existingOverrideTeamIds])}`) // First pass - apply the desired overrides. Done sequentially so that the // apply and clear passes cannot race on the same membership. for (const [teamId, overrides] of Object.entries(desiredTeamApplicationroles)) { const membership = await app.db.models.TeamMember.getTeamMembership(user.id, teamId) if (membership) { const newPermissions = { applications: overrides, sso: true } await app.db.controllers.Team.changeUserTeamPermissions(teamId, user.hashid, newPermissions) // This team has been dealt with - don't clear it in the second pass existingOverrideTeamIds.delete(teamId) } else { app.log.debug(`User ${user.name} not a member of Team ${teamId} so not overriding Application access`) } } // Second pass - any remaining teams had overrides that are no longer // desired, so clear them. for (const teamId of existingOverrideTeamIds) { const membership = await app.db.models.TeamMember.getTeamMembership(user.id, teamId) if (membership) { membership.permissions = {} await membership.save() } } } /** * Update a user's team memberships according to the SAML Assertions * received when they logged in. * * @param {*} samlUser The user profile object provided by the authentication provider * @param {*} user The FF User object who is logging in * @param {*} providerOpts The SAML Provider configuration object */ async function updateTeamMembership (samlUser, user, providerOpts) { // Look for the expected assertion in the SAML profile we have received // This is an array of groups the user belongs to. We expect them to be // of the form 'ff-SLUG-ROLE' - anything else is ignored let groupAssertions = samlUser[providerOpts.groupAssertionName] if (groupAssertions) { const promises = [] if (!Array.isArray(groupAssertions)) { groupAssertions = [groupAssertions] } let adminGroup = false const desiredTeamMemberships = {} const desiredTeamApplicationroles = {} app.log.debug(`SAML Group Assertions for ${user.username} ${JSON.stringify(groupAssertions)}`) for (const ga of groupAssertions) { // Trim prefix/postfix from group name let shortGA = ga if (providerOpts.groupPrefixLength || providerOpts.groupSuffixLength) { const start = providerOpts.groupPrefixLength || 0 const end = providerOpts.groupSuffixLength || 0 shortGA = ga.slice(start, ga.length - end) app.log.debug(`Converting Group name ${ga} to ${shortGA}`) } // Parse the group name - format: 'ff-SLUG-ROLE' // Generate a slug->role object (desiredTeamMemberships) const match = /^ff-(.+)-([^-]+)$/.exec(shortGA) if (match) { // check for Application override groups of the form `ff-<team>[<application>]-<role>` if (!await recordApplicationOverride(desiredTeamApplicationroles, match, providerOpts)) { const teamSlug = match[1] const teamRoleName = match[2] const teamRole = Roles[teamRoleName] // Check this role is a valid team role if (TeamRoles.includes(teamRole)) { // Check if this team is allowed to be managed for this SSO provider // - either `groupAllTeams` is true (allowing all teams to be managed this way) // - or `groupTeams` (array) contains the teamSlug if (providerOpts.groupAllTeams || (providerOpts.groupTeams || []).includes(teamSlug)) { // In case we have multiple assertions for a single team, // ensure we keep the highest level of access desiredTeamMemberships[teamSlug] = Math.max(desiredTeamMemberships[teamSlug] || 0, teamRole) } } } } if (providerOpts.groupAdmin && providerOpts.groupAdminName === ga) { adminGroup = true } } if (providerOpts.groupAdmin) { if (user.admin && !adminGroup) { app.auditLog.User.user.updatedUser(0, null, [{ key: 'admin', old: true, new: false }], user) user.admin = false try { await user.save() } catch (err) { // did we just fail remove the last admin? app.log.info(`Failed to remove admin from ${user.username}, as this would have been the last admin`) } } else if (adminGroup && !user.admin) { app.auditLog.User.user.updatedUser(0, null, [{ key: 'admin', old: false, new: true }], user) user.admin = true await user.save() } } app.log.debug(`Desired Teams for ${user.username} ${JSON.stringify(desiredTeamMemberships)}`) // Get the existing memberships and generate a slug->membership object (existingMemberships) const existingMemberships = {} ;((await user.getTeamMemberships(true)) || []).forEach(membership => { // Filter out any teams that are not to be managed by this configuration. // A team is managed by this configuration if any of the follow is true: // - groupAllTeams is true (all teams to be managed) // - groupTeams includes this team (this is explicitly a team to be managed) // - groupOtherTeams is false (not allowed to be a member of other teams - so need to remove them) if ( providerOpts.groupAllTeams || (providerOpts.groupTeams || []).includes(membership.Team.slug) || !providerOpts.groupOtherTeams ) { existingMemberships[membership.Team.slug] = membership } }) app.log.debug(`Existing Teams for ${user.username} ${JSON.stringify(existingMemberships)}`) // We now have the list of desiredTeamMemberships and existingMemberships // that are in scope of being modified // - Check each existing membership // - if in desired list, update role to match and delete from desired list // - if not in desired list, // - if groupOtherTeams is false or, delete membership // - else leave alone for (const [teamSlug, membership] of Object.entries(existingMemberships)) { if (Object.hasOwn(desiredTeamMemberships, teamSlug)) { // This team is in the desired list if (desiredTeamMemberships[teamSlug] !== membership.role) { // Role has changed - update membership const updates = new app.auditLog.formatters.UpdatesCollection() const oldRole = app.auditLog.formatters.roleObject(membership.role) const role = app.auditLog.formatters.roleObject(desiredTeamMemberships[teamSlug]) updates.push('role', oldRole.role, role.role) membership.role = desiredTeamMemberships[teamSlug] promises.push(membership.save().then(() => { return app.auditLog.Team.team.user.roleChanged(user, null, membership.Team, user, updates) })) } else { // Role has not changed - no update needed // console.log(`no change needed for team ${teamSlug} role ${membership.role}`) } // Remove from the desired list as it has been dealt with delete desiredTeamMemberships[teamSlug] } else { // console.log(`removing from team ${teamSlug}`) // This team is not in the desired list - delete the membership promises.push(membership.destroy().then(() => { return app.auditLog.Team.team.user.removed(user, null, membership.Team, user) })) } } // - Check remaining desired memberships // - create membership for (const [teamSlug, teamRole] of Object.entries(desiredTeamMemberships)) { // This is a new team membership promises.push(app.db.models.Team.bySlug(teamSlug).then(team => { if (team) { // console.log(`adding to team ${teamSlug} role ${teamRole}`) return app.db.controllers.Team.addUser(team, user, teamRole).then(() => { return app.auditLog.Team.team.user.added(user, null, team, user) }) } else { // console.log(`team not found ${teamSlug}`) // Unrecognised team - ignore return null } })) } await Promise.all(promises) // Now all group membership has been updated, apply application overrides await applyApplicationOverrides(user, desiredTeamApplicationroles, providerOpts) } else { const missingGroupAssertions = new Error(`SAML response missing ${providerOpts.groupAssertionName} assertion`) missingGroupAssertions.code = 'unknown_sso_user' throw missingGroupAssertions } } // LDAP Group Membership async function updateTeamMembershipLDAP (adminClient, user, userDN, providerOpts) { const filter = `(|(uniqueMember=${userDN})(member=${userDN}))` const { searchEntries } = await adminClient.search(providerOpts.groupsDN, { filter, attributes: ['cn'] }) app.log.debug(`LDAP Groups for ${user.username} ${JSON.stringify(searchEntries)}`) const promises = [] let adminGroup = false const desiredTeamMemberships = {} const desiredTeamApplicationroles = {} const groupRegEx = /^ff-(.+)-([^-]+)$/ for (const i in searchEntries) { let shortCN = searchEntries[i].cn if (providerOpts.groupPrefixLength || providerOpts.groupSuffixLength) { // Trim prefix and postfix const start = providerOpts.groupPrefixLength || 0 const end = providerOpts.groupSuffixLength || 0 shortCN = searchEntries[i].cn.slice(start, searchEntries[i].cn.length - end) } const match = groupRegEx.exec(shortCN) if (match) { // check for Application override groups of the form `ff-<team>[<application>]-<role>` if (!await recordApplicationOverride(desiredTeamApplicationroles, match, providerOpts)) { app.log.debug(`Found group ${searchEntries[i].cn} for user ${user.username}`) const teamSlug = match[1] const teamRoleName = match[2] const teamRole = Roles[teamRoleName] // Check this role is a valid team role if (TeamRoles.includes(teamRole)) { // Check if this team is allowed to be managed for this SSO provider // - either `groupAllTeams` is true (allowing all teams to be managed this way) // - or `groupTeams` (array) contains the teamSlug if (providerOpts.groupAllTeams || (providerOpts.groupTeams || []).includes(teamSlug)) { // In case we have multiple assertions for a single team, // ensure we keep the highest level of access desiredTeamMemberships[teamSlug] = Math.max(desiredTeamMemberships[teamSlug] || 0, teamRole) } } } } if (providerOpts.groupAdmin && providerOpts.groupAdminName === searchEntries[i].cn) { adminGroup = true } } app.log.debug(`Desired Teams for ${user.username} ${JSON.stringify(desiredTeamMemberships)}`) if (providerOpts.groupAdmin) { if (user.admin && !adminGroup) { app.auditLog.User.user.updatedUser(0, null, [{ key: 'admin', old: true, new: false }], user) user.admin = false try { await user.save() } catch (err) { // did we just fail remove the last admin? app.log.info(`Failed to remove admin from ${user.username}, as this would have been the last admin`) } } else if (adminGroup && !user.admin) { app.auditLog.User.user.updatedUser(0, null, [{ key: 'admin', old: false, new: true }], user) user.admin = true await user.save() } } // Get the existing memberships and generate a slug->membership object (existingMemberships) const existingMemberships = {} ;((await user.getTeamMemberships(true)) || []).forEach(membership => { // Filter out any teams that are not to be managed by this configuration. // A team is managed by this configuration if any of the follow is true: // - groupAllTeams is true (all teams to be managed) // - groupTeams includes this team (this is explicitly a team to be managed) // - groupOtherTeams is false (not allowed to be a member of other teams - so need to remove them) if ( providerOpts.groupAllTeams || (providerOpts.groupTeams || []).includes(membership.Team.slug) || !providerOpts.groupOtherTeams ) { existingMemberships[membership.Team.slug] = membership } }) app.log.debug(`Existing Teams for ${user.username} ${JSON.stringify(existingMemberships)}`) // We now have the list of desiredTeamMemberships and existingMemberships // that are in scope of being modified // - Check each existing membership // - if in desired list, update role to match and delete from desired list // - if not in desired list, // - if groupOtherTeams is false or, delete membership // - else leave alone for (const [teamSlug, membership] of Object.entries(existingMemberships)) { if (Object.hasOwn(desiredTeamMemberships, teamSlug)) { // This team is in the desired list if (desiredTeamMemberships[teamSlug] !== membership.role) { // Role has changed - update membership const updates = new app.auditLog.formatters.UpdatesCollection() const oldRole = app.auditLog.formatters.roleObject(membership.role) const role = app.auditLog.formatters.roleObject(desiredTeamMemberships[teamSlug]) updates.push('role', oldRole.role, role.role) membership.role = desiredTeamMemberships[teamSlug] promises.push(membership.save().then(() => { return app.auditLog.Team.team.user.roleChanged(user, null, membership.Team, user, updates) })) } else { // Role has not changed - no update needed // console.log(`no change needed for team ${teamSlug} role ${membership.role}`) } // Remove from the desired list as it has been dealt with delete desiredTeamMemberships[teamSlug] } else { // console.log(`removing from team ${teamSlug}`) // This team is not in the desired list - delete the membership promises.push(membership.destroy().then(() => { return app.auditLog.Team.team.user.removed(user, null, membership.Team, user) })) } } // - Check remaining desired memberships // - create membership for (const [teamSlug, teamRole] of Object.entries(desiredTeamMemberships)) { // This is a new team membership promises.push(app.db.models.Team.bySlug(teamSlug).then(team => { if (team) { // console.log(`adding to team ${teamSlug} role ${teamRole}`) return app.db.controllers.Team.addUser(team, user, teamRole).then(() => { return app.auditLog.Team.team.user.added(user, null, team, user) }) } else { // console.log(`team not found ${teamSlug}`) // Unrecognised team - ignore return null } })) } await Promise.all(promises) // Now all group membership has been updated, apply application overrides await applyApplicationOverrides(user, desiredTeamApplicationroles, providerOpts) } return { handleLoginRequest, isSSOEnabledForEmail, getProviderOptions, getProviderForEmail, updateTeamMembership, isUserMembershipManaged, getUserGroups } }