2022-01-07 14:06:13 +01:00
'use strict' ;
exports = module . exports = {
2023-08-03 02:26:11 +05:30
getConfig ,
setConfig ,
2022-01-07 14:06:13 +01:00
start ,
2022-02-16 12:57:38 -08:00
stop ,
2022-11-30 15:16:16 +01:00
checkCertificate ,
2022-01-07 14:06:13 +01:00
} ;
const assert = require ( 'assert' ) ,
2023-08-26 08:18:58 +05:30
AuditSource = require ( './auditsource.js' ) ,
2022-01-07 14:06:13 +01:00
BoxError = require ( './boxerror.js' ) ,
constants = require ( './constants.js' ) ,
2022-08-15 19:14:02 +02:00
debug = require ( 'debug' ) ( 'box:directoryserver' ) ,
2022-01-07 14:06:13 +01:00
eventlog = require ( './eventlog.js' ) ,
groups = require ( './groups.js' ) ,
ldap = require ( 'ldapjs' ) ,
2022-02-16 12:57:38 -08:00
path = require ( 'path' ) ,
paths = require ( './paths.js' ) ,
2022-11-11 16:21:16 +01:00
reverseProxy = require ( './reverseproxy.js' ) ,
2022-01-07 14:06:13 +01:00
safe = require ( 'safetydance' ) ,
settings = require ( './settings.js' ) ,
2024-10-14 19:10:31 +02:00
shell = require ( './shell.js' ) ( 'directoryserver' ) ,
2022-01-07 14:06:13 +01:00
users = require ( './users.js' ) ,
2022-02-16 12:57:38 -08:00
util = require ( 'util' ) ,
validator = require ( 'validator' ) ;
2022-01-07 14:06:13 +01:00
2022-11-28 22:32:34 +01:00
let gServer = null , gCertificate = null ;
2022-01-07 14:06:13 +01:00
const NOOP = function ( ) { } ;
2022-02-16 12:57:38 -08:00
const SET _LDAP _ALLOWLIST _CMD = path . join ( _ _dirname , 'scripts/setldapallowlist.sh' ) ;
2023-08-03 02:26:11 +05:30
async function getConfig ( ) {
const value = await settings . get ( settings . DIRECTORY _SERVER _KEY ) ;
if ( value === null ) return {
enabled : false ,
secret : '' ,
2024-01-13 12:30:39 +01:00
allowlist : ''
2023-08-03 02:26:11 +05:30
} ;
return JSON . parse ( value ) ;
}
2022-02-16 12:57:38 -08:00
async function validateConfig ( config ) {
const { enabled , secret , allowlist } = config ;
if ( ! enabled ) return ;
if ( ! secret ) throw new BoxError ( BoxError . BAD _FIELD , 'secret cannot be empty' ) ;
let gotOne = false ;
for ( const line of allowlist . split ( '\n' ) ) {
if ( ! line || line . startsWith ( '#' ) ) continue ;
const rangeOrIP = line . trim ( ) ;
// this checks for IPv4 and IPv6
if ( ! validator . isIP ( rangeOrIP ) && ! validator . isIPRange ( rangeOrIP ) ) throw new BoxError ( BoxError . BAD _FIELD , ` ${ rangeOrIP } is not a valid IP or range ` ) ;
gotOne = true ;
}
// only allow if we at least have one allowed IP/range
if ( ! gotOne ) throw new BoxError ( BoxError . BAD _FIELD , 'allowlist must at least contain one IP or range' ) ;
}
async function applyConfig ( config ) {
assert . strictEqual ( typeof config , 'object' ) ;
// this is done only because it's easier for the shell script and the firewall service to get the value
if ( config . enabled ) {
if ( ! safe . fs . writeFileSync ( paths . LDAP _ALLOWLIST _FILE , config . allowlist + '\n' , 'utf8' ) ) throw new BoxError ( BoxError . FS _ERROR , safe . error . message ) ;
} else {
safe . fs . unlinkSync ( paths . LDAP _ALLOWLIST _FILE ) ;
}
2024-10-14 19:10:31 +02:00
const [ error ] = await safe ( shell . promises . sudo ( [ SET _LDAP _ALLOWLIST _CMD ] , { } ) ) ;
2022-02-16 12:57:38 -08:00
if ( error ) throw new BoxError ( BoxError . IPTABLES _ERROR , ` Error setting ldap allowlist: ${ error . message } ` ) ;
2022-08-15 21:08:22 +02:00
2023-10-01 13:26:43 +05:30
if ( ! config . enabled ) {
await stop ( ) ;
return ;
}
if ( ! gServer ) await start ( ) ;
2022-02-16 12:57:38 -08:00
}
2022-01-07 14:06:13 +01:00
2024-01-13 12:18:14 +01:00
async function setConfig ( directoryServerConfig , auditSource ) {
2023-08-03 02:26:11 +05:30
assert . strictEqual ( typeof directoryServerConfig , 'object' ) ;
2024-01-13 12:18:14 +01:00
assert ( auditSource && typeof auditSource === 'object' ) ;
2023-08-03 02:26:11 +05:30
2024-01-13 21:15:41 +01:00
if ( constants . DEMO ) throw new BoxError ( BoxError . BAD _STATE , 'Not allowed in demo mode' ) ;
2023-08-03 02:26:11 +05:30
2024-01-13 12:18:14 +01:00
const oldConfig = await getConfig ( ) ;
2023-08-03 02:26:11 +05:30
const config = {
enabled : directoryServerConfig . enabled ,
secret : directoryServerConfig . secret ,
2024-01-13 12:30:39 +01:00
allowlist : directoryServerConfig . allowlist
2023-08-03 02:26:11 +05:30
} ;
await validateConfig ( config ) ;
2023-08-03 11:34:33 +05:30
await settings . setJson ( settings . DIRECTORY _SERVER _KEY , config ) ;
2023-08-03 02:26:11 +05:30
await applyConfig ( config ) ;
2024-01-13 12:18:14 +01:00
await eventlog . add ( eventlog . ACTION _DIRECTORY _SERVER _CONFIGURE , auditSource , { fromEnabled : oldConfig . enabled , toEnabled : config . enabled } ) ;
2023-08-03 02:26:11 +05:30
}
2022-01-07 14:06:13 +01:00
// helper function to deal with pagination
function finalSend ( results , req , res , next ) {
2024-10-14 19:10:31 +02:00
const min = 0 , max = results . length ;
2022-01-07 14:06:13 +01:00
let cookie = null ;
let pageSize = 0 ;
// check if this is a paging request, if so get the cookie for session info
req . controls . forEach ( function ( control ) {
if ( control . type === ldap . PagedResultsControl . OID ) {
pageSize = control . value . size ;
cookie = control . value . cookie ;
}
} ) ;
function sendPagedResults ( start , end ) {
start = ( start < min ) ? min : start ;
end = ( end > max || end < min ) ? max : end ;
let i ;
for ( i = start ; i < end ; i ++ ) {
res . send ( results [ i ] ) ;
}
return i ;
}
if ( cookie && Buffer . isBuffer ( cookie ) ) {
// we have pagination
2022-04-14 17:41:41 -05:00
let first = min ;
2022-01-07 14:06:13 +01:00
if ( cookie . length !== 0 ) {
first = parseInt ( cookie . toString ( ) , 10 ) ;
}
2022-04-14 17:41:41 -05:00
const last = sendPagedResults ( first , first + pageSize ) ;
2022-01-07 14:06:13 +01:00
2022-04-14 17:41:41 -05:00
let resultCookie ;
2022-01-07 14:06:13 +01:00
if ( last < max ) {
resultCookie = Buffer . from ( last . toString ( ) ) ;
} else {
resultCookie = Buffer . from ( '' ) ;
}
res . controls . push ( new ldap . PagedResultsControl ( {
value : {
size : pageSize , // correctness not required here
cookie : resultCookie
}
} ) ) ;
} else {
// no pagination simply send all
results . forEach ( function ( result ) {
res . send ( result ) ;
} ) ;
}
// all done
res . end ( ) ;
next ( ) ;
}
async function authorize ( req , res , next ) {
debug ( 'authorize: ' , req . connection . ldap . bindDN . toString ( ) ) ;
// this is for connection attempts without previous bind
if ( req . connection . ldap . bindDN . equals ( 'cn=anonymous' ) ) return next ( new ldap . InsufficientAccessRightsError ( ) ) ;
// we only allow this one DN to pass
if ( ! req . connection . ldap . bindDN . equals ( constants . USER _DIRECTORY _LDAP _DN ) ) return next ( new ldap . InsufficientAccessRightsError ( ) ) ;
return next ( ) ;
}
2022-10-12 21:58:14 +02:00
// https://ldapwiki.com/wiki/RootDSE / RFC 4512 - ldapsearch -x -h "${CLOUDRON_LDAP_SERVER}" -p "${CLOUDRON_LDAP_PORT}" -b "" -s base
// ldapjs seems to call this handler for everything when search === ''
async function maybeRootDSE ( req , res , next ) {
debug ( ` maybeRootDSE: requested with scope: ${ req . scope } dn: ${ req . dn . toString ( ) } ` ) ;
if ( req . scope !== 'base' ) return next ( new ldap . NoSuchObjectError ( ) ) ; // per the spec, rootDSE search require base scope
if ( ! req . dn || req . dn . toString ( ) !== '' ) return next ( new ldap . NoSuchObjectError ( ) ) ;
res . send ( {
dn : '' ,
attributes : {
objectclass : [ 'RootDSE' , 'top' , 'OpenLDAProotDSE' ] ,
supportedLDAPVersion : '3' ,
vendorName : 'Cloudron LDAP' ,
vendorVersion : '1.0.0'
}
} ) ;
res . end ( ) ;
}
2022-01-07 14:06:13 +01:00
async function userSearch ( req , res , next ) {
debug ( 'user search: dn %s, scope %s, filter %s (from %s)' , req . dn . toString ( ) , req . scope , req . filter . toString ( ) , req . connection . ldap . id ) ;
2022-08-15 19:50:22 +02:00
const [ error , allUsers ] = await safe ( users . list ( ) ) ;
2024-01-03 14:51:00 +01:00
if ( error ) return next ( new ldap . OperationsError ( error . message ) ) ;
2022-01-07 14:06:13 +01:00
2022-08-04 11:22:16 +02:00
const [ groupsError , allGroups ] = await safe ( groups . listWithMembers ( ) ) ;
2024-01-03 14:51:00 +01:00
if ( groupsError ) return next ( new ldap . OperationsError ( groupsError . message ) ) ;
2022-08-04 11:22:16 +02:00
2024-10-14 19:10:31 +02:00
const results = [ ] ;
2022-01-07 14:06:13 +01:00
// send user objects
2022-08-15 19:50:22 +02:00
for ( const user of allUsers ) {
2022-01-07 14:06:13 +01:00
// skip entries with empty username. Some apps like owncloud can't deal with this
2022-08-15 19:50:22 +02:00
if ( ! user . username ) continue ;
2022-01-07 14:06:13 +01:00
2022-07-29 11:17:31 +02:00
const dn = ldap . parseDN ( ` cn= ${ user . id } ,ou=users,dc=cloudron ` ) ;
2022-01-07 14:06:13 +01:00
const displayName = user . displayName || user . username || '' ; // displayName can be empty and username can be null
2024-02-06 16:43:05 +01:00
const { firstName , lastName , middleName } = users . parseDisplayName ( displayName ) ;
2022-01-07 14:06:13 +01:00
2024-02-06 16:43:05 +01:00
// https://datatracker.ietf.org/doc/html/rfc2798
2022-01-07 14:06:13 +01:00
const obj = {
dn : dn . toString ( ) ,
attributes : {
objectclass : [ 'user' , 'inetorgperson' , 'person' ] ,
objectcategory : 'person' ,
2022-02-18 17:31:02 +01:00
cn : displayName ,
2022-01-07 14:06:13 +01:00
uid : user . id ,
entryuuid : user . id , // to support OpenLDAP clients
mail : user . email ,
mailAlternateAddress : user . fallbackEmail ,
displayname : displayName ,
givenName : firstName ,
2024-02-06 16:43:05 +01:00
sn : lastName ,
middleName : middleName ,
2022-01-07 14:06:13 +01:00
username : user . username ,
samaccountname : user . username , // to support ActiveDirectory clients
2022-10-30 15:07:26 +01:00
memberof : allGroups . filter ( function ( g ) { return g . userIds . indexOf ( user . id ) !== - 1 ; } ) . map ( function ( g ) { return ` cn= ${ g . name } ,ou=groups,dc=cloudron ` ; } )
2022-01-07 14:06:13 +01:00
}
} ;
2022-08-02 14:02:35 +02:00
if ( user . twoFactorAuthenticationEnabled ) obj . attributes . twoFactorAuthenticationEnabled = true ;
2022-01-07 14:06:13 +01:00
// ensure all filter values are also lowercase
const lowerCaseFilter = safe ( function ( ) { return ldap . parseFilter ( req . filter . toString ( ) . toLowerCase ( ) ) ; } , null ) ;
2024-01-03 14:51:00 +01:00
if ( ! lowerCaseFilter ) return next ( new ldap . OperationsError ( safe . error . message ) ) ;
2022-01-07 14:06:13 +01:00
if ( ( req . dn . equals ( dn ) || req . dn . parentOf ( dn ) ) && lowerCaseFilter . matches ( obj . attributes ) ) {
results . push ( obj ) ;
}
2022-08-15 19:50:22 +02:00
}
2022-01-07 14:06:13 +01:00
finalSend ( results , req , res , next ) ;
}
async function groupSearch ( req , res , next ) {
debug ( 'group search: dn %s, scope %s, filter %s (from %s)' , req . dn . toString ( ) , req . scope , req . filter . toString ( ) , req . connection . ldap . id ) ;
2022-08-15 19:50:22 +02:00
const [ error , allUsers ] = await safe ( users . list ( ) ) ;
2024-01-03 14:51:00 +01:00
if ( error ) return next ( new ldap . OperationsError ( error . message ) ) ;
2022-01-07 14:06:13 +01:00
const results = [ ] ;
2024-10-14 19:10:31 +02:00
const [ errorGroups , allGroups ] = await safe ( groups . listWithMembers ( ) ) ;
2024-01-03 14:51:00 +01:00
if ( errorGroups ) return next ( new ldap . OperationsError ( errorGroups . message ) ) ;
2022-01-07 14:06:13 +01:00
2022-08-15 19:50:22 +02:00
for ( const group of allGroups ) {
2022-07-29 11:17:31 +02:00
const dn = ldap . parseDN ( ` cn= ${ group . name } ,ou=groups,dc=cloudron ` ) ;
2022-08-15 19:50:22 +02:00
const members = group . userIds . filter ( function ( uid ) { return allUsers . map ( function ( u ) { return u . id ; } ) . indexOf ( uid ) !== - 1 ; } ) ;
2022-01-07 14:06:13 +01:00
const obj = {
dn : dn . toString ( ) ,
attributes : {
objectclass : [ 'group' ] ,
cn : group . name ,
2022-07-29 11:17:31 +02:00
gidnumber : group . id ,
2023-07-31 13:13:02 +02:00
memberuid : members ,
member : members . map ( ( userId ) => ` cn= ${ userId } ,ou=users,dc=cloudron ` ) ,
uniquemember : members . map ( ( userId ) => ` cn= ${ userId } ,ou=users,dc=cloudron ` )
2022-01-07 14:06:13 +01:00
}
} ;
// ensure all filter values are also lowercase
const lowerCaseFilter = safe ( function ( ) { return ldap . parseFilter ( req . filter . toString ( ) . toLowerCase ( ) ) ; } , null ) ;
2024-01-03 14:51:00 +01:00
if ( ! lowerCaseFilter ) return next ( new ldap . OperationsError ( safe . error . message ) ) ;
2022-01-07 14:06:13 +01:00
if ( ( req . dn . equals ( dn ) || req . dn . parentOf ( dn ) ) && lowerCaseFilter . matches ( obj . attributes ) ) {
results . push ( obj ) ;
}
2022-08-15 19:50:22 +02:00
}
2022-01-07 14:06:13 +01:00
finalSend ( results , req , res , next ) ;
}
// Will attach req.user if successful
async function userAuth ( req , res , next ) {
// extract the common name which might have different attribute names
2022-08-02 14:02:35 +02:00
const cnAttributeName = Object . keys ( req . dn . rdns [ 0 ] . attrs ) [ 0 ] ;
const commonName = req . dn . rdns [ 0 ] . attrs [ cnAttributeName ] . value ;
2024-01-03 15:19:03 +01:00
if ( ! commonName ) return next ( new ldap . NoSuchObjectError ( 'Missing CN' ) ) ;
2022-01-07 14:06:13 +01:00
2024-01-07 20:38:36 +01:00
// totptoken is passed as the "attribute" using the '+' separator in the first RDNS of the request DN
// when totptoken attribute is present, it signals that we must enforce totp check
// totp check is currently requested by the client. this is the only way to auth against external cloudron dashboard, external cloudron app and external apps
2022-08-02 14:02:35 +02:00
const TOTPTOKEN _ATTRIBUTE _NAME = 'totptoken' ; // This has to be in-sync with externalldap.js
2024-01-07 20:38:36 +01:00
const totpToken = TOTPTOKEN _ATTRIBUTE _NAME in req . dn . rdns [ 0 ] . attrs ? req . dn . rdns [ 0 ] . attrs [ TOTPTOKEN _ATTRIBUTE _NAME ] . value : null ;
2024-01-07 22:01:57 +01:00
const skipTotpCheck = ! ( TOTPTOKEN _ATTRIBUTE _NAME in req . dn . rdns [ 0 ] . attrs ) ;
2022-08-02 14:02:35 +02:00
2022-01-07 14:06:13 +01:00
let verifyFunc ;
2022-08-02 14:02:35 +02:00
if ( cnAttributeName === 'mail' ) {
2022-01-07 14:06:13 +01:00
verifyFunc = users . verifyWithEmail ;
} else if ( commonName . indexOf ( '@' ) !== - 1 ) { // if mail is specified, enforce mail check
verifyFunc = users . verifyWithEmail ;
} else if ( commonName . indexOf ( 'uid-' ) === 0 ) {
verifyFunc = users . verify ;
} else {
verifyFunc = users . verifyWithUsername ;
}
2024-01-07 22:01:57 +01:00
const [ error , user ] = await safe ( verifyFunc ( commonName , req . credentials || '' , '' , { totpToken , skipTotpCheck } ) ) ;
2024-01-03 14:51:00 +01:00
if ( error && error . reason === BoxError . NOT _FOUND ) return next ( new ldap . NoSuchObjectError ( error . message ) ) ;
if ( error && error . reason === BoxError . INVALID _CREDENTIALS ) return next ( new ldap . InvalidCredentialsError ( error . message ) ) ;
2022-01-07 14:06:13 +01:00
if ( error ) return next ( new ldap . OperationsError ( error . message ) ) ;
req . user = user ;
next ( ) ;
}
async function start ( ) {
2023-10-01 13:26:43 +05:30
assert ( gServer === null , 'Already running' ) ;
2022-01-07 14:06:13 +01:00
const logger = {
trace : NOOP ,
debug : NOOP ,
info : debug ,
warn : debug ,
error : debug ,
fatal : debug
} ;
2022-11-28 22:32:34 +01:00
gCertificate = await reverseProxy . getDirectoryServerCertificate ( ) ;
2022-01-07 14:06:13 +01:00
gServer = ldap . createServer ( {
2022-11-28 22:32:34 +01:00
certificate : gCertificate . cert ,
key : gCertificate . key ,
2022-01-07 14:06:13 +01:00
log : logger
} ) ;
gServer . on ( 'error' , function ( error ) {
2023-04-16 10:49:59 +02:00
debug ( 'server startup error: %o' , error ) ;
2022-01-07 14:06:13 +01:00
} ) ;
gServer . bind ( 'ou=system,dc=cloudron' , async function ( req , res , next ) {
debug ( 'system bind: %s (from %s)' , req . dn . toString ( ) , req . connection . ldap . id ) ;
2023-08-03 02:26:11 +05:30
const config = await getConfig ( ) ;
2022-01-07 14:06:13 +01:00
2024-01-03 15:19:03 +01:00
if ( ! req . dn . equals ( constants . USER _DIRECTORY _LDAP _DN ) ) return next ( new ldap . InvalidCredentialsError ( 'Invalid DN' ) ) ;
if ( req . credentials !== config . secret ) return next ( new ldap . InvalidCredentialsError ( 'Invalid Secret' ) ) ;
2022-01-07 14:06:13 +01:00
2022-08-15 19:14:02 +02:00
req . user = { user : 'directoryServerAdmin' } ;
2022-01-07 14:06:13 +01:00
res . end ( ) ;
// if we use next in the callback, ldapjs requires this after res.end();
return next ( ) ;
} ) ;
gServer . search ( 'ou=users,dc=cloudron' , authorize , userSearch ) ;
gServer . search ( 'ou=groups,dc=cloudron' , authorize , groupSearch ) ;
gServer . bind ( 'ou=users,dc=cloudron' , userAuth , async function ( req , res ) {
assert . strictEqual ( typeof req . user , 'object' ) ;
2023-08-26 08:18:58 +05:30
await eventlog . upsertLoginEvent ( req . user . ghost ? eventlog . ACTION _USER _LOGIN _GHOST : eventlog . ACTION _USER _LOGIN , AuditSource . fromDirectoryServerRequest ( req ) , { userId : req . user . id , user : users . removePrivateFields ( req . user ) } ) ;
2022-01-07 14:06:13 +01:00
res . end ( ) ;
} ) ;
2022-10-12 21:58:14 +02:00
gServer . search ( '' , maybeRootDSE ) ; // when '', it seems the callback is called for everything else
2022-01-07 14:06:13 +01:00
// just log that an attempt was made to unknown route, this helps a lot during app packaging
gServer . use ( function ( req , res , next ) {
debug ( 'not handled: dn %s, scope %s, filter %s (from %s)' , req . dn ? req . dn . toString ( ) : '-' , req . scope , req . filter ? req . filter . toString ( ) : '-' , req . connection . ldap . id ) ;
return next ( ) ;
} ) ;
debug ( ` starting server on port ${ constants . USER _DIRECTORY _LDAPS _PORT } ` ) ;
2022-02-15 14:27:51 -08:00
await util . promisify ( gServer . listen . bind ( gServer ) ) ( constants . USER _DIRECTORY _LDAPS _PORT , '::' ) ;
2022-01-07 14:06:13 +01:00
}
async function stop ( ) {
if ( ! gServer ) return ;
debug ( 'stopping server' ) ;
2024-01-23 11:44:55 +01:00
await util . promisify ( gServer . close . bind ( gServer ) ) ( ) ;
2022-01-07 14:06:13 +01:00
gServer = null ;
}
2022-11-11 18:09:10 +01:00
2022-11-30 15:16:16 +01:00
async function checkCertificate ( ) {
2023-10-01 13:26:43 +05:30
assert ( gServer !== null , 'Directory server is not running' ) ;
2022-11-28 22:32:34 +01:00
const certificate = await reverseProxy . getDirectoryServerCertificate ( ) ;
2022-11-30 09:54:35 +01:00
if ( certificate . cert === gCertificate . cert ) {
2022-11-30 15:16:16 +01:00
debug ( 'checkCertificate: certificate has not changed' ) ;
2022-11-28 22:32:34 +01:00
return ;
}
2022-11-30 15:16:16 +01:00
debug ( 'checkCertificate: certificate changed. restarting' ) ;
2022-11-11 18:09:10 +01:00
await stop ( ) ;
await start ( ) ;
}