-
Notifications
You must be signed in to change notification settings - Fork 7
/
index.js
413 lines (385 loc) · 15.8 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
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
const _ = require('lodash');
const humanname = require('humanname');
const util = require('util');
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.label = spec.label || spec.name;
spec.options.callbackURL = self.getCallbackUrl(spec, true);
self.strategies[spec.name] = new Strategy(spec.options, self.findOrCreateUser(spec));
self.apos.login.passport.use(self.strategies[spec.name]);
self.addLoginRoute(spec);
self.addCallbackRoute(spec);
self.addFailureRoute(spec);
});
self.on('apostrophe-login:after', 'redirectToNewLocale', async req => {
const workflow = self.apos.modules['apostrophe-workflow'];
if (!workflow) {
return;
}
const cacheSet = util.promisify(workflow.crossDomainSessionCache.set);
const deserializeUser = util.promisify(self.apos.login.deserializeUser);
if (!req.session.passportWorkflow) {
return;
}
const {
oldLocale,
newLocale,
oldSlug
} = req.session.passportWorkflow;
delete req.session.passportWorkflow;
const crossDomainSessionToken = self.apos.utils.generateId();
await cacheSet(crossDomainSessionToken, JSON.stringify(req.session), 60 * 60);
req.user = await deserializeUser(req.user._id);
let doc = await self.apos.docs.find(req, {
slug: oldSlug
}).workflowLocale(oldLocale).joins(false).areas(false).toObject();
if (doc && doc.workflowGuid) {
doc = await self.apos.docs.find(req, {
workflowGuid: doc.workflowGuid
}).workflowLocale(newLocale).toObject();
}
if (doc) {
slug = doc.slug;
} else {
// Fall back to home page
slug = '/';
if (workflow.options.prefixes && workflow.options.prefixes[newLocale]) {
slug = workflow.options.prefixes[newLocale] + '/';
}
}
let url = self.apos.urls.build(workflow.action + '/link-to-locale', {
slug,
newLocale,
workflowCrossDomainSessionToken: crossDomainSessionToken,
cb: Math.random().toString().replace('.', '')
});
if (workflow.hostnames && workflow.hostnames[newLocale]) {
const oldLocale = req.locale;
req.locale = newLocale;
url = self.apos.pages.getBaseUrl(req) + url;
req.locale = oldLocale;
}
if (url.match(/^https?:/)) {
req.redirect = url;
} else {
// Because any sitewide prefix will already be added
// by res.redirect() as patched by apostrophe and invoked
// by the afterLogin handler
req.redirect = url.replace(self.apos.prefix, '');
}
});
};
// 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 `/auth/strategyname/login`, where the strategy name
// depends on the passport module being used.
//
// Redirect users to this URL to start the process of logging them in via each strategy
self.addLoginRoute = function(spec) {
self.apos.app.get(self.getLoginUrl(spec), (req, res, next) => {
if (req.query.newLocale) {
req.session.passportWorkflow = {
oldLocale: req.query.oldLocale,
newLocale: req.query.newLocale,
oldSlug: req.query.oldSlug
};
return res.redirect(self.apos.urls.build(req.url, { newLocale: null, oldLocale: null, oldSlug: null }));
}
return next();
}, self.apos.login.passport.authenticate(spec.name, spec.authenticate));
};
// Adds the callback route associated with a strategy. oauth-based strategies and
// certain others redirect here to complete the login handshake
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.findOrCreateUser = function(spec) {
return function(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);
});
});
};
};
// 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 if (profile.firstName || profile.lastName) {
user.firstName = profile.firstName;
user.lastName = profile.lastName;
} else if (profile.displayName) {
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(`${spec.label}: ${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(`${spec.label}: ${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);
});
};
self.addHelpers({
loginLinks() {
const workflow = self.apos.modules['apostrophe-workflow'];
const contextReq = self.apos.templates.contextReq;
return self.options.strategies.map(spec => {
let href = self.getLoginUrl(spec, true);
if (workflow && (Object.keys(workflow.locales).length > 1)) {
href = self.apos.urls.build(href, {
oldLocale: contextReq.locale,
newLocale: workflow.liveify(contextReq.locale),
oldSlug: workflow.getContext(contextReq) && workflow.getContext(contextReq).slug
});
}
return {
name: spec.name,
label: spec.label,
href
};
});
}
})
}
};