From 8cb48abb3955bca936cdef278653b3658f45e03b Mon Sep 17 00:00:00 2001 From: Evert Date: Thu, 23 Nov 2017 18:18:26 +0200 Subject: [PATCH 1/9] 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 } } From 0d792817fe0f6e67375ab18a65723decbc722fe5 Mon Sep 17 00:00:00 2001 From: Evert Date: Thu, 23 Nov 2017 18:26:53 +0200 Subject: [PATCH 2/9] Do some cleanups on the data --- server/api/external.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/server/api/external.js b/server/api/external.js index 4e41c87..5921124 100644 --- a/server/api/external.js +++ b/server/api/external.js @@ -62,6 +62,17 @@ const API = { uuid: uuidV1() }, data) + // Some data cleanups + + // Limit display name length + udataLimited.display_name = udataLimited.display_name.substring(0, 32) + + // Remove illegal characters from the username + udataLimited.username = udataLimited.username.replace(/\W+/gi, '') + + // Limit user name length + udataLimited.username = udataLimited.username.substring(0, 26) + // Check if the username is already taken if (await UAPI.User.get(udataLimited.username) != null) { udataLimited.username = udataLimited.username + UAPI.Hash(4) @@ -77,7 +88,7 @@ const API = { } } - // Create a new user based on the information we got from Facebook + // Create a new user based on the information we got from an external service let newUser = await models.User.query().insert(udataLimited) await API.Common.new(service, identifier, newUser) @@ -317,7 +328,7 @@ const API = { } let cleanedData = Object.assign(data, { - username: data.name.replace(/\W+/gi, ''), + username: data.name, display_name: data.name, email: data.email || '' }) @@ -404,7 +415,6 @@ const API = { // Create a new user let cleanedData = Object.assign(ddata, { - username: ddata.username.replace(/\W+/gi, '_'), display_name: ddata.username, email: ddata.email || '' }) From b4b88a5657c484cca0ccdb00a9af372d4557030a Mon Sep 17 00:00:00 2001 From: Evert Date: Thu, 23 Nov 2017 18:41:48 +0200 Subject: [PATCH 3/9] Change csrf checking to a middleware function --- server/routes/index.js | 62 ++++++++++++------------------------------ 1 file changed, 18 insertions(+), 44 deletions(-) diff --git a/server/routes/index.js b/server/routes/index.js index f9cb2a4..75d156e 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -276,17 +276,22 @@ function cleanString (input) { return output } +// Make sure CSRF tokens are present and valid in every form +function csrfValidation (req, res, next) { + if (req.body.csrf !== req.session.csrf) { + return formError(req, res, 'Invalid session! Try reloading the page.') + } + + next() +} + // Enabling 2fa -router.post('/user/two-factor', wrap(async (req, res, next) => { +router.post('/user/two-factor', csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() if (!req.body.code) { return formError(req, res, 'You need to enter the code.') } - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - let verified = await API.User.Login.totpCheck(req.session.user, req.body.code) if (!verified) { return formError(req, res, 'Something went wrong! Try scanning the code again.') @@ -296,11 +301,8 @@ router.post('/user/two-factor', wrap(async (req, res, next) => { })) // Disabling 2fa -router.post('/user/two-factor/disable', wrap(async (req, res, next) => { +router.post('/user/two-factor/disable', csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } if (!req.body.password) { return formError(req, res, 'Please enter your password.') @@ -315,17 +317,13 @@ router.post('/user/two-factor/disable', wrap(async (req, res, next) => { })) // Verify 2FA for login -router.post('/login/verify', wrap(async (req, res, next) => { +router.post('/login/verify', csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() if (req.session.totp_check === null) return res.redirect('/login') if (!req.body.code && !req.body.recovery) { return formError(req, res, 'You need to enter the code.') } - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - let totpCheck = await API.User.Login.totpCheck(req.session.totp_check, req.body.code, req.body.recovery || false) if (!totpCheck) { return formError(req, res, 'Invalid code!') @@ -339,16 +337,12 @@ router.post('/login/verify', wrap(async (req, res, next) => { })) // Log the user in. Limited resource -router.post('/login', accountLimiter, wrap(async (req, res, next) => { +router.post('/login', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() if (!req.body.username || !req.body.password || req.body.username === '') { return res.redirect('/login') } - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - let user = await API.User.get(req.body.username) if (!user) return formError(req, res, 'Invalid username or password.') @@ -390,16 +384,12 @@ router.post('/login', accountLimiter, wrap(async (req, res, next) => { })) // Protected & Limited resource: Account registration -router.post('/register', accountLimiter, wrap(async (req, res, next) => { +router.post('/register', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() if (!req.body.username || !req.body.display_name || !req.body.password || !req.body.email) { return formError(req, res, 'Please fill in all the fields.') } - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - // Ban check let banStatus = await API.User.getBanStatus(req.realIP, true) if (banStatus.length) { @@ -483,13 +473,9 @@ router.post('/register', accountLimiter, wrap(async (req, res, next) => { })) // Change display name -router.post('/user/manage', wrap(async (req, res, next) => { +router.post('/user/manage', csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - if (!req.body.display_name) { return formError(req, res, 'Display Name cannot be blank.') } @@ -521,13 +507,9 @@ router.post('/user/manage', wrap(async (req, res, next) => { })) // Change user password -router.post('/user/manage/password', accountLimiter, wrap(async (req, res, next) => { +router.post('/user/manage/password', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - if (!req.body.password_old) { return formError(req, res, 'Please enter your current password.') } @@ -572,13 +554,9 @@ router.post('/user/manage/password', accountLimiter, wrap(async (req, res, next) })) // Change email address -router.post('/user/manage/email', accountLimiter, wrap(async (req, res, next) => { +router.post('/user/manage/email', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - let user = await API.User.get(req.session.user) let email = req.body.email let newEmail = req.body.email_new @@ -667,11 +645,7 @@ router.get('/news/compose', newsPrivilege, formKeep, (req, res) => { res.render('news/composer') }) -router.post('/news/compose', newsPrivilege, wrap(async (req, res) => { - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - +router.post('/news/compose', newsPrivilege, csrfValidation, wrap(async (req, res) => { if (!req.body.title || !req.body.content) { return formError(req, res, 'Required fields missing!') } From 6e13dce8452b794e919749cf52b38b33e76674eb Mon Sep 17 00:00:00 2001 From: Evert Date: Thu, 30 Nov 2017 23:13:14 +0200 Subject: [PATCH 4/9] A whole load of stuff - Added password reset, changed error returns to throws and some other things --- package-lock.json | 26 ++++- package.json | 1 + scripts/existsSync.js | 17 ---- scripts/logger.js | 6 +- server/api/admin.js | 24 ++--- server/api/external.js | 13 ++- server/api/image.js | 14 +-- server/api/index.js | 84 ++++++++++++--- server/api/news.js | 4 +- server/routes/admin.js | 24 +---- server/routes/api.js | 27 ++--- server/routes/index.js | 147 ++++++++++++++++++++------- src/script/admin.js | 15 +-- src/style/main.styl | 5 + templates/reset_password/html.pug | 6 ++ templates/reset_password/subject.pug | 1 + views/user/login.pug | 2 + views/user/password_new.pug | 2 - views/user/reset_password.pug | 23 +++++ 19 files changed, 300 insertions(+), 141 deletions(-) delete mode 100644 scripts/existsSync.js create mode 100644 templates/reset_password/html.pug create mode 100644 templates/reset_password/subject.pug create mode 100644 views/user/reset_password.pug diff --git a/package-lock.json b/package-lock.json index aefe5e1..e3ca338 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2104,6 +2104,16 @@ "version": "https://registry.npmjs.org/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz", "integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=" }, + "fs-extra": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.2.tgz", + "integrity": "sha1-+RcExT0bRh+JNFKwwwfZmXZHq2s=", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "4.0.0", + "universalify": "0.1.1" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2269,8 +2279,7 @@ "graceful-fs": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", - "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", - "dev": true + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" }, "graceful-readlink": { "version": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", @@ -2822,6 +2831,14 @@ "version": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "4.1.11" + } + }, "jsonify": { "version": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" @@ -5082,6 +5099,11 @@ "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", "dev": true }, + "universalify": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.1.tgz", + "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc=" + }, "unpipe": { "version": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" diff --git a/package.json b/package.json index 6aae3eb..8b3a1f3 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "express": "^4.15.3", "express-rate-limit": "^2.9.0", "express-session": "^1.15.3", + "fs-extra": "^4.0.2", "gm": "^1.23.0", "knex": "^0.13.0", "multiparty": "^4.1.3", diff --git a/scripts/existsSync.js b/scripts/existsSync.js deleted file mode 100644 index 42adacd..0000000 --- a/scripts/existsSync.js +++ /dev/null @@ -1,17 +0,0 @@ -import path from 'path' -import Promise from 'bluebird' -import fs from 'fs' - -const access = Promise.promisify(fs.access) - -async function exists (fpath) { - try { - await access(path.resolve(fpath)) - } catch (e) { - return false - } - - return true -} - -module.exports = exists diff --git a/scripts/logger.js b/scripts/logger.js index 516d645..1baa486 100644 --- a/scripts/logger.js +++ b/scripts/logger.js @@ -1,9 +1,7 @@ import config from './load-config' import path from 'path' import util from 'util' - -import Promise from 'bluebird' -const fs = Promise.promisifyAll(require('fs')) +import fs from 'fs-extra' let lfs @@ -57,7 +55,7 @@ async function initializeLogger () { let logPath = path.resolve(config.logger.file) try { - await fs.accessAsync(logPath, fs.W_OK) + await fs.access(logPath, fs.W_OK) lfs = fs.createWriteStream(logPath, {flags: 'a'}) } catch (e) { lfs = null diff --git a/server/api/admin.js b/server/api/admin.js index cbc9b98..e2ec69a 100644 --- a/server/api/admin.js +++ b/server/api/admin.js @@ -84,7 +84,7 @@ const API = { getAllUsers: async function (page, adminId) { let count = await Models.User.query().count('id as ids') if (!count.length || !count[0]['ids'] || isNaN(page)) { - return { error: 'No users found' } + throw new Error('No users found') } count = count[0].ids @@ -108,7 +108,7 @@ const API = { getAllClients: async function (page) { let count = await Models.OAuth2Client.query().count('id as ids') if (!count.length || !count[0]['ids'] || isNaN(page)) { - return { error: 'No clients found' } + return { error: 'No clients' } } count = count[0].ids @@ -141,7 +141,7 @@ const API = { ] data = dataFilter(data, fields, ['scope', 'verified']) - if (!data) return { error: 'Missing fields' } + if (!data) throw new Error('Missing fields') data.verified = (data.verified != null ? 1 : 0) @@ -149,20 +149,20 @@ const API = { await Models.OAuth2Client.query().patchAndFetchById(id, data) await Models.OAuth2AuthorizedClient.query().delete().where('client_id', id) } catch (e) { - return { error: 'No such client' } + throw new Error('No such client') } return {} }, // Create a new secret for a client newSecret: async function (id) { - if (isNaN(id)) return { error: 'Invalid client ID' } + if (isNaN(id)) throw new Error('Invalid client ID') let secret = Users.Hash(16) try { await Models.OAuth2Client.query().patchAndFetchById(id, {secret: secret}) } catch (e) { - return { error: 'No such client' } + throw new Error('No such client') } return {} @@ -174,7 +174,7 @@ const API = { ] data = dataFilter(data, fields, ['scope']) - if (!data) return { error: 'Missing fields' } + if (!data) throw new Error('Missing fields') let obj = Object.assign({ secret: Users.Hash(16), @@ -187,7 +187,7 @@ const API = { }, // Remove a client and all associated data removeClient: async function (id) { - if (isNaN(id)) return {error: 'Invalid ID number'} + if (isNaN(id)) throw new Error('Invalid ID number') await Models.OAuth2Client.query().delete().where('id', id) await Models.OAuth2AuthorizedClient.query().delete().where('client_id', id) await Models.OAuth2AccessToken.query().delete().where('client_id', id) @@ -198,7 +198,7 @@ const API = { getAllBans: async function (page) { let count = await Models.Ban.query().count('id as ids') if (!count.length || !count[0]['ids'] || isNaN(page)) { - return {error: 'No bans on record'} + throw new Error('No bans on record') } count = count[0].ids @@ -225,12 +225,12 @@ const API = { addBan: async function (data, adminId) { let user = await Users.User.get(parseInt(data.user_id)) - if (!user) return {error: 'No such user.'} - if (user.id === adminId) return {error: 'Cannot ban yourself!'} + if (!user) throw new Error('No such user.') + if (user.id === adminId) throw new Error('Cannot ban yourself!') let admin = await Users.User.get(adminId) - if (user.nw_privilege > admin.nw_privilege) return {error: 'Cannot ban user.'} + if (user.nw_privilege > admin.nw_privilege) throw new Error('Cannot ban user.') let banAdd = { reason: data.reason || 'Unspecified ban', diff --git a/server/api/external.js b/server/api/external.js index 5921124..7419d3e 100644 --- a/server/api/external.js +++ b/server/api/external.js @@ -79,12 +79,11 @@ const API = { } // Check if the email given to us is already registered, if so, - // associate an external node with the user bearing the email + // tell them to log in first. if (udataLimited.email && udataLimited.email !== '') { let getByEmail = await UAPI.User.get(udataLimited.email) if (getByEmail) { - await API.Common.new(service, identifier, getByEmail) - return {error: null, user: getByEmail} + throw new Error('An user with this email address is already registered, but this external account is are not linked. If you wish to link the account, please log in first.') } } @@ -158,8 +157,12 @@ const API = { } } - let newUser = await API.Common.newUser(identifier, uid, newUData) - if (!newUser) return {error: 'Failed to create user.'} + let newUser + try { + newUser = await API.Common.newUser(identifier, uid, newUData) + } catch (e) { + return {error: e.message} + } return {error: null, user: newUser} } diff --git a/server/api/image.js b/server/api/image.js index a2e8b19..ce89916 100644 --- a/server/api/image.js +++ b/server/api/image.js @@ -6,7 +6,7 @@ import Promise from 'bluebird' import http from '../../scripts/http' -const fs = Promise.promisifyAll(require('fs')) +const fs = require('fs-extra') const uploads = path.join(__dirname, '../../', 'usercontent') const images = path.join(uploads, 'images') @@ -49,8 +49,8 @@ function saneFields (fields) { } async function bailOut (file, error) { - await fs.unlinkAsync(file) - return { error: error } + await fs.unlink(file) + throw new Error(error) } async function imageBase64 (baseObj) { @@ -68,7 +68,7 @@ async function imageBase64 (baseObj) { let fpath = path.join(images, imageName) try { - await fs.writeFileAsync(fpath, imgData.data) + await fs.writeFile(fpath, imgData.data) } catch (e) { console.error(e) return null @@ -93,11 +93,11 @@ async function downloadImage (imgUrl, designation) { return null } - return {fileName: imageName} + return imageName } async function uploadImage (identifier, fields, files) { - if (!files.image) return {error: 'No image file'} + if (!files.image) throw new Error('No image file') let file = files.image[0] if (file.size > maxFileSize) return bailOut(file.path, 'Image is too large! 1 MB max') @@ -158,7 +158,7 @@ async function uploadImage (identifier, fields, files) { }) }) - await fs.unlinkAsync(file) + await fs.unlink(file) } catch (e) { console.error(e) return bailOut(file, 'An error occured while cropping.') diff --git a/server/api/index.js b/server/api/index.js index 028531d..3c29c76 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -2,16 +2,13 @@ import path from 'path' import cprog from 'child_process' import config from '../../scripts/load-config' import http from '../../scripts/http' -import exists from '../../scripts/existsSync' import models from './models' import crypto from 'crypto' import notp from 'notp' import base32 from 'thirty-two' import emailer from './emailer' import uuidV1 from 'uuid/v1' - -import Promise from 'bluebird' -const fs = Promise.promisifyAll(require('fs')) +import fs from 'fs-extra' const emailRe = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ @@ -178,7 +175,7 @@ const API = { }, update: async function (user, data) { user = await API.User.ensureObject(user) - if (!user) return {error: 'No such user.'} + if (!user) throw new Error('No such user.') data = Object.assign({ updated_at: new Date() @@ -191,20 +188,20 @@ const API = { let uploadsDir = path.join(__dirname, '../../', 'usercontent', 'images') let pathOf = path.join(uploadsDir, fileName) - if (!await exists(pathOf)) { - return {error: 'No such file'} + if (!await fs.exists(pathOf)) { + throw new Error('No such file') } // Delete previous upload if (user.avatar_file != null) { let file = path.join(uploadsDir, user.avatar_file) - if (await exists(file)) { - await fs.unlinkAsync(file) + if (await fs.exists(file)) { + await fs.unlink(file) } } await API.User.update(user, {avatar_file: fileName}) - return { file: fileName } + return fileName }, removeAvatar: async function (user) { user = await API.User.ensureObject(user, ['avatar_file']) @@ -212,7 +209,7 @@ const API = { if (!user.avatar_file) return {} let file = path.join(uploadsDir, user.avatar_file) - if (await exists(file)) { + if (await fs.exists(file)) { await fs.unlinkAsync(file) } @@ -252,7 +249,7 @@ const API = { return bcryptTask({task: 'compare', password: password, hash: user.password}) }, activationToken: async function (token) { - let getToken = await models.Token.query().where('token', token) + let getToken = await models.Token.query().where('token', token).andWhere('type', 1) if (!getToken || !getToken.length) return false let user = await API.User.get(getToken[0].user_id) @@ -359,12 +356,12 @@ const API = { let userTest = await API.User.get(regdata.username) if (userTest) { - return {error: 'This username is already taken!'} + throw new Error('This username is already taken!') } let emailTest = await API.User.get(regdata.email) if (emailTest) { - return {error: 'This email address is already registered!'} + throw new Error('This email address is already registered!') } // Create user @@ -393,11 +390,66 @@ const API = { } catch (e) { console.error(e) await models.User.query().delete().where('id', user.id) - return {error: 'Invalid email address!'} + throw new Error('Invalid email address!') } } - return {error: null, user: user} + return user + } + }, + Reset: { + reset: async function (email, passRequired = true) { + let emailEnabled = config.email && config.email.enabled + + if (!emailEnabled) throw new Error('Cannot reset password.') + + let user = await API.User.get(email) + if (!user) throw new Error('This email address does not match any user in our database.') + if (!user.password && passRequired) throw new Error('The user associated with this email address has used an external website to log in, thus the password cannot be reset.') + + let resetToken = API.Hash(16) + await models.Token.query().insert({ + expires_at: new Date(Date.now() + 86400000), // 1 day + token: resetToken, + user_id: user.id, + type: 2 + }) + + // Send Reset Email + console.debug('Reset token:', resetToken) + if (email) { + try { + let em = await emailer.pushMail('reset_password', user.email, { + domain: config.server.domain, + display_name: user.display_name, + reset_token: resetToken + }) + + console.debug(em) + } catch (e) { + console.error(e) + throw new Error('Invalid email address!') + } + } + + return resetToken + }, + resetToken: async function (token) { + let getToken = await models.Token.query().where('token', token).andWhere('type', 2) + if (!getToken || !getToken.length) return null + + let user = await API.User.get(getToken[0].user_id) + if (!user) return null + + return user + }, + changePassword: async function (user, password, token) { + let hashed = await API.User.Register.hashPassword(password) + + await models.User.query().patchAndFetchById(user.id, {password: hashed, updated_at: new Date()}) + await models.Token.query().delete().where('token', token) + + return true } }, OAuth2: { diff --git a/server/api/news.js b/server/api/news.js index 4814d1d..3ccb650 100644 --- a/server/api/news.js +++ b/server/api/news.js @@ -53,7 +53,7 @@ const News = { if (page < 1) page = 1 if (!count.length || !count[0]['ids'] || isNaN(page)) { - return {error: 'No articles found'} + return { error: 'No news' } } count = count[0].ids @@ -100,7 +100,7 @@ const News = { } let result = await Models.News.query().patchAndFetchById(id, patch) - if (!result) return {error: 'Something went wrong.'} + if (!result) throw new Error('Something went wrong.') return {} } } diff --git a/server/routes/admin.js b/server/routes/admin.js index 088a80f..2a898c4 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -128,10 +128,7 @@ apiRouter.post('/client/new', wrap(async (req, res) => { return res.status(400).jsonp({error: 'Invalid session'}) } - let update = await API.createClient(req.body, req.session.user) - if (update.error) { - return res.status(400).jsonp({error: update.error}) - } + await API.createClient(req.body, req.session.user) res.status(204).end() })) @@ -145,10 +142,7 @@ apiRouter.post('/client/update', wrap(async (req, res) => { return res.status(400).jsonp({error: 'Invalid session'}) } - let update = await API.updateClient(id, req.body) - if (update.error) { - return res.status(400).jsonp({error: update.error}) - } + await API.updateClient(id, req.body) res.status(204).end() })) @@ -160,9 +154,6 @@ apiRouter.post('/client/new_secret/:id', wrap(async (req, res) => { } let client = await API.newSecret(id) - if (client.error) { - return res.status(400).jsonp({error: client.error}) - } res.jsonp(client) })) @@ -174,9 +165,6 @@ apiRouter.post('/client/delete/:id', wrap(async (req, res) => { } let client = await API.removeClient(id) - if (client.error) { - return res.status(400).jsonp({error: client.error}) - } res.jsonp(client) })) @@ -203,9 +191,6 @@ apiRouter.post('/ban/pardon/:id', wrap(async (req, res) => { } let ban = await API.removeBan(id) - if (ban.error) { - return res.status(400).jsonp({error: ban.error}) - } res.jsonp(ban) })) @@ -217,16 +202,13 @@ apiRouter.post('/ban', wrap(async (req, res) => { } let result = await API.addBan(req.body, req.session.user.id) - if (result.error) { - return res.status(400).jsonp({error: result.error}) - } res.jsonp(result) })) apiRouter.use((err, req, res, next) => { console.error(err) - return res.status(500).jsonp({error: 'Internal server error'}) + return res.status(400).jsonp({error: err.message}) }) router.use('/api', apiRouter) diff --git a/server/routes/api.js b/server/routes/api.js index 1ff26e3..2f0e207 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -317,9 +317,10 @@ router.post('/news/edit/:id', wrap(async (req, res, next) => { return res.status(400).jsonp({error: 'Content is required.'}) } - let result = await News.edit(id, req.body) - if (result.error) { - return res.status(400).jsonp({error: result.error}) + try { + await News.edit(id, req.body) + } catch (e) { + return res.status(400).jsonp({error: e.message}) } res.status(204).end() @@ -363,19 +364,19 @@ async function promiseForm (req) { router.post('/avatar', uploadLimiter, wrap(async (req, res, next) => { if (!req.session.user) return next() let data = await promiseForm(req) - let result = await Image.uploadImage(req.session.user.username, data.fields, data.files) - if (result.error) { - return res.status(400).jsonp({error: result.error}) + let avatarFile + + try { + let result = await Image.uploadImage(req.session.user.username, data.fields, data.files) + + avatarFile = await API.User.changeAvatar(req.session.user, result.file) + } catch (e) { + return res.status(400).jsonp({error: e.message}) } - let avatarUpdate = await API.User.changeAvatar(req.session.user, result.file) - if (avatarUpdate.error) { - return res.status(400).jsonp({error: avatarUpdate.error}) - } - - if (avatarUpdate.file) { - req.session.user.avatar_file = avatarUpdate.file + if (avatarFile) { + req.session.user.avatar_file = avatarFile } res.status(200).jsonp({}) diff --git a/server/routes/index.js b/server/routes/index.js index 75d156e..f8a7f94 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -1,10 +1,9 @@ -import fs from 'fs' +import fs from 'fs-extra' import path from 'path' import express from 'express' import RateLimit from 'express-rate-limit' import ensureLogin from '../../scripts/ensureLogin' import config from '../../scripts/load-config' -import exists from '../../scripts/existsSync' import wrap from '../../scripts/asyncRoute' import http from '../../scripts/http' import API from '../api' @@ -135,6 +134,28 @@ function formKeep (req, res, next) { next() } +// Password reset request endpoint +router.get('/login/reset', extraButtons, (req, res) => { + if (req.session.user) return redirectLogin(req, res) + + res.render('user/reset_password', {sent: req.query.success != null}) +}) + +// Password reset endpoint (emailed link) +router.get('/reset/:token', wrap(async (req, res) => { + if (req.session.user) return res.redirect('/login') + let token = req.params.token + let success = await API.User.Reset.resetToken(token) + + if (!success) { + req.flash('message', {error: true, text: 'Invalid or expired reset token.'}) + res.redirect('/login') + return + } + + res.render('user/password_new', {token: true}) +})) + router.get('/login', extraButtons, (req, res) => { if (req.session.user) return redirectLogin(req, res) if (req.query.returnTo) { @@ -383,6 +404,61 @@ router.post('/login', accountLimiter, csrfValidation, wrap(async (req, res, next res.redirect(uri) })) +// Password reset +router.post('/login/reset', accountLimiter, csrfValidation, wrap(async (req, res, next) => { + if (req.session.user) return next() + if (!req.body.email) { + return formError(req, res, 'You need to enter your email address.') + } + + let email = req.body.email + let validEmail = await API.User.Register.validateEmail(email) + if (!validEmail) { + return formError(req, res, 'You need to enter a valid email address.') + } + + try { + await API.User.Reset.reset(email) + res.redirect('/login/reset?success=true') + } catch (e) { + return formError(req, res, e.message) + } +})) + +// Password reset endpoint (emailed link) +router.post('/reset/:token', csrfValidation, wrap(async (req, res) => { + if (req.session.user) return res.redirect('/login') + let token = req.params.token + let user = await API.User.Reset.resetToken(token) + + if (!user) { + req.flash('message', {error: true, text: 'Invalid or expired reset token.'}) + res.redirect('/login') + return + } + + // 4th Check: Password length + let password = req.body.password + if (!password || password.length < 8) { + return formError(req, res, 'Invalid password! Please use at least 8 characters!') + } + + // 5th Check: Password match + let passwordAgain = req.body.password_repeat + if (!passwordAgain || password !== passwordAgain) { + return formError(req, res, 'Passwords do not match!') + } + + try { + await API.User.Reset.changePassword(user, password, token) + + req.flash('message', {error: false, text: 'Your password has been changed successfully. You may now log in!'}) + res.redirect('/login') + } catch (e) { + return formError(req, res, e.message) + } +})) + // Protected & Limited resource: Account registration router.post('/register', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() @@ -448,18 +524,19 @@ router.post('/register', accountLimiter, csrfValidation, wrap(async (req, res, n // Hash the password let hash = await API.User.Register.hashPassword(password) + let newUser // Attempt to create the user - let newUser = await API.User.Register.newAccount({ - username: username, - display_name: cleanString(displayName), - password: hash, - email: email, - ip_address: req.realIP - }) - - if (!newUser || newUser.error != null) { - return formError(req, res, newUser.error) + try { + newUser = await API.User.Register.newAccount({ + username: username, + display_name: cleanString(displayName), + password: hash, + email: email, + ip_address: req.realIP + }) + } catch (e) { + return formError(req, res, e.message) } // Do not include activation link message when the user is already activated @@ -492,12 +569,12 @@ router.post('/user/manage', csrfValidation, wrap(async (req, res, next) => { return res.redirect('/user/manage') } - let success = await API.User.update(req.session.user, { - display_name: displayName - }) - - if (success.error) { - return formError(req, res, success.error) + try { + await API.User.update(req.session.user, { + display_name: displayName + }) + } catch (e) { + return formError(req, res, e.message) } req.session.user.display_name = displayName @@ -532,12 +609,12 @@ router.post('/user/manage/password', accountLimiter, csrfValidation, wrap(async password = await API.User.Register.hashPassword(password) - let success = await API.User.update(user, { - password: password - }) - - if (success.error) { - return formError(req, res, success.error) + try { + await API.User.update(user, { + password: password + }) + } catch (e) { + return formError(req, res, e.message) } console.warn('[SECURITY AUDIT] User \'%s\' password has been changed from %s', user.username, req.realIP) @@ -591,12 +668,12 @@ router.post('/user/manage/email', accountLimiter, csrfValidation, wrap(async (re return formError(req, res, 'This email is already taken.') } - let success = await API.User.update(user, { - email: newEmail - }) - - if (success.error) { - return formError(req, res, success.error) + try { + await API.User.update(user, { + email: newEmail + }) + } catch (e) { + return formError(req, res, e.message) } // TODO: Send necessary emails @@ -618,7 +695,7 @@ router.post('/user/manage/email', accountLimiter, csrfValidation, wrap(async (re const docsDir = path.join(__dirname, '../../documents') router.get('/docs/:name', wrap(async (req, res, next) => { let doc = path.join(docsDir, req.params.name + '.html') - if (!await exists(docsDir) || !await exists(doc)) { + if (!await fs.exists(docsDir) || !await fs.exists(doc)) { return next() } @@ -650,9 +727,11 @@ router.post('/news/compose', newsPrivilege, csrfValidation, wrap(async (req, res return formError(req, res, 'Required fields missing!') } - let result = await News.compose(req.session.user, req.body) - if (result.error) { - return formError(req, res, result.error) + let result + try { + result = await News.compose(req.session.user, req.body) + } catch (e) { + return formError(req, res, e.message) } res.redirect('/news/' + result.id + '-' + result.slug) diff --git a/src/script/admin.js b/src/script/admin.js index 854eb1a..356c245 100644 --- a/src/script/admin.js +++ b/src/script/admin.js @@ -12,15 +12,20 @@ function buildTemplateScript (id, ctx) { function paginationButton (pages) { var html = '
' html += 'Page ' + pages.page + ' of ' + pages.pages + '' + if (pages.page > 1) { html += '
Previous
' } + for (var i = 0; i < pages.pages; i++) { - html += '
' + (i + 1) + '
' + html += '
' + (i + 1) + '
' } + if (pages.pages > pages.page) { html += '
Next
' } + + html += '(' + pages.total + ' Total Entries)' html += '
' return html } @@ -271,11 +276,9 @@ $(document).ready(function () { setInterval(function () { $.get({ - url: '/admin/access', - success: function (data) { - if (data && data.access) return - window.location.reload() - } + url: '/admin/access' + }).fail(function () { + window.location.reload() }) }, 30000) }) diff --git a/src/style/main.styl b/src/style/main.styl index b6c8af1..4b18b9d 100644 --- a/src/style/main.styl +++ b/src/style/main.styl @@ -206,6 +206,8 @@ input:not([type="submit"]) font-size: 120% margin: 10px 0 cursor: pointer + &.active + background-color: #ddd .button display: inline-block @@ -254,6 +256,9 @@ input:not([type="submit"]) .pagenum display: inline-block padding: 10px + &.total + color: #a9a9a9 + font-style: italic .dlbtn display: block diff --git a/templates/reset_password/html.pug b/templates/reset_password/html.pug new file mode 100644 index 0000000..e4c8fce --- /dev/null +++ b/templates/reset_password/html.pug @@ -0,0 +1,6 @@ +h1 Hello, #{display_name}! +p You've requested to reset your password on Icy Network. +p Click on or copy the following link into your URL bar in order to reset your Icy Network account password: +a.activate(href=domain + "/reset/" + reset_token, target="_blank", rel="nofollow")= domain + "/reset/" + reset_token +p If you did not request for a password reset on Icy Network, please ignore this email. +small This email has been sent to you because of an action performed on the IcyNet.eu website. diff --git a/templates/reset_password/subject.pug b/templates/reset_password/subject.pug new file mode 100644 index 0000000..85b8d0b --- /dev/null +++ b/templates/reset_password/subject.pug @@ -0,0 +1 @@ +|Icy Network - Password reset request diff --git a/views/user/login.pug b/views/user/login.pug index fe6e6a8..5c9a099 100644 --- a/views/user/login.pug +++ b/views/user/login.pug @@ -23,5 +23,7 @@ block body input(type="password", name="password", id="password") input(type="submit", value="Log in") a#create(href="/register") Create an account + span.divider · + a#create(href="/login/reset") Forgot password? .right include ../includes/external.pug diff --git a/views/user/password_new.pug b/views/user/password_new.pug index bbc09a7..87d0c09 100644 --- a/views/user/password_new.pug +++ b/views/user/password_new.pug @@ -19,8 +19,6 @@ block body if !token label(for="password_old") Current Password input(type="password", name="password_old", id="password_old") - else - input(type="hidden", name="token", value=token) label(for="password") New Password input(type="password", name="password", id="password") label(for="password_repeat") Repeat New Password diff --git a/views/user/reset_password.pug b/views/user/reset_password.pug new file mode 100644 index 0000000..854a1d5 --- /dev/null +++ b/views/user/reset_password.pug @@ -0,0 +1,23 @@ +extends ../layout.pug +block title + |Icy Network - Reset Password + +block body + .wrapper + .boxcont + .box#totpcheck + h1 Reset your password + p Enter your email below in order to reset your password. + if message.text + if message.error + .message.error + span #{message.text} + else + .message + span #{message.text} + if !sent + form#loginForm(method="POST", action=post) + input(type="hidden", name="csrf", value=csrf) + label(for="email") Email Address + input(type="email", name="email", id="email") + input(type="submit", value="Continue") From f76135f00fcacdd174da41739b21b2b1e9f18e29 Mon Sep 17 00:00:00 2001 From: Evert Date: Thu, 30 Nov 2017 23:26:41 +0200 Subject: [PATCH 5/9] Token expiry checking, limit amount of resets in a day --- server/api/admin.js | 4 ++-- server/api/index.js | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/server/api/admin.js b/server/api/admin.js index e2ec69a..79f8b80 100644 --- a/server/api/admin.js +++ b/server/api/admin.js @@ -84,7 +84,7 @@ const API = { getAllUsers: async function (page, adminId) { let count = await Models.User.query().count('id as ids') if (!count.length || !count[0]['ids'] || isNaN(page)) { - throw new Error('No users found') + return { error: 'No users found in database' } } count = count[0].ids @@ -198,7 +198,7 @@ const API = { getAllBans: async function (page) { let count = await Models.Ban.query().count('id as ids') if (!count.length || !count[0]['ids'] || isNaN(page)) { - throw new Error('No bans on record') + return { error: 'No bans on record' } } count = count[0].ids diff --git a/server/api/index.js b/server/api/index.js index 3c29c76..d359ce2 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -252,11 +252,15 @@ const API = { let getToken = await models.Token.query().where('token', token).andWhere('type', 1) if (!getToken || !getToken.length) return false - let user = await API.User.get(getToken[0].user_id) + getToken = getToken[0] + + if (getToken.expires_at && new Date(getToken.expires_at).getTime() < Date.now()) return false + + let user = await API.User.get(getToken.user_id) if (!user) return false await models.User.query().patchAndFetchById(user.id, {activated: 1}) - await models.Token.query().delete().where('id', getToken[0].id) + await models.Token.query().delete().where('id', getToken.id) return true }, totpTokenRequired: async function (user) { @@ -407,6 +411,11 @@ const API = { if (!user) throw new Error('This email address does not match any user in our database.') if (!user.password && passRequired) throw new Error('The user associated with this email address has used an external website to log in, thus the password cannot be reset.') + let recentTokens = await models.Token.query().where('user_id', user.id).andWhere('expires_at', '>', new Date()).andWhere('type', 2) + if (recentTokens.length >= 2) { + throw new Error('You\'ve made too many reset requests recently. Please slow down.') + } + let resetToken = API.Hash(16) await models.Token.query().insert({ expires_at: new Date(Date.now() + 86400000), // 1 day @@ -438,7 +447,11 @@ const API = { let getToken = await models.Token.query().where('token', token).andWhere('type', 2) if (!getToken || !getToken.length) return null - let user = await API.User.get(getToken[0].user_id) + getToken = getToken[0] + + if (getToken.expires_at && new Date(getToken.expires_at).getTime() < Date.now()) return null + + let user = await API.User.get(getToken.user_id) if (!user) return null return user From 34a81d79236bb415ac35c451937a9435a05ce1aa Mon Sep 17 00:00:00 2001 From: Evert Date: Thu, 30 Nov 2017 23:45:21 +0200 Subject: [PATCH 6/9] some more things --- server/api/external.js | 10 +++++----- server/api/index.js | 2 +- server/routes/index.js | 2 ++ templates/reset_password/html.pug | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/server/api/external.js b/server/api/external.js index 7419d3e..306116b 100644 --- a/server/api/external.js +++ b/server/api/external.js @@ -1,7 +1,7 @@ import config from '../../scripts/load-config' import http from '../../scripts/http' import models from './models' -import image from './image' +import Image from './image' import UAPI from './index' import qs from 'querystring' import oauth from 'oauth-libre' @@ -173,7 +173,7 @@ const API = { if (rawData.picture) { if (rawData.picture.is_silhouette === false && rawData.picture.url) { - let imgdata = await image.downloadImage(rawData.picture.url) + let imgdata = await Image.downloadImage(rawData.picture.url) if (imgdata && imgdata.fileName) { profilepic = imgdata.fileName } @@ -224,7 +224,7 @@ const API = { let profilepic = null if (rawData.profile_image_url_https) { - let imgdata = await image.downloadImage(rawData.profile_image_url_https) + let imgdata = await Image.downloadImage(rawData.profile_image_url_https) if (imgdata && imgdata.fileName) { profilepic = imgdata.fileName } @@ -303,7 +303,7 @@ const API = { getAvatar: async (rawData) => { let profilepic = null if (rawData.image) { - let imgdata = await image.downloadImage(rawData.image) + let imgdata = await Image.downloadImage(rawData.image) if (imgdata && imgdata.fileName) { profilepic = imgdata.fileName } @@ -345,7 +345,7 @@ const API = { let aviSnowflake = rawData.avatar if (aviSnowflake) { try { - let avpt = await image.downloadImage('https://cdn.discordapp.com/avatars/' + rawData.id + '/' + aviSnowflake + '.png') + let avpt = await Image.downloadImage('https://cdn.discordapp.com/avatars/' + rawData.id + '/' + aviSnowflake + '.png') if (avpt && avpt.fileName) { profilepic = avpt.fileName } diff --git a/server/api/index.js b/server/api/index.js index d359ce2..f7d0f62 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -210,7 +210,7 @@ const API = { let file = path.join(uploadsDir, user.avatar_file) if (await fs.exists(file)) { - await fs.unlinkAsync(file) + await fs.unlink(file) } return API.User.update(user, {avatar_file: null}) diff --git a/server/routes/index.js b/server/routes/index.js index f8a7f94..e857f71 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -452,6 +452,8 @@ router.post('/reset/:token', csrfValidation, wrap(async (req, res) => { try { await API.User.Reset.changePassword(user, password, token) + console.warn('[SECURITY AUDIT] User \'%s\' password has been changed from %s', user.username, req.realIP) + req.flash('message', {error: false, text: 'Your password has been changed successfully. You may now log in!'}) res.redirect('/login') } catch (e) { diff --git a/templates/reset_password/html.pug b/templates/reset_password/html.pug index e4c8fce..8a8f29c 100644 --- a/templates/reset_password/html.pug +++ b/templates/reset_password/html.pug @@ -2,5 +2,5 @@ h1 Hello, #{display_name}! p You've requested to reset your password on Icy Network. p Click on or copy the following link into your URL bar in order to reset your Icy Network account password: a.activate(href=domain + "/reset/" + reset_token, target="_blank", rel="nofollow")= domain + "/reset/" + reset_token -p If you did not request for a password reset on Icy Network, please ignore this email. +p If you did not request a password reset on Icy Network, please ignore this email. small This email has been sent to you because of an action performed on the IcyNet.eu website. From f8ed8e244d77f71906275568c4b25028fd57e2e1 Mon Sep 17 00:00:00 2001 From: Evert Date: Thu, 30 Nov 2017 23:56:27 +0200 Subject: [PATCH 7/9] success message on password reset --- server/routes/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/routes/index.js b/server/routes/index.js index e857f71..48c8a21 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -419,6 +419,8 @@ router.post('/login/reset', accountLimiter, csrfValidation, wrap(async (req, res try { await API.User.Reset.reset(email) + + req.flash('message', {error: false, text: 'We\'ve sent a link to your email address. Please check spam folders too!'}) res.redirect('/login/reset?success=true') } catch (e) { return formError(req, res, e.message) From e15dc7902ccff8bd953e905482d685f41ed625a2 Mon Sep 17 00:00:00 2001 From: Evert Date: Fri, 1 Dec 2017 12:42:08 +0200 Subject: [PATCH 8/9] colorful logs --- scripts/logger.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/scripts/logger.js b/scripts/logger.js index 1baa486..30c998d 100644 --- a/scripts/logger.js +++ b/scripts/logger.js @@ -19,16 +19,15 @@ function dateFormat (date) { } // Console.log/error/warn "middleware" - add timestamp and write to file -function stampAndWrite (fnc, prfx, message) { +function stampAndWrite (fnc, color, prfx, message) { let prefix = '[' + prfx + '] [' + dateFormat(new Date()) + '] ' - message = prefix + message + message = color + prefix + message + message = message.replace(/\\u001b/g, '\x1b') if (lfs) { - lfs.write(message + '\n') + lfs.write(message.replace(/(\u001b\[\d\d?m)/g, '') + '\n') } - message = message.replace(/\\u001b/g, '\x1b') - fnc.call(this, message) } @@ -36,19 +35,19 @@ function stampAndWrite (fnc, prfx, message) { const realConsoleLog = console.log console.log = function () { let message = util.format.apply(null, arguments) - stampAndWrite.call(this, realConsoleLog, 'info', message) + stampAndWrite.call(this, realConsoleLog, '', 'info', message) } const realConsoleWarn = console.warn console.warn = function () { let message = util.format.apply(null, arguments) - stampAndWrite.call(this, realConsoleWarn, 'warn', message) + stampAndWrite.call(this, realConsoleWarn, '\x1b[33m', 'warn', message) } const realConsoleError = console.error console.error = function () { let message = util.format.apply(null, arguments) - stampAndWrite.call(this, realConsoleError, ' err', message) + stampAndWrite.call(this, realConsoleError, '\x1b[31m', ' err', message) } async function initializeLogger () { From f3d9435be08aa83cac24535271ee82431bb30540 Mon Sep 17 00:00:00 2001 From: Evert Date: Fri, 1 Dec 2017 13:35:47 +0200 Subject: [PATCH 9/9] even more minor changes and reorganizing --- server/api/emailer.js | 1 + server/api/external.js | 11 +++++---- server/api/image.js | 11 +++------ server/api/index.js | 10 ++++---- server/api/minecraft.js | 3 ++- server/index.js | 10 ++++---- server/routes/admin.js | 3 ++- server/routes/api.js | 7 +++--- server/routes/index.js | 48 +++++++++++++++++++++++--------------- server/routes/minecraft.js | 1 + server/routes/oauth2.js | 5 ++-- server/server.js | 2 +- 12 files changed, 62 insertions(+), 50 deletions(-) diff --git a/server/api/emailer.js b/server/api/emailer.js index 1779a1b..0135742 100644 --- a/server/api/emailer.js +++ b/server/api/emailer.js @@ -1,6 +1,7 @@ import {EmailTemplate} from 'email-templates' import path from 'path' import nodemailer from 'nodemailer' + import config from '../../scripts/load-config' const templateDir = path.join(__dirname, '../../', 'templates') diff --git a/server/api/external.js b/server/api/external.js index 306116b..74c44dd 100644 --- a/server/api/external.js +++ b/server/api/external.js @@ -1,12 +1,13 @@ +import qs from 'querystring' +import oauth from 'oauth-libre' +import uuidV1 from 'uuid/v1' +import crypto from 'crypto' + 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 uuidV1 from 'uuid/v1' -import crypto from 'crypto' const userFields = ['username', 'email', 'avatar_file', 'display_name', 'ip_address'] @@ -74,7 +75,7 @@ const API = { udataLimited.username = udataLimited.username.substring(0, 26) // Check if the username is already taken - if (await UAPI.User.get(udataLimited.username) != null) { + if (await UAPI.User.get(udataLimited.username) != null || udataLimited.username.length < 4) { udataLimited.username = udataLimited.username + UAPI.Hash(4) } diff --git a/server/api/image.js b/server/api/image.js index ce89916..3e6fb11 100644 --- a/server/api/image.js +++ b/server/api/image.js @@ -1,8 +1,7 @@ import gm from 'gm' import url from 'url' import path from 'path' -import crypto from 'crypto' -import Promise from 'bluebird' +import uuid from 'uuid/v4' import http from '../../scripts/http' @@ -17,10 +16,6 @@ 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 = {} @@ -60,7 +55,7 @@ async function imageBase64 (baseObj) { if (!imgData) return null if (!imageTypes[imgData.type]) return null - let imageName = 'base64-' + imageUniquifier() + let imageName = 'base64-' + uuid() let ext = imageTypes[imgData.type] || '.png' imageName += ext @@ -81,7 +76,7 @@ async function downloadImage (imgUrl, designation) { if (!imgUrl) return null if (!designation) designation = 'download' - let imageName = designation + '-' + imageUniquifier() + let imageName = designation + '-' + uuid() let uridata = url.parse(imgUrl) let pathdata = path.parse(uridata.path) diff --git a/server/api/index.js b/server/api/index.js index f7d0f62..d7df691 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -1,15 +1,16 @@ import path from 'path' import cprog from 'child_process' -import config from '../../scripts/load-config' -import http from '../../scripts/http' -import models from './models' import crypto from 'crypto' import notp from 'notp' import base32 from 'thirty-two' -import emailer from './emailer' import uuidV1 from 'uuid/v1' import fs from 'fs-extra' +import config from '../../scripts/load-config' +import http from '../../scripts/http' +import models from './models' +import emailer from './emailer' + const emailRe = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ // Fork a bcrypt process to hash and compare passwords @@ -533,7 +534,6 @@ const API = { console.debug('IPN Verified Notification:', body) } - // TODO: add database field for this if (body.txn_id) { if (txnStore.indexOf(body.txn_id) !== -1) return true txnStore.push(body.txn_id) diff --git a/server/api/minecraft.js b/server/api/minecraft.js index 4c72685..e7c6936 100644 --- a/server/api/minecraft.js +++ b/server/api/minecraft.js @@ -1,6 +1,7 @@ +import crypto from 'crypto' + import API from './index' import Model from './models' -import crypto from 'crypto' const mAPI = { getToken: async function (user) { diff --git a/server/index.js b/server/index.js index 060bdf1..1f72ef9 100644 --- a/server/index.js +++ b/server/index.js @@ -15,7 +15,7 @@ const args = { function spawnWorkers () { let workerCount = config.server.workers === 0 ? cpuCount : config.server.workers - console.log('Spinning up ' + workerCount + ' worker process' + (workerCount !== 1 ? 'es' : '')) + console.log('Spinning up %d worker process%s', workerCount, (workerCount !== 1 ? 'es' : '')) for (let i = 0; i < workerCount; i++) { spawnWorker() @@ -68,14 +68,14 @@ function spawnWorker (oldWorker) { w.process.stderr.on('data', (data) => { console.log(w.process.pid, data.toString().trim()) }) - args.verbose && console.log('Starting worker process ' + w.process.pid + '...') + args.verbose && console.log('Starting worker process %d...', w.process.pid) w.on('message', (message) => { if (message === 'started') { workers.push(w) - args.verbose && console.log('Started worker process ' + w.process.pid) + args.verbose && console.log('Started worker process', w.process.pid) if (oldWorker) { - args.verbose && console.log('Stopping worker process ' + oldWorker.process.pid) + args.verbose && console.log('Stopping worker process', oldWorker.process.pid) oldWorker.send('stop') } } else { @@ -99,7 +99,7 @@ cluster.setupMaster({ cluster.on('exit', (worker, code, signal) => { let extra = ((code || '') + ' ' + (signal || '')).trim() - console.error('Worker process ' + worker.process.pid + ' exited ' + (extra ? '(' + extra + ')' : '')) + console.error('Worker process %d exited %s', worker.process.pid, (extra ? '(' + extra + ')' : '')) let index = workers.indexOf(worker) diff --git a/server/routes/admin.js b/server/routes/admin.js index 2a898c4..4ab3a46 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -1,8 +1,9 @@ import express from 'express' + import ensureLogin from '../../scripts/ensureLogin' import wrap from '../../scripts/asyncRoute' -import {User} from '../api' import API from '../api/admin' +import {User} from '../api' const router = express.Router() const apiRouter = express.Router() diff --git a/server/routes/api.js b/server/routes/api.js index 2f0e207..70a0330 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -1,12 +1,13 @@ import express from 'express' import RateLimit from 'express-rate-limit' import multiparty from 'multiparty' + import config from '../../scripts/load-config' import wrap from '../../scripts/asyncRoute' -import API from '../api' -import News from '../api/news' -import Image from '../api/image' import APIExtern from '../api/external' +import Image from '../api/image' +import News from '../api/news' +import API from '../api' let router = express.Router() let dev = process.env.NODE_ENV !== 'production' diff --git a/server/routes/index.js b/server/routes/index.js index 48c8a21..93f1947 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -2,6 +2,7 @@ import fs from 'fs-extra' import path from 'path' import express from 'express' import RateLimit from 'express-rate-limit' + import ensureLogin from '../../scripts/ensureLogin' import config from '../../scripts/load-config' import wrap from '../../scripts/asyncRoute' @@ -78,7 +79,7 @@ router.use(wrap(async (req, res, next) => { } // Update user session - let udata = await API.User.get(req.session.user.id) + let udata = await API.User.get(req.session.user) setSession(req, udata) // Update IP address @@ -158,6 +159,7 @@ router.get('/reset/:token', wrap(async (req, res) => { router.get('/login', extraButtons, (req, res) => { if (req.session.user) return redirectLogin(req, res) + if (req.query.returnTo) { req.session.redirectUri = req.query.returnTo } @@ -175,6 +177,21 @@ router.get('/register', extraButtons, formKeep, (req, res) => { res.render('user/register') }) +// User activation endpoint (emailed link) +router.get('/activate/:token', wrap(async (req, res) => { + if (req.session.user) return res.redirect('/login') + let token = req.params.token + let success = await API.User.Login.activationToken(token) + + if (!success) { + req.flash('message', {error: true, text: 'Invalid or expired activation token.'}) + } else { + req.flash('message', {error: false, text: 'Your account has been activated! You may now log in.'}) + } + + res.redirect('/login') +})) + // View for enabling Two-Factor Authentication router.get('/user/two-factor', ensureLogin, wrap(async (req, res) => { let twoFaEnabled = await API.User.Login.totpTokenRequired(req.session.user) @@ -291,9 +308,11 @@ function formError (req, res, error, redirect) { // Make sure characters are UTF-8 function cleanString (input) { let output = '' + for (let i = 0; i < input.length; i++) { output += input.charCodeAt(i) <= 127 ? input.charAt(i) : '' } + return output } @@ -309,6 +328,7 @@ function csrfValidation (req, res, next) { // Enabling 2fa router.post('/user/two-factor', csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() + if (!req.body.code) { return formError(req, res, 'You need to enter the code.') } @@ -340,7 +360,9 @@ router.post('/user/two-factor/disable', csrfValidation, wrap(async (req, res, ne // Verify 2FA for login router.post('/login/verify', csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() + if (req.session.totp_check === null) return res.redirect('/login') + if (!req.body.code && !req.body.recovery) { return formError(req, res, 'You need to enter the code.') } @@ -360,6 +382,7 @@ router.post('/login/verify', csrfValidation, wrap(async (req, res, next) => { // Log the user in. Limited resource router.post('/login', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() + if (!req.body.username || !req.body.password || req.body.username === '') { return res.redirect('/login') } @@ -373,6 +396,7 @@ router.post('/login', accountLimiter, csrfValidation, wrap(async (req, res, next if (!pwMatch) return formError(req, res, 'Invalid username or password.') if (user.activated === 0) return formError(req, res, 'Please activate your account first.') + if (user.locked === 1) return formError(req, res, 'This account has been locked.') // Check if the user is banned @@ -407,6 +431,7 @@ router.post('/login', accountLimiter, csrfValidation, wrap(async (req, res, next // Password reset router.post('/login/reset', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() + if (!req.body.email) { return formError(req, res, 'You need to enter your email address.') } @@ -420,7 +445,7 @@ router.post('/login/reset', accountLimiter, csrfValidation, wrap(async (req, res try { await API.User.Reset.reset(email) - req.flash('message', {error: false, text: 'We\'ve sent a link to your email address. Please check spam folders too!'}) + req.flash('message', {error: false, text: 'We\'ve sent a link to your email address. Please check spam folders, too!'}) res.redirect('/login/reset?success=true') } catch (e) { return formError(req, res, e.message) @@ -466,6 +491,7 @@ router.post('/reset/:token', csrfValidation, wrap(async (req, res) => { // Protected & Limited resource: Account registration router.post('/register', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() + if (!req.body.username || !req.body.display_name || !req.body.password || !req.body.email) { return formError(req, res, 'Please fill in all the fields.') } @@ -563,7 +589,7 @@ router.post('/user/manage', csrfValidation, wrap(async (req, res, next) => { let displayName = req.body.display_name if (!displayName || !displayName.match(/^([^\\`]{3,32})$/i)) { - return formError(req, res, 'Invalid display name!') + return formError(req, res, 'Invalid Display Name!') } displayName = cleanString(displayName) @@ -680,7 +706,6 @@ router.post('/user/manage/email', accountLimiter, csrfValidation, wrap(async (re return formError(req, res, e.message) } - // TODO: Send necessary emails console.warn('[SECURITY AUDIT] User \'%s\' email has been changed from %s', user.username, req.realIP) req.session.user.email = newEmail @@ -790,21 +815,6 @@ router.get('/logout', (req, res) => { res.redirect('/') }) -// User activation endpoint (emailed link) -router.get('/activate/:token', wrap(async (req, res) => { - if (req.session.user) return res.redirect('/login') - let token = req.params.token - let success = await API.User.Login.activationToken(token) - - if (!success) { - req.flash('message', {error: true, text: 'Invalid or expired activation token.'}) - } else { - req.flash('message', {error: false, text: 'Your account has been activated! You may now log in.'}) - } - - res.redirect('/login') -})) - router.use('/api', apiRouter) router.use('/admin', adminRouter) router.use('/mc', mcRouter) diff --git a/server/routes/minecraft.js b/server/routes/minecraft.js index d31aae5..6795589 100644 --- a/server/routes/minecraft.js +++ b/server/routes/minecraft.js @@ -1,4 +1,5 @@ import express from 'express' + import ensureLogin from '../../scripts/ensureLogin' import wrap from '../../scripts/asyncRoute' import Minecraft from '../api/minecraft' diff --git a/server/routes/oauth2.js b/server/routes/oauth2.js index 073d636..a29920f 100644 --- a/server/routes/oauth2.js +++ b/server/routes/oauth2.js @@ -1,5 +1,6 @@ import express from 'express' -import uapi from '../api' + +import UAPI from '../api' import OAuth2 from '../api/oauth2' import RateLimit from 'express-rate-limit' import wrap from '../../scripts/asyncRoute' @@ -31,7 +32,7 @@ router.post('/introspect', oauth.controller.introspection) // Protected user information resource router.get('/user', oauth.bearer, wrap(async (req, res) => { let accessToken = req.oauth2.accessToken - let user = await uapi.User.get(accessToken.user_id) + let user = await UAPI.User.get(accessToken.user_id) if (!user) { return res.status(404).jsonp({ diff --git a/server/server.js b/server/server.js index 215ffcd..82a8549 100644 --- a/server/server.js +++ b/server/server.js @@ -68,7 +68,7 @@ module.exports = (args) => { app.set('views', path.join(__dirname, '../views')) if (args.dev) { - console.log('Worker is in development mode') + console.warn('Worker is in development mode') // Dev logger const morgan = require('morgan')