Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 80 additions & 2 deletions lib/controllers.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
'use strict';

const fetch = require('node-fetch');
const path = require('path');
const os = require('os');
const fs = require('fs').promises;

const fetch = require('node-fetch');
const nconf = require.main.require('nconf');

const db = require.main.require('./src/database');
const file = require.main.require('./src/file');
const groups = require.main.require('./src/groups');
const slugify = require.main.require('./src/slugify');
const helpers = require.main.require('./src/controllers/helpers');
Expand Down Expand Up @@ -62,6 +66,43 @@ Controllers.getStrategy = async (req, res) => {
helpers.formatApiResponse(200, res, { strategy });
};

Controllers.uploadIcon = async (req, res) => {
const dataUrl = req.body && (req.body.dataUrl || req.body.iconFile);
if (!dataUrl || typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/')) {
return helpers.formatApiResponse(400, res, new Error('[[error:invalid-file]]'));
}
const maxBytes = 100 * 1024;
const match = dataUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/);
if (!match) {
return helpers.formatApiResponse(400, res, new Error('[[error:invalid-file]]'));
}
const mime = match[1];
const base64Data = match[2];
const buffer = Buffer.from(base64Data, 'base64');
if (buffer.length > maxBytes) {
return helpers.formatApiResponse(400, res, new Error('[[error:file-too-big, 100]]'));
}
const allowedTypes = /^image\/(png|jpe?g|gif|webp|svg\+xml)$/i;
if (!allowedTypes.test(mime)) {
return helpers.formatApiResponse(400, res, new Error('[[error:invalid-file]]'));
}
const ext = file.typeToExtension(mime) || '.png';
const iconsFolder = 'plugins/sso-oauth2-multiple';
const filename = `${Date.now()}${ext}`;
const tempPath = path.join(os.tmpdir(), `oauth2-icon-${Date.now()}${ext}`);
try {
await fs.writeFile(tempPath, buffer);
const relativePath = nconf.get('relative_path') || '';
const uploadResult = await file.saveFileToLocal(filename, iconsFolder, tempPath);
const url = relativePath + uploadResult.url;
helpers.formatApiResponse(200, res, { url });
} catch (err) {
helpers.formatApiResponse(500, res, err);
} finally {
await file.delete(tempPath);
}
};

Controllers.editStrategy = async (req, res) => {
const name = slugify(req.params.name || req.body.name);
const payload = { ...req.body };
Expand All @@ -74,11 +115,48 @@ Controllers.editStrategy = async (req, res) => {

payload.enabled = !!req.body.enabled;

const checkboxes = ['forceUsernameViaEmail', 'usernameViaEmail', 'trustEmailVerified', 'syncFullname', 'syncPicture'];
const checkboxes = ['forceUsernameViaEmail', 'usernameViaEmail', 'usernameChoice', 'trustEmailVerified', 'syncFullname', 'syncPicture'];
checkboxes.forEach((prop) => {
payload[prop] = payload.hasOwnProperty(prop) && payload[prop] === 'on' ? 1 : 0;
});

if (payload.removeIcon === 'on') {
payload.iconUrl = '';
}
delete payload.removeIcon;

const relativePath = nconf.get('relative_path') || '';
const uploadPath = nconf.get('upload_path');
const iconsFolder = 'plugins/sso-oauth2-multiple';
const uploadUrlPrefix = `${relativePath}/assets/uploads/${iconsFolder}/`;

const getIconFilePathFromUrl = (url) => {
if (!url || typeof url !== 'string') {
return null;
}
const prefix = `${relativePath}/assets/uploads/${iconsFolder}/`;
if (!url.startsWith(prefix)) {
return null;
}
const filename = url.slice(prefix.length);
if (!filename || filename.includes('/')) {
return null;
}
return path.join(uploadPath, iconsFolder, filename);
};

const existing = await main.getStrategy(name);
const existingIconPath = existing ? getIconFilePathFromUrl(existing.iconUrl) : null;

if (payload.iconUrl) {
const currentPrefix = `${relativePath}/assets/uploads/${iconsFolder}/`;
if (!payload.iconUrl.startsWith(currentPrefix)) {
throw new Error('[[error:invalid-data]]');
}
} else if (existingIconPath) {
await file.delete(existingIconPath);
}

await Promise.all([
db.sortedSetAdd('oauth2-multiple:strategies', Date.now(), name),
db.setObject(`oauth2-multiple:strategies:${name}`, payload),
Expand Down
183 changes: 149 additions & 34 deletions library.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const meta = require.main.require('./src/meta');
const groups = require.main.require('./src/groups');
const authenticationController = require.main.require('./src/controllers/authentication');
const routeHelpers = require.main.require('./src/routes/helpers');
const slugify = require.main.require('./src/slugify');
const utils = require.main.require('./public/src/utils');

const OAuth = module.exports;

Expand All @@ -29,7 +31,7 @@ OAuth.addRoutes = async ({ router, middleware }) => {
];

routeHelpers.setupApiRoute(router, 'get', '/oauth2-multiple/discover', middlewares, controllers.getOpenIdMetadata);

routeHelpers.setupApiRoute(router, 'post', '/oauth2-multiple/upload-icon', middlewares, controllers.uploadIcon);
routeHelpers.setupApiRoute(router, 'post', '/oauth2-multiple/strategies', middlewares, controllers.editStrategy);
routeHelpers.setupApiRoute(router, 'get', '/oauth2-multiple/strategies/:name', middlewares, controllers.getStrategy);
routeHelpers.setupApiRoute(router, 'delete', '/oauth2-multiple/strategies/:name', middlewares, controllers.deleteStrategy);
Expand Down Expand Up @@ -62,12 +64,14 @@ OAuth.getStrategy = async (name) => {
async function getStrategies(names, full) {
const strategies = await db.getObjects(names.map(name => `oauth2-multiple:strategies:${name}`), full ? undefined : ['enabled']);
strategies.forEach((strategy, idx) => {
if (!strategy) {
return;
}
strategy.name = names[idx];
strategy.enabled = strategy.enabled === 'true' || strategy.enabled === true;
strategy.callbackUrl = `${nconf.get('url')}/auth/${names[idx]}/callback`;
});

return strategies;
return strategies.filter(Boolean);
}

OAuth.loadStrategies = async (strategies) => {
Expand Down Expand Up @@ -102,7 +106,8 @@ OAuth.loadStrategies = async (strategies) => {
handle: displayName,
email,
email_verified,
});
}, req);

winston.verbose(`[plugin/sso-oauth2-multiple] Successful login to uid ${user.uid} via ${name} (remote id ${id})`);
await authenticationController.onSuccessfulLogin(req, user.uid);
await OAuth.assignGroups({ provider: name, user, profile });
Expand All @@ -120,22 +125,39 @@ OAuth.loadStrategies = async (strategies) => {
passport.use(configured[idx].name, strategy);
});

strategies.push(...configured.map(({ name, scope, loginLabel, registerLabel, faIcon }) => ({
strategies.push(...configured.map(({
name,
url: `/auth/${name}`,
callbackURL: `/auth/${name}/callback`,
icon: faIcon || 'fa-right-to-bracket',
icons: {
normal: `fa ${faIcon || 'fa-right-to-bracket'}`,
square: `fa ${faIcon || 'fa-right-to-bracket'}`,
},
labels: {
login: loginLabel || 'Log In',
register: registerLabel || 'Register',
},
color: '#666',
scope: scope || 'openid email profile',
})));
scope,
loginLabel,
registerLabel,
faIcon,
iconUrl,
}) => {
const hasCustomIcon = Boolean(iconUrl);
const fallbackIcon = faIcon || 'fa-right-to-bracket';
const iconClass = `fa ${fallbackIcon}`;
const escapedUrl = hasCustomIcon ? iconUrl.replace(/'/g, "\\'") : '';

return {
name,
url: `/auth/${name}`,
callbackURL: `/auth/${name}/callback`,
icon: fallbackIcon,
icons: {
normal: iconClass,
square: iconClass,
...(hasCustomIcon && {
svg: `<i class="sso-oauth2-icon" style="background-image:url('${escapedUrl}');display:inline-block;width:1em;height:1em;background-size:contain;background-repeat:no-repeat;background-position:center;vertical-align:middle;"></i>`,
}),
},
labels: {
login: loginLabel || 'Log In',
register: registerLabel || 'Register',
},
color: '#666',
scope: scope || 'openid email profile',
};
}));

return strategies;
};
Expand Down Expand Up @@ -211,45 +233,61 @@ OAuth.getAssociations = async () => {
}));
};

OAuth.login = async (payload) => {
OAuth.login = async (payload, req) => {
let uid = await OAuth.getUidByOAuthid(payload.name, payload.oAuthid);
if (uid !== null) {
// Existing User
return ({ uid });
}

const { trustEmailVerified } = await OAuth.getStrategy(payload.name);
const { trustEmailVerified, usernameChoice } = await OAuth.getStrategy(payload.name);
const { email } = payload;
const email_verified =
parseInt(trustEmailVerified, 10) &&
(payload.email_verified || payload.email_verified === true);


// Check for user via email fallback
// Check for user via email fallback -- linking path, never shows the picker
if (email && email_verified) {
uid = await user.getUidByEmail(payload.email);
}

if (!uid) {
// New user
uid = await user.create({
username: payload.handle,
});
if (uid) {
await user.setUserField(uid, `${payload.name}Id`, payload.oAuthid);
await db.setObjectField(`${payload.name}Id:uid`, payload.oAuthid, uid);
return { uid };
}

// Automatically confirm user email
if (email) {
await user.setUserField(uid, 'email', email);
// Brand-new user. When usernameChoice is on, create with a unique placeholder
// and stage a session marker so middleware.registrationComplete routes the
// logged-in user through /register/complete, where our interstitial fires.
const pickerMode = Boolean(parseInt(usernameChoice, 10) && req);
const username = pickerMode ?
`oauth2-tmp-${payload.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` :
payload.handle;

if (email_verified) {
await user.email.confirmByUid(uid);
}
uid = await user.create({ username });

if (email) {
await user.setUserField(uid, 'email', email);
if (email_verified) {
await user.email.confirmByUid(uid);
}
}

// Save provider-specific information to the user
await user.setUserField(uid, `${payload.name}Id`, payload.oAuthid);
await db.setObjectField(`${payload.name}Id:uid`, payload.oAuthid, uid);

if (pickerMode) {
req.session.registration = req.session.registration || {};
req.session.registration.uid = uid;
req.session.registration._oauth2MultipleRename = {
provider: payload.name,
suggestedBase: payload.handle || (email ? email.split('@')[0] : ''),
email: email || '',
};
}

return { uid };
};

Expand Down Expand Up @@ -326,3 +364,80 @@ OAuth.whitelistFields = async (params) => {

return params;
};

OAuth.addInterstitial = async (data) => {
const { userData, interstitials } = data;
const marker = userData && userData._oauth2MultipleRename;
if (!marker || !userData.uid) {
return data;
}

const suggested = await OAuth.suggestAvailableUsername(marker.suggestedBase, marker.email);

interstitials.push({
template: 'partials/oauth2-multiple/choose-username.tpl',
data: {
provider: marker.provider,
suggestedUsername: suggested,
email: marker.email || '',
},
callback: OAuth.onUsernameSubmit,
});

return data;
};

OAuth.onUsernameSubmit = async (userData, formData) => {
const marker = userData && userData._oauth2MultipleRename;
if (!marker || !userData.uid) {
return userData;
}

const chosen = (formData.username || '').trim();
if (!chosen || !utils.isUserNameValid(chosen)) {
throw new Error('[[error:invalid-username]]');
}

const slug = slugify(chosen);
if (!slug) {
throw new Error('[[error:invalid-username]]');
}

const [existingUid, groupExists] = await Promise.all([
user.getUidByUserslug(slug),
groups.exists(chosen),
]);
if ((existingUid && parseInt(existingUid, 10) !== parseInt(userData.uid, 10)) || groupExists) {
throw new Error('[[error:username-taken]]');
}

await user.updateProfile(userData.uid, { uid: userData.uid, username: chosen }, ['username']);

// Strip marker + temp fields so registerComplete's setUserFields doesn't overwrite the rename.
delete userData._oauth2MultipleRename;
Object.keys(userData).forEach((key) => {
if (key !== 'uid' && key !== 'returnTo') {
delete userData[key];
}
});
return userData;
};

OAuth.suggestAvailableUsername = async (handle, email) => {
const base = ((handle && handle.trim()) ||
(email && email.split('@')[0]) || 'user').trim();
const baseSlug = slugify(base);
if (baseSlug && !(await user.existsBySlug(baseSlug)) && !(await groups.exists(base))) {
return base;
}
for (let i = 1; i < 100; i += 1) {
const candidate = `${base}${i}`;
const slug = slugify(candidate);
/* eslint-disable no-await-in-loop */
if (slug && !(await user.existsBySlug(slug)) && !(await groups.exists(candidate))) {
return candidate;
}
/* eslint-enable no-await-in-loop */
}
return base;
};
9 changes: 7 additions & 2 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@
{ "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
{ "hook": "static:user.delete", "method": "deleteUserData" },
{ "hook": "filter:user.whitelistFields", "method": "whitelistFields" },
{ "hook": "filter:auth.init", "method": "loadStrategies" }
{ "hook": "filter:auth.init", "method": "loadStrategies" },
{ "hook": "filter:register.interstitial", "method": "addInterstitial" }
],
"modules": {
"../admin/plugins/sso-oauth2-multiple.js": "./static/lib/admin.js"
},
"scss": [
"static/scss/oauth2-icons.scss"
],
"acpScripts": [
"static/lib/acp.js"
],
"templates": "static/templates"
"templates": "static/templates",
"languages": "static/languages"
}
6 changes: 6 additions & 0 deletions static/languages/en-GB/oauth2-multiple.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"choose-username.title": "Choose your username",
"choose-username.intro": "You signed in via %1. Pick the username you want to use on this forum. This cannot be easily changed later.",
"choose-username.help": "Letters, numbers, spaces, periods, hyphens and underscores are allowed.",
"choose-username.email-provider": "Your email comes from your identity provider and cannot be edited here."
}
Loading