diff --git a/src/middleware/render-data.js b/src/middleware/render-data.js index 95a916e..e518285 100644 --- a/src/middleware/render-data.js +++ b/src/middleware/render-data.js @@ -92,6 +92,11 @@ async function renderDataMiddleware(request, response, next) { request.pnid = await database.PNID.findOne({ pid: response.locals.account.pid }); request.account = response.locals.account; + if (request.pnid.deleted) { + // TODO - We just need to overhaul our API tbh + throw new Error('User not found'); + } + return next(); } catch (error) { response.cookie('error_message', error.message, { domain: '.pretendo.network' }); diff --git a/src/routes/account.js b/src/routes/account.js index 1f398b0..a78a6a1 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -71,7 +71,8 @@ router.get('/', requireLoginMiddleware, async (request, response) => { router.get('/login', async (request, response) => { const renderData = { - error: request.cookies.error_message + error: request.cookies.error_message, + loginPath: '/account/login' }; response.render('account/login', renderData); @@ -88,7 +89,6 @@ router.post('/login', async (request, response) => { response.cookie('token_type', tokens.token_type, { domain: '.pretendo.network' }); response.redirect(request.redirect || '/account'); - } catch (error) { console.log(error); response.cookie('error_message', error.message, { domain: '.pretendo.network' }); @@ -407,5 +407,138 @@ router.post('/stripe/webhook', async (request, response) => { response.json({ received: true }); }); +router.get('/sso/discourse', async (request, response, next) => { + if (!request.query.sso || !request.query.sig) { + return next(); // * 404 + } + + const signature = util.signDiscoursePayload(request.query.sso); + + if (signature !== request.query.sig) { + return next(); // * 404 + } + + const decodedPayload = new URLSearchParams(Buffer.from(request.query.sso, 'base64').toString()); + + if (!decodedPayload.has('nonce') || !decodedPayload.has('return_sso_url')) { + return next(); // * 404 + } + + // * User already logged in, don't show the login prompt + if (request.cookies.access_token && request.cookies.refresh_token) { + try { + const accountData = await util.getUserAccountData(request, response); + + // * Discourse REQUIRES unique emails, however we do not due to NN also + // * not requiring unique email addresses. Email addresses, for now, + // * are faked using the users PID. This will essentially disable email + // * for the forum, but it's a bullet we have to bite for right now. + // TODO - We can run our own SMTP server which maps fake emails (pid@pretendo.whatever) to users real emails + const payload = Buffer.from(new URLSearchParams({ + nonce: decodedPayload.get('nonce'), + external_id: accountData.pid, + email: `${accountData.pid}@invalid.com`, // * Hack to get unique emails + username: accountData.username, + name: accountData.username, + avatar_url: accountData.mii.image_url, + avatar_force_update: true + }).toString()).toString('base64'); + + const query = new URLSearchParams({ + sso: payload, + sig: util.signDiscoursePayload(payload) + }).toString(); + + return response.redirect(`${decodedPayload.get('return_sso_url')}?${query}`); + } catch (error) { + console.log(error); + response.cookie('error_message', error.message, { domain: '.pretendo.network' }); + return response.redirect('/account/logout'); + } + } + + // * User not logged in already, show the login page + const renderData = { + discourse: { + // * Fast and dirty sanitization. If the strings contain + // * characters not allow in their encodings, they are removed + // * when doing this decode-encode. Since neither base64/hex + // * allow characters such as < and >, this prevents injection. + payload: Buffer.from(request.query.sso, 'base64').toString('base64'), + signature: Buffer.from(request.query.sig, 'hex').toString('hex') + }, + loginPath: '/account/sso/discourse' + }; + + response.render('account/login', renderData); // * Just reuse the /account/login page, no need to duplicate the pages +}); + +router.post('/sso/discourse', async (request, response, next) => { + if (!request.body['discourse-sso-payload'] || !request.body['discourse-sso-signature']) { + return next(); // * 404 + } + + const { username, password } = request.body; + + // * Fast and dirty sanitization. If the strings contain + // * characters not allow in their encodings, they are removed + // * when doing this decode-encode. Since neither base64/hex + // * allow characters such as < and >, this prevents injection. + const discoursePayload = Buffer.from(request.body['discourse-sso-payload'], 'base64').toString('base64'); + const discourseSignature = Buffer.from(request.body['discourse-sso-signature'], 'hex').toString('hex'); + + const signature = util.signDiscoursePayload(discoursePayload); + + if (signature !== discourseSignature) { + return next(); // * 404 + } + + const decodedPayload = new URLSearchParams(Buffer.from(discoursePayload, 'base64').toString()); + + if (!decodedPayload.has('nonce') || !decodedPayload.has('return_sso_url')) { + return next(); // * 404 + } + + try { + const tokens = await util.login(username, password); + + response.cookie('refresh_token', tokens.refresh_token, { domain: '.pretendo.network' }); + response.cookie('access_token', tokens.access_token, { domain: '.pretendo.network' }); + response.cookie('token_type', tokens.token_type, { domain: '.pretendo.network' }); + + // * Need to set these here so that getUserAccountData can see them + request.cookies.refresh_token = tokens.refresh_token; + request.cookies.access_token = tokens.access_token; + request.cookies.token_type = tokens.token_type; + + const accountData = await util.getUserAccountData(request, response); + + // * Discourse REQUIRES unique emails, however we do not due to NN also + // * not requiring unique email addresses. Email addresses, for now, + // * are faked using the users PID. This will essentially disable email + // * for the forum, but it's a bullet we have to bite for right now. + // TODO - We can run our own SMTP server which maps fake emails (pid@pretendo.whatever) to users real emails + const payload = Buffer.from(new URLSearchParams({ + nonce: decodedPayload.get('nonce'), + external_id: accountData.pid, + email: `${accountData.pid}@invalid.com`, // * Hack to get unique emails + username: accountData.username, + name: accountData.username, + avatar_url: accountData.mii.image_url, + avatar_force_update: true + }).toString()).toString('base64'); + + const query = new URLSearchParams({ + sso: payload, + sig: util.signDiscoursePayload(payload) + }).toString(); + + return response.redirect(`${decodedPayload.get('return_sso_url')}?${query}`); + } catch (error) { + console.log(error); + response.cookie('error_message', error.message, { domain: '.pretendo.network' }); + return response.redirect('/account/login'); + } +}); module.exports = router; diff --git a/src/schema/pnid.js b/src/schema/pnid.js index a3f023d..e613ceb 100644 --- a/src/schema/pnid.js +++ b/src/schema/pnid.js @@ -2,6 +2,7 @@ const { Schema } = require('mongoose'); // Only define what we will be using const PNIDSchema = new Schema({ + deleted: Boolean, pid: { type: Number, unique: true diff --git a/src/util.js b/src/util.js index 043091a..f9d52ab 100644 --- a/src/util.js +++ b/src/util.js @@ -247,6 +247,10 @@ async function removeDiscordMemberTesterRole(memberId) { } } +function signDiscoursePayload(payload) { + return crypto.createHmac('sha256', config.discourse.sso.secret).update(payload).digest('hex'); +} + module.exports = { fullUrl, getLocale, @@ -265,5 +269,6 @@ module.exports = { assignDiscordMemberSupporterRole, assignDiscordMemberTesterRole, removeDiscordMemberSupporterRole, - removeDiscordMemberTesterRole + removeDiscordMemberTesterRole, + signDiscoursePayload }; diff --git a/views/account/login.handlebars b/views/account/login.handlebars index ce234d0..79afa97 100644 --- a/views/account/login.handlebars +++ b/views/account/login.handlebars @@ -5,9 +5,8 @@ {{> header}}
-
-