diff --git a/README.md b/README.md index b449e29..5a7df1e 100644 --- a/README.md +++ b/README.md @@ -12,21 +12,19 @@ Icy Network is a community network aimed at anyone who likes friendly discussion More to come! ## The Goal of this Application -This application is used for authentication services such as OAuth2 in order to unite our websites with a single login and as a centeral news outlet for Icy Network services. +This application is used for authentication services such as OAuth2 in order to unite our websites with a single login and as a central news outlet for Icy Network services. ## Setup -The first time you run the application, it will migrate the database and that may take a while. +The first time you run the application, it will migrate the database and that may take a while. **You will also need a running instance of `redis-server` for session storage!** ### Development -Clone this repository and then - -1. `npm install` to get all the packages -2. `cp config.example.toml config.toml` copy the configuration -3. `npm run watch` to run the style and front-end script building watch task -4. `npm start -- -d` to start the application in development mode +1. Clone this repository and `cd` into it +2. `npm install` - Get all the dependencies +3. `cp config.example.toml config.toml` - Copy the configuration +4. `npm run watch` - Run the style and front-end script watch task +5. `npm start -- -d` - Start the application in development mode There is also a watch mode for the server. To enable `server` file tree watching you must provide both `-d` and `-w` as parameters. This task will reset all workers when any file in the `server` directory changes, enabling for live debugging. ### Production - -1. `npm run build` -2. `npm start` +1. `npm run build` - Build the front-end +2. `npm start` - Start the application in production mode diff --git a/config.example.toml b/config.example.toml index 01ec759..e59023d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -4,11 +4,12 @@ port=8282 # Session key session_key="Session" - # Session secret + # Session secret (keep this a secret) session_secret="hackmysessions" # Number of worker processes (0 to use all CPU cores) workers=1 # Domain of this application + # Used for the links in emails domain="http://localhost:8282" # Database diff --git a/package-lock.json b/package-lock.json index 567cce2..69edebe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2090,7 +2090,7 @@ "merge-descriptors": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "methods": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "on-finished": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "parseurl": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz", + "parseurl": "1.3.1", "path-to-regexp": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "proxy-addr": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.1.4.tgz", "qs": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz", @@ -2131,7 +2131,7 @@ "debug": "https://registry.npmjs.org/debug/-/debug-2.6.7.tgz", "depd": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz", "on-headers": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz", - "parseurl": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz", + "parseurl": "1.3.1", "uid-safe": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.4.tgz", "utils-merge": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz" }, @@ -2222,7 +2222,7 @@ "encodeurl": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz", "escape-html": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "on-finished": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "parseurl": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz", + "parseurl": "1.3.1", "statuses": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", "unpipe": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" }, @@ -3835,7 +3835,8 @@ "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=" }, "parseurl": { - "version": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz", "integrity": "sha1-yKuMkiO6NIiKpkopeyiFO+wY2lY=" }, "path-browserify": { @@ -4639,7 +4640,7 @@ "requires": { "encodeurl": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz", "escape-html": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "parseurl": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz", + "parseurl": "1.3.1", "send": "https://registry.npmjs.org/send/-/send-0.15.3.tgz" } }, diff --git a/server/routes/api.js b/server/routes/api.js index 2fc9774..cdc4e4a 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -1,6 +1,5 @@ import express from 'express' import RateLimit from 'express-rate-limit' -import path from 'path' import multiparty from 'multiparty' import config from '../../scripts/load-config' import wrap from '../../scripts/asyncRoute' @@ -9,16 +8,17 @@ import News from '../api/news' import Image from '../api/image' import APIExtern from '../api/external' -// const userContent = path.join(__dirname, '../..', 'usercontent') - let router = express.Router() +let dev = process.env.NODE_ENV !== 'production' +// Restrict API usage let apiLimiter = new RateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 100, delayMs: 0 }) +// Restrict image uploads let uploadLimiter = new RateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 10, @@ -81,7 +81,8 @@ function JsonData (req, res, error, redirect = '/') { * Ajax POST only * No tokens saved in configs, everything works out-of-the-box */ -router.post('/external/facebook/callback', wrap(async (req, res) => { +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 @@ -252,14 +253,19 @@ router.get('/external/discord/remove', wrap(async (req, res) => { * ======== */ -// Get page of articles +// Cache news for one day +router.get('/news', (req, res, next) => { + if (!dev) res.header('Cache-Control', 'max-age=' + 24 * 60 * 60 * 1000) // 1 day + next() +}) + +// Get a page of articles router.get('/news/all/:page', wrap(async (req, res) => { if (!req.params.page || isNaN(parseInt(req.params.page))) { return res.status(400).jsonp({error: 'Invalid page number.'}) } let page = parseInt(req.params.page) - let articles = await News.listNews(page) res.jsonp(articles) @@ -277,8 +283,8 @@ router.get('/news/:id', wrap(async (req, res) => { } let id = parseInt(req.params.id) - let article = await News.article(id) + res.jsonp(article) })) @@ -370,6 +376,7 @@ router.use('/avatar', (req, res) => { * ===================== */ +// List authorizations router.get('/oauth2/authorized-clients', wrap(async (req, res, next) => { if (!req.session.user) return next() @@ -379,7 +386,8 @@ router.get('/oauth2/authorized-clients', wrap(async (req, res, next) => { res.jsonp(list) })) -router.post('/oauth2/authorized-clients/delete', wrap(async (req, res, next) => { +// Revoke an authorization +router.post('/oauth2/authorized-clients/revoke', wrap(async (req, res, next) => { if (!req.session.user) return next() let clientId = parseInt(req.body.client_id) diff --git a/server/routes/index.js b/server/routes/index.js index 4eaf15c..06bb854 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -2,7 +2,6 @@ import fs from 'fs' import path from 'path' import express from 'express' import RateLimit from 'express-rate-limit' -import parseurl from 'parseurl' import config from '../../scripts/load-config' import wrap from '../../scripts/asyncRoute' import http from '../../scripts/http' @@ -15,6 +14,7 @@ import oauthRouter from './oauth2' let router = express.Router() +// Restrict account creation let accountLimiter = new RateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 10, @@ -22,6 +22,7 @@ let accountLimiter = new RateLimit({ 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, @@ -34,6 +35,7 @@ function setSession (req, user) { } 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 = {} @@ -85,6 +87,8 @@ function extraButtons (req, res, next) { next() } +// Make sure the user is logged in +// Redirect to login page and store the current path in the session for redirecting later function ensureLogin (req, res, next) { if (req.session.user) return next() req.session.redirectUri = req.originalUrl @@ -124,6 +128,7 @@ router.get('/register', extraButtons, (req, res) => { res.render('user/register') }) +// 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) if (twoFaEnabled) return res.redirect('/') @@ -134,6 +139,7 @@ router.get('/user/two-factor', ensureLogin, wrap(async (req, res) => { res.render('user/totp', { uri: newToken }) })) +// View for disabling Two-Factor Authentication router.get('/user/two-factor/disable', ensureLogin, wrap(async (req, res) => { let twoFaEnabled = await API.User.Login.totpTokenRequired(req.session.user) @@ -141,10 +147,12 @@ router.get('/user/two-factor/disable', ensureLogin, wrap(async (req, res) => { 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 let socialStatus = await API.User.socialStatus(req.session.user) @@ -180,10 +188,12 @@ router.get('/user/manage', ensureLogin, wrap(async (req, res) => { res.render('user/settings', {totp: totpEnabled, password: socialStatus.password}) })) +// Change password router.get('/user/manage/password', ensureLogin, wrap(async (req, res) => { res.render('user/password_new') })) +// Change email router.get('/user/manage/email', ensureLogin, wrap(async (req, res) => { let obfuscated = req.session.user.email if (obfuscated) { @@ -203,6 +213,7 @@ router.get('/user/manage/email', ensureLogin, wrap(async (req, res) => { ================= */ +// 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 (let key in req.body) { @@ -213,9 +224,10 @@ function formError (req, res, error, redirect) { req.flash('formkeep', req.body || {}) req.flash('message', {error: true, text: error}) - res.redirect(redirect || parseurl(req).path) + res.redirect(redirect || req.originalUrl) } +// Make sure characters are UTF-8 function cleanString (input) { let output = '' for (let i = 0; i < input.length; i++) { @@ -224,6 +236,7 @@ function cleanString (input) { return output } +// Enabling 2fa router.post('/user/two-factor', wrap(async (req, res, next) => { if (!req.session.user) return next() if (!req.body.code) { @@ -236,12 +249,13 @@ router.post('/user/two-factor', wrap(async (req, res, next) => { let verified = await API.User.Login.totpCheck(req.session.user, req.body.code) if (!verified) { - return formError(req, res, 'Try again!') + return formError(req, res, 'Something went wrong! Try scanning the code again.') } res.redirect('/') })) +// Disabling 2fa router.post('/user/two-factor/disable', wrap(async (req, res, next) => { if (!req.session.user) return next() if (req.body.csrf !== req.session.csrf) { @@ -260,6 +274,7 @@ router.post('/user/two-factor/disable', wrap(async (req, res, next) => { res.redirect('/') })) +// Verify 2FA for login router.post('/login/verify', wrap(async (req, res, next) => { if (req.session.user) return next() if (req.session.totp_check === null) return res.redirect('/login') @@ -302,6 +317,7 @@ router.post('/login/verify', wrap(async (req, res, next) => { res.redirect(uri) })) +// Log the user in router.post('/login', wrap(async (req, res, next) => { if (req.session.user) return next() if (!req.body.username || !req.body.password || req.body.username === '') { @@ -323,6 +339,7 @@ router.post('/login', wrap(async (req, res, next) => { 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.') + // Redirect to the verification dialog if 2FA is enabled let totpRequired = await API.User.Login.totpTokenRequired(user) if (totpRequired) { req.session.totp_check = user.id @@ -347,6 +364,7 @@ router.post('/login', wrap(async (req, res, next) => { res.redirect(uri) })) +// Protected & Limited resource: Account registration router.post('/register', accountLimiter, 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) { @@ -423,10 +441,17 @@ router.post('/register', accountLimiter, wrap(async (req, res, next) => { return formError(req, res, newUser.error) } - req.flash('message', {error: false, text: 'Account created successfully! Please check your email for an activation link.'}) + // Do not include activation link message when the user is already activated + let registerMessage = 'Account created successfully!' + if (newUser.user && newUser.user.activated !== 1) { + registerMessage += ' Please check your email for an activation link.' + } + + req.flash('message', {error: false, text: registerMessage}) res.redirect('/login') })) +// Change display name router.post('/user/manage', wrap(async (req, res, next) => { if (!req.session.user) return next() @@ -460,10 +485,11 @@ router.post('/user/manage', wrap(async (req, res, next) => { req.session.user.display_name = displayName - req.flash('message', {error: false, text: 'Settings changed successfully. Please note that it may take time to update on other websites and devices.'}) + req.flash('message', {error: false, text: 'Settings changed successfully.'}) res.redirect('/user/manage') })) +// Change user password router.post('/user/manage/password', wrap(async (req, res, next) => { if (!req.session.user) return next() @@ -514,6 +540,7 @@ router.post('/user/manage/password', wrap(async (req, res, next) => { return res.redirect('/user/manage') })) +// Change email address router.post('/user/manage/email', wrap(async (req, res, next) => { if (!req.session.user) return next() @@ -578,6 +605,7 @@ router.post('/user/manage/email', wrap(async (req, res, next) => { ============= */ +// Serve a document form the documents directory, cache it. const docsDir = path.join(__dirname, '../../documents') router.get('/docs/:name', (req, res, next) => { let doc = path.join(docsDir, req.params.name + '.html') @@ -591,9 +619,11 @@ router.get('/docs/:name', (req, res, next) => { return next(e) } + res.header('Cache-Control', 'max-age=' + 7 * 24 * 60 * 60 * 1000) // 1 week res.render('document', {doc: doc}) }) +// Serve news router.get('/news/:id?-*', wrap(async (req, res) => { let id = parseInt(req.params.id) if (isNaN(id)) { @@ -605,6 +635,7 @@ router.get('/news/:id?-*', wrap(async (req, res) => { return res.status(404).render('article', {article: null}) } + res.header('Cache-Control', 'max-age=' + 24 * 60 * 60 * 1000) // 1 day res.render('article', {article: article}) })) @@ -619,6 +650,7 @@ router.get('/news/', wrap(async (req, res) => { res.render('news', {news: news}) })) +// Render partials router.get('/partials/:view', wrap(async (req, res, next) => { if (!req.params.view) return next() @@ -639,6 +671,7 @@ router.get('/logout', wrap(async (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 diff --git a/server/routes/oauth2.js b/server/routes/oauth2.js index 6b70ee1..77794be 100644 --- a/server/routes/oauth2.js +++ b/server/routes/oauth2.js @@ -11,7 +11,7 @@ router.use(oauth.express()) let oauthLimiter = new RateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes - max: 100, + max: 10, delayMs: 0 }) @@ -23,10 +23,12 @@ function ensureLoggedIn (req, res, next) { res.redirect('/login') } +// Generic OAuth2 endpoints router.use('/authorize', ensureLoggedIn, oauth.controller.authorization) router.post('/token', oauth.controller.token) 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) diff --git a/server/server.js b/server/server.js index 8819252..78c5789 100644 --- a/server/server.js +++ b/server/server.js @@ -31,17 +31,21 @@ app.use(session({ })) app.use((req, res, next) => { + // Inject a cleaner version of the user's IP Address into the request let ipAddr = req.headers['x-forwarded-for'] || req.connection.remoteAddress if (ipAddr.indexOf('::ffff:') !== -1) { ipAddr = ipAddr.replace('::ffff:', '') } + req.realIP = ipAddr + + // Make sure CSRF token is present in the session if (!req.session.csrf) { req.session.csrf = crypto.randomBytes(12).toString('hex') } - req.realIP = ipAddr + // Add user and csrf token into rendering information res.locals = Object.assign(res.locals, { user: req.session.user || null, csrf: req.session.csrf @@ -56,8 +60,10 @@ module.exports = (args) => { app.set('views', path.join(__dirname, '../views')) if (args.dev) console.log('Worker is in development mode') - let staticAge = args.dev ? 1000 : 7 * 24 * 60 * 60 * 1000 + let staticAge = args.dev ? 1000 : 7 * 24 * 60 * 60 * 1000 // 1 week of cache in production + // Static content directories, cache these requests. + // It is also a good idea to use nginx to serve these directories in order to save on computing power app.use('/style', express.static(path.join(__dirname, '../build/style'), { maxAge: staticAge })) app.use('/script', express.static(path.join(__dirname, '../build/script'), { maxAge: staticAge })) app.use('/static', express.static(path.join(__dirname, '../static'), { maxAge: staticAge })) @@ -67,6 +73,8 @@ module.exports = (args) => { app.listen(args.port, () => { console.log('Listening on 0.0.0.0:' + args.port) + + // Initialize the email transporter (if configured) email.init() }) } diff --git a/src/script/main.js b/src/script/main.js index bfa060d..3cc67c1 100644 --- a/src/script/main.js +++ b/src/script/main.js @@ -39,7 +39,7 @@ $(document).ready(function () { function removeAuthorization (clientId) { $.ajax({ type: 'post', - url: '/api/oauth2/authorized-clients/delete', + url: '/api/oauth2/authorized-clients/revoke', data: { client_id: clientId }, success: function (data) { loadAuthorizations() diff --git a/src/style/main.styl b/src/style/main.styl index cc7e7e2..3f78d4d 100644 --- a/src/style/main.styl +++ b/src/style/main.styl @@ -458,6 +458,17 @@ span.load .option display: block +.specify + display: inline-block + width: 22px + text-align: center + margin-left: 5px + background-color: #2196F3 + color: #fff + border-radius: 100px + cursor: help + font-size: 19px + .dialog-drop display: block position: fixed diff --git a/views/user/settings.pug b/views/user/settings.pug index 8ea8ea8..30c6f51 100644 --- a/views/user/settings.pug +++ b/views/user/settings.pug @@ -31,6 +31,7 @@ block body input(type="submit", value="Save Settings") .right h3 Social Media Accounts + .specify(title="You can add social media accounts to your account for ease of login. Once added, logging in from linked sources logs you into this account automatically.") ? include ../includes/external.pug if twitter_auth == false a.option.accdisconnect(href="/api/external/twitter/remove") @@ -61,7 +62,8 @@ block body i.fa.fa-fw.fa-envelope |Change Email Address .clients - h2 OAuth2 Authorized Applications + h2 Authorized Applications + .specify(title="Applications which have access to basic user information. You may restrict access at any time by pressing the red icon on the top right of the application card.") ? .cl#clientlist span.load i.fa.fa-spin.fa-spinner.fa-2x