feat: discourse SSO

This commit is contained in:
Jonathan Barrow 2024-05-07 17:19:03 -04:00
parent ad0563b244
commit 255e68a881
No known key found for this signature in database
GPG Key ID: E86E9FE9049C741F
5 changed files with 151 additions and 6 deletions

View File

@ -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' });

View File

@ -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;

View File

@ -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

View File

@ -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
};

View File

@ -5,9 +5,8 @@
{{> header}}
<div class="wrapper">
<div class="account-form-wrapper">
<form action="/account/login" method="post" class="account">
<form action="{{ loginPath }}" method="post" class="account">
<h2>{{ locale.account.loginForm.login }}</h2>
<p>{{ locale.account.loginForm.detailsPrompt }}</p>
<div>
@ -23,8 +22,10 @@
<input name="redirect" id="redirect" type="hidden" value="{{redirect}}">
<div class="buttons">
<button type="submit">{{ locale.account.loginForm.login }}</button>
<a href="/account/register{{#if redirect}}?redirect={{redirect}}{{/if}}" class="register">{{ locale.account.loginForm.registerPrompt }}</a>
<a href="/account/register{{#if redirect}}?redirect={{redirect}}{{/if}}" class="register">{{ locale.account.loginForm.registerPrompt }}</a>
</div>
<input type="hidden" id="discourse-sso-payload" name="discourse-sso-payload" value="{{ discourse.payload }}" />
<input type="hidden" id="discourse-sso-signature" name="discourse-sso-signature" value="{{ discourse.signature }}" />
</form>
</div>
</div>