UNPKG

16.7 kBJavaScriptView Raw
1/**
2* vim:set sw=2 ts=2 sts=2 ft=javascript expandtab:
3*
4* # Authentification Module
5*
6* ## License
7*
8* Licensed to the Apache Software Foundation (ASF) under one
9* or more contributor license agreements. See the NOTICE file
10* distributed with this work for additional information
11* regarding copyright ownership. The ASF licenses this file
12* to you under the Apache License, Version 2.0 (the
13* "License"); you may not use this file except in compliance
14* with the License. You may obtain a copy of the License at
15*
16* http://www.apache.org/licenses/LICENSE-2.0
17*
18* Unless required by applicable law or agreed to in writing,
19* software distributed under the License is distributed on an
20* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
21* KIND, either express or implied. See the License for the
22* specific language governing permissions and limitations
23* under the License.
24*
25* ## Description
26*
27* This module contains all functions about authentification of MyPads users.
28* It's mainly based upon the excellent *passport* and *jsonwebtoken* Node
29* libraries.
30*
31* TODO: abstract passport.authenticate to allow more easily custom errors
32*/
33
34// External dependencies
35var ld = require('lodash');
36var jwt = require('jsonwebtoken');
37var ExtractJwt = require('passport-jwt').ExtractJwt;
38var LdapAuth = require('ldapauth-fork');
39var CasAuth = require('simple-cas-interface');
40var settings;
41try {
42 // Normal case : when installed as a plugin
43 settings = require('ep_etherpad-lite/node/utils/Settings');
44}
45catch (e) {
46 if (process.env.TEST_LDAP) {
47 settings = {
48 'users': {
49 'admin': {
50 'password': 'admin',
51 'is_admin': true
52 },
53 'parker': {
54 'password': 'lovesKubiak',
55 'is_admin': false
56 }
57 },
58 'ep_mypads': {
59 'ldap': {
60 'url': 'ldap://rroemhild-test-openldap',
61 'bindDN': 'cn=admin,dc=planetexpress,dc=com',
62 'bindCredentials': 'GoodNewsEveryone',
63 'searchBase': 'ou=people,dc=planetexpress,dc=com',
64 'searchFilter': '(uid={{username}})',
65 'properties': {
66 'login': 'uid',
67 'email': 'mail',
68 'firstname': 'givenName',
69 'lastname': 'sn'
70 },
71 'defaultLang': 'fr'
72 }
73 }
74 };
75 } else {
76 // Testing case : we need to mock the express dependency
77 settings = {
78 users: {
79 admin: { password: 'admin', is_admin: true },
80 grace: { password: 'admin', is_admin: true },
81 parker: { password: 'lovesKubiak', is_admin: false }
82 }
83 };
84 }
85}
86var passport = require('passport');
87var JWTStrategy = require('passport-jwt').Strategy;
88var cuid = require('cuid');
89
90// Local dependencies
91var common = require('./model/common.js');
92var user = require('./model/user.js');
93var conf = require('./configuration.js');
94
95var NOT_INTERNAL_AUTH_PWD = 'soooooo_useless';
96
97module.exports = (function () {
98 'use strict';
99
100 /**
101 * ## Authentification
102 *
103 * - `tokens` holds all actives tokens with user logins as key and user data
104 * as value, plus a special `key` attribute to check validity
105 * - `secret` is the temporary random string key. It will be reinitialized
106 * after server relaunch, invaliditing all active users
107 */
108
109 var auth = {
110 tokens: {},
111 adminTokens: {},
112 secret: cuid() // secret per etherpad session
113 };
114
115 /**
116 * ## Internal functions
117 *
118 * These functions are not private like with closures, for testing purposes,
119 * but they are expected be used only internally by other MyPads functions.
120 */
121
122 auth.fn = {};
123
124 /**
125 * ### getUser
126 *
127 * `getUser` is a synchronous function that checks if the given encrypted
128 * `token` is valid, ie if login has been already found in local cache and if
129 * the given key is the same as the generated one.
130 * It returns the *user* object in case of success and *false* otherwise.
131 *
132 * TODO: unit test missing
133 */
134
135 auth.fn.getUser = function (token) {
136 var jwt_payload = jwt.decode(token, auth.secret);
137 if (!jwt_payload) { return false; }
138 var login = jwt_payload.login;
139 var userAuth = (login && auth.tokens[login]);
140 if (userAuth && (auth.tokens[login].key === jwt_payload.key)) {
141 return auth.tokens[login];
142 } else {
143 return false;
144 }
145 };
146
147 /**
148 * ### local
149 *
150 * `local` is a synchronous function used to set up JWT strategy.
151 */
152
153 auth.fn.local = function () {
154 var opts = {
155 secretOrKey: auth.secret,
156 passReqToCallback: true,
157 jwtFromRequest: ExtractJwt.fromExtractors([
158 ExtractJwt.fromUrlQueryParameter('auth_token'),
159 ExtractJwt.fromBodyField('auth_token'),
160 ExtractJwt.fromAuthHeaderWithScheme('JWT')
161 ])
162 };
163 passport.use(new JWTStrategy(opts,
164 function (req, jwt_payload, callback) {
165 var isFS = function (s) { return (ld.isString(s) && !ld.isEmpty(s)); };
166 if (!isFS(jwt_payload.login)) {
167 throw new TypeError('BACKEND.ERROR.TYPE.LOGIN_STR');
168 }
169 if (!ld.isFunction(callback)) {
170 throw new TypeError('BACKEND.ERROR.TYPE.CALLBACK_FN');
171 }
172 auth.fn.JWTFn(req, jwt_payload, false, callback);
173 }
174 ));
175 };
176
177 /**
178 * ### checkMyPadsUser
179 *
180 * `checkMyPadsUser` checks user existence from given `login` and `pass`. It
181 * uses the last argument, `callback` function, to return an *error* or *null*
182 * and the *user* object.
183 */
184
185 auth.fn.checkMyPadsUser = function (login, pass, callback) {
186 switch (conf.get('authMethod')) {
187 case 'ldap':
188 // ld.cloneDeep because LdapAuth would otherwise modify authLdapSettings conf
189 var ldapConf = ld.cloneDeep(conf.get('authLdapSettings'));
190 var lauth = new LdapAuth(ldapConf);
191 lauth.authenticate(login, pass, function(err, ldapuser) {
192 lauth.close(function(error) {
193 if (error) { console.error(error); }
194 });
195 if (err) {
196 var emsg = err;
197 // openldap error message || active directory error message
198 if (ld.isString(err.lde_message) &&
199 (err.lde_message === 'Invalid Credentials' ||
200 err.lde_message.match(/data 52e,/))
201 ) {
202 emsg = 'BACKEND.ERROR.AUTHENTICATION.PASSWORD_INCORRECT';
203 } else if (
204 (ld.isString(err) && err.match(/no such user/)) ||
205 (ld.isString(err.lde_message) &&
206 (err.lde_message.match(/no such user/) ||
207 err.lde_message.match(/data 525,/)))
208 ) {
209 emsg = 'BACKEND.ERROR.USER.NOT_FOUND';
210 } else {
211 console.error('LdapAuth error: ', err);
212 }
213 return callback(new Error(emsg), false);
214 }
215 user.get(login, function(err, u) {
216 var props = ldapConf.properties;
217 var mail;
218 if (Array.isArray(ldapuser[props.email])) {
219 if (ldapuser[props.email].length > 0) {
220 mail = ldapuser[props.email][0];
221 } else {
222 console.error('Ldap error: ldapuser[props.email] is an empty array');
223 }
224 } else if (ldapuser[props.email]) {
225 mail = ldapuser[props.email];
226 }
227 if (!ld.isEmail(mail)) {
228 emsg = 'BACKEND.ERROR.AUTHENTICATION.LDAP_NO_VALID_MAIL';
229 return callback(new Error(emsg), false);
230 }
231 if (err) {
232 // We have to create the user in mypads database
233 ldapConf = conf.get('authLdapSettings');
234 user.set({
235 login: ldapuser[props.login],
236 password: NOT_INTERNAL_AUTH_PWD,
237 firstname: ldapuser[props.firstname],
238 lastname: ldapuser[props.lastname],
239 email: mail,
240 lang: ldapConf.defaultLang || 'en'
241 }, callback);
242 } else if (u.email !== mail ||
243 u.firstname !== ldapuser[props.firstname] ||
244 u.lastname !== ldapuser[props.lastname]) {
245 // Update database and cache informations if needed
246 // (i.e. update from LDAP)
247 u.email = mail;
248 u.firstname = ldapuser[props.firstname];
249 u.lastname = ldapuser[props.lastname];
250 u.password = NOT_INTERNAL_AUTH_PWD;
251 user.set(u, callback);
252 } else {
253 return callback(null, u);
254 }
255 });
256 });
257 break;
258 case 'cas':
259 user.get(login.login, function(err, u) {
260 // If the user does not exist, we create the user
261 if (err) {
262 user.set(login, callback);
263 } else {
264 return callback(null, u);
265 }
266 });
267 break;
268 default:
269 /* Prevents to use default external auth password if configuration has been changed
270 * and now use internal authentification
271 */
272 if (pass === NOT_INTERNAL_AUTH_PWD) {
273 var emsg = 'BACKEND.ERROR.AUTHENTICATION.PLEASE_CHANGE_YOUR_PASSWORD';
274 return callback(new Error(emsg), false);
275 }
276 user.get(login, function (err, u) {
277 if (err) { return callback(err); }
278 auth.fn.isPasswordValid(u, pass, function (err, isValid) {
279 if (err) { return callback(err); }
280 if (!isValid) {
281 var emsg = 'BACKEND.ERROR.AUTHENTICATION.PASSWORD_INCORRECT';
282 return callback(new Error(emsg), false);
283 }
284 return callback(null, u);
285 });
286 });
287 }
288 };
289
290 /**
291 * ### checkAdminUser
292 *
293 * `checkAdminUser` checks admin existence from given `login` and `pass`. It
294 * uses the last argument, `callback` function, to return an *error* or *null*
295 * and the *user* object.
296 */
297
298 auth.fn.checkAdminUser = function (login, pass, callback) {
299 if (!settings || !settings.users || !settings.users[login]) {
300 return callback(new Error('BACKEND.ERROR.USER.NOT_FOUND'), null);
301 }
302 var u = settings.users[login];
303 u.login = login;
304 var emsg;
305 if (pass !== u.password) {
306 emsg = 'BACKEND.ERROR.AUTHENTICATION.PASSWORD_INCORRECT';
307 return callback(new Error(emsg, false));
308 }
309 if (!u.is_admin) {
310 emsg = 'BACKEND.ERROR.AUTHENTICATION.ADMIN';
311 return callback(new Error(emsg, false));
312 }
313 return callback(null, u);
314 };
315
316 /*
317 * ### casAuth
318 *
319 * `casAuth` checks that the given CAS ticket is valid. It uses the last
320 * argument, `callback` function, to return an *error* or *null* and the *user*
321 * object.
322 */
323 var rgx = new RegExp('api/auth/login/cas$');
324 auth.fn.casAuth = function (req, res, callback) {
325 var ticket = req.body.ticket;
326 if (ticket) {
327 var acs = ld.cloneDeep(conf.get('authCasSettings'));
328 var props = acs.properties;
329 var defaultLang = acs.defaultLang;
330 delete acs.properties;
331 delete acs.defaultLang;
332 var host = req.hostname || req.host;
333 acs.serviceUrl = req.protocol+'://'+host+req.path.replace(rgx, '?/login');
334 var cas = new CasAuth(acs);
335 cas.validateServiceTicket(ticket)
336 .then(function(info) {
337 console.debug('mypads:casAuth - The ticket %s validation provided user informations ', ticket, info);
338 return callback(null, {
339 login: info.attributes[props.login] || info[props.login],
340 password: NOT_INTERNAL_AUTH_PWD,
341 firstname: info.attributes[props.firstname],
342 lastname: info.attributes[props.lastname],
343 email: info.attributes[props.email],
344 lang: defaultLang || 'en'
345 });
346 })
347 .catch(function(err) {
348 console.error(err);
349 return callback(new Error(err));
350 });
351 } else {
352 callback(new Error('BACKEND.ERROR.AUTHENTICATION.CAS_NO_TICKET'));
353 }
354 };
355
356 /**
357 * ### JWTFn
358 *
359 * `JWTFn` is the function used by JWT strategy for verifying if used
360 * encrypted jwt token is correct. It takes:
361 *
362 * - the `req` Express request object, automatically populated from the
363 * strategy
364 * - the JWT decoded token `jwt_payload` object
365 * - a `admin` boolean
366 * - a `callback` function, returning
367 * - *Error* if there is a problem
368 * - *null*, *false* and an object for auth error
369 * - *null* and the *user* or *token* object for auth success
370 *
371 * TODO: expiration handling?
372 */
373
374 auth.fn.JWTFn = function (req, jwt_payload, admin, callback) {
375 var checkFn = (admin ? auth.fn.checkAdminUser : auth.fn.checkMyPadsUser);
376 var ns = (admin ? 'adminTokens' : 'tokens');
377 var login = jwt_payload.login;
378 var token = auth[ns][login];
379 if (!token || !jwt_payload.key) {
380 var pass = jwt_payload.password;
381 if (!ld.isString(pass)) {
382 return callback(new Error('BACKEND.ERROR.TYPE.PASSWORD_STR'));
383 }
384 if (conf.get('authMethod') === 'cas' && !admin) {
385 login = jwt_payload;
386 }
387 checkFn(login, pass, function (err, u) {
388 if (err) { return callback(err, u); }
389 auth[ns][u.login] = u;
390 auth[ns][u.login].key = cuid();
391 return callback(null, u);
392 });
393 } else {
394 if (token.key !== jwt_payload.key) {
395 var emsg = 'BACKEND.ERROR.AUTHENTICATION.NOT_AUTH';
396 return callback(new Error(emsg), false);
397 }
398 req.mypadsLogin = login;
399 callback(null, token);
400 }
401 };
402
403 /**
404 * ### localFn
405 *
406 * `localFn` is a function that checks if login and password are correct. It
407 * takes :
408 *
409 * - a `login` string
410 * - a `password` string
411 * - a `callback` function, returning
412 * - *Error* if there is a problem
413 * - *null*, *false* and an object for auth error
414 * - *null* and the *user* object for auth success
415 */
416
417 auth.fn.localFn = function (login, password, callback) {
418 user.get(login, function (err, u) {
419 if (err) { return callback(err); }
420 auth.fn.isPasswordValid(u, password, function (err, isValid) {
421 if (err) { return callback(err); }
422 if (!isValid) {
423 var emsg = 'BACKEND.ERROR.AUTHENTICATION.PASSWORD_INCORRECT';
424 callback(new Error(emsg), false);
425 } else {
426 callback(null, u);
427 }
428 });
429 });
430 };
431
432 /**
433 * ### isPasswordValid
434 *
435 * `isPasswordValid` compares the hash of given password and saved salt with
436 * the one saved in database.
437 * It takes :
438 *
439 * - the `u` user object
440 * - the `password` string
441 * - a `callback` function, returning *Error* or *null* and a boolean
442 */
443
444 auth.fn.isPasswordValid = function (u, password, callback) {
445 if (!ld.isString(password)) {
446 return callback(new TypeError('BACKEND.ERROR.TYPE.PASSWORD_MISSING'));
447 }
448 // if u.visibility is defined, u is a group, which we shouldn't authenticate against LDAP
449 if (conf.get('authMethod') === 'ldap' && ld.isUndefined(u.visibility)) {
450 // ld.cloneDeep because LdapAuth would otherwise modify authLdapSettings conf
451 var lauth = new LdapAuth(ld.cloneDeep(conf.get('authLdapSettings')));
452 if (ld.isUndefined(u) || ld.isNull(u) || ld.isUndefined(u.login) || ld.isNull(u.login)) {
453 return callback(null, false);
454 } else {
455 lauth.authenticate(u.login, password, function(err) {
456 lauth.close(function(error) {
457 if (error) { console.error(error); }
458 });
459 if (err) {
460 if (err.lde_message === 'Invalid Credentials') {
461 err = 'BACKEND.ERROR.AUTHENTICATION.PASSWORD_INCORRECT';
462 } else if (err.match(/no such user/)) {
463 err = 'BACKEND.ERROR.USER.NOT_FOUND';
464 }
465 console.error('LdapAuth error: ', err);
466 return callback(null, false);
467 }
468 return callback(null, true);
469 });
470 }
471 } else {
472 common.hashPassword(u.password.salt, password, function (err, res) {
473 if (err) { return callback(err); }
474 callback(null, (res.hash === u.password.hash));
475 });
476 }
477 };
478
479 /**
480 * ## Public functions
481 *
482 * ### init
483 *
484 * `init` is a synchronous function used to set up authentification. It :
485 *
486 * - initializes local strategy by default
487 * - uses of passport middlwares for express
488 * - launch session middleware bundled with express, using secret phrase saved
489 * in database
490 * - mocks admin behavior if in testingMode
491 */
492
493 auth.init = function (app) {
494 app.use(passport.initialize());
495 auth.fn.local();
496 };
497
498 return auth;
499
500}).call(this);