diff --git a/example.config.json b/example.config.json index ae8ef7d0..b23f6a62 100644 --- a/example.config.json +++ b/example.config.json @@ -46,5 +46,32 @@ "api_token": "api_token", "board_name": "board_name" }, - "api_base": "https://api.domain.com" + "api_base": "https://api.domain.com", + "discourse": { + "sso": { + "secret": "Discourse SSO secret" + }, + "api": { + "base_url": "https://discourse.example.com", + "username": "system", + "key": "Discourse API key" + }, + "groups": { + "access_level": { + "1": "testers", + "2": "juxt-moderators", + "3": "developers" + }, + "stripe_tier": { + "1": "supporters-mario", + "2": "supporters-super", + "3": "supporters-mega" + }, + "discord_role": { + "1234567890123456789": "developers", + "9876543210987654321": "discord-moderators", + "1234567890987654321": "network-moderators" + } + } + } } diff --git a/src/routes/account.js b/src/routes/account.js index bff830fa..29d7bbb5 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -454,20 +454,7 @@ router.get('/sso/discourse', async (request, response, next) => { 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 payload = await util.createDiscoursePayload(decodedPayload.get('nonce'), accountData); const query = new URLSearchParams({ sso: payload, @@ -538,20 +525,7 @@ router.post('/sso/discourse', async (request, response, next) => { 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 payload = await util.createDiscoursePayload(decodedPayload.get('nonce'), accountData); const query = new URLSearchParams({ sso: payload, diff --git a/src/schema/pnid.js b/src/schema/pnid.js index e613ceb6..297e4e43 100644 --- a/src/schema/pnid.js +++ b/src/schema/pnid.js @@ -10,6 +10,10 @@ const PNIDSchema = new Schema({ server_access_level: String, access_level: Number, username: String, + mii: { + name: String, + image_url: String + }, connections: { discord: { id: String diff --git a/src/stripe.js b/src/stripe.js index eddb3820..3880ac70 100644 --- a/src/stripe.js +++ b/src/stripe.js @@ -286,6 +286,15 @@ async function handleStripeEvent(event) { } } } + + try { + if (await util.discourseUserExists(pid)) { + const updatedPNID = await database.PNID.findOne({ pid }); + await util.syncDiscourseSso(updatedPNID); + } + } catch (error) { + logger.error(`Error syncing user Discourse SSO | ${pid} | - ${error.message}`); + } } } diff --git a/src/util.js b/src/util.js index 00593112..ec439beb 100644 --- a/src/util.js +++ b/src/util.js @@ -231,6 +231,12 @@ function nintendoPasswordHash(password, pid) { return hashed; } +async function discordMemberHasRole(memberId, roleId) { + const response = await discordRest.get(DiscordRoutes.guildMember(config.discord.guild_id, memberId)); + + return response.roles.includes(roleId); +} + async function assignDiscordMemberSupporterRole(memberId, roleId) { if (memberId && memberId.trim() !== '') { await discordRest.put(DiscordRoutes.guildMemberRole(config.discord.guild_id, memberId, config.discord.roles.supporter)); @@ -257,10 +263,102 @@ async function removeDiscordMemberTesterRole(memberId) { } } +async function createDiscoursePayload(nonce, accountData) { + const groups = config.discourse.groups; + const managedGroups = Object.values(groups).flatMap(category => Object.values(category)); + const addGroups = []; + + // * If more than one of the provided groups in add_groups are configured to + // * be automatically set as the primary group, Discourse unfortunately + // * appears to set the user's primary group arbitrarily and + // * non-deterministically. However, it also ignores groups that the user + // * was already in before this sign-in, so the primary group won't change + // * if none of the user's group memberships change. + if (accountData.connections.discord?.id) { + for (const role in groups.discord_role) { + if (await discordMemberHasRole(accountData.connections.discord.id, role)) { + addGroups.push(groups.discord_role[role]); + } + } + } + + if (accountData.connections.stripe?.tier_level) { + for (const tier in groups.stripe_tier) { + if (accountData.connections.stripe.tier_level.toString() === tier) { + addGroups.push(groups.stripe_tier[tier]); + } + } + } + + for (const level in groups.access_level) { + if (accountData.access_level.toString() === level) { + addGroups.push(groups.access_level[level]); + } + } + + const removeGroups = managedGroups.filter(group => !addGroups.includes(group)); + + // * Discourse SSO Payload + // * https://meta.discourse.org/t/official-single-sign-on-for-discourse-sso/13045 + + // * 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 + return Buffer.from(new URLSearchParams({ + nonce: nonce, + external_id: accountData.pid, + email: `${accountData.pid}@invalid.com`, // * Hack to get unique emails + username: accountData.username, + name: accountData.mii.name, + avatar_url: accountData.mii.image_url, + avatar_force_update: true, + add_groups: addGroups.join(','), + remove_groups: removeGroups.join(',') + }).toString()).toString('base64'); +} + function signDiscoursePayload(payload) { return crypto.createHmac('sha256', config.discourse.sso.secret).update(payload).digest('hex'); } +async function discourseUserExists(pid) { + const response = await got.get(`${config.discourse.api.base_url}/users/by-external/${pid}.json`, { + throwHttpErrors: false, + responseType: 'json' + }); + + if (response.statusCode === 200) { + return true; + } else if (response.statusCode === 404) { + return false; + } else { + throw new Error(`Discourse API error while checking if user ${pid} exists: ${response.statusCode} - ${JSON.stringify(response.body)}`); + } +} + +async function syncDiscourseSso(pnid) { + // * Documentation: https://meta.discourse.org/t/sync-discourseconnect-user-data-with-the-sync-sso-route/84398 + const headers = { + 'Content-Type': 'multipart/form-data', + 'Api-Username': config.discourse.api.username, + 'Api-Key': config.discourse.api.key + }; + + const payload = await createDiscoursePayload('', pnid); + const post_data = { + 'sso': payload, + 'sig': signDiscoursePayload(payload) + }; + + return got.post(`${config.discourse.api.base_url}/admin/users/sync_sso`, { + headers: headers, + form: post_data, + responseType: 'json' + }); +} + module.exports = { fullUrl, getLocale, @@ -281,5 +379,8 @@ module.exports = { assignDiscordMemberTesterRole, removeDiscordMemberSupporterRole, removeDiscordMemberTesterRole, - signDiscoursePayload + createDiscoursePayload, + signDiscoursePayload, + discourseUserExists, + syncDiscourseSso };