From 8cb48abb3955bca936cdef278653b3658f45e03b Mon Sep 17 00:00:00 2001 From: Evert Date: Thu, 23 Nov 2017 18:18:26 +0200 Subject: [PATCH] Change external login system so it uses common funtions, reducing repeated code --- server/api/external.js | 333 +++++++++++++++++------------------------ server/api/image.js | 29 +++- server/routes/api.js | 93 ++++-------- server/routes/index.js | 4 +- 4 files changed, 192 insertions(+), 267 deletions(-) diff --git a/server/api/external.js b/server/api/external.js index 4ef9c5c..4e41c87 100644 --- a/server/api/external.js +++ b/server/api/external.js @@ -1,20 +1,26 @@ import config from '../../scripts/load-config' import http from '../../scripts/http' import models from './models' +import image from './image' import UAPI from './index' import qs from 'querystring' import oauth from 'oauth-libre' -import path from 'path' -import url from 'url' import uuidV1 from 'uuid/v1' +import crypto from 'crypto' -const imgdir = path.join(__dirname, '../../', 'usercontent', 'images') +const userFields = ['username', 'email', 'avatar_file', 'display_name', 'ip_address'] let twitterApp let discordApp const API = { Common: { + // Generate a hash based on the current session + stateGenerator: (req) => { + let sessionCrypto = req.session.id + ':' + config.server.session_secret + return crypto.createHash('sha256').update(sessionCrypto).digest('hex') + }, + // Find an user with an external ID getExternal: async (service, identifier) => { let extr = await models.External.query().where('service', service).andWhere('identifier', identifier) if (!extr || !extr.length) return null @@ -30,10 +36,12 @@ const API = { return extr }, + // Get user ban status getBan: async (user, ipAddress) => { let banList = await UAPI.User.getBanStatus(ipAddress || user.id, ipAddress != null) return banList }, + // Create a new `external` instance for a user new: async (service, identifier, user) => { let data = { user_id: user.id, @@ -45,6 +53,7 @@ const API = { await await models.External.query().insert(data) return true }, + // Create a new user newUser: async (service, identifier, data) => { let udataLimited = Object.assign({ activated: 1, @@ -74,6 +83,7 @@ const API = { return newUser }, + // Remove an `external` object (thus unlinking from a service) remove: async (user, service) => { user = await UAPI.User.ensureObject(user, ['password']) let userExterns = await models.External.query().orderBy('created_at', 'asc').where('user_id', user.id) @@ -89,30 +99,81 @@ const API = { return models.External.query().delete().where('user_id', user.id).andWhere('service', service) }, - saveAvatar: async (avatarUrl) => { - if (!avatarUrl) return null - let imageName = 'download-' + UAPI.Hash(12) - let uridata = url.parse(avatarUrl) - let pathdata = path.parse(uridata.path) + // Common code for all auth callbacks + callback: async (identifier, uid, user, ipAddress, remoteData, avatarFunc) => { + let exists = await API.Common.getExternal(identifier, uid) - imageName += pathdata.ext || '.png' + if (user) { + // Get bans for user + let bans = await API.Common.getBan(user) + if (bans.length) return { banned: bans, ip: false } - try { - await http.Download(avatarUrl, path.join(imgdir, imageName)) - } catch (e) { - return null + if (exists) return {error: null, user: user} + + await API.Common.new(identifier, uid, user) + return {error: null, user: user} } - return {fileName: imageName} + // Callback succeeded with user id and the external table exists, we log in the user + if (exists) { + // Get bans for user + let bans = await API.Common.getBan(exists.user) + + if (bans.length) return { banned: bans, ip: false } + return {error: null, user: exists.user} + } + + // Get bans for IP address + let bans = await API.Common.getBan(null, ipAddress) + if (bans.length) return { banned: bans, ip: true } + + // Run the function for avatar fetching + let avatar = null + if (avatarFunc) { + avatar = await avatarFunc(remoteData) + } + + // Assign the data + let newUData = Object.assign({ + email: remoteData.email || '', + avatar_file: avatar, + ip_address: ipAddress + }, remoteData) + + // Remove unnecessary fields + for (let i in newUData) { + if (userFields.indexOf(i) === -1) { + delete newUData[i] + } + } + + let newUser = await API.Common.newUser(identifier, uid, newUData) + if (!newUser) return {error: 'Failed to create user.'} + + return {error: null, user: newUser} } }, Facebook: { - callback: async (user, data) => { - if (!data.authResponse || data.status !== 'connected') { + getAvatar: async (rawData) => { + let profilepic = null + + if (rawData.picture) { + if (rawData.picture.is_silhouette === false && rawData.picture.url) { + let imgdata = await image.downloadImage(rawData.picture.url) + if (imgdata && imgdata.fileName) { + profilepic = imgdata.fileName + } + } + } + + return profilepic + }, + callback: async (user, authResponse, ipAddress) => { + if (!authResponse) { return {error: 'No Authorization'} } - let uid = data.authResponse.userID + let uid = authResponse.userID if (!uid) { return {error: 'No Authorization'} } @@ -120,7 +181,7 @@ const API = { // Get facebook user information in order to create a new user or verify let fbdata let intel = { - access_token: data.authResponse.accessToken, + access_token: authResponse.accessToken, fields: 'name,email,picture,short_name' } @@ -135,57 +196,28 @@ const API = { return {error: fbdata.error.message} } - let exists = await API.Common.getExternal('fb', uid) - - if (user) { - // Get bans for user - let bans = await API.Common.getBan(user) - if (bans.length) return { banned: bans, ip: false } - - if (exists) return {error: null, user: user} - - await API.Common.new('fb', uid, user) - return {error: null, user: user} - } - - // Callback succeeded with user id and the external table exists, we log in the user - if (exists) { - // Get bans for user - let bans = await API.Common.getBan(exists.user) - if (bans.length) return { banned: bans, ip: false } - return {error: null, user: exists.user} - } - - // Get bans for IP address - let bans = await API.Common.getBan(null, data.ip_address) - if (bans.length) return { banned: bans, ip: true } - - // Determine profile picture - let profilepic = null - if (fbdata.picture) { - if (fbdata.picture.is_silhouette === false && fbdata.picture.url) { - let imgdata = await API.Common.saveAvatar(fbdata.picture.url) - if (imgdata && imgdata.fileName) { - profilepic = imgdata.fileName - } - } - } - - let newUData = { + let cleanedData = Object.assign(fbdata, { username: fbdata.short_name || 'FB' + UAPI.Hash(4), display_name: fbdata.name, - email: fbdata.email || '', - avatar_file: profilepic, - ip_address: data.ip_address - } + email: fbdata.email || '' + }) - let newUser = await API.Common.newUser('fb', uid, newUData) - if (!newUser) return {error: 'Failed to create user.'} - - return {error: null, user: newUser} + return API.Common.callback('facebook', uid, user, ipAddress, cleanedData, API.Facebook.getAvatar) } }, Twitter: { + getAvatar: async function (rawData) { + let profilepic = null + + if (rawData.profile_image_url_https) { + let imgdata = await image.downloadImage(rawData.profile_image_url_https) + if (imgdata && imgdata.fileName) { + profilepic = imgdata.fileName + } + } + + return profilepic + }, oauthApp: function () { if (!twitterApp) { let redirectUri = config.server.domain + '/api/external/twitter/callback' @@ -243,56 +275,28 @@ const API = { } let uid = twdata.id_str - let exists = await API.Common.getExternal('twitter', uid) - if (user) { - // Get bans for user - let bans = await API.Common.getBan(user) - if (bans.length) return { banned: bans, ip: false } + let cleanedData = Object.assign(twdata, { + username: twdata.screen_name, + display_name: twdata.name, + email: twdata.email || '' + }) - if (exists) return {error: null, user: user} - - await API.Common.new('twitter', uid, user) - return {error: null, user: user} - } - - // Callback succeeded with user id and the external table exists, we log in the user - if (exists) { - // Get bans for user - let bans = await API.Common.getBan(exists.user) - if (bans.length) return { banned: bans, ip: false } - return {error: null, user: exists.user} - } - - // Get bans for IP - let bans = await API.Common.getBan(null, ipAddress) - if (bans.length) return { banned: bans, ip: true } - - // Determine profile picture + return API.Common.callback('twitter', uid, user, ipAddress, cleanedData, API.Twitter.getAvatar) + } + }, + Google: { + getAvatar: async (rawData) => { let profilepic = null - if (twdata.profile_image_url_https) { - let imgdata = await API.Common.saveAvatar(twdata.profile_image_url_https) + if (rawData.image) { + let imgdata = await image.downloadImage(rawData.image) if (imgdata && imgdata.fileName) { profilepic = imgdata.fileName } } - // Create a new user - let newUData = { - username: twdata.screen_name, - display_name: twdata.name, - email: twdata.email || '', - avatar_file: profilepic, - ip_address: ipAddress - } - - let newUser = await API.Common.newUser('twitter', uid, newUData) - if (!newUser) return {error: 'Failed to create user.'} - - return {error: null, user: newUser} - } - }, - Google: { + return profilepic + }, callback: async (user, data, ipAddress) => { let uid @@ -312,56 +316,32 @@ const API = { return {error: e.message} } - let exists = await API.Common.getExternal('google', uid) - - if (user) { - // Get bans for user - let bans = await API.Common.getBan(user) - if (bans.length) return { banned: bans, ip: false } - - if (exists) return {error: null, user: user} - - await API.Common.new('google', uid, user) - return {error: null, user: user} - } - - // Callback succeeded with user id and the external table exists, we log in the user - if (exists) { - // Get bans for user - let bans = await API.Common.getBan(exists.user) - if (bans.length) return { banned: bans, ip: false } - return {error: null, user: exists.user} - } - - // Get bans for IP - let bans = await API.Common.getBan(null, ipAddress) - if (bans.length) return { banned: bans, ip: true } - - // Determine profile picture - let profilepic = null - if (data.image) { - let imgdata = await API.Common.saveAvatar(data.image) - if (imgdata && imgdata.fileName) { - profilepic = imgdata.fileName - } - } - - // Create a new user - let newUData = { + let cleanedData = Object.assign(data, { username: data.name.replace(/\W+/gi, ''), display_name: data.name, - email: data.email || '', - avatar_file: profilepic, - ip_address: ipAddress - } + email: data.email || '' + }) - let newUser = await API.Common.newUser('google', uid, newUData) - if (!newUser) return {error: 'Failed to create user.'} - - return {error: null, user: newUser} + return API.Common.callback('google', uid, user, ipAddress, cleanedData, API.Google.getAvatar) } }, Discord: { + getAvatar: async (rawData) => { + let profilepic = null + let aviSnowflake = rawData.avatar + if (aviSnowflake) { + try { + let avpt = await image.downloadImage('https://cdn.discordapp.com/avatars/' + rawData.id + '/' + aviSnowflake + '.png') + if (avpt && avpt.fileName) { + profilepic = avpt.fileName + } + } catch (e) { + profilepic = null + } + } + + return profilepic + }, oauth2App: function () { if (discordApp) return discordApp = new oauth.PromiseOAuth2( @@ -374,9 +354,9 @@ const API = { discordApp.useAuthorizationHeaderforGET(true) }, - getAuthorizeURL: function () { + getAuthorizeURL: function (req) { if (!discordApp) API.Discord.oauth2App() - let state = UAPI.Hash(6) + let state = API.Common.stateGenerator(req) let redirectUri = config.server.domain + '/api/external/discord/callback' const params = { @@ -421,58 +401,15 @@ const API = { } let uid = ddata.id - let exists = await API.Common.getExternal('discord', uid) - - if (user) { - // Get bans for user - let bans = await API.Common.getBan(user) - if (bans.length) return { banned: bans, ip: false } - - if (exists) return {error: null, user: user} - - await API.Common.new('discord', uid, user) - return {error: null, user: user} - } - - // Callback succeeded with user id and the external table exists, we log in the user - if (exists) { - // Get bans for user - let bans = await API.Common.getBan(exists.user) - if (bans.length) return { banned: bans, ip: false } - return {error: null, user: exists.user} - } - - // Get bans for IP - let bans = await API.Common.getBan(null, ipAddress) - if (bans.length) return { banned: bans, ip: true } - - // Download profile picture - let profilepic = null - let aviSnowflake = ddata.avatar - if (aviSnowflake) { - try { - let avpt = await API.Common.saveAvatar('https://cdn.discordapp.com/avatars/' + ddata.id + '/' + aviSnowflake + '.png') - if (avpt && avpt.fileName) { - profilepic = avpt.fileName - } - } catch (e) { - profilepic = null - } - } // Create a new user - let newUData = { + let cleanedData = Object.assign(ddata, { username: ddata.username.replace(/\W+/gi, '_'), display_name: ddata.username, - email: ddata.email || '', - avatar_file: profilepic, - ip_address: ipAddress - } + email: ddata.email || '' + }) - let newUser = await API.Common.newUser('discord', uid, newUData) - if (!newUser) return {error: 'Failed to create user.'} - - return {error: null, user: newUser} + return API.Common.callback('discord', uid, user, ipAddress, cleanedData, API.Discord.getAvatar) } } } diff --git a/server/api/image.js b/server/api/image.js index 6d6706c..a2e8b19 100644 --- a/server/api/image.js +++ b/server/api/image.js @@ -1,8 +1,11 @@ import gm from 'gm' +import url from 'url' import path from 'path' import crypto from 'crypto' import Promise from 'bluebird' +import http from '../../scripts/http' + const fs = Promise.promisifyAll(require('fs')) const uploads = path.join(__dirname, '../../', 'usercontent') @@ -14,6 +17,10 @@ const imageTypes = { 'image/jpeg': '.jpeg' } +function imageUniquifier () { + return crypto.randomBytes(12).toString('hex') +} + function decodeBase64Image (dataString) { let matches = dataString.match(/^data:([A-Za-z-+/]+);base64,(.+)$/) let response = {} @@ -53,7 +60,7 @@ async function imageBase64 (baseObj) { if (!imgData) return null if (!imageTypes[imgData.type]) return null - let imageName = 'base64-' + crypto.randomBytes(12).toString('hex') + let imageName = 'base64-' + imageUniquifier() let ext = imageTypes[imgData.type] || '.png' imageName += ext @@ -70,6 +77,25 @@ async function imageBase64 (baseObj) { return {file: fpath} } +async function downloadImage (imgUrl, designation) { + if (!imgUrl) return null + if (!designation) designation = 'download' + + let imageName = designation + '-' + imageUniquifier() + let uridata = url.parse(imgUrl) + let pathdata = path.parse(uridata.path) + + imageName += pathdata.ext || '.png' + + try { + await http.Download(imgUrl, path.join(images, imageName)) + } catch (e) { + return null + } + + return {fileName: imageName} +} + async function uploadImage (identifier, fields, files) { if (!files.image) return {error: 'No image file'} @@ -142,6 +168,7 @@ async function uploadImage (identifier, fields, files) { } module.exports = { + downloadImage: downloadImage, uploadImage: uploadImage, imageBase64: imageBase64, types: imageTypes diff --git a/server/routes/api.js b/server/routes/api.js index e260b37..1ff26e3 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -79,16 +79,34 @@ function JsonData (req, res, error, redirect = '/') { res.jsonp({error: error, redirect: redirect}) } +// Common middleware for all external account unlinks +function removeAuthMiddleware (identifier) { + return wrap(async (req, res) => { + if (!req.session.user) return res.redirect('/login') + let done = await APIExtern.Common.remove(req.session.user, identifier) + + if (!done) { + req.flash('message', {error: true, text: 'Unable to unlink social media account'}) + } + + res.redirect('/user/manage') + }) +} + /** FACEBOOK LOGIN * Ajax POST only * No tokens saved in configs, everything works out-of-the-box */ router.post('/external/facebook/callback', wrap(async (req, res, next) => { if (!config.facebook || !config.facebook.client) return next() - let sane = objectAssembler(req.body) - sane.ip_address = req.realIP - let response = await APIExtern.Facebook.callback(req.session.user, sane) + // Fix up the retarded object Facebook sends us + let sane = objectAssembler(req.body) + if (!sane || !sane.authResponse) { + return next() + } + + let response = await APIExtern.Facebook.callback(req.session.user, sane.authResponse, req.realIP) if (response.banned) { return JsonData(req, res, 'You are banned.') @@ -107,16 +125,7 @@ router.post('/external/facebook/callback', wrap(async (req, res, next) => { JsonData(req, res, null, '/login') })) -router.get('/external/facebook/remove', wrap(async (req, res) => { - if (!req.session.user) return res.redirect('/login') - let done = await APIExtern.Common.remove(req.session.user, 'fb') - - if (!done) { - req.flash('message', {error: true, text: 'Unable to unlink social media account'}) - } - - res.redirect('/user/manage') -})) +router.get('/external/facebook/remove', removeAuthMiddleware('facebook')) /** TWITTER LOGIN * OAuth1.0a flows @@ -172,17 +181,7 @@ router.get('/external/twitter/callback', wrap(async (req, res) => { res.render('redirect', {url: uri}) })) -router.get('/external/twitter/remove', wrap(async (req, res) => { - if (!req.session.user) return res.redirect('/login') - - let done = await APIExtern.Common.remove(req.session.user, 'twitter') - - if (!done) { - req.flash('message', {error: true, text: 'Unable to unlink social media account'}) - } - - res.redirect('/user/manage') -})) +router.get('/external/twitter/remove', removeAuthMiddleware('twitter')) /** DISCORD LOGIN * OAuth2 flows @@ -191,22 +190,16 @@ router.get('/external/twitter/remove', wrap(async (req, res) => { router.get('/external/discord/login', wrap(async (req, res) => { if (!config.discord || !config.discord.api) return res.redirect('/') - let infos = APIExtern.Discord.getAuthorizeURL() - - req.session.discord_auth = { - state: infos.state - } + let infos = APIExtern.Discord.getAuthorizeURL(req) res.redirect(infos.url) })) router.get('/external/discord/callback', wrap(async (req, res) => { if (!config.discord || !config.discord.api) return res.redirect('/login') - if (!req.session.discord_auth) return res.redirect('/login') let code = req.query.code let state = req.query.state - let da = req.session.discord_auth let uri = '/login' if (!code) { @@ -214,7 +207,7 @@ router.get('/external/discord/callback', wrap(async (req, res) => { return res.redirect(uri) } - if (!state || state !== da.state) { + if (!state || state !== APIExtern.Common.stateGenerator(req)) { req.flash('message', {error: true, text: 'Request got intercepted, try again.'}) return res.redirect(uri) } @@ -245,29 +238,7 @@ router.get('/external/discord/callback', wrap(async (req, res) => { res.render('redirect', {url: uri}) })) -router.get('/external/discord/remove', wrap(async (req, res) => { - if (!req.session.user) return res.redirect('/login') - - let done = await APIExtern.Common.remove(req.session.user, 'discord') - - if (!done) { - req.flash('message', {error: true, text: 'Unable to unlink social media account'}) - } - - res.redirect('/user/manage') -})) - -router.get('/external/discord/remove', wrap(async (req, res) => { - if (!req.session.user) return res.redirect('/login') - - let done = await APIExtern.Common.remove(req.session.user, 'discord') - - if (!done) { - req.flash('message', {error: true, text: 'Unable to unlink social media account'}) - } - - res.redirect('/user/manage') -})) +router.get('/external/discord/remove', removeAuthMiddleware('discord')) /** GOOGLE LOGIN * Google Token Verification @@ -303,17 +274,7 @@ router.post('/external/google/callback', wrap(async (req, res) => { JsonData(req, res, null, '/login') })) -router.get('/external/google/remove', wrap(async (req, res) => { - if (!req.session.user) return res.redirect('/login') - - let done = await APIExtern.Common.remove(req.session.user, 'google') - - if (!done) { - req.flash('message', {error: true, text: 'Unable to unlink social media account'}) - } - - res.redirect('/user/manage') -})) +router.get('/external/google/remove', removeAuthMiddleware('google')) /* ======== * NEWS diff --git a/server/routes/index.js b/server/routes/index.js index f01d609..f9cb2a4 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -205,9 +205,9 @@ router.get('/user/manage', ensureLogin, wrap(async (req, res) => { } if (config.facebook && config.facebook.client) { - if (!socialStatus.enabled.fb) { + if (!socialStatus.enabled.facebook) { res.locals.facebook_auth = config.facebook.client - } else if (socialStatus.source !== 'fb') { + } else if (socialStatus.source !== 'facebook') { res.locals.facebook_auth = false } }