diff --git a/.env b/.env index 9512ca8..8f5abf1 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ REACT_APP_basePath="/" NODE_CONFIG_ENV='production' +LOG_LINE=1 \ No newline at end of file diff --git a/client/.env b/client/.env index 28a8ef8..f333588 100644 --- a/client/.env +++ b/client/.env @@ -2,3 +2,4 @@ PORT=3001 PUBLIC_URL=/ HOST=nuc.ketrenos.com DANGEROUSLY_DISABLE_HOST_CHECK='true' +LOG_LINE=1 \ No newline at end of file diff --git a/client/src/App.js b/client/src/App.js index 968ada0..c4e7ad2 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -24,6 +24,7 @@ import { base } from "./Common.js"; const App = () => { const [ user, setUser ] = useState(null); const [ csrfToken, setCsrfToken ] = useState(undefined); + const [ loading, setLoading ] = useState(true); useEffect(() => { const effect = async () => { @@ -43,7 +44,7 @@ const App = () => { }, []); useEffect(() => { - if (csrfToken) { + if (!csrfToken) { return; } const effect = async () => { @@ -57,6 +58,12 @@ const App = () => { }, }); const data = await res.json(); + setLoading(false); + if (res.status >= 400) { + console.log(data.message ? data.message : res.statusText); + return; + } + setUser(data); }; @@ -83,9 +90,12 @@ const App = () => { You need to verify your email via the link sent to {user.email}.}/> } - { !user && + { !user && !loading && } /> } + { !user && loading && + Loading...} /> + } diff --git a/client/src/Dashboard.js b/client/src/Dashboard.js index 0f577d2..b5eefd7 100644 --- a/client/src/Dashboard.js +++ b/client/src/Dashboard.js @@ -23,7 +23,7 @@ function Dashboard() { const effect = async () => { const res = await window.fetch( `${base}/api/v1/groups`, { - method: 'POST', + method: 'GET', cache: 'no-cache', credentials: 'same-origin', headers: { diff --git a/client/src/SignIn.js b/client/src/SignIn.js index 27a4231..59c7188 100644 --- a/client/src/SignIn.js +++ b/client/src/SignIn.js @@ -1,4 +1,4 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext, useState, useCallback } from 'react'; import Avatar from '@mui/material/Avatar'; import Button from '@mui/material/Button'; import CssBaseline from '@mui/material/CssBaseline'; @@ -37,10 +37,10 @@ export default function SignIn() { const [ error, setError ] = useState(undefined); const navigate = useNavigate(); - const handleSubmit = (event) => { + const handleSubmit = useCallback(async (event) => { event.preventDefault(); - const data = new FormData(event.currentTarget); - window.fetch(`${base}/api/v1/users/signin`, { + const form = new FormData(event.currentTarget); + const res = await window.fetch(`${base}/api/v1/users/signin`, { method: 'POST', cache: 'no-cache', credentials: 'same-origin', @@ -49,28 +49,22 @@ export default function SignIn() { 'CSRF-Token': csrfToken }, body: JSON.stringify({ - email: data.get('email'), - password: data.get('password') + email: form.get('email'), + password: form.get('password') }) - }) - .then(async (res) => { - return [ res.status, res.statusText, await res.json() ]; - }) - .then(([ status, statusText, data ]) => { - if (status >= 400) { - setError(data.message ? data.message : statusText); - return null; - } if (!data) { - return; - } - setUser(data); - navigate("/"); - }) - .catch((error) => { - console.error(`Error: `, error); - setError(`Error authenticating.`); }); - }; + const data = await res.json(); + if (res.status >= 400) { + setError(data.message ? data.message : res.statusText); + return null; + } + + if (!data) { + return; + } + setUser(data); + navigate("/"); + }, [csrfToken, navigate, setUser ]); return ( diff --git a/server/.eslintrc.json b/server/.eslintrc.json index 74afe30..9661f7f 100644 --- a/server/.eslintrc.json +++ b/server/.eslintrc.json @@ -11,6 +11,9 @@ "indent": [ "error", 2 - ] - } + ], + "quotes": [ 2, "single", { + "allowTemplateLiterals": true + } ] + } } diff --git a/server/app.js b/server/app.js index cb14d2e..0332f95 100755 --- a/server/app.js +++ b/server/app.js @@ -8,12 +8,16 @@ const express = require('express'), bodyParser = require('body-parser'), config = require('config'), session = require('express-session'), + sqliteStoreFactory = require('express-session-sqlite').default, basePath = require('./basepath'), - cookieParser = require('cookie-parser'), app = express(), csrf = require('csurf'), http = require('http'), - { goodTimesDB } = require('./db.js'); + methodOverride = require('method-override'), + cookieParser = require('cookie-parser'), + { goodTimesDB } = require('./db.js'), + sqlite3 = require('sqlite3'), + SqliteStore = sqliteStoreFactory(session); require('./console-line.js'); /* Monkey-patch console.log with line numbers */ @@ -21,12 +25,27 @@ require('./console-line.js'); /* Monkey-patch console.log with line numbers */ * set in the headers */ app.set('trust proxy', true); app.use(session({ - secret: 'm@g1x!', - resave: false, + secret: 'm@g1x!c00k13$', + maxAge: 14 * 24 * 60 * 60 * 1000, /* 2 weeks */ + resave: true, saveUninitialized: false, - cookie: { secure: true } + cookie: { secure: true }, + store: new SqliteStore({ + driver: sqlite3.Database, + path: config.get('sessions.db'), + ttl: 5000, + cleanupInterval: 300000 + }), })); + app.use(bodyParser.urlencoded({ extended: false })); +app.use((req, res, next) => { + console.log('CSRF debug: ', { + token: req.header('CSRF-Token'), + method: req.method + }); + next(); +}); app.use(cookieParser()); app.use(csrf({ cookie: true @@ -35,22 +54,30 @@ app.use(bodyParser.json()); //const ws = require('express-ws')(app, server); -app.use(function (err, req, res) { - console.error(err.message); - res.status(err.status || 500).json({ - message: err.message, - error: {} - }); -}); - /* Initialize the data base, configure routes, and open server */ goodTimesDB.init().then((db) => { console.log('DB connected. Configuring routes.'); app.locals.db = db; app.set('basePath', basePath); + app.use(`${basePath}api/v1/groups`, require('./routes/groups')); app.use(`${basePath}api/v1/users`, require('./routes/users')); + + /* Error handler and catch for 404 */ + app.use(methodOverride()); + app.use((err, req, res, next) => { + console.log('CSRF debug: ', { + token: req.header('CSRF-Token'), + csrf: req.csrfToken(), + method: req.method + }); + console.error(err); + res.status(err.status || 500).json({ + message: err.message, + error: {} + }); + }); }).then(() => { const server = http.createServer(app), serverConfig = config.get('server'); diff --git a/server/package-lock.json b/server/package-lock.json index 0731ee6..1e31931 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -18,9 +18,11 @@ "csurf": "^1.11.0", "express": "^4.17.3", "express-session": "^1.17.1", + "express-session-sqlite": "^2.0.10", "express-ws": "^5.0.2", "fast-deep-equal": "^3.1.3", "handlebars": "^4.7.7", + "method-override": "^3.0.0", "moment": "^2.24.0", "morgan": "^1.9.1", "node-fetch": "^2.6.0", @@ -31,7 +33,7 @@ "pluralize": "^8.0.0", "random-words": "^1.1.2", "sequelize": "^5.21.6", - "sqlite3": "^4.1.1", + "sqlite3": "^4.2.0", "typeface-roboto": "0.0.75", "ws": "^8.5.0" }, @@ -1135,6 +1137,37 @@ "node": ">= 0.8.0" } }, + "node_modules/express-session-sqlite": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/express-session-sqlite/-/express-session-sqlite-2.0.10.tgz", + "integrity": "sha512-VBNvSlVUqAHHYU4Cca4Q1waJe1CeW2VFlW/PDtGY/cyJUV5Zy+qkN4A18SNvNR//SDZO+Pc3qd/AgVVQw2/W2Q==", + "dependencies": { + "debug": "^4.2.0", + "sql-template-strings": "^2.2.2", + "sqlite": "^4.0.14" + } + }, + "node_modules/express-session-sqlite/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-session-sqlite/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/express-ws": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz", @@ -1990,6 +2023,28 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" }, + "node_modules/method-override": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/method-override/-/method-override-3.0.0.tgz", + "integrity": "sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==", + "dependencies": { + "debug": "3.1.0", + "methods": "~1.1.2", + "parseurl": "~1.3.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/method-override/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dependencies": { + "ms": "2.0.0" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -3003,6 +3058,19 @@ "node": ">=0.10.0" } }, + "node_modules/sql-template-strings": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/sql-template-strings/-/sql-template-strings-2.2.2.tgz", + "integrity": "sha1-PxFQiiWt384hejBCqdMAwxk7lv8=", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/sqlite": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-4.0.25.tgz", + "integrity": "sha512-gqCEcLF8FOTeW/na3SRYWLQkw2jZXgVj1DdgRJbm0jvrhnUgBIuNDUUm649AnBNDNHhI5XskwT8dvc8vearRLQ==" + }, "node_modules/sqlite3": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-4.2.0.tgz", @@ -4330,6 +4398,31 @@ "uid-safe": "~2.1.5" } }, + "express-session-sqlite": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/express-session-sqlite/-/express-session-sqlite-2.0.10.tgz", + "integrity": "sha512-VBNvSlVUqAHHYU4Cca4Q1waJe1CeW2VFlW/PDtGY/cyJUV5Zy+qkN4A18SNvNR//SDZO+Pc3qd/AgVVQw2/W2Q==", + "requires": { + "debug": "^4.2.0", + "sql-template-strings": "^2.2.2", + "sqlite": "^4.0.14" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "express-ws": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz", @@ -4939,6 +5032,27 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" }, + "method-override": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/method-override/-/method-override-3.0.0.tgz", + "integrity": "sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==", + "requires": { + "debug": "3.1.0", + "methods": "~1.1.2", + "parseurl": "~1.3.2", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -5728,6 +5842,16 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" }, + "sql-template-strings": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/sql-template-strings/-/sql-template-strings-2.2.2.tgz", + "integrity": "sha1-PxFQiiWt384hejBCqdMAwxk7lv8=" + }, + "sqlite": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-4.0.25.tgz", + "integrity": "sha512-gqCEcLF8FOTeW/na3SRYWLQkw2jZXgVj1DdgRJbm0jvrhnUgBIuNDUUm649AnBNDNHhI5XskwT8dvc8vearRLQ==" + }, "sqlite3": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-4.2.0.tgz", diff --git a/server/package.json b/server/package.json index f09fa10..397efc3 100644 --- a/server/package.json +++ b/server/package.json @@ -18,9 +18,11 @@ "csurf": "^1.11.0", "express": "^4.17.3", "express-session": "^1.17.1", + "express-session-sqlite": "^2.0.10", "express-ws": "^5.0.2", "fast-deep-equal": "^3.1.3", "handlebars": "^4.7.7", + "method-override": "^3.0.0", "moment": "^2.24.0", "morgan": "^1.9.1", "node-fetch": "^2.6.0", @@ -31,7 +33,7 @@ "pluralize": "^8.0.0", "random-words": "^1.1.2", "sequelize": "^5.21.6", - "sqlite3": "^4.1.1", + "sqlite3": "^4.2.0", "typeface-roboto": "0.0.75", "ws": "^8.5.0" }, diff --git a/server/routes/groups.js b/server/routes/groups.js index 4ede84d..5ad0479 100755 --- a/server/routes/groups.js +++ b/server/routes/groups.js @@ -2,22 +2,6 @@ const express = require('express'), router = express.Router(), crypto = require('crypto'); -/* Simple NO-OP to set session cookie so user-id can use it as the - * index */ -router.get('/', (req, res/*, next*/) => { - let userId; - if (!req.cookies.user) { - userId = crypto.randomBytes(16).toString('hex'); - res.cookie('user', userId); - } else { - userId = req.cookies.user; - } - - console.log(`[${userId.substring(0, 8)}]: Browser hand-shake achieved.`); - - return res.status(200).send({ user: userId }); -}); - router.get('/', async (req, res/*, next*/) => { console.log('GET /groups/', req.session.userId); return res.status(200).send( diff --git a/server/routes/users.js b/server/routes/users.js index 7d55ce1..e5e47a0 100755 --- a/server/routes/users.js +++ b/server/routes/users.js @@ -8,17 +8,18 @@ const express = require('express'), const router = express.Router(); const autoAuthenticate = 1; -router.get('/', function(req, res/*, next*/) { +router.get('/', (req, res/*, next*/) => { console.log('GET /users/'); + return getSessionUser(req).then((user) => { + if (typeof user === 'string') { + return res.status(403).send({ message: user }); + } return res.status(200).send(user); - }).catch((error) => { - console.log('User not logged in: ' + error); - return res.status(200).send({}); }); }); -router.put('/password', function(req, res) { +router.put('/password', async (req, res) => { console.log('/users/password'); const db = req.app.locals.db; @@ -28,28 +29,32 @@ router.put('/password', function(req, res) { }; if (!changes.currentPassword || !changes.newPassword) { - return res.status(400).send('Missing current password and/or new password.'); + return res.status(400).send( + 'Missing current password and/or new password.'); } if (changes.currentPassword == changes.newPassword) { - return res.status(400).send('Attempt to set new password to current password.'); + return res.status(400).send( + 'Attempt to set new password to current password.'); } - return getSessionUser(req).then(function(user) { - return db.sequelize.query('SELECT id FROM users ' + - 'WHERE uid=:username AND password=:password', { - replacements: { - username: user.username, - password: crypto.createHash('sha256').update(changes.currentPassword).digest('base64') - }, - type: db.Sequelize.QueryTypes.SELECT, - raw: true - }).then(function(users) { - if (users.length != 1) { - return null; - } - return user; - }); + const user = await getSessionUser(req); + if (typeof user === 'string') { + return res.status(403).send({ message: user }); + } + return db.sequelize.query('SELECT id FROM users ' + + 'WHERE uid=:username AND password=:password', { + replacements: { + username: user.username, + password: crypto.createHash('sha256').update(changes.currentPassword).digest('base64') + }, + type: db.Sequelize.QueryTypes.SELECT, + raw: true + }).then(function(users) { + if (users.length != 1) { + return null; + } + return user; }).then(function(user) { if (!user) { console.log('Invalid password'); @@ -74,8 +79,9 @@ router.put('/password', function(req, res) { }); router.get('/csrf', (req, res) => { - console.log('/users/csrf'); - res.json({ csrfToken: req.csrfToken() }); + const token = req.csrfToken(); + console.log('/users/csrf', token); + res.json({ csrfToken: token }); }); router.post('/signup', function(req, res) { @@ -141,6 +147,9 @@ router.post('/signup', function(req, res) { req.session.userId = metadata.lastID; } return getSessionUser(req).then(function(user) { + if (typeof user === 'string') { + return res.status(403).send({ message: user }); + } res.status(200).send(user); user.id = req.session.userId; return sendVerifyMail(db, req, user); @@ -151,53 +160,48 @@ router.post('/signup', function(req, res) { }); }); -const getSessionUser = function(req) { +const getSessionUser = async (req) => { const db = req.app.locals.db; - return Promise.resolve().then(function() { - if (!req.session || !req.session.userId) { - throw 'Unauthorized. You must be logged in.'; - } - let query = 'SELECT ' + - 'uid AS username,firstName,familyName,mailVerified,authenticated,memberSince,email,md5 ' + - 'FROM users WHERE id=:id'; - return db.sequelize.query(query, { - replacements: { - id: req.session.userId - }, - type: db.Sequelize.QueryTypes.SELECT, - raw: true - }).then(function(results) { - if (results.length != 1) { - throw 'Invalid account.'; - } + console.log(req.session); + if (!req.session || !req.session.userId) { + return 'Unauthorized. You must be logged in.'; + } - let user = results[0]; - - if (!user.mailVerified) { - user.restriction = user.restriction || 'Email address not verified.'; - return user; - } - - if (!user.authenticated) { - user.restriction = user.restriction || 'Accout not authorized.'; - return user; - } - - return user; - }); - }).then(function(user) { - /* Strip out any fields that shouldn't be there. The allowed fields are: */ - let allowed = [ - 'maintainer', 'username', 'firstName', 'familyName', 'mailVerified', 'authenticated', 'name', 'email', 'restriction', 'md5' - ]; - for (let field in user) { - if (allowed.indexOf(field) == -1) { - delete user[field]; - } - } - return user; + let query = 'SELECT ' + + 'uid AS username,firstName,familyName,mailVerified,authenticated,memberSince,email,md5 ' + + 'FROM users WHERE id=:id'; + const results = await db.sequelize.query(query, { + replacements: { + id: req.session.userId + }, + type: db.Sequelize.QueryTypes.SELECT, + raw: true }); + + if (results.length != 1) { + return 'Invalid account.'; + } + + const user = results[0]; + + if (!user.mailVerified) { + user.restriction = user.restriction || 'Email address not verified.'; + } else if (!user.authenticated) { + user.restriction = user.restriction || 'Accout not authorized.'; + } + + /* Strip out any fields that shouldn't be there. + * The allowed fields are: */ + const allowed = [ + 'maintainer', 'username', 'firstName', 'familyName', 'mailVerified', 'authenticated', 'name', 'email', 'restriction', 'md5' + ]; + for (let field in user) { + if (allowed.indexOf(field) == -1) { + delete user[field]; + } + } + return user; }; router.post('/verify-email', async (req, res) => { @@ -254,13 +258,16 @@ router.post('/verify-email', async (req, res) => { req.session.userId = user.id; }).then(() => { return getSessionUser(req).then((user) => { + if (typeof user === 'string') { + return res.status(403).send({ message: user }); + } return res.status(200).send(user); }); }); } }); -router.post('/signin', function(req, res) { +router.post('/signin', (req, res) => { console.log('/users/signin'); const db = req.app.locals.db; @@ -310,12 +317,12 @@ router.post('/signin', function(req, res) { console.log(message); } - return getSessionUser(req).then(function(user) { + return getSessionUser(req).then((user) => { + if (typeof user === 'string') { + return res.status(403).send({ message: user }); + } return res.status(200).send(user); }); - }).catch(function(error) { - console.log(error); - return res.status(403).send(error); }); });