Skip to content

Commit

Permalink
feat(auth): allow Auth0 as authentication server, after bulk user imp…
Browse files Browse the repository at this point in the history
…ort (#705)

Fork of PR #593. May contribute to #669.

## What does this PR do / solve?

Make Openwhyd more secure by delegating auth and user management to Auth0.

## Overview of changes

When Auth0 env vars are provided, Openwhyd delegates the following features to Auth0:
- login/logout
- signup
- password change (forgotten or not)
- ...

Otherwise, the legacy auth and user management implementation is used, as currently.

## How to test this PR?

### Prerequisite

To do once for all:

1. setup a Auth0 account with a "user-password" auth database, with "usernames" login enabled
2. paste Auth0 credentials to `env-vars-testing.conf`
3. (re)start openwhyd + db in docker: `$ docker compose up --build --detach`
4. seed test users: `$ make docker-seed`

To repeat after each code change:

3. (re)start openwhyd + db: `$ make dev`

=> when you're done testing, don't forget to run `$ make down`.

### Bulk user import

1. copy-paste [this token](https://manage.auth0.com/dashboard/eu/dev-vh1nl8wh3gmzgnhp/apis/management/explorer) to file: `scripts/auth0/.token`
2.  run `$ scripts/auth0/import-test-users.sh`
3. check that users are imported: [Users](https://manage.auth0.com/dashboard/eu/dev-vh1nl8wh3gmzgnhp/users) + [Logs](https://manage.auth0.com/dashboard/eu/dev-vh1nl8wh3gmzgnhp/logs)

### Login+logout

1. open http://localhost:8080
2. click on "log in" => you're redirected to auth0's login page
3. login with `admin`/`admin` or `dummy`/`admin`
4. back on openwhyd, logout

### Signup

1. open http://localhost:8080
2. click on "sign up" => you're redirected to auth0's signup page
3. submit a username (e.g. `adrien`), an email address and a password
4. back on openwhyd, logout
6. follow the "login+logout" procedure (above), to check that you can login with username or email

### Change of email address

Once you're logged in:

1. open http://localhost:8080/settings
2. change the email address
3. click "save changes", ignore the error message
4. check that the email address was updated, in [Auth0's user list](https://manage.auth0.com/dashboard/eu/dev-vh1nl8wh3gmzgnhp/users)

### Change of password

Once you're logged in:

1. open http://localhost:8080/settings
2. click on the "password" tab
3. type the same password in the 3 fields (any valid value will do)
4. click "save changes" => a message tells you that you'll receive an email
6. logout
7. open the email, click the link, pick a new password
8. back on http://localhost:8080, login with your new password

### Change of handle/username

Once you're logged in:

1. open http://localhost:8080/settings
2. type a username in the "Custom URL" field
3. click "save changes"
4. logout
5. login with your new username+password

### Account deletion

Once you're logged in:

1. open http://localhost:8080/settings
2. click on "delete your account" and confirm
3. check that the user is not listed anymore in [Auth0's user list](https://manage.auth0.com/dashboard/eu/dev-vh1nl8wh3gmzgnhp/users)

## TODO / probably worth doing before meging

- [ ] import users' Facebook id to Auth0 too, so users can still login to Openwhyd using their Facebook account, after migrating to Auth0 => we could delete all facebook-related code and possibly close #658. => test that it works as expected.

## To be done later

- forward change of avatar to Auth0

## References and resources

- Auth0 [Application Details](https://manage.auth0.com/dashboard/eu/dev-vh1nl8wh3gmzgnhp/applications/87WcUOdE9SGegwDcZfPRdl4Kw3T21pqs/settings)
- npm package used for auth: [express-openid-connect](https://auth0.github.io/express-openid-connect/)
- API reference of npm package used for other operations: [node-auth0](https://auth0.github.io/node-auth0/index.html)
  • Loading branch information
adrienjoly committed Oct 15, 2023
1 parent 254f301 commit a65723f
Show file tree
Hide file tree
Showing 21 changed files with 2,002 additions and 216 deletions.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
.gitignore
.git
.env
.token
.port
.DS_Store
Dockerfile
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.env
.token
.port
.idea
.DS_Store
Expand Down
39 changes: 35 additions & 4 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ const dbCreds = {
mongoDbDatabase: process.env['MONGODB_DATABASE'], // || "openwhyd_data",
};

const useAuth0AsIdentityProvider = !!process.env.AUTH0_ISSUER_BASE_URL;

const params = (process.appParams = {
// server level
port: process.env['WHYD_PORT'] || 8080, // overrides app.conf
Expand All @@ -77,8 +79,9 @@ const params = (process.appParams = {
isOnTestDatabase: dbCreds.mongoDbDatabase === 'openwhyd_test',
color: true,

// secrets
genuineSignupSecret: process.env.WHYD_GENUINE_SIGNUP_SECRET,
// authentication
useAuth0AsIdentityProvider,
genuineSignupSecret: process.env.WHYD_GENUINE_SIGNUP_SECRET, // used by legacy auth

// workers and general site logic
searchModule:
Expand Down Expand Up @@ -143,9 +146,33 @@ function start() {
throw new Error(`missing env var: WHYD_SESSION_SECRET`);

const myHttp = require('./app/lib/my-http-wrapper/http');
const { makeFeatures } = require('./app/domain/OpenWhydFeatures');
const {
userCollection,
} = require('./app/infrastructure/mongodb/UserCollection');
const { ImageStorage } = require('./app/infrastructure/ImageStorage.js');
const { unsetPlaylist } = require('./app/models/post.js');
const { makeAuthFeatures } = require('./app/lib/auth0/features.js');

// Initialize features
/** @typedef {import('./app/domain/api/Features').Features} Features*/
/** @type {Features & Partial<{auth: import('./app/lib/my-http-wrapper/http/AuthFeatures').AuthFeatures}>} */
const features = makeFeatures({
userRepository: userCollection,
imageRepository: new ImageStorage(),
releasePlaylistPosts: async (userId, playlistId) =>
new Promise((resolve) => unsetPlaylist(userId, playlistId, resolve)),
});

// Inject Auth0 user auth and management features, if enabled
if (process.appParams.useAuth0AsIdentityProvider) {
features.auth = makeAuthFeatures(process.env);
}

// Legacy user auth and session management
const session = require('express-session');
const MongoStore = require('connect-mongo')(session);
const sessionMiddleware = session({
const legacySessionMiddleware = session({
secret: process.env.WHYD_SESSION_SECRET,
store: new MongoStore({
url: makeMongoUrl(dbCreds),
Expand All @@ -159,11 +186,15 @@ function start() {
resave: false, // required, cf https://www.npmjs.com/package/express-session#resave
saveUninitialized: false, // required, cf https://www.npmjs.com/package/express-session#saveuninitialized
});

const serverOptions = {
features,
urlPrefix: params.urlPrefix,
port: params.port,
appDir: __dirname,
sessionMiddleware,
sessionMiddleware: useAuth0AsIdentityProvider
? null
: legacySessionMiddleware,
errorHandler: function (req, params = {}, response, statusCode) {
// to render 404 and 401 error pages from server/router
require('./app/templates/error.js').renderErrorResponse(
Expand Down
27 changes: 18 additions & 9 deletions app/controllers/admin/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,25 @@ const AdminLists = require('../../templates/adminLists.js').AdminLists;

// ACTION HANDLERS

/** @typedef {{auth?: import('../../lib/my-http-wrapper/http/AuthFeatures.js').AuthFeatures}} Features */

/** @type {Record<string, (params: any, cb: (any) => void, features: Features) => void>} */
const handlers = {
rename: function (p, cb) {
rename: function (p, cb, features) {
if (p._id && p.name)
userModel.renameUser(p._id, p.name, function (result) {
userModel.renameUser(features, p._id, p.name, function (result) {
result = result || {};
if (result.error) console.trace('user rename error:', result.error);
else result.message = 'The user name has been set to ' + p.name;
cb(result);
});
else cb({ error: 'missing arguments' });
},
delete: function (p, cb) {
delete: function (p, cb, features) {
const id = p._id && p._id.join ? p._id[0] : p._id;
if (id) {
console.log('delete user ', id);
userModel.delete({ _id: id }, function (res) {
userModel.delete(features, { _id: id }, function (res) {
res = res || {};
res.json = JSON.parse(JSON.stringify(res));
cb(res);
Expand Down Expand Up @@ -98,7 +101,7 @@ function renderTemplate(items) {

// MAIN CONTROLLER / REQUEST HANDLING CODE

exports.handleRequest = function (request, reqParams, response) {
exports.handleRequest = function (request, reqParams, response, features) {
request.logToConsole('admin/users.controller', reqParams);

// make sure an admin is logged, or return an error page
Expand All @@ -123,7 +126,7 @@ exports.handleRequest = function (request, reqParams, response) {
reqParams = reqParams || {};

if (reqParams.action && handlers[reqParams.action])
handlers[reqParams.action](reqParams, renderResult);
handlers[reqParams.action](reqParams, renderResult, features);
else
userModel.fetchMulti(
{},
Expand All @@ -134,13 +137,19 @@ exports.handleRequest = function (request, reqParams, response) {
);
};

exports.controller = function (request, getParams, response) {
/** @param {Features} features */
exports.controller = function (request, getParams, response, features) {
//request.logToConsole("admin/users.controller", request.method);
if (request.method.toLowerCase() === 'post') {
//var form = new formidable.IncomingForm();
//form.parse(request, function(err, postParams) {
// if (err) console.log(err);
exports.handleRequest(request, request.body /*postParams*/, response);
exports.handleRequest(
request,
request.body /*postParams*/,
response,
features,
);
//});
} else exports.handleRequest(request, getParams, response);
} else exports.handleRequest(request, getParams, response, features);
};
95 changes: 54 additions & 41 deletions app/controllers/api/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ function addUserInfo(userSub, mySub) {
return userSub;
}

/** @typedef {{auth?: import('../../lib/my-http-wrapper/http/AuthFeatures.js').AuthFeatures}} Features */

/** @type {Record<string, (params: any, cb: (any) => void, features: Features) => void>} */
const publicActions = {
subscriptions: function (p, cb) {
if (!p || !p.id) cb({ error: 'user not found' });
Expand Down Expand Up @@ -99,12 +102,12 @@ const publicActions = {
});
});
},
delete: function (p, cb) {
delete: function (p, cb, features) {
if (!p || !p.loggedUser || !p.loggedUser.id)
cb({ error: 'Please log in first' });
else {
console.log('deleting user... ', p.loggedUser.id);
userModel.delete({ _id: p.loggedUser.id }, function (r) {
userModel.delete(features, { _id: p.loggedUser.id }, function (r) {
console.log('deleted user', p.loggedUser.id, r);
});
notifEmails.sendUserDeleted(p.loggedUser.id, p.loggedUser.name);
Expand Down Expand Up @@ -133,9 +136,10 @@ function defaultSetter(fieldName) {
};
}

/** @type {Record<string, (params: any, cb: (any) => void, features: Features) => void>} */
const fieldSetters = {
name: function (p, cb) {
userModel.renameUser(p._id, p.name, cb);
name: function (p, cb, features) {
userModel.renameUser(features, p._id, p.name, cb);
},
img: function (p, cb) {
userModel.fetchByUid(p._id, async function (user) {
Expand Down Expand Up @@ -177,29 +181,36 @@ const fieldSetters = {
else userModel.update(p._id, { $unset: { cvrImg: 1 } }, cb); // remove cvrImg attribute
});
},
pwd: function (p, cb) {
pwd: function (p, cb, features) {
userModel.fetchByUid(p._id, function (item) {
if (item && item.pwd == userModel.md5(p.oldPwd || '')) {
if (features.auth?.sendPasswordChangeRequest) {
features.auth
.sendPasswordChangeRequest(item.email)
.then(() =>
cb({ error: 'We sent you an email to change your password.' }),
);
} else if (item && item.pwd == userModel.md5(p.oldPwd || '')) {
defaultSetter('pwd')({ _id: p._id, pwd: userModel.md5(p.pwd) }, cb);
notifEmails.sendPasswordUpdated(p._id, item.email);
// TODO: inform Auth0, if applicable
} else cb({ error: 'Your current password is incorrect' });
});
},
handle: function (p, cb) {
userModel.setHandle(p._id, p.handle, cb);
// TODO: inform Auth0, if applicable
handle: function (p, cb, features) {
userModel.setHandle(features, p._id, p.handle, cb);
},
email: function (p, cb) {
email: function (p, cb, features) {
p.email = emailModel.normalize(p.email);
if (!emailModel.validate(p.email))
cb({ error: 'This email address is invalid' });
else
userModel.fetchByEmail(p.email, function (existingUser) {
userModel.fetchByEmail(p.email, async function (existingUser) {
if (!existingUser) {
notifEmails.sendEmailUpdated(p._id, p.email);
// TODO: inform Auth0, if applicable
defaultSetter('email')(p, cb);
const savedUser = await new Promise((resolve) =>
defaultSetter('email')(p, resolve),
);
if (savedUser) features.auth?.setUserEmail(p._id, p.email);
cb(savedUser);
} else if ('' + existingUser._id == p._id)
// no change
cb({ email: p.email });
Expand Down Expand Up @@ -314,14 +325,14 @@ function fetchUserByIdOrHandle(uidOrHandle, options, cb) {
else userModel.fetchByHandle(uidOrHandle, returnUser);
}

function handlePublicRequest(loggedUser, reqParams, localRendering) {
function handlePublicRequest(loggedUser, reqParams, localRendering, features) {
// transforming sequential parameters to named parameters
reqParams = snip.translateFields(reqParams, SEQUENCED_PARAMETERS);

const handler = publicActions[reqParams.action];
if (handler) {
reqParams.loggedUser = loggedUser;
handler(reqParams, localRendering);
handler(reqParams, localRendering, features);
return true;
} else if (reqParams.id) {
reqParams.excludePrivateFields = true;
Expand All @@ -342,7 +353,7 @@ function handlePublicRequest(loggedUser, reqParams, localRendering) {
}
}

function handleAuthRequest(loggedUser, reqParams, localRendering) {
function handleAuthRequest(loggedUser, reqParams, localRendering, features) {
// make sure a registered user is logged, or return an error page
if (false == loggedUser)
return localRendering({ error: 'user not logged in' });
Expand All @@ -363,7 +374,7 @@ function handleAuthRequest(loggedUser, reqParams, localRendering) {
const fieldName = toUpdate.pop();
console.log('calling field setter: ', fieldName);
reqParams._id = loggedUser._id; // force the logged user id
fieldSetters[fieldName](reqParams, setNextField);
fieldSetters[fieldName](reqParams, setNextField, features);
}
})();
} else {
Expand All @@ -372,14 +383,15 @@ function handleAuthRequest(loggedUser, reqParams, localRendering) {
}

// old name: setUserFields()
function handleRequest(loggedUser, reqParams, localRendering) {
function handleRequest(loggedUser, reqParams, localRendering, features) {
try {
if (handlePublicRequest(loggedUser, reqParams, localRendering)) return true;
if (handlePublicRequest(loggedUser, reqParams, localRendering, features))
return true;
} catch (e) {
console.error('user api error', e, e.stack);
return localRendering({ error: e });
}
return handleAuthRequest(loggedUser, reqParams, localRendering);
return handleAuthRequest(loggedUser, reqParams, localRendering, features);
}

// these error messages are displayed to the user, we don't need to log them
Expand All @@ -388,33 +400,34 @@ const USER_ERRORS = [
'This username is taken by another user',
];

exports.controller = function (request, reqParams, response) {
function localRendering(reqParams, r) {
if (r) delete r.pwd;
if (!r || r.error) {
const errMessage = (r || {}).error || r;
const isUserError =
typeof errMessage === 'string' &&
USER_ERRORS.some((userError) => errMessage.includes(userError));
if (!isUserError)
console.log(
'api.user.' + (reqParams._action || 'controller') + ' ERROR:',
errMessage,
);
}

return reqParams.callback ? snip.renderJsCallback(reqParams.callback, r) : r;
}

/** @param {Features} features */
exports.controller = function (request, reqParams, response, features) {
request.logToConsole('api.user.controller', reqParams);
reqParams = reqParams || {};

function localRendering(r) {
if (r) delete r.pwd;
if (!r || r.error) {
const errMessage = (r || {}).error || r;
const isUserError =
typeof errMessage === 'string' &&
USER_ERRORS.some((userError) => errMessage.includes(userError));
if (!isUserError)
console.log(
'api.user.' + (reqParams._action || 'controller') + ' ERROR:',
errMessage,
);
}
response.renderJSON(
reqParams.callback ? snip.renderJsCallback(reqParams.callback, r) : r,
);
}

const loggedUser = request.checkLogin(/*response*/);
handleRequest(
loggedUser,
request.method.toLowerCase() === 'post' ? request.body : reqParams,
localRendering,
(result) => response.renderJSON(localRendering(reqParams, result)),
features,
);
};

Expand Down
21 changes: 19 additions & 2 deletions app/controllers/private/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,26 @@ exports.registerInvitedUser = function (request, user, response) {
else registerUser();
};

exports.controller = function (request, getParams, response) {
exports.controller = async function (request, getParams, response, features) {
request.logToConsole('register.controller', request.method);
if (request.method.toLowerCase() === 'post')
const newUserFromAuth0 = features.auth?.getAuthenticatedUser(request);
if (newUserFromAuth0) {
// finalize user signup from Auth0, by persisting them into our database
const storedUser = await new Promise((resolve) =>
userModel.save(newUserFromAuth0, resolve),
);
if (storedUser) {
notifEmails.sendRegWelcomeAsync(storedUser);
response.renderHTML(htmlRedirect('/')); // in reality, this ends up redirecting to the consent request page
} else {
renderError(
request,
storedUser,
response,
'Oops, your registration failed... Please reach out to contact@openwhyd.org',
);
}
} else if (request.method.toLowerCase() === 'post')
// sent by (new) register form
exports.registerInvitedUser(request, request.body, response);
else inviteController.renderRegisterPage(request, getParams, response);
Expand Down

0 comments on commit a65723f

Please sign in to comment.