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' import { httpPOST } from '../../scripts/http' import { User, Login, Reset, Register } from '../api' import * as News from '../api/news' import { pushMail } from '../api/emailer' import apiRouter from './api' import oauthRouter from './oauth2' import adminRouter from './admin' const router = express.Router() // Restrict account creation const accountLimiter = new RateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 10, delayMs: 0, message: 'Whoa, slow down there, buddy! You just hit our rate limits. Try again in 1 hour.' }) // Set the user session function setSession (req, user) { req.session.user = { id: user.id, username: user.username, display_name: user.display_name, email: user.email, avatar_file: user.avatar_file, privilege: user.nw_privilege, session_refresh: Date.now() + 1800000 // 30 minutes } } function redirectLogin (req, res) { let uri = '/' if (req.session.redirectUri) { uri = req.session.redirectUri delete req.session.redirectUri } res.redirect(uri) } router.use(wrap(async (req, res, next) => { // Add form messages into the template rendering if present let messages = req.flash('message') if (!messages || !messages.length) { messages = {} } else { messages = messages[0] } res.locals.message = messages // Update user session every 30 minutes if (req.session.user) { if (!req.session.user.session_refresh) { req.session.user.session_refresh = Date.now() + 1800000 } if (req.session.user.session_refresh < Date.now()) { console.debug('User session update') // Check for bans const banStatus = await User.getBanStatus(req.session.user.id) if (banStatus.length) { delete req.session.user return next() } // Update user session const udata = await User.get(req.session.user) setSession(req, udata) // Update IP address await User.update(udata, { ip_address: req.realIP }) } } next() })) router.use('/oauth2', oauthRouter) /* ================ RENDER VIEWS ================ */ router.get('/', (req, res) => { res.render('index') }) // Add social media login buttons function extraButtons (recheck) { const et = config.external return function (req, res, next) { if (!et) return next() res.locals.auth = { registrations: et.registrations } if (recheck && et.registrations !== true) return next() if (et.twitter && et.twitter.api) { res.locals.auth.twitter = true } if (et.discord && et.discord.api) { res.locals.auth.discord = true } if (et.facebook && et.facebook.client) { res.locals.auth.facebook = et.facebook.client } if (et.google && et.google.api) { res.locals.auth.google = et.google.api } next() } } // Retrieve form data if formError was called function formKeep (req, res, next) { let dataSave = req.flash('formkeep') if (dataSave.length) { dataSave = dataSave[0] } else { dataSave = {} } res.locals.formkeep = dataSave next() } // Password reset request endpoint router.get('/login/reset', extraButtons(false), (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') const token = req.params.token const success = await 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(false), formKeep, (req, res) => { if (req.session.user) return redirectLogin(req, res) if (req.query.returnTo) { req.session.redirectUri = req.query.returnTo } res.render('user/login') }) router.get('/register', extraButtons(true), formKeep, (req, res) => { if (req.session.user) return redirectLogin(req, res) if (config.security.recaptcha && config.security.recaptcha.site_key) { res.locals.recaptcha = config.security.recaptcha.site_key } 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') const token = req.params.token const success = await 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) => { const twoFaEnabled = await Login.totpTokenRequired(req.session.user) if (twoFaEnabled) return res.redirect('/') const newToken = await Login.totpAquire(req.session.user) if (!newToken) return res.redirect('/') res.render('user/totp', { uri: newToken }) })) // View for disabling Two-Factor Authentication router.get('/user/two-factor/disable', ensureLogin, wrap(async (req, res) => { const twoFaEnabled = await Login.totpTokenRequired(req.session.user) if (!twoFaEnabled) return res.redirect('/') res.render('user/password') })) // Two-Factor Authentication verification on login router.get('/login/verify', (req, res) => { res.render('user/totp-check') }) // User settings page router.get('/user/manage', ensureLogin, wrap(async (req, res) => { let totpEnabled = false const socialStatus = await User.socialStatus(req.session.user) if (socialStatus.password) { totpEnabled = await Login.totpTokenRequired(req.session.user) } const et = config.external if (et) { res.locals.auth = {} // Decide whether we need a disconnect or a log in with button for social account logins if (et.twitter && et.twitter.api) { if (!socialStatus.enabled.twitter) { res.locals.auth.twitter = true } else if (socialStatus.source !== 'twitter') { res.locals.auth.twitter = false } } if (et.discord && et.discord.api) { if (!socialStatus.enabled.discord) { res.locals.auth.discord = true } else if (socialStatus.source !== 'discord') { res.locals.auth.discord = false } } if (et.facebook && et.facebook.client) { if (!socialStatus.enabled.facebook) { res.locals.auth.facebook = et.facebook.client } else if (socialStatus.source !== 'facebook') { res.locals.auth.facebook = false } } if (et.google && et.google.api) { if (!socialStatus.enabled.google) { res.locals.auth.google = et.google.api } else if (socialStatus.source !== 'google') { res.locals.auth.google = false } } } res.render('user/settings', { totp: totpEnabled, password: socialStatus.password }) })) // Change password router.get('/user/manage/password', ensureLogin, wrap(async (req, res) => { const socialStatus = await User.socialStatus(req.session.user) res.render('user/password_new', { token: !socialStatus.password }) })) // Change email router.get('/user/manage/email', ensureLogin, wrap(async (req, res) => { let obfuscated = req.session.user.email if (obfuscated) { const split = obfuscated.split('@') const rep = split[0].charAt(0) + '***' + split[0].charAt(split[0].length - 1) obfuscated = rep + '@' + split[1] } const socialStatus = await User.socialStatus(req.session.user) res.render('user/email_change', { email: obfuscated, password: socialStatus.password }) })) router.get('/donate', wrap(async (req, res, next) => { if (!config.donations || !config.donations.business) return next() res.render('donate', config.donations) })) /* ================= POST HANDLING ================= */ // Used to display errors on forms and save data function formError (req, res, error, redirect) { // Security measures: never store any passwords in any session for (const key in req.body) { if (key.indexOf('password') !== -1) { delete req.body[key] } } req.flash('formkeep', req.body || {}) req.flash('message', { error: true, text: error }) res.redirect(redirect || req.originalUrl) } // 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 } // 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', 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.') } const verified = await Login.totpCheck(req.session.user, req.body.code) if (!verified) { return formError(req, res, 'Something went wrong! Try scanning the code again.') } res.redirect('/') })) // Disabling 2fa router.post('/user/two-factor/disable', csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() if (!req.body.password) { return formError(req, res, 'Please enter your password.') } const purge = await Login.purgeTotp(req.session.user, req.body.password) if (!purge) { return formError(req, res, 'Invalid password.') } res.redirect('/') })) // 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.') } const totpCheck = await Login.totpCheck(req.session.totp_check, req.body.code, req.body.recovery || false) if (!totpCheck) { return formError(req, res, 'Invalid code!') } const user = await User.get(req.session.totp_check) delete req.session.totp_check setSession(req, user) redirectLogin(req, res) })) // 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') } const user = await User.get(req.body.username) if (!user) return formError(req, res, 'Invalid username or password.') if (!user.password || user.password === '') return formError(req, res, 'Please log in using the buttons on the right.') const pwMatch = await Login.password(user, req.body.password) if (!pwMatch) return formError(req, res, 'Invalid username or password.') if (user.activated === 0) return formError(req, res, 'Please activate your account first. If you did not receive an email, please contact an administrator.') if (user.locked === 1) return formError(req, res, 'This account has been locked. Please contact an administrator for more information.') // Check if the user is banned const banStatus = await User.getBanStatus(user.id) if (banStatus.length) { return res.render('user/banned', { bans: banStatus, ipban: false }) } // Redirect to the verification dialog if 2FA is enabled const totpRequired = await Login.totpTokenRequired(user) if (totpRequired) { req.session.totp_check = user.id return res.redirect('/login/verify') } // Set session setSession(req, user) let uri = '/' if (req.session.redirectUri) { uri = req.session.redirectUri delete req.session.redirectUri } if (req.query.redirect) { uri = req.query.redirect } 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.') } const email = req.body.email const validEmail = await Register.validateEmail(email) if (!validEmail) { return formError(req, res, 'You need to enter a valid email address.') } try { await Reset.reset(email, false) 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) } })) // Password reset endpoint (emailed link) router.post('/reset/:token', csrfValidation, wrap(async (req, res) => { if (req.session.user) return res.redirect('/login') const token = req.params.token const user = await Reset.resetToken(token) if (!user) { req.flash('message', { error: true, text: 'Invalid or expired reset token.' }) res.redirect('/login') return } // 4th Check: Password length const 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 const passwordAgain = req.body.password_repeat if (!passwordAgain || password !== passwordAgain) { return formError(req, res, 'Passwords do not match!') } try { await 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) { 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() if (!req.body.username || !req.body.display_name || !req.body.password || !req.body.email) { return formError(req, res, 'Please fill in all the fields.') } // Ban check const banStatus = await User.getBanStatus(req.realIP, true) if (banStatus.length) { return res.render('user/banned', { bans: banStatus, ipban: true }) } // 1st Check: Username Characters and length const username = req.body.username if (!username || !username.match(/^([\w-_]{3,26})$/i)) { return formError(req, res, 'Invalid username! Must be between 3-26 characters and composed of alphanumeric characters!') } // 2nd Check: Display Name const displayName = req.body.display_name if (!displayName || !displayName.match(/^([^\\`]{3,32})$/i)) { return formError(req, res, 'Invalid display name!') } // 3rd Check: Email Address const email = req.body.email if (!email || !Register.validateEmail(email)) { return formError(req, res, 'Invalid email address!') } // 4th Check: Password length const 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 const passwordAgain = req.body.password_repeat if (!passwordAgain || password !== passwordAgain) { return formError(req, res, 'Passwords do not match!') } // 6th Check: reCAPTCHA (if configuration contains key) if (config.security && config.security.recaptcha && config.security.recaptcha.site_key) { if (!req.body['g-recaptcha-response']) return formError(req, res, 'Please complete the reCAPTCHA!') try { let data = await httpPOST('https://www.google.com/recaptcha/api/siteverify', {}, { secret: config.security.recaptcha.secret_key, response: req.body['g-recaptcha-response'] }) data = JSON.parse(data) if (!data.success) { return formError(req, res, 'Please complete the reCAPTCHA!') } } catch (e) { console.error(e) return formError(req, res, 'Internal server error') } } // Hash the password const hash = await Register.hashPassword(password) let newUser // Attempt to create the user try { newUser = await 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 let registerMessage = 'Account created successfully!' if (newUser && newUser.activated !== 1) { registerMessage += ' Please check your inbox for an activation link. Also, make sure to look into spam folders.' } req.flash('message', { error: false, text: registerMessage }) res.redirect('/login') })) // Change display name router.post('/user/manage', csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() if (!req.body.display_name) { return formError(req, res, 'Display Name cannot be blank.') } let displayName = req.body.display_name if (!displayName || !displayName.match(/^([^\\`]{3,32})$/i)) { return formError(req, res, 'Invalid Display Name!') } displayName = cleanString(displayName) // No change if (displayName === req.session.user.display_name) { return res.redirect('/user/manage') } try { await User.update(req.session.user, { display_name: displayName }) } catch (e) { return formError(req, res, e.message) } req.session.user.display_name = displayName req.flash('message', { error: false, text: 'Settings changed successfully.' }) res.redirect('/user/manage') })) // Change user password router.post('/user/manage/password', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() const user = req.session.user const socialStatus = await User.socialStatus(user) if (!req.body.password_old && socialStatus.password) { return formError(req, res, 'Please enter your current password.') } if (socialStatus.password) { const passwordMatch = await Login.password(user, req.body.password_old) if (!passwordMatch) { return formError(req, res, 'The password you provided is incorrect.') } } let password = req.body.password if (!password || password.length < 8) { return formError(req, res, 'Invalid password! Please use at least 8 characters!') } const passwordAgain = req.body.password_repeat if (!passwordAgain || password !== passwordAgain) { return formError(req, res, 'The passwords do not match!') } password = await Register.hashPassword(password) try { await 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) if (config.email && config.email.enabled) { await pushMail('password_alert', user.email, { display_name: user.display_name, ip: req.realIP }) } req.flash('message', { error: false, text: 'Password changed successfully.' }) return res.redirect('/user/manage') })) // Change email address router.post('/user/manage/email', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() const user = await User.get(req.session.user) const email = req.body.email const newEmail = req.body.email_new const password = req.body.password if (!newEmail || (!email && user.email !== '')) { return formError(req, res, 'Please fill in all of the fields.') } if (req.session.user.email !== '' && email !== user.email) { return formError(req, res, 'The email you provided is incorrect.') } if (user.password != null && user.password !== '') { if (!password) { return formError(req, res, 'Enter a password.') } const passwordMatch = await Login.password(user, password) if (!passwordMatch) { return formError(req, res, 'The password you provided is incorrect.') } } const emailValid = Register.validateEmail(newEmail) if (!emailValid) { return formError(req, res, 'Invalid email address.') } const emailTaken = await User.get(newEmail) if (emailTaken) { return formError(req, res, 'This email is already taken.') } try { await User.update(user, { email: newEmail }) } catch (e) { return formError(req, res, e.message) } console.warn('[SECURITY AUDIT] User \'%s\' email has been changed from %s', user.username, req.realIP) req.session.user.email = newEmail req.flash('message', { error: false, text: 'Email changed successfully.' }) return res.redirect('/user/manage') })) /* ============= DOCUMENTS ============= */ // Serve a document form the documents directory, cache it. const docsDir = path.join(__dirname, '../../documents') router.get('/docs/:name', wrap(async (req, res, next) => { const doc = path.join(docsDir, req.params.name + '.html') if (!await fs.exists(docsDir) || !await fs.exists(doc)) { return next() } fs.readFile(doc, { encoding: 'utf8' }, (err, contents) => { if (err) return next(err) res.header('Cache-Control', 'max-age=' + 7 * 24 * 60 * 60 * 1000) // 1 week res.render('document', { doc: contents }) }) })) /* ======== NEWS ======== */ function newsPrivilege (req, res, next) { if (!req.session.user) return res.redirect('/news') if (req.session.user.privilege < 1) return res.redirect('/news') next() } router.get('/news/compose', newsPrivilege, formKeep, (req, res) => { res.render('news/composer') }) router.post('/news/compose', newsPrivilege, csrfValidation, wrap(async (req, res) => { if (!req.body.title || !req.body.content) { return formError(req, res, 'Required fields missing!') } 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) })) // Serve news router.get('/news/:id?-*', wrap(async (req, res) => { const id = parseInt(req.params.id) if (isNaN(id)) { return res.status(404).render('article', { article: null }) } const article = await News.article(id) if (!article.id) { return res.status(404).render('article', { article: null }) } let editing = false if (req.query.edit === '1' && req.session.user && req.session.user.privilege > 1) { editing = true } res.render('news/article', { article: article, editing: editing }) })) router.get('/news/', wrap(async (req, res) => { let page = parseInt(req.query.page) if (isNaN(page)) { page = 1 } const news = await News.listNews(page) res.render('news/news', { news: news }) })) router.get('/news/atom.xml', wrap(async (req, res) => { const feed = await News.generateFeed() res.set('Content-Type', 'application/atom+xml') res.send(feed.atom1()) })) router.get('/news/feed.json', wrap(async (req, res) => { const feed = await News.generateFeed() res.set('Content-Type', 'application/json') res.send(feed.json1()) })) // Render partials router.get('/partials/:view', (req, res, next) => { if (!req.params.view) return next() res.render('user/partials/' + req.params.view) }) /* ========= OTHER ========= */ router.get('/logout', (req, res) => { req.session.destroy() res.redirect('/') }) router.use('/api', apiRouter) router.use('/admin', adminRouter) /* FALLBACK ROUTES BEYOND THIS POINT */ // Handle 'Failed to lookup view' errors router.use((err, req, res, next) => { if (err && err.stack) { if (err.stack.indexOf('Failed to lookup view') !== -1) { return next() // To 404 } } next(err) // To error handler }) // 404 - last route router.use((req, res) => { res.status(404).render('404') }) // Error handler router.use((err, req, res, next) => { console.error(err) if (process.env.NODE_ENV !== 'production') { return res.end(err.stack) } next() }) export default router