Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): allow Auth0 as authentication server, after bulk user import #705

Merged
merged 49 commits into from Oct 15, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
cb642a5
(squashed changes from #593)
adrienjoly Sep 2, 2023
145511c
clean up
adrienjoly Sep 2, 2023
513fb2c
mark `mongodb.forEach` as deprecated
adrienjoly Sep 4, 2023
857e9bd
we need to create a /signup route for Auth0 => make it work with the …
adrienjoly Sep 4, 2023
878c004
make signup work with auth0
adrienjoly Sep 4, 2023
cd531ef
fix TS errors
adrienjoly Sep 4, 2023
336e781
Merge branch 'main' into auth0-after-bulk-user-import
adrienjoly Sep 4, 2023
7563b87
update deprecated `usernames` refs counter
adrienjoly Sep 4, 2023
81ceb45
fix: don't use full email address as user name
adrienjoly Sep 4, 2023
151adf7
feat: user rename is propagated to Auth0 database
adrienjoly Sep 4, 2023
3ab9d12
check the presence of AUTH0 env vars, if active
adrienjoly Sep 4, 2023
862ad09
refactor: extract `auth0.updateUserName`
adrienjoly Sep 4, 2023
771cf9a
refactor: extract auth0 wrapper with `makeExpressAuthMiddleware` method
adrienjoly Sep 4, 2023
adef2e7
refactor: extract `auth0.makeSignupRoute`
adrienjoly Sep 4, 2023
565bd01
don't use AUTH0 env vars from everywhere
adrienjoly Sep 4, 2023
68c9ffd
don't use `oidc` directly everywhere
adrienjoly Sep 4, 2023
b34185b
make oidc user fields explicit + validate in `getAuthenticatedUser`
adrienjoly Sep 4, 2023
9978b09
refactor: extract `auth0.mapToOpenwhydUser`
adrienjoly Sep 4, 2023
21e0feb
refactor: turn `updateUserName` into a method
adrienjoly Sep 4, 2023
e1b2aa2
fix TS errors in api/user.js
adrienjoly Sep 4, 2023
299c956
feat: propagate change of user email to Auth0 database
adrienjoly Sep 4, 2023
92de193
Merge branch 'main' into auth0-after-bulk-user-import
adrienjoly Sep 5, 2023
c17b4e8
add script to import test users to Auth0 database
adrienjoly Sep 5, 2023
95eabb5
feat: ask auth0 to send a password renewal email
adrienjoly Sep 5, 2023
6616e27
fix `user?.sub?.replace is not a function`
adrienjoly Sep 5, 2023
7be4b8b
Merge branch 'main' into auth0-after-bulk-user-import
adrienjoly Sep 5, 2023
9014dfe
feat: propagate change of username to Auth0 database
adrienjoly Sep 5, 2023
75a3b24
feat: propagate user deletion to Auth0 database
adrienjoly Sep 5, 2023
a385434
refactor: extract `getManagementClient()` method
adrienjoly Sep 5, 2023
a8cbcee
refactor: extract `getAuthenticationClient()` method
adrienjoly Sep 5, 2023
dc51b1d
🧹 clean up
adrienjoly Sep 5, 2023
23d6e6b
refactor: extract `injectExpressRoutes`
adrienjoly Sep 5, 2023
942c8f2
refactor: extract `sendPasswordChangeRequest` feature
adrienjoly Sep 5, 2023
8b2cd9f
refactor: re-use `auth0` wrapper across auth features
adrienjoly Sep 5, 2023
cca36f4
remove useless `async`
adrienjoly Sep 5, 2023
49161f5
refactor: extract `setUsername` feature
adrienjoly Sep 5, 2023
cadee7e
refactor: extract `setUserEmail` feature
adrienjoly Sep 5, 2023
2878f91
refactor: extract `getAuthenticatedUser` feature
adrienjoly Sep 5, 2023
515a5b3
refactor: extract `setUserFullName` feature
adrienjoly Sep 5, 2023
52bedce
refactor: rename `setUsername` --> `setUserHandle`
adrienjoly Sep 5, 2023
84eb59f
refactor: rename `setUserFullName` --> `setUserProfileName`
adrienjoly Sep 5, 2023
dde3420
refactor: extract `deleteUser` feature
adrienjoly Sep 5, 2023
473ff22
refactor: move features init to app.js
adrienjoly Sep 5, 2023
f058b80
Merge branch 'main' into auth0-after-bulk-user-import
adrienjoly Sep 11, 2023
76e2cad
fix(sonar): `Expected non-Promise value in a boolean conditional.`
adrienjoly Sep 20, 2023
3b1aac1
fix(sonar): `Refactor this function to reduce its Cognitive Complexit…
adrienjoly Sep 20, 2023
4e5102a
fix(sonar): `Promise returned in function argument where a void retur…
adrienjoly Sep 20, 2023
43f6fe6
Merge branch 'main' into auth0-after-bulk-user-import
adrienjoly Sep 20, 2023
ab5811f
Merge branch 'main' into auth0-after-bulk-user-import
adrienjoly Oct 15, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 9 additions & 2 deletions app.js
Expand Up @@ -143,27 +143,34 @@
throw new Error(`missing env var: WHYD_SESSION_SECRET`);

const myHttp = require('./app/lib/my-http-wrapper/http');

// 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),
}),
cookie: {
maxAge: 365 * 24 * 60 * 60 * 1000, // cookies expire in 1 year (provided in milliseconds)
// secure: process.appParams.urlPrefix.startsWith('https://'), // if true, cookie will be accessible only when website if opened over HTTPS
sameSite: 'strict',
},
name: 'whydSid',
resave: false, // required, cf https://www.npmjs.com/package/express-session#resave
saveUninitialized: false, // required, cf https://www.npmjs.com/package/express-session#saveuninitialized
});

Check warning

Code scanning / CodeQL

Clear text transmission of sensitive cookie Medium

Sensitive cookie sent without enforcing SSL encryption.

const useAuth0AsIdentityProvider = process.env.AUTH0_ISSUER_BASE_URL;

const serverOptions = {
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
26 changes: 22 additions & 4 deletions app/controllers/invite.js
Expand Up @@ -8,6 +8,7 @@ const users = require('../models/user.js');
const invitePage = require('../templates/invitePage.js');
const inviteFormTemplate = require('../templates/inviteForm.js');
const templateLoader = require('../templates/templateLoader.js');
const mainTemplate = require('../templates/mainTemplate.js');
const makeSignupToken = require('../genuine.js').makeSignupToken;

// genuine signup V1
Expand Down Expand Up @@ -88,8 +89,8 @@ exports.renderRegisterPage = function (request, reqParams, response) {

if (reqParams.inviteCode)
exports.checkInviteCode({ url: request.url }, reqParams, response, render);
// signup pop-in
else if (reqParams.popin) {
// signup popin
templateLoader.loadTemplate(
'app/templates/popinSignup.html',
function (template) {
Expand All @@ -98,9 +99,26 @@ exports.renderRegisterPage = function (request, reqParams, response) {
},
);
} else {
//console.log("inviteCode parameter is required");
response.redirect('/#signup');
return false;
// signup page => render a page with a minimal header
const whydHeaderContent = `
<div id="headCenter">
<a target="_top" class="homeLink" href="/">
<img id="logo" src="/images/logo-s.png"/>
</a>
</div>
`;
templateLoader.loadTemplate(
'app/templates/popinSignup.html',
function (template) {
response.renderHTML(
mainTemplate.renderWhydPage({
pageTitle: 'Sign up',
whydHeaderContent,
content: template.render(reqParams),
}),
);
},
);
}
};

Expand Down
45 changes: 37 additions & 8 deletions app/controllers/private/register.js
@@ -1,3 +1,5 @@
// @ts-check

/**
* register controller (derived from facebookLogin)
* register new users coming from the /invite form
Expand Down Expand Up @@ -43,6 +45,21 @@ function renderError(request, getParams, response, errorMsg) {
);
}

async function persistNewUserFromAuth0(oidcUser) {
const dbUser = {
_id: oidcUser.sub.replace(/^auth0\|/, ''),
name: oidcUser.username ?? oidcUser.name, // note: for some reason, the username provided during signup is not included in oidcUser
// handle: oidcUser.username, // TODO: check that it complies with our rules, first
email: oidcUser.email,
img: oidcUser.picture,
};
const stored = await new Promise((resolve) =>
userModel.save(dbUser, resolve),
);
if (stored) notifEmails.sendRegWelcomeAsync(stored);
return stored;
}

/**
* called when user submits the form from register.html
*/
Expand Down Expand Up @@ -103,7 +120,7 @@ exports.registerInvitedUser = function (request, user, response) {
if (user.name == 'Your Name' || user.name.trim() == '')
return error(request, user, response, "Don't you have a name, dude ?");

if (user.fbId && isNaN('' + user.fbId))
if (user.fbId && isNaN(user.fbId))
return error(request, user, response, 'Invalid Facebook id');

if (user.password == 'password')
Expand Down Expand Up @@ -137,7 +154,7 @@ exports.registerInvitedUser = function (request, user, response) {
name: user.name,
email: user.email,
pwd: userModel.md5(user.password),
arPwd: argon2.hash(user.password).toString('hex'),
arPwd: argon2.hash(user.password).toString(), // should convert to hex first?
img: '/images/blank_user.gif', //"http://www.gravatar.com/avatar/" + userModel.md5(user.email)
};

Expand Down Expand Up @@ -167,16 +184,15 @@ exports.registerInvitedUser = function (request, user, response) {
'Oops, your registration failed... Please try again!',
);

if (user.fbRequest) userModel.removeInviteByFbRequestIds(user.fbRequest);
else userModel.removeInvite(user.inviteCode);
userModel.removeInvite(user.inviteCode);

function loginAndRedirectTo(url) {
request.session.whydUid = storedUser.id || storedUser._id; // CREATING SESSION
if (user.ajax) {
const json = { redirect: url, uId: '' + storedUser._id };
const renderJSON = () => {
const renderJSON = (jsonData) => {
response[user.ajax == 'iframe' ? 'renderWrappedJSON' : 'renderJSON'](
json,
jsonData,
);
};
if (user.includeUser) {
Expand Down Expand Up @@ -215,10 +231,23 @@ exports.registerInvitedUser = function (request, user, response) {
else registerUser();
};

exports.controller = function (request, getParams, response) {
exports.controller = async function (request, getParams, response) {
request.logToConsole('register.controller', request.method);
if (request.method.toLowerCase() === 'post')
// sent by (new) register form
exports.registerInvitedUser(request, request.body, response);
else inviteController.renderRegisterPage(request, getParams, response);
else if (!!process.env.AUTH0_SECRET && request.oidc.user) {
// finalize user signup from Auth0, by persisting them into our database
const storedUser = await persistNewUserFromAuth0(request.oidc.user);
if (!storedUser) {
renderError(
request,
storedUser,
response,
'Oops, your registration failed... Please reach out to contact@openwhyd.org',
);
} else {
response.renderHTML(htmlRedirect('/')); // in reality, this ends up redirecting to the consent request page
}
} else inviteController.renderRegisterPage(request, getParams, response);
};
49 changes: 49 additions & 0 deletions app/lib/my-http-wrapper/http/Application.js
Expand Up @@ -145,6 +145,55 @@
});
});
}

const {
AUTH0_ISSUER_BASE_URL,
AUTH0_SECRET, // to generate with $ openssl rand -hex 32
AUTH0_CLIENT_ID,
} = process.env;

if (AUTH0_ISSUER_BASE_URL) {
const openId = require('express-openid-connect');

// auth router attaches /login, /logout, and /callback routes to the baseURL
app.use(
openId.auth({
authRequired: false,
auth0Logout: true,
secret: AUTH0_SECRET,
baseURL: this._urlPrefix,
clientID: AUTH0_CLIENT_ID,
issuerBaseURL: AUTH0_ISSUER_BASE_URL,
// cf https://auth0.github.io/express-openid-connect/interfaces/ConfigParams.html#afterCallback
// afterCallback: async (req, res, session, decodedState) => {
// const userProfile = await request(
// `${AUTH0_ISSUER_BASE_URL}/userinfo`,
// );
// console.warn('afterCallback', {
// session,
// decodedState,
// userProfile,
// });
// return { ...session, userProfile }; // access using req.appSession.userProfile
// },
}),
);

// example of route that gets user profile info from auth0
// app.get('/profile', openId.requiresAuth(), (req, res) => {
// const user = req.oidc.user; // e.g. {"nickname":"admin","name":"admin","picture":"https://s.gravatar.com/avatar/xxxxxx.png","updated_at":"2023-08-30T15:02:17.071Z","email":"test@openwhyd.org","sub":"auth0|000000000000000000000001","sid":"XXXXXX-XXXXXX-XXXXXX"}
// res.send(JSON.stringify(user));
// });

// redirects to Auth0's sign up dialog
app.get('/signup', (req, res) => {
res.oidc.login({
authorizationParams: { screen_hint: 'signup' },
returnTo: '/register', // so we can create the user in our database too
});
});
Fixed Show fixed Hide fixed
}

// app.set('view engine', 'hogan'); // TODO: use hogan.js to render "mustache" templates when res.render() is called
app.use(noCache); // called on all requests
app.use(express.static(this._publicDir));
Expand Down
51 changes: 25 additions & 26 deletions app/models/logging.js
Expand Up @@ -52,17 +52,6 @@

// ========= COOKIE STUFF

/**
* Generates a user session cookie string
* that can be supplied to a Set-Cookie HTTP header.
*/
/*
exports.makeCookie = function(user) {
var date = new Date((new Date()).getTime() + 1000 * 60 * 60 * 24 * 365);
return 'whydUid="'+(user.id || '')+'"; Expires=' + date.toGMTString();
};
*/

/**
* Transforms cookies found in the request into an object
*/
Expand Down Expand Up @@ -139,35 +128,45 @@
return this.getFbUid();
};

const useAuth0AsIdentityProvider = process.env.AUTH0_ISSUER_BASE_URL;

/**
* Returns the logged in user's uid, from its openwhyd session cookie
* Returns the logged in user's uid
*/
http.IncomingMessage.prototype.getUid = function () {
/*
var uid = (this.getCookies() || {})["whydUid"];
if (uid) uid = uid.replace(/\"/g, "");
//if (uid) console.log("found openwhyd session cookie", uid);
return uid;
*/
return (this.session || {}).whydUid;
};
http.IncomingMessage.prototype.getUid = useAuth0AsIdentityProvider
? function () {
const userId = this.oidc.isAuthenticated()
? this.oidc.user?.sub.replace('auth0|', '')
: null;
if (userId) {
this.session = this.session || {};

Check notice on line 142 in app/models/logging.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/models/logging.js#L142

Avoid assignments in operands
this.session.whydUid = userId;

Check notice on line 143 in app/models/logging.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/models/logging.js#L143

Avoid assignments in operands
}
return userId;
}
: function () {
return (this.session || {}).whydUid;
};

/**
* Returns the logged in user as an object {_id, id, fbId, name, img}
* @deprecated because it relies on a in-memory cache of users, call fetchByUid() instead.
*/
http.IncomingMessage.prototype.getUser = function () {
const uid = this.getUid();
if (uid) {
const user = mongodb.usernames[uid];
if (user) user.id = '' + user._id;
return user;
} else return null;
if (!uid) return null;
const user = mongodb.usernames[uid];
if (!user) console.trace(`logged user ${uid} not found in user cache`);
else user.id = '' + user._id;
return user ?? null;
};

//http.IncomingMessage.prototype.getUserFromFbUid = mongodb.getUserFromFbUid;

/** @deprecated because it relies on a in-memory cache of users, call fetchByUid() instead. */
http.IncomingMessage.prototype.getUserFromId = mongodb.getUserFromId;

/** @deprecated because it relies on a in-memory cache of users, call fetchByUid() instead. */
http.IncomingMessage.prototype.getUserNameFromId = mongodb.getUserNameFromId;

// ========= LOGIN/SESSION/PRIVILEGES STUFF
Expand Down
1 change: 1 addition & 0 deletions app/models/mongodb.js
Expand Up @@ -99,6 +99,7 @@ exports.cacheUsers = function (callback) {
);
};

/** @deprecated because the cursor cannot be released until results are exhausted. */
exports.forEach = async function (colName, params, handler, cb, cbParam) {
let q = {};
params = params || {};
Expand Down
2 changes: 1 addition & 1 deletion app/models/user.js
Expand Up @@ -323,7 +323,7 @@ exports.update = function (uid, update, handler) {

/**
*
* @param {UserDocument} pUser
* @param {Partial<UserDocument> & ({_id: string} | {id: string} | {email: string})} pUser
* @param {(userDocument:UserDocument) => any} handler
*/
exports.save = function (pUser, handler) {
Expand Down
2 changes: 1 addition & 1 deletion docs/login-flow.md
Expand Up @@ -6,7 +6,7 @@ Here's the login flow:
- in the case of a successfull login, `renderRedirect()` will indirectly initiate a cookie session, by storing the user id in `request.session`
- the cookie will be created by `express-session` which is attached to the web framework in the `start()` function of [`/app.js`](/app.js) (main entry point of the web app)
- the session is stored in the mongodb database by `connect-mongo`
- the session will be checked in all following HTTP requests received by openwhyd's web app, if they contain the `whydUid` cookie in their headers
- the session will be checked in all following HTTP requests received by openwhyd's web app, if they contain the `whydUid` cookie in their headers, thru the `getUid()` or `getUser()` methods added by `logging.js` to `IncomingMessage.prototype`

Notes:

Expand Down
6 changes: 6 additions & 0 deletions env-vars-testing.conf
Expand Up @@ -22,3 +22,9 @@ ALGOLIA_APP_ID=""
ALGOLIA_API_KEY=""
DISABLE_DATADOG="true"
LOG_REQ_THRESHOLD_MS="5000"

# experimental: use Auth0 as indentity provider, instead of checking password hashes ourselves
AUTH0_ISSUER_BASE_URL=""
AUTH0_CLIENT_ID=""
AUTH0_SECRET="" # to generate with $ openssl rand -hex 32
# dont forget to run $ ngrok tcp 27117, to expose local user accounts to Auth0