fix(signup): signup auth (#15628)
* fix(models.user): Colocate all user methods Moved user methods/extensions into one file. Tracked down `next method called more than once` error and setting headers after their sent. Let regular error handler handle api errors as well. * feat(server.auth): Disable github account creation We are no longer allowing account creation through github * refactor(Auth): Move user identity link into models dir * feat(Disable link account login): This removes the ability to use a linked account t * feat(errorhandlers): Add opbeat, filter out handled error
This commit is contained in:
committed by
mrugesh mohapatra
parent
7805d74ea7
commit
2fcd976700
88
common/models/User-Credential.js
Normal file
88
common/models/User-Credential.js
Normal file
@ -0,0 +1,88 @@
|
||||
import { Observable } from 'rx';
|
||||
import debug from 'debug';
|
||||
|
||||
import { observeMethod, observeQuery } from '../../server/utils/rx';
|
||||
import {
|
||||
createUserUpdatesFromProfile,
|
||||
getSocialProvider
|
||||
} from '../../server/utils/auth';
|
||||
|
||||
const log = debug('fcc:models:UserCredential');
|
||||
module.exports = function(UserCredential) {
|
||||
UserCredential.link = function(
|
||||
userId,
|
||||
_provider,
|
||||
authScheme,
|
||||
profile,
|
||||
credentials,
|
||||
options = {},
|
||||
cb
|
||||
) {
|
||||
if (typeof options === 'function' && !cb) {
|
||||
cb = options;
|
||||
options = {};
|
||||
}
|
||||
const User = UserCredential.app.models.User;
|
||||
const findCred = observeMethod(UserCredential, 'findOne');
|
||||
const createCred = observeMethod(UserCredential, 'create');
|
||||
|
||||
const provider = getSocialProvider(_provider);
|
||||
const query = {
|
||||
where: {
|
||||
provider: provider,
|
||||
externalId: profile.id
|
||||
}
|
||||
};
|
||||
|
||||
// find createCred if they exist
|
||||
// if not create it
|
||||
// if yes, update credentials
|
||||
// also if github
|
||||
// update profile
|
||||
// update username
|
||||
// update picture
|
||||
log('link query', query);
|
||||
return findCred(query)
|
||||
.flatMap(_credentials => {
|
||||
const modified = new Date();
|
||||
const updateUser = User.update$(
|
||||
{ id: userId },
|
||||
createUserUpdatesFromProfile(provider, profile)
|
||||
);
|
||||
let updateCredentials;
|
||||
if (!_credentials) {
|
||||
updateCredentials = createCred({
|
||||
provider,
|
||||
externalId: profile.id,
|
||||
authScheme,
|
||||
// we no longer want to keep the profile
|
||||
// this is information we do not need or use
|
||||
profile: null,
|
||||
credentials,
|
||||
userId,
|
||||
created: modified,
|
||||
modified
|
||||
});
|
||||
}
|
||||
_credentials.credentials = credentials;
|
||||
updateCredentials = observeQuery(
|
||||
_credentials,
|
||||
'updateAttributes',
|
||||
{
|
||||
profile: null,
|
||||
credentials,
|
||||
modified
|
||||
}
|
||||
);
|
||||
return Observable.combineLatest(
|
||||
updateUser,
|
||||
updateCredentials,
|
||||
(_, credentials) => credentials
|
||||
);
|
||||
})
|
||||
.subscribe(
|
||||
credentials => cb(null, credentials),
|
||||
cb
|
||||
);
|
||||
};
|
||||
};
|
@ -1,172 +1,131 @@
|
||||
import loopback from 'loopback';
|
||||
import debugFactory from 'debug';
|
||||
import { Observable } from 'rx';
|
||||
// import debug from 'debug';
|
||||
import dedent from 'dedent';
|
||||
|
||||
import {
|
||||
setProfileFromGithub,
|
||||
getFirstImageFromProfile,
|
||||
getSocialProvider,
|
||||
getUsernameFromProvider,
|
||||
getSocialProvider
|
||||
createUserUpdatesFromProfile
|
||||
} from '../../server/utils/auth';
|
||||
import { defaultProfileImage } from '../utils/constantStrings.json';
|
||||
import { observeMethod, observeQuery } from '../../server/utils/rx';
|
||||
import { wrapHandledError } from '../../server/utils/create-handled-error.js';
|
||||
|
||||
const githubRegex = (/github/i);
|
||||
const debug = debugFactory('fcc:models:userIdent');
|
||||
// const log = debug('fcc:models:userIdent');
|
||||
|
||||
export default function(UserIdent) {
|
||||
UserIdent.on('dataSourceAttached', () => {
|
||||
UserIdent.findOne$ = observeMethod(UserIdent, 'findOne');
|
||||
});
|
||||
// original source
|
||||
// github.com/strongloop/loopback-component-passport
|
||||
const createAccountMessage =
|
||||
'Accounts can only be created using GitHub or though email';
|
||||
// find identity if it exist
|
||||
// if not redirect to email signup
|
||||
// if yes and github
|
||||
// update profile
|
||||
// update username
|
||||
// update picture
|
||||
UserIdent.login = function(
|
||||
provider,
|
||||
_provider,
|
||||
authScheme,
|
||||
profile,
|
||||
credentials,
|
||||
options,
|
||||
cb
|
||||
) {
|
||||
const User = UserIdent.app.models.User;
|
||||
const AccessToken = UserIdent.app.models.AccessToken;
|
||||
const provider = getSocialProvider(_provider);
|
||||
options = options || {};
|
||||
if (typeof options === 'function' && !cb) {
|
||||
cb = options;
|
||||
options = {};
|
||||
}
|
||||
const userIdentityModel = UserIdent;
|
||||
profile.id = profile.id || profile.openid;
|
||||
const filter = {
|
||||
const query = {
|
||||
where: {
|
||||
provider: getSocialProvider(provider),
|
||||
provider: provider,
|
||||
externalId: profile.id
|
||||
}
|
||||
},
|
||||
include: 'user'
|
||||
};
|
||||
return userIdentityModel.findOne(filter)
|
||||
.then(identity => {
|
||||
return UserIdent.findOne$(query)
|
||||
.flatMap(identity => {
|
||||
if (!identity) {
|
||||
throw wrapHandledError(
|
||||
new Error('user identity account not found'),
|
||||
{
|
||||
message: dedent`
|
||||
New accounts can only be created using an email address.
|
||||
Please create an account below
|
||||
`,
|
||||
type: 'info',
|
||||
redirectTo: '/signup'
|
||||
}
|
||||
);
|
||||
}
|
||||
const modified = new Date();
|
||||
const user = identity.user();
|
||||
if (!user) {
|
||||
const username = getUsernameFromProvider(provider, profile);
|
||||
return observeQuery(
|
||||
identity,
|
||||
'updateAttributes',
|
||||
{
|
||||
isOrphaned: username || true
|
||||
}
|
||||
)
|
||||
.do(() => {
|
||||
throw wrapHandledError(
|
||||
new Error('user identity is not associated with a user'),
|
||||
{
|
||||
type: 'info',
|
||||
redirectTo: '/signup',
|
||||
message: dedent`
|
||||
The user account associated with the ${provider} user ${username || 'Anon'}
|
||||
no longer exists.
|
||||
`
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
const updateUser = User.update$(
|
||||
{ id: user.id },
|
||||
createUserUpdatesFromProfile(provider, profile)
|
||||
).map(() => user);
|
||||
// identity already exists
|
||||
// find user and log them in
|
||||
if (identity) {
|
||||
identity.credentials = credentials;
|
||||
const options = {
|
||||
profile: profile,
|
||||
credentials: credentials,
|
||||
modified: new Date()
|
||||
};
|
||||
return identity.updateAttributes(options)
|
||||
// grab user associated with identity
|
||||
.then(() => identity.user())
|
||||
.then(user => {
|
||||
// Create access token for user
|
||||
const options = {
|
||||
created: new Date(),
|
||||
ttl: user.constructor.settings.ttl
|
||||
};
|
||||
return user.accessTokens.create(options)
|
||||
.then(token => ({ user, token }));
|
||||
})
|
||||
.then(({ token, user })=> {
|
||||
cb(null, user, identity, token);
|
||||
})
|
||||
.catch(err => cb(err));
|
||||
}
|
||||
// Find the user model
|
||||
const userModel = userIdentityModel.relations.user &&
|
||||
userIdentityModel.relations.user.modelTo ||
|
||||
loopback.getModelByType(loopback.User);
|
||||
|
||||
const userObj = options.profileToUser(provider, profile, options);
|
||||
if (getSocialProvider(provider) !== 'github') {
|
||||
const err = new Error(createAccountMessage);
|
||||
err.userMessage = createAccountMessage;
|
||||
err.messageType = 'info';
|
||||
err.redirectTo = '/signin';
|
||||
return process.nextTick(() => cb(err));
|
||||
}
|
||||
|
||||
let query;
|
||||
if (userObj.email) {
|
||||
query = { or: [
|
||||
{ username: userObj.username },
|
||||
{ email: userObj.email }
|
||||
]};
|
||||
} else {
|
||||
query = { username: userObj.username };
|
||||
}
|
||||
return userModel.findOrCreate({ where: query }, userObj)
|
||||
.then(([ user ]) => {
|
||||
const promises = [
|
||||
userIdentityModel.create({
|
||||
provider: getSocialProvider(provider),
|
||||
externalId: profile.id,
|
||||
authScheme: authScheme,
|
||||
profile: profile,
|
||||
credentials: credentials,
|
||||
userId: user.id,
|
||||
created: new Date(),
|
||||
modified: new Date()
|
||||
}),
|
||||
user.accessTokens.create({
|
||||
created: new Date(),
|
||||
ttl: user.constructor.settings.ttl
|
||||
})
|
||||
];
|
||||
return Promise.all(promises)
|
||||
.then(([ identity, token ]) => ({ user, identity, token }));
|
||||
})
|
||||
.then(({ user, token, identity }) => cb(null, user, identity, token))
|
||||
.catch(err => cb(err));
|
||||
});
|
||||
identity.credentials = credentials;
|
||||
const attributes = {
|
||||
// we no longer want to keep the profile
|
||||
// this is information we do not need or use
|
||||
profile: null,
|
||||
credentials: credentials,
|
||||
modified
|
||||
};
|
||||
const updateIdentity = observeQuery(
|
||||
identity,
|
||||
'updateAttributes',
|
||||
attributes
|
||||
);
|
||||
const createToken = observeQuery(
|
||||
AccessToken,
|
||||
'create',
|
||||
{
|
||||
userId: user.id,
|
||||
created: new Date(),
|
||||
ttl: user.constructor.settings.ttl
|
||||
}
|
||||
);
|
||||
return Observable.combineLatest(
|
||||
updateUser,
|
||||
updateIdentity,
|
||||
createToken,
|
||||
(user, identity, token) => ({ user, identity, token })
|
||||
);
|
||||
})
|
||||
.subscribe(
|
||||
({ user, identity, token }) => cb(null, user, identity, token),
|
||||
cb
|
||||
);
|
||||
};
|
||||
|
||||
UserIdent.observe('before save', function(ctx, next) {
|
||||
const userIdent = ctx.currentInstance || ctx.instance;
|
||||
if (!userIdent) {
|
||||
debug('no user identity instance found');
|
||||
return next();
|
||||
}
|
||||
return userIdent.user(function(err, user) {
|
||||
let userChanged = false;
|
||||
if (err) { return next(err); }
|
||||
if (!user) {
|
||||
debug('no user attached to identity!');
|
||||
return next();
|
||||
}
|
||||
|
||||
const { profile, provider } = userIdent;
|
||||
const picture = getFirstImageFromProfile(profile);
|
||||
|
||||
debug('picture', picture, user.picture);
|
||||
// check if picture was found
|
||||
// check if user has no picture
|
||||
// check if user has default picture
|
||||
// set user.picture from oauth provider
|
||||
if (
|
||||
picture &&
|
||||
(!user.picture || user.picture === defaultProfileImage)
|
||||
) {
|
||||
debug('setting user picture');
|
||||
user.picture = picture;
|
||||
userChanged = true;
|
||||
}
|
||||
|
||||
if (!githubRegex.test(provider) && profile) {
|
||||
user[provider] = getUsernameFromProvider(provider, profile);
|
||||
userChanged = true;
|
||||
}
|
||||
|
||||
// if user signed in with github refresh their info
|
||||
if (githubRegex.test(provider) && profile && profile._json) {
|
||||
debug("user isn't github cool or username from github is different");
|
||||
setProfileFromGithub(user, profile, profile._json);
|
||||
userChanged = true;
|
||||
}
|
||||
|
||||
|
||||
if (userChanged) {
|
||||
return user.save(function(err) {
|
||||
if (err) { return next(err); }
|
||||
return next();
|
||||
});
|
||||
}
|
||||
debug('exiting after user identity before save');
|
||||
return next();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -6,14 +6,26 @@ import debugFactory from 'debug';
|
||||
import { isEmail } from 'validator';
|
||||
import path from 'path';
|
||||
|
||||
import { saveUser, observeMethod } from '../../server/utils/rx';
|
||||
import { blacklistedUsernames } from '../../server/utils/constants';
|
||||
import { saveUser, observeMethod } from '../../server/utils/rx.js';
|
||||
import { blacklistedUsernames } from '../../server/utils/constants.js';
|
||||
import { wrapHandledError } from '../../server/utils/create-handled-error.js';
|
||||
|
||||
const debug = debugFactory('fcc:user:remote');
|
||||
const BROWNIEPOINTS_TIMEOUT = [1, 'hour'];
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const devHost = process.env.HOST || 'localhost';
|
||||
|
||||
const createEmailError = () => new Error(
|
||||
'Please check to make sure the email is a valid email address.'
|
||||
);
|
||||
|
||||
function destroyAll(id, Model) {
|
||||
return Observable.fromNodeCallback(
|
||||
Model.destroyAll,
|
||||
Model
|
||||
)({ userId: id });
|
||||
}
|
||||
|
||||
function getAboutProfile({
|
||||
username,
|
||||
githubProfile: github,
|
||||
@ -64,10 +76,99 @@ module.exports = function(User) {
|
||||
User.count$ = Observable.fromNodeCallback(User.count, User);
|
||||
});
|
||||
|
||||
User.beforeRemote('create', function({ req }) {
|
||||
const body = req.body;
|
||||
// note(berks): we now require all new users to supply an email
|
||||
// this was not always the case
|
||||
if (
|
||||
typeof body.email !== 'string' ||
|
||||
!isEmail(body.email)
|
||||
) {
|
||||
return Promise.reject(createEmailError());
|
||||
}
|
||||
// assign random username to new users
|
||||
// actual usernames will come from github
|
||||
body.username = 'fcc' + uuid.v4();
|
||||
if (body) {
|
||||
// this is workaround for preventing a server crash
|
||||
// we do this on create and on save
|
||||
// refer strongloop/loopback/#1364
|
||||
if (body.password === '') {
|
||||
body.password = null;
|
||||
}
|
||||
// set email verified false on user email signup
|
||||
// should not be set with oauth signin methods
|
||||
body.emailVerified = false;
|
||||
}
|
||||
return User.doesExist(null, body.email)
|
||||
.catch(err => {
|
||||
throw wrapHandledError(err, { redirectTo: '/email-signup' });
|
||||
})
|
||||
.then(exists => {
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
const err = wrapHandledError(
|
||||
new Error('user already exists'),
|
||||
{
|
||||
redirectTo: '/email-signin',
|
||||
message: dedent`
|
||||
The ${body.email} email address is already associated with an account.
|
||||
Try signing in with it here instead.
|
||||
`
|
||||
}
|
||||
);
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
|
||||
// send welcome email to new camper
|
||||
User.afterRemote('create', function({ req, res }, user, next) {
|
||||
debug('user created, sending email');
|
||||
if (!user.email || !isEmail(user.email)) { return next(); }
|
||||
const redirect = req.session && req.session.returnTo ?
|
||||
req.session.returnTo :
|
||||
'/';
|
||||
|
||||
var mailOptions = {
|
||||
type: 'email',
|
||||
to: user.email,
|
||||
from: 'team@freecodecamp.com',
|
||||
subject: 'Welcome to freeCodeCamp!',
|
||||
protocol: isDev ? null : 'https',
|
||||
host: isDev ? devHost : 'freecodecamp.com',
|
||||
port: isDev ? null : 443,
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'server',
|
||||
'views',
|
||||
'emails',
|
||||
'a-extend-user-welcome.ejs'
|
||||
),
|
||||
redirect: '/email-signin'
|
||||
};
|
||||
|
||||
debug('sending welcome email');
|
||||
return user.verify(mailOptions, function(err) {
|
||||
if (err) { return next(err); }
|
||||
req.flash('success', {
|
||||
msg: [ 'Congratulations ! We\'ve created your account. ',
|
||||
'Please check your email. We sent you a link that you can ',
|
||||
'click to verify your email address and then login.'
|
||||
].join('')
|
||||
});
|
||||
return res.redirect(redirect);
|
||||
});
|
||||
});
|
||||
|
||||
User.observe('before save', function({ instance: user }, next) {
|
||||
if (user) {
|
||||
// Some old accounts will not have emails associated with theme
|
||||
// we verify only if the email field is populated
|
||||
if (user.email && !isEmail(user.email)) {
|
||||
return next(new Error('Email format is not valid'));
|
||||
return next(createEmailError());
|
||||
}
|
||||
user.username = user.username.trim().toLowerCase();
|
||||
user.email = typeof user.email === 'string' ?
|
||||
@ -82,6 +183,7 @@ module.exports = function(User) {
|
||||
user.progressTimestamps.push({ timestamp: Date.now() });
|
||||
}
|
||||
// this is workaround for preventing a server crash
|
||||
// we do this on save and on create
|
||||
// refer strongloop/loopback/#1364
|
||||
if (user.password === '') {
|
||||
user.password = null;
|
||||
@ -90,6 +192,40 @@ module.exports = function(User) {
|
||||
return next();
|
||||
});
|
||||
|
||||
// remove lingering user identities before deleting user
|
||||
User.observe('before delete', function(ctx, next) {
|
||||
const UserIdentity = User.app.models.UserIdentity;
|
||||
const UserCredential = User.app.models.UserCredential;
|
||||
debug('removing user', ctx.where);
|
||||
var id = ctx.where && ctx.where.id ? ctx.where.id : null;
|
||||
if (!id) {
|
||||
return next();
|
||||
}
|
||||
return Observable.combineLatest(
|
||||
destroyAll(id, UserIdentity),
|
||||
destroyAll(id, UserCredential),
|
||||
function(identData, credData) {
|
||||
return {
|
||||
identData: identData,
|
||||
credData: credData
|
||||
};
|
||||
}
|
||||
)
|
||||
.subscribe(
|
||||
function(data) {
|
||||
debug('deleted', data);
|
||||
},
|
||||
function(err) {
|
||||
debug('error deleting user %s stuff', id, err);
|
||||
next(err);
|
||||
},
|
||||
function() {
|
||||
debug('user stuff deleted for user %s', id);
|
||||
next();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
debug('setting up user hooks');
|
||||
|
||||
User.beforeRemote('confirm', function(ctx, _, next) {
|
||||
@ -153,41 +289,9 @@ module.exports = function(User) {
|
||||
return ctx.res.redirect(redirect);
|
||||
});
|
||||
|
||||
User.beforeRemote('create', function({ req, res }, _, next) {
|
||||
req.body.username = 'fcc' + uuid.v4().slice(0, 8);
|
||||
if (!req.body.email) {
|
||||
return next();
|
||||
}
|
||||
if (!isEmail(req.body.email)) {
|
||||
return next(new Error('Email format is not valid'));
|
||||
}
|
||||
return User.doesExist(null, req.body.email)
|
||||
.then(exists => {
|
||||
if (!exists) {
|
||||
return next();
|
||||
}
|
||||
|
||||
req.flash('error', {
|
||||
msg: dedent`
|
||||
The ${req.body.email} email address is already associated with an account.
|
||||
Try signing in with it here instead.
|
||||
`
|
||||
});
|
||||
|
||||
return res.redirect('/email-signin');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
req.flash('error', {
|
||||
msg: 'Oops, something went wrong, please try again later'
|
||||
});
|
||||
return res.redirect('/email-signup');
|
||||
});
|
||||
});
|
||||
|
||||
User.on('resetPasswordRequest', function(info) {
|
||||
if (!isEmail(info.email)) {
|
||||
console.error(new Error('Email format is not valid'));
|
||||
console.error(createEmailError());
|
||||
return null;
|
||||
}
|
||||
let url;
|
||||
@ -232,7 +336,7 @@ module.exports = function(User) {
|
||||
const { body } = ctx.req;
|
||||
if (body && typeof body.email === 'string') {
|
||||
if (!isEmail(body.email)) {
|
||||
return next(new Error('Email format is not valid'));
|
||||
return next(createEmailError());
|
||||
}
|
||||
body.email = body.email.toLowerCase();
|
||||
}
|
||||
@ -392,9 +496,7 @@ module.exports = function(User) {
|
||||
true;
|
||||
|
||||
if (!isEmail('' + email)) {
|
||||
return Observable.throw(
|
||||
new Error('The submitted email not valid.')
|
||||
);
|
||||
return Observable.throw(createEmailError());
|
||||
}
|
||||
// email is already associated and verified with this account
|
||||
if (ownEmail && this.emailVerified) {
|
||||
@ -588,11 +690,13 @@ module.exports = function(User) {
|
||||
|
||||
User.prototype.updateTheme = function updateTheme(theme) {
|
||||
if (!this.constructor.themes[theme]) {
|
||||
const err = new Error(
|
||||
'Theme is not valid.'
|
||||
const err = wrapHandledError(
|
||||
new Error('Theme is not valid.'),
|
||||
{
|
||||
Type: 'info',
|
||||
message: err.message
|
||||
}
|
||||
);
|
||||
err.messageType = 'info';
|
||||
err.userMessage = err.message;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return this.update$({ theme })
|
||||
|
Reference in New Issue
Block a user