forked from apostrophecms/apostrophe-passport
-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.js
342 lines (311 loc) · 13 KB
/
index.js
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
var _ = require('lodash');
var humanname = require('humanname');
module.exports = {
afterConstruct: function(self, callback) {
self.enablePassportStrategies();
self.enableListUrlsTask();
return self.ensureGroup(callback);
},
construct: function(self, options) {
self.enablePassportStrategies = function() {
self.strategies = {};
if (!self.apos.baseUrl) {
throw new Error('apostrophe-passport: you must configure the top-level "baseUrl" option to apostrophe');
}
if (!Array.isArray(self.options.strategies)) {
throw new Error('apostrophe-passport: you must configure the "strategies" option');
}
_.each(self.options.strategies, function(spec) {
var Strategy;
if (spec.module) {
Strategy = self.apos.root.require(spec.module);
} else {
Strategy = spec.Strategy;
}
if (!Strategy) {
throw new Error('apostrophe-login-auth: each strategy must have a "module" setting\n' +
'giving the name of an npm module installed in your project that\n' +
'is passport-oauth2, passport-oauth or a subclass with a compatible\n' +
'interface, such as passport-gitlab2, passport-twitter, etc.\n\n' +
'You may instead pass a strategy constructor as a Strategy property,\n' +
'but the other way is much more convenient.');
}
// Are there strategies requiring no options? Probably not, but maybe...
spec.options = spec.options || {};
if (!spec.name) {
// It's hard to find the strategy name; it's not the same
// as the npm name. And we need it to build the callback URL
// sensibly. But we can do it by making a dummy strategy object now
var dummy = new Strategy(_.assign(
{
callbackURL: 'https://dummy/test'
},
spec.options
), self.findOrCreateUser(spec));
spec.name = dummy.name;
}
spec.options.callbackURL = self.getCallbackUrl(spec, true);
if (spec.ldapAuth !== true)
self.strategies[spec.name] = new Strategy(spec.options, self.findOrCreateUser(spec));
else
self.strategies[spec.name] = new Strategy(spec.options, self.ldapFindOrCreateUser(spec));
self.apos.login.passport.use(self.strategies[spec.name]);
// self.apos.app.use(self.apos.login.passport.initialize());
// self.apos.app.use(self.apos.login.passport.session());
self.addLoginRoute(spec);
self.addCallbackRoute(spec);
self.addFailureRoute(spec);
});
};
// Returns the oauth2 callback URL, which must match the route
// established by `addCallbackRoute`. If `absolute` is true
// then `baseUrl` and `apos.prefix` are prepended, otherwise
// not (because `app.get` automatically prepends a prefix).
// If the callback URL was preconfigured via spec.options.callbackURL
// it is returned as-is when `absolute` is true, otherwise
// the pathname is returned with any `apos.prefix` removed
// to avoid adding it twice in `app.get` calls.
self.getCallbackUrl = function(spec, absolute) {
if (spec.options && spec.options.callbackURL) {
var url = spec.options.callbackURL;
if (absolute) {
return url;
}
var parsed = require('url').parse(url);
url = parsed.pathname;
if (self.apos.prefix) {
// Remove the prefix if present, so that app.get doesn't
// add it redundantly
return url.replace(new RegExp('^' + self.apos.utils.regExpQuote(self.apos.prefix)), '');
}
return parsed.pathname;
}
return (absolute ? (self.apos.baseUrl + self.apos.prefix) : '') + '/auth/' + spec.name + '/callback';
};
// Returns the URL you should link users to in order for them
// to log in. If `absolute` is true then `baseUrl` and `apos.prefix`
// are prepended, otherwise not (because `app.get` automatically prepends a prefix).
self.getLoginUrl = function(spec, absolute) {
return (absolute ? (self.apos.baseUrl + self.apos.prefix) : '') + '/auth/' + spec.name + '/login';
}
// Adds the login route, which will be `/modules/apostrophe-login-gitlab/login`.
// Redirect users to this URL to start the process of logging them in via gitlab
self.addLoginRoute = function(spec) {
if (spec.postRoute !== true)
self.apos.app.get(self.getLoginUrl(spec), self.apos.login.passport.authenticate(spec.name, spec.authenticate));
else
self.apos.app.post(self.getLoginUrl(spec), self.apos.login.passport.authenticate(spec.name, {successRedirect: '/',failureRedirect: '/login?error=1'}));
};
// Adds the oauth2 callback route, which is invoked
self.addCallbackRoute = function(spec) {
self.apos.app.get(self.getCallbackUrl(spec, false),
// middleware
self.apos.login.passport.authenticate(
spec.name,
{
failureRedirect: self.getFailureUrl(spec)
}
),
// actual route
self.apos.login.afterLogin
);
};
self.addFailureRoute = function(spec) {
self.apos.app.get(self.getFailureUrl(spec), function(req, res) {
// Gets i18n'd in the template, also bc with what templates that tried to work
// before certain fixes would expect (this is why we still pass a string and not
// a flag, and why we call it `message`)
return self.sendPage(req, 'error', { spec: spec, message: 'Your credentials were not accepted, your account is not affiliated with this site, or an existing account has the same username or email address.' });
});
}
self.getFailureUrl = function(spec) {
return '/auth/' + spec.name + '/error';
};
// Given a strategy spec from the configuration, return
// an oauth passport callback function to find the user based
// on the profile, creating them if appropriate.
self.ldapFindOrCreateUser = function(spec) {
return function(profile, callback) {
self.completeLdapProfile(profile);
self.implementFindOrCreateUser(spec, "", "", profile, callback);
}
};
self.completeLdapProfile = function(profile) {
profile.username = profile.uid;
profile.displayName = profile.uid;
profile.email = profile.mail;
}
self.implementFindOrCreateUser = function(spec, accessToken, refreshToken, profile, callback) {
var req = self.apos.tasks.getReq();
var criteria = {};
var emails;
if (spec.accept) {
if (!spec.accept(profile)) {
return callback(null, false);
}
}
emails = self.getRelevantEmailsFromProfile(spec, profile);
if (spec.emailDomain && (!emails.length)) {
// Email domain filter is in effect and user has no emails or
// only emails in the wrong domain
return callback(null, false);
}
if (typeof(spec.match) === 'function') {
criteria = spec.match(profile);
} else {
switch (spec.match || 'username') {
case 'id':
criteria = {};
if (!profile.id) {
console.error('apostrophe-passport: profile has no id. You probably want to set the "match" option for this strategy to "username" or "email".');
return callback(null, false);
}
criteria[spec.name + 'Id'] = profile.id;
break;
case 'username':
if (!profile.username) {
console.error('apostrophe-passport: profile has no username. You probably want to set the "match" option for this strategy to "id" or "email".');
return callback(null, false);
}
criteria.username = profile.username;
break;
case 'email':
case 'emails':
if (!emails.length) {
// User has no email
return callback(null, false);
}
criteria.$or = _.map(emails, function(email) {
return { email: email };
});
break;
default:
return callback(new Error('apostrophe-passport: ' + spec.match + ' is not a supported value for the match property'));
}
}
criteria.disabled = { $ne: true };
return self.apos.users.find(req, criteria).toObject(function(err, user) {
if (err) {
return callback(err);
}
if (user) {
return callback(null, user);
}
if (!self.options.create) {
return callback(null, false);
}
return self.createUser(spec, profile, function(err, user) {
if (err) {
// Typically a duplicate key, not surprising with username and
// email address duplication possibilities when we're matching
// on the other field, treat it as a login error
return callback(null, false);
}
return callback(null, user);
});
});
}
self.findOrCreateUser = function(spec) {
return function(accessToken, refreshToken, profile, callback) {
self.implementFindOrCreateUser(spec, accessToken, refreshToken, profile, callback);
};
};
// Returns an array of email addresses found in the user's
// profile, via profile.emails[n].value, profile.emails[n] (a string),
// or profile.email. Passport strategies usually normalize
// to the first of the three.
self.getRelevantEmailsFromProfile = function(spec, profile) {
var emails = [];
if (Array.isArray(profile.emails) && profile.emails.length) {
_.each(profile.emails || [], function(email) {
if (typeof(email) === 'string') {
// maybe someone does this as simple strings...
emails.push(email);
// but google does it as objects with value properties
} else if (email && email.value) {
emails.push(email.value);
}
});
} else if (profile.email) {
emails.push(profile.email);
}
if (spec.emailDomain) {
emails = _.filter(emails, function(email) {
var endsWith = '@' + spec.emailDomain;
return email.substr(email.length - endsWith.length) === endsWith;
});
}
return emails;
};
// Create a new user based on a profile. This occurs only
// if the "create" option is set and a user arrives who has
// a valid passport profile but does not exist in the local database.
self.createUser = function(spec, profile, callback) {
var user = self.apos.users.newInstance();
user.username = profile.username;
user.title = profile.displayName || profile.username || '';
user[spec.name + 'Id'] = profile.id;
if (!user.username) {
user.username = self.apos.utils.slugify(user.title);
}
var emails = self.getRelevantEmailsFromProfile(spec, profile);
if (emails.length) {
user.email = emails[0];
}
if (profile.name) {
user.firstName = profile.name.givenName;
if (profile.name.middleName) {
user.firstName += ' ' + profile.name.middleName;
}
user.lastName = profile.name.familyName;
} else {
parsedName = humanname.parse(profile.displayName);
user.firstName = parsedName.firstName;
user.lastName = parsedName.lastName;
}
var req = self.apos.tasks.getReq();
if (self.createGroup) {
user.groupIds = [ self.createGroup._id ];
}
if (spec.import) {
// Allow for specialized import of more fields
spec.import(profile, user);
}
return self.apos.users.insert(req, user, function(err) {
return callback(err, user);
});
};
self.enableListUrlsTask = function() {
self.apos.tasks.add(self.__meta.name, 'list-urls',
'Run this task to list the login URLs for each registered strategy.\n' +
'This is helpful when writing markup to invite users to log in.',
function(apos, argv, callback) {
return self.listUrlsTask(callback);
}
);
};
self.listUrlsTask = function(callback) {
console.log('These are the login URLs you may wish to link users to:\n');
_.each(self.options.strategies, function(spec) {
console.log(self.getLoginUrl(spec, true));
});
console.log('\nThese are the callback URLs you may need to configure on sites:\n');
_.each(self.options.strategies, function(spec) {
console.log(self.getCallbackUrl(spec, true));
});
return callback(null);
};
// Ensure the existence of an apostrophe-group for newly
// created users, as configured via the `group` subproperty
// of the `create` option.
self.ensureGroup = function(callback) {
if (!(self.options.create && self.options.create.group)) {
return setImmediate(callback);
}
return self.apos.users.ensureGroup(self.options.create.group, function(err, group) {
self.createGroup = group;
return callback(err);
});
};
}
};