Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

105

106

107

108

109

110

111

112

113

114

115

116

117

118

119

120

121

122

123

124

125

126

127

128

129

130

131

132

133

134

135

136

137

138

139

140

141

142

143

144

145

146

147

148

149

150

151

152

153

154

155

156

157

158

159

160

161

162

163

164

165

166

167

168

169

170

171

172

173

174

175

176

177

178

179

180

181

182

183

184

185

186

187

188

189

190

191

192

193

194

195

196

197

198

199

200

201

202

203

204

205

206

207

208

209

210

211

212

213

214

215

216

217

218

219

220

221

222

223

224

225

226

227

228

229

230

231

232

233

234

235

236

237

238

239

240

241

242

243

244

245

246

247

248

249

250

251

252

253

254

255

256

257

258

259

260

261

262

263

264

265

266

267

268

269

270

271

272

273

274

275

276

277

278

279

280

281

282

283

284

285

286

287

288

289

290

291

292

293

294

295

296

297

298

299

300

301

302

303

304

305

306

307

308

309

310

311

312

313

314

315

316

317

318

319

320

321

322

323

324

325

326

327

328

329

330

331

332

333

334

335

336

337

338

339

340

341

342

343

344

345

346

347

348

349

350

351

352

353

354

355

356

357

358

359

360

361

362

363

364

365

366

367

368

369

370

371

372

373

374

375

376

377

378

379

380

381

382

383

384

385

386

387

388

389

390

391

392

393

394

395

396

397

398

399

400

401

402

403

404

405

406

407

408

409

410

411

412

413

414

415

416

417

418

419

420

421

422

423

424

425

426

427

428

429

430

431

432

433

434

435

436

437

438

439

440

441

442

443

444

445

446

447

448

449

450

451

452

453

454

455

456

457

458

459

460

461

462

463

464

465

466

467

468

469

470

471

472

473

474

475

476

477

478

479

480

481

482

483

484

485

486

487

488

489

490

491

492

493

494

495

496

497

498

499

500

501

502

503

504

505

506

507

508

509

510

511

512

513

514

515

516

517

518

519

520

521

522

523

524

525

526

527

528

529

530

531

532

533

534

535

536

537

############################################################################# 

# LDAP support module. 

# 

# vim:ts=3:sw=3:expandtab 

# 

# Copyright (C) 2014 SurfCrew, Inc. 

# 

# Author: Lionel Litty 

# 

# Module encapsulating ldap interaction, including state, and making it 

# asynchronous. 

# 

# python-ldap provides non-blocking versions of the APIs (for the most part) 

# but no notification mechanism. This forces us to resort to periodic polling 

# to check if results have been received. This is achieved by adding one 

# PeriodicCallback per pending LDAP call to the Tornado ioloop. 

# 

# Encapsulation + asynchrony means four layers of callbacks are used: 

# * Callbacks from the tornado ioloop to the LDAPConnection object (_get_result). 

# * Handling of result (_on_xxx_result). 

# * Callback from the result handler to the logic flow (verifying a user 

# involves two LDAP calls: binding with credentials, then checking group 

# membership). 

# * Callback from the module to the requester with final outcome. 

# 

############################################################################# 

 

import functools 

import json 

import logging 

 

import ldap 

import tornado.ioloop 

 

from safly.config import config 

from safly.logger import get_logger, SafelyLoggerMixin 

 

log = get_logger('auth-ldap', auto_prefix=True) 

 

# FIXME - SECURITY! This disables verification of the server certificate. 

ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) 

 

# If the server is not responsive at the network level, abandon after 10 secs. 

ldap.set_option(ldap.OPT_NETWORK_TIMEOUT, 10) 

 

# Cache the service ldap connection object and try to reuse it. 

service_con = None 

 

# Cache the parsed domain dict. 

domain_dict = None 

 

def _get_domains(): 

global domain_dict 

if not domain_dict: 

try: 

domain_dict = json.loads(config.get('authentication', 

'ldap_domain_dict')) 

except Exception as e: 

log.error({'error': e}, 

event='auth-config-processing-failed') 

domain_dict = {} 

return domain_dict 

 

class LDAPConnection(SafelyLoggerMixin): 

# When waiting for a response, check every POLL_INTERVAL ms. 

POLL_INTERVAL = 100 

 

# How long before we timeout LDAP calls, in seconds. 

CALL_TIMEOUT = 20 

 

def __init__(self, ldap_host=None): 

SafelyLoggerMixin.__init__(self, 'auth-ldap') 

# We test this in the destructor, make sure the attribute exists if 

# initialize throws. 

self.con = None 

if not ldap_host: 

ldap_host = config.get('authentication', 'ldap_host') 

# Note that initialize does not result in any network communication. 

self.con = ldap.initialize('%s://%s:%s' % ( 

'ldaps' if config.get('authentication', 

'ldap_mode') == 'ldaps' else 'ldap', 

ldap_host, 

config.getint('authentication', 'ldap_port'))) 

# Results we are polling for, indexed by msgid. 

self.pending_results = {} 

# Once bound, the identity we used to bind. 

self.who = None 

# start_tls does not have an asynchronous version so we unfortunately 

# block here. 

if config.get('authentication', 'ldap_mode') == 'ldap_starttls': 

# This will throw on failure. 

self.con.start_tls_s() 

 

def __del__(self): 

"""Log exception and proceed on failure.""" 

if not self.con: 

return 

 

try: 

self.con.unbind() 

except Exception as e: 

self.log.error({'error': e}, event='ldap-unbind-failed') 

else: 

self.log.debug('Unbound connection for %s.', self.who) 

self.con = None 

 

def _poll_for_result(self, msgid, callback): 

"""Initiates a periodic check for the result of @msgid. 

 

callback takes three arguments: success (True or False), result_type and 

result_data. When an error occurs, success is False, result_type is None 

and result_data has details. 

""" 

self.log.debug('Polling for result for %s', msgid) 

periodic_callback = tornado.ioloop.PeriodicCallback( 

functools.partial(self._get_result, msgid, 

tornado.ioloop.IOLoop.instance().time(), callback), 

self.POLL_INTERVAL) 

self.pending_results[msgid] = periodic_callback 

periodic_callback.start() 

 

 

def _get_result(self, msgid, start_time, callback): 

"""Invokes callback once result has been obtained or timeout was reached. 

 

callback takes three arguments: success (True or False), result_type and 

result_data. When an error occurs, success is False, result_type is None 

and result_data has details. 

""" 

success = False 

try: 

res_type, res_data = self.con.result(msgid, timeout=0) 

except Exception as e: 

if self.log.isEnabledFor(logging.DEBUG): 

self.log.exception({'error': e}, event='ldap-no-result') 

 

res_data = str(e) 

res_type = None 

else: 

# No result yet. 

if not res_type: 

if tornado.ioloop.IOLoop.instance().time() - start_time < self.CALL_TIMEOUT: 

self.log.debug('No result yet for %s', msgid) 

return 

try: 

self.con.abandon(msgid) 

except: 

pass 

res_data = 'Timed out' 

else: 

success = True 

 

self.log.debug('Got some results for msgid %s among %s', msgid, 

self.pending_results.keys()) 

assert msgid in self.pending_results 

self.pending_results[msgid].stop() 

del self.pending_results[msgid] 

self.log.debug('Result for %s after %s seconds: %s, %s.', 

msgid, tornado.ioloop.IOLoop.instance().time() - start_time, 

res_type, res_data) 

callback(success, res_type, res_data) 

 

def _on_bind_result(self, callback, success, res_type, res_data): 

"""Invoked once the bind call completed. 

 

@callback takes one argument: the outcome. 

""" 

if not success or res_type != ldap.RES_BIND or res_data: 

self.log.error({'result_type': res_type, 'result_data': res_data}, 

event='ldap-bind-failed', opslog=True) 

callback(False) 

else: 

callback(True) 

 

def bind(self, who, password, callback): 

"""Binds to the server as @who. 

 

When done, invokes @callback with a boolean indicating success. 

""" 

self.who = who 

try: 

msgid = self.con.simple_bind(who, password) 

except Exception as e: 

self.log.error({'error': e}, event='ldap-bind-failed') 

callback(False) 

return 

 

self.log.debug('Bind for %s: %s', who, msgid) 

self._poll_for_result(msgid, 

functools.partial(self._on_bind_result, callback)) 

 

def _on_recursive_check_result(self, callback, username, success, res_type, 

res_data): 

"""Invoked once the recursive check for membership completes. 

 

@callback takes one argument: the outcome. 

""" 

if not success or res_type != ldap.RES_SEARCH_RESULT: 

self.log.error({'details': 'membership check', 

'result_type': res_type, 'result_data': res_data}, 

event='ldap-auth-error', opslog=True) 

callback(False) 

return 

# If the result list is not empty, the user belongs to the group. 

if res_data: 

callback(username) 

else: 

self.log.error({'details': 'recursive membership check', 

'result_type': res_type, 'result_data': res_data}, 

event='ldap-auth-error', opslog=True) 

callback(False, error='ID_ALERT_NOT_AUTHORIZED') 

 

def _on_search_result(self, callback, username, res_type, res_data): 

"""Invoked once the search call for membership completes. 

 

@callback takes one argument: the outcome. 

""" 

user_dn = None 

authorized_group = config.get('authentication', 

'ldap_authorized_group').lower() 

# Check if the surfcrew group is in the list. 

try: 

member_of = res_data[0][1].get('memberOf', []) 

for group in member_of: 

if group.lower() == authorized_group: 

callback(username) 

return 

user_dn = res_data[0][0] 

except Exception as e: 

details = {'details': 'membership check', 'error': e} 

if self.log.isEnabledFor(logging.DEBUG): 

self.log.exception(details, event='ldap-auth-error') 

else: 

self.log.error(details, event='ldap-auth-error', opslog=True) 

 

if not config.getboolean('authentication', 'ldap_recursive_group_check'): 

self.log.info({'details': 'membership check', 

'result_type': res_type, 'result_data': res_data}, 

event='ldap-auth-failed', opslog=True) 

callback(False, error='ID_ALERT_NOT_AUTHORIZED') 

return 

 

if not user_dn: 

self.log.error({'details': 'no dn for recursive membership check', 

'result_type': res_type, 'result_data': res_data}, 

event='ldap-auth-error', opslog=True) 

callback(False, error='ID_ALERT_NOT_AUTHORIZED') 

return 

 

# Use LDAP_MATCHING_RULE_IN_CHAIN OID (1.2.840.113556.1.4.1941) to 

# traverse groups for membership. 

# attrlist does not matter. We specify one to limit the size of the 

# response. 

try: 

msgid = self.con.search( 

user_dn, ldap.SCOPE_BASE, attrlist=['cn'], 

filterstr='(memberof:1.2.840.113556.1.4.1941:=%s)' % 

authorized_group) 

except Exception as e: 

self.log.error({'details': 'recursive membership check', 

'error': e}, event='ldap-auth-error') 

callback(False) 

return 

 

self.log.debug('Recursive membership check for %s, %s: %s', 

user_dn, authorized_group, msgid) 

self._poll_for_result( 

msgid, functools.partial(self._on_recursive_check_result, 

callback, username)) 

 

def check_membership(self, username, callback): 

"""Asks the server if @username belongs to the authorized group. 

 

When done, invokes @callback with a boolean indicating success. 

""" 

if not '\\' in username: 

self.log.error({'username': username}, event='ldap-invalid-username') 

callback(False, error='ID_ALERT_INVALID_USERNAME') 

return 

 

domain_name, account_name = username.split('\\', 1) 

self._get_attributes(account_name, domain_name, ['memberOf'], 

self._on_search_result, callback) 

 

def _std_checks(self, cb, callback, username, success, res_type, res_data): 

if not success or res_type != ldap.RES_SEARCH_RESULT: 

self.log.error({'details': 'attribute retrieval', 

'result_type': res_type, 'result_data': res_data}, 

event='ldap-auth-error', opslog=True) 

callback(False) 

return 

 

# Filter out ref: from res_data - they don't contain the DN part 

res_data = [k for k in res_data if k[0]] 

 

# There should be only one result. 

if len(res_data) != 1: 

if not res_data: 

self.log.error({'details': 'user not found'}, 

event='ldap-auth-error', opslog=True) 

else: 

self.log.error({'details': 'cannot uniquely identify user', 

'result_data': res_data}, 

event='ldap-auth-error', opslog=True) 

callback(False) 

return 

cb(callback, username, res_type, res_data) 

 

def _get_attributes(self, account_name, domain_name, attributes, on_search, 

callback): 

if domain_name: 

domain_info = _get_domains().get(domain_name) 

if not domain_info: 

self.log.error({'details': 'could not get domain info', 

'ldap_domain': domain_name}, 

event='ldap-auth-error', opslog=True) 

callback(False, error='ID_ALERT_BAD_DOMAIN') 

return 

domain_dn = domain_info[0] 

else: 

domain_dn = '' 

 

custom_clause = (config.getboolean('authentication', 

'ldap_use_custom_filter_clause') and 

config.get('authentication', 

'ldap_custom_filter_clause') or '') 

search_filter = (config.get('authentication', 

'ldap_default_search_filter') % 

{'account_name': account_name, 

'custom_clause': custom_clause}) 

# TODO: catching the exception should really happen in 

# _get_attributes, and passed to callback, but 

# _do_with_service_con expects exceptions to be thrown 

msgid = self.con.search( 

domain_dn, ldap.SCOPE_SUBTREE, attrlist=attributes, 

filterstr=search_filter) 

 

self.log.debug('Getting attributes %s for %s, %s: %s', attributes, 

account_name, domain_dn, msgid) 

username = (domain_name and '\\'.join([domain_name, account_name]) or 

account_name) 

self._poll_for_result( 

msgid, functools.partial(self._std_checks, 

on_search, callback, username)) 

 

def get_attributes(self, account_name, domain, attributes, callback): 

def _cont(callback, username, res_type, res_data): 

res_data[0][1]['dn'] = [res_data[0][0]] 

callback(res_data[0][1]) 

 

self._get_attributes(account_name, domain, attributes, _cont, callback) 

 

def _con_bound(callback, query, success): 

"""Invoked once the binding step is complete (it may have failed).""" 

if not success: 

callback(False) 

return 

# Now we do the query - con is bound, we can do a search. 

query() 

 

def _do_with_service_con(method, callback): 

global service_con 

 

if not service_con: 

log.debug('Establishing service connection.') 

try: 

service_con = LDAPConnection() 

except Exception as e: 

log.error({'error': e}, event='ldap-service-connection-failed') 

callback(False) 

return 

service_con.bind(config.get('authentication', 'ldap_service_username'), 

config.get('authentication', 'ldap_service_password'), 

functools.partial(_con_bound, callback, 

functools.partial(method, service_con))) 

return 

 

log.debug('Reusing service connection.') 

try: 

method(service_con) 

except ldap.SERVER_DOWN as e: 

# TODO: this is a horrendous mixture of callback-based and try-catch based 

# error handling 

# TODO: now we only handle reconnection attempts if search fails, but not 

# if ldap.SERVER_DOWN occurs asynchronously in _get_result 

if log.isEnabledFor(logging.DEBUG): 

log.exception({'details': 'Failed to reuse service connection', 

'error': e}, event='ldap-service-communication-failed') 

# Reset service_con and retry. 

service_con = None 

_do_with_service_con(method, callback) 

except Exception as e: 

log.error({'error': e}, event='ldap-service-communication-failed') 

callback(False) 

return 

 

def _on_credentential_check_result(username, member, callback, success): 

"""Invoked once we have bound as the user.""" 

if success: 

if member: 

callback(username) 

else: 

callback(False, error='ID_ALERT_NOT_AUTHORIZED') 

else: 

callback(False) 

 

def verify_user(account_name, password, domain, callback=None): 

"""Check credentials and authorization. 

 

This is done by binding to the LDAP server with the form 

domain\account_name. 

 

On success, callback is invoked with the user's username 

(domain\account_name). On failure, callback is invoked with False. 

""" 

if not callback: 

# should not happen, as callback is really mandatory; it is a kwarg 

# only to enable its use with tornado.gen.Task 

callback = lambda *args, **kwargs: None 

 

domain_info = _get_domains().get(domain) 

if not domain_info: 

log.error({'details': 'no domain info', 'ldap_domain': domain}, 

event='ldap-auth-error') 

callback(False) 

return 

 

ldap_host = domain_info[1] 

username = '\\'.join([domain, account_name]) 

# Always use a new connection to verify a user's identity. 

log.debug('Verifying credentials for %s', username) 

 

try: 

con = LDAPConnection(ldap_host) 

except Exception as e: 

log.error({'error': e}, event='ldap-service-connection-failed') 

callback(False) 

return 

 

method = functools.partial(con.check_membership, username, callback) 

con.bind(username, password, functools.partial(_con_bound, callback, method)) 

 

def attribute_lookup(account_name, domain, attributes, callback): 

"""Get the list of attributes. 

 

This is done by binding to the LDAP server with the admin credentials. 

 

On success, callback is invoked with the user's attributes. 

On failure, callback is invoked with False. 

""" 

def method(con): 

try: 

passthrough_method(con) 

except Exception as e: 

if log.isEnabledFor(logging.DEBUG): 

details = {'details': 'get attributes', 'error': e, 

'attributes': attributes, 'account_name': account_name, 

'ldap_domain': domain} 

log.exception(details, event='ldap-auth-error') 

else: 

log.error(details, event='ldap-auth-error') 

callback(False) 

 

def passthrough_method(con): 

con.get_attributes(account_name, domain, attributes, callback) 

 

if not config.getboolean('authentication', 'ldap_user_details_lookup'): 

log.error({}, event='ldap-user-details-lookup-disabled') 

callback(False, error='ID_ALERT_NOT_ENABLED') 

return 

 

if not domain: 

# if domain is not specified, reuse service connection 

log.debug('Retrieving attributes for %s using service connection', 

account_name) 

_do_with_service_con(passthrough_method, callback) 

return 

 

domain_info = _get_domains().get(domain) 

if not domain_info: 

log.error({'details': 'no domain info', 'ldap_domain': domain}, 

event='ldap-auth-error') 

callback(False) 

return 

 

ldap_host = domain_info[1] 

admin = config.get('authentication', 'ldap_service_username') 

password = config.get('authentication', 'ldap_service_password') 

 

log.debug('Retrieving attributes for %s', account_name) 

try: 

# TODO: reuse existing connections to get user's attributes 

con = LDAPConnection(ldap_host) 

except Exception as e: 

log.error({'error': e}, event='ldap-service-connection-failed') 

callback(False) 

return 

 

con.bind(admin, password, 

functools.partial(_con_bound, callback, functools.partial(method, 

con))) 

 

def authorize_user(username, callback=None): 

"""Check that user is still authorized to use the service. 

 

User is in the domain\\username form. 

 

The user is already authenticated and did not provide credentials, so an 

service account is used for the lookup. 

 

On success, callback is invoked with the user's DN. On failure, callback is 

invoked with False. 

""" 

if not callback: 

# should not happen, as callback is really mandatory; it is a kwarg 

# only to enable its use with tornado.gen.Task 

callback = lambda *args, **kwargs: None 

 

log.debug('Verifying authorization for %s', username) 

 

# Without a service account, we cannot re-authorize the user, so 

# re-authorization always succeeds. 

if not config.getboolean('authentication', 'ldap_use_service'): 

callback(username) 

return 

 

method = functools.partial(LDAPConnection.check_membership, 

username=username, callback=callback) 

_do_with_service_con(method, callback) 

 

def get_domain_dict(): 

"""Retrieve a dictionary of domains the user can choose from. 

 

The list is part of the config (populated via the dashboard). 

""" 

# We do not need to send the domain DN to the user. 

return {k: k for k in _get_domains().keys()}