diff --git a/backend/config.json.example b/backend/config.json.example index d3b00c5..763b2b4 100644 --- a/backend/config.json.example +++ b/backend/config.json.example @@ -3,11 +3,14 @@ "https": true, "alter_db": true, "port": 8080, + "frontend_url": "localhost:3000", "db_url": "postgres://postgres:@127.0.0.1/todo", "cert": "", "cert_key": "", "mail_host": "", "mail_port": 465, "mail_username": "", - "mail_password": "" + "mail_password": "", + "discord_id": "", + "discord_secret": "" } \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 2415daa..21a2f92 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,8 @@ "main": "src/index.js", "scripts": { "who": "pwd", - "start": "node src/index.js" + "start": "node src/index.js", + "test": "mocha" }, "repository": { "type": "git", @@ -18,11 +19,18 @@ "cors": "^2.8.5", "express": "^4.17.1", "express-paginate": "^1.0.2", - "nodemailer": "^6.6.1", + "http-proxy": "^1.18.1", + "node-fetch": "^2.6.1", + "nodemailer": "^6.6.2", "pg": "^8.6.0", - "sequelize": "^6.6.2" + "sequelize": "^6.6.5" }, "devDependencies": { - "sequelize-cli": "^6.2.0" + "chai": "^4.3.4", + "mocha": "^9.0.2", + "proxyrequire": "^1.0.21", + "sequelize-cli": "^6.2.0", + "sequelize-test-helpers": "^1.3.3", + "sinon": "^11.1.1" } } \ No newline at end of file diff --git a/backend/src/db_interface.js b/backend/src/db_interface.js index 84e9668..605c5cb 100644 --- a/backend/src/db_interface.js +++ b/backend/src/db_interface.js @@ -1,5 +1,6 @@ const Sequelize = require('sequelize'); const Config = require('./config.js'); +const Models = require('./models'); if (!Config.config.db_url) { console.error('No database url found. please set `db_url` in config.json'); @@ -8,118 +9,6 @@ if (!Config.config.db_url) { const db = new Sequelize(Config.config.db_url); -const UnverifiedUser = db.define('UnverifiedUser', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.UUIDV4, - allowNull: false, - primaryKey: true, - unique: true - }, - verificationToken: { - type: Sequelize.DataTypes.STRING, - allowNull: false - }, - email: { - type: Sequelize.DataTypes.STRING, - allowNull: false, - unique: true - }, - password_hash: { - type: Sequelize.DataTypes.STRING, - allowNull: true - } -}); - -const User = db.define('User', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.UUIDV4, - allowNull: false, - primaryKey: true, - unique: true - }, - email: { - type: Sequelize.DataTypes.STRING, - allowNull: false, - unique: true - }, - password_hash: { - type: Sequelize.DataTypes.STRING, - allowNull: true - } -}); - -const Todo = db.define('Todo', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.UUIDV4, - allowNull: false, - primaryKey: true, - unique: true - }, - user: { - type: Sequelize.DataTypes.UUID, - allowNull: false - }, - content: { - type: Sequelize.DataTypes.TEXT, - allowNull: false - }, - tags: { - type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING), - allowNull: true - }, - complete: { - type: Sequelize.DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false - }, - deadline: { - type: Sequelize.DataTypes.DATE, - allowNull: true - } -}); - -const Grouping = db.define('Grouping', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.UUIDV4, - allowNull: false, - primaryKey: true, - unique: true - }, - complete: { - type: Sequelize.DataTypes.BOOLEAN, - allowNull: true - }, - manually_added: { - type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.UUID), - allowNull: true - }, - required: { - type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING), - allowNull: true - }, - exclusions: { - type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING), - allowNull: true - } -}); - -let options = { - alter: false -}; -if (Config.config.alter_db) { - options.alter = true; -} -let start = async () => { - await UnverifiedUser.sync(options); - await User.sync(options); - await Todo.sync(options); - await Grouping.sync(options); -}; -start(); module.exports = { db: db, constructors: { @@ -127,10 +16,5 @@ module.exports = { return User.build(); } }, - schemas: { - user: User, - unverifiedUser: UnverifiedUser, - todo: Todo, - grouping: Grouping - } + schemas: Models(db, Sequelize) }; diff --git a/backend/src/index.js b/backend/src/index.js index ed6b230..d3b4995 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,8 +1,10 @@ const http = require('http'); const https = require('https'); +const httpProxy = require('http-proxy'); const cors = require('cors'); const express = require('express'); const cookieParser = require('cookie-parser'); +const fs = require('fs'); const Config = require('./config.js'); const UserInterface = require('./user.js'); @@ -15,6 +17,10 @@ if (Config.config.https) { credentials.key = fs.readFileSync(Config.config.cert_key); credentials.cert = fs.readFileSync(Config.config.cert); } + else { + console.error('could not load certs') + process.exit() + } } let app = express(); @@ -43,10 +49,14 @@ if (!Config.config.secret) { app.use('/api/user', UserInterface.router); app.use('/api/todo', TodoInterface.router); -// serve static files last -// app.use(express.static('./static')); -// DISABLED: no longer needs to serve static files -// due to frontend being employed in elm +if (Config.config.frontend_url) { + const proxy = httpProxy.createProxyServer({}) + app.use('/', (req, res) => { + return proxy.web(req, res, { + target: Config.config.frontend_url + }) + }); +} if (Config.config.https) { var server = https.createServer(credentials, app); diff --git a/backend/src/models.js b/backend/src/models.js new file mode 100644 index 0000000..4b219e2 --- /dev/null +++ b/backend/src/models.js @@ -0,0 +1,141 @@ +const Sequelize = require('sequelize'); +const Config = require('./config.js'); + +const models = (db) => { + const UnverifiedUser = db.define('UnverifiedUser', { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.UUIDV4, + allowNull: false, + primaryKey: true, + unique: true + }, + verificationToken: { + type: Sequelize.DataTypes.STRING, + allowNull: false + }, + email: { + type: Sequelize.DataTypes.STRING, + allowNull: false, + unique: true + }, + discord_only_account: { + type: Sequelize.DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + discord_id: { + type: Sequelize.DataTypes.STRING, + allowNull: true + }, + password_hash: { + type: Sequelize.DataTypes.STRING, + allowNull: true + } + }); + + const User = db.define('User', { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.UUIDV4, + allowNull: false, + primaryKey: true, + unique: true + }, + email: { + type: Sequelize.DataTypes.STRING, + allowNull: false, + unique: true + }, + discord_only_account: { + type: Sequelize.DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + discord_id: { + type: Sequelize.DataTypes.STRING, + allowNull: true + }, + password_hash: { + type: Sequelize.DataTypes.STRING, + allowNull: true + } + }); + + const Todo = db.define('Todo', { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.UUIDV4, + allowNull: false, + primaryKey: true, + unique: true + }, + user: { + type: Sequelize.DataTypes.UUID, + allowNull: false + }, + content: { + type: Sequelize.DataTypes.TEXT, + allowNull: false + }, + tags: { + type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING), + allowNull: true + }, + complete: { + type: Sequelize.DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + deadline: { + type: Sequelize.DataTypes.DATE, + allowNull: true + } + }); + + const Grouping = db.define('Grouping', { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.UUIDV4, + allowNull: false, + primaryKey: true, + unique: true + }, + complete: { + type: Sequelize.DataTypes.BOOLEAN, + allowNull: true + }, + manually_added: { + type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.UUID), + allowNull: true + }, + required: { + type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING), + allowNull: true + }, + exclusions: { + type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING), + allowNull: true + } + }); + + let options = { + alter: false + }; + if (Config.config.alter_db) { + options.alter = true; + } + UnverifiedUser.sync(options); + User.sync(options); + Todo.sync(options); + Grouping.sync(options); + + return { + user: User, + unverifiedUser: UnverifiedUser, + todo: Todo, + grouping: Grouping + }; +}; + +module.exports = models; diff --git a/backend/src/user.js b/backend/src/user.js index 2968380..151d1c3 100644 --- a/backend/src/user.js +++ b/backend/src/user.js @@ -1,5 +1,6 @@ const express = require('express'); const crypto = require('crypto'); +const fetch = require('node-fetch'); const Config = require('./config.js'); const Database = require('./db_interface.js'); const Mail = require('./mail.js'); @@ -12,6 +13,86 @@ let session_entropy = {}; user_cache = {}; email_cache = {}; +discord_cache = {}; +discord_user_cache = {}; + +async function fetch_user(where) { + let user = await Database.schemas.user.findOne({ where: where }); + if (user === null) { + return undefined; + } + user_cache[user.id] = { + id: user.id, + email: user.email, + discord_id: user.discord_id, + password_hash: user.password_hash + }; + email_cache[user.email] = user.id; + if (user.discord_id) { + discord_cache[user.discord_id] = user.id + } + return user_cache[user.id] +} + +async function fetch_discord_user(auth) { + const result = await fetch(`https://discord.com/api/v8/users/@me`, { + headers: { + 'Authorization': auth.token_type + ' ' + auth.access_token + } + }); + const json = result.json(); + discord_user_cache[json.id] = { + user: json, + auth: auth, + expires: (new Date().getTime()) + (json.expires_in * 1000) + } + return discord_user_cache[id]; +} + +async function acquire_discord_token(code, redirect) { + let data = { + client_id: Config.config.discord_id, + client_secret: Config.config.discord_secret, + grant_type: 'authorization_code', + code: code, + redirect_uri: redirect + } + const result = await fetch(`https://discord.com/api/oauth2/token`, { + method: 'POST', + body: data, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }).catch(err => console.error(err)); + if (!result.ok) { + return res.status(500).json({error: "could not fetch user details"}) + } + const json = result.json(); + return fetch_discord_user(json); +} + +async function refresh_discord_token(id) { + let data = { + client_id: Config.config.discord_id, + client_secret: Config.config.discord_secret, + grant_type: 'refresh_token', + refresh_token: discord_user_cache[id].auth.refresh_token + } + const result = await fetch(`https://discord.com/api/oauth2/token`, { + method: 'POST', + body: data, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }).catch(err => console.error(err)); + if (!result.ok) { + return false; + } + const json = result.json(); + discord_user_cache[id].auth.access_token = json.access_token; + discord_user_cache[id].expires = (new Date().getTime()) + (json.expires_in * 1000); + return true; +} async function get_user_details(id) { if (!id || id === 'undefined') { @@ -19,18 +100,9 @@ async function get_user_details(id) { } console.log(`search for user with id ${id}`); if (!user_cache[id]) { - let user = await Database.schemas.user.findOne({ where: { id: id } }); - if (user === null) { - return undefined; - } - user_cache[user.id] = { - id: user.id, - email: user.email, - password_hash: user.password_hash - }; - email_cache[user.email] = user.id; + return await fetch_user({ id: id }) } - console.log(`returning ${JSON.stringify(user_cache[id])}`); + // console.log(`returning ${JSON.stringify(user_cache[id])}`); return user_cache[id]; } @@ -40,38 +112,21 @@ async function get_user_details_by_email(email) { } console.log(`search for user with email ${email}}`); if (!email_cache[email] || !user_cache[email_cache[email]]) { - let user = await Database.schemas.user.findOne({ where: { email: email } }); - if (user === null) { - return undefined; - } - user_cache[user.id] = { - id: user.id, - email: user.email, - password_hash: user.password_hash - }; - email_cache[user.email] = user.id; + return await fetch_user({ email: email }) } - console.log(`returning ${JSON.stringify(user_cache[email_cache[email]])}`); + // console.log(`returning ${JSON.stringify(user_cache[email_cache[email]])}`); return user_cache[email_cache[email]]; } -router.get('/byEmail/:email', async (req, res) => { - if (!req.params?.email) { - res.status(400).json({ - error: 'email is a required parameter' - }); +async function get_user_details_by_discord_id(id) { + if (!id || id === 'undefined') { + return undefined; } - let user = get_user_details_by_email(req.params.email); - console.log(user); - if (user !== undefined && user !== {}) { - res.json({ - id: user.id, - email: user.email - }); - } else { - res.sendStatus(404); + if (!discord_cache[id] || !user_cache[discord_cache[id]]) { + return await fetch_user({ discord_id: id }) } -}); + return user_cache[discord_cache[id]]; +} function hash(secret, password, base64 = true) { let pw_hash = crypto.pbkdf2Sync( @@ -164,9 +219,8 @@ router.post('/signup', async (req, res) => { password_hash: password_hash, verificationToken: get_session_token(randomString, password_hash, false) }); - const link = `${Config.config.https ? 'https://' : 'http://'}${req.headers.host}/api/user/verify?verification=${ - user.verificationToken - }`; + const link = `${Config.config.https ? 'https://' : 'http://'}${req.headers.host}/api/user/verify?verification=${user.verificationToken + }`; const content = `Click here to verify your sign-up: ${link}`; const contentHtml = `

Click here to verify your sign-up:

@@ -218,6 +272,45 @@ router.get('/verify', async (req, res) => { } }); +router.get('/login/discord', async (req, res) => { + if (!Config.config.discord_id || !Config.config.discord_secret) { + return res.status(403).send("discord login is not enabled."); + } + const url = encodeURIComponent(`${req.headers.host}discord`); + return res.send(`https://discord.com/api/oauth2/authorize?client_id=${Config.config.discord_id}&redirect_uri=${url}&response_type=code&scope=identify%20email%20guilds`); +}); + +router.post('/login/discord', async (req, res) => { + if (!Config.config.discord_id || !Config.config.discord_secret) { + return res.status(403).json({ error: "discord login is not enabled." }); + } + if (!req.params.code || !req.headers.host) { + return res.status(400).json({error: "invalid oauth request"}); + } + const result = await acquire_discord_token(req.params.code, req.headers.host); + const matching_account = await get_user_details_by_discord_id(result.user.id); + if (!matching_account) { + let user = await Database.schemas.unverifiedUser.create({ + email: String(result.user.email), + discord_id: user.id, + verificationToken: get_session_token(randomString, result.auth.access_token, false) + }); + return res.json({ + type: 'unverified', + verificationToken: user.verificationToken + }) + } + return res.json({ + type: 'verified', + userid: matching_account.id, + session_token: get_session_token(matching_account.id, result.auth.access_token) + }); +}); + +//TODO +router.post('/discord/create', async (req, res) =>{}); +router.post('/discord/link', async (req, res) =>{}); + router.post('/login', async (req, res) => { if (!req.body?.email || !req.body?.password) { return res.status(400).json({ @@ -244,15 +337,11 @@ router.post('/login', async (req, res) => { }); }); -router.post('/logout', async (req, res) => { - if (!req.body?.session_token || !req.body?.userid) { - return res.status(400).json({ - error: 'must include user id and session token to log out' - }); - } - let userid = req.body?.userid; - let session_token = req.body?.session_token; +router.use('/logout', enforce_session_login) +router.post('/logout', async (req, res) => { + let userid = req.get('id'); + let session_token = req.get('authorization'); let user = await get_user_details(userid); if (!user) { @@ -272,6 +361,27 @@ router.post('/logout', async (req, res) => { return res.sendStatus(204); }); +router.use('/byEmail', enforce_session_login) +router.get('/byEmail/:email', async (req, res) => { + if (!req.params?.email) { + res.status(400).json({ + error: 'email is a required parameter' + }); + } + let user = get_user_details_by_email(req.params.email); + console.log(user); + if (user !== undefined && user !== {}) { + res.json({ + id: user.id, + email: user.email + }); + } else { + res.sendStatus(404); + } +}); + + +router.use('/', enforce_session_login) router.get('/:id([a-f0-9-]+)', async (req, res) => { console.log(req.params); if (!req.params?.id) { diff --git a/backend/test/db_interface.spec.js b/backend/test/db_interface.spec.js new file mode 100644 index 0000000..cf2fe82 --- /dev/null +++ b/backend/test/db_interface.spec.js @@ -0,0 +1,17 @@ +const { expect } = require('chai'); +const Models = require('../src/models'); + +const { sequelize, checkModelName, checkUniqueIndex, checkPropertyExists } = require('sequelize-test-helpers'); + +describe('Sequelize model tests', function () { + const models = Models(sequelize); + + checkModelName(models.user)('User'); + checkModelName(models.unverifiedUser)('UnverifiedUser'); + checkModelName(models.grouping)('Grouping'); + checkModelName(models.todo)('Todo'); + + context('user props', function () { + ['id', 'email', 'discord_only_account'].forEach(checkPropertyExists(new models.user())); + }); +}); diff --git a/backend/test/user.spec.js b/backend/test/user.spec.js new file mode 100644 index 0000000..2861017 --- /dev/null +++ b/backend/test/user.spec.js @@ -0,0 +1,10 @@ +const { expect } = require('chai'); +const proxyrequire = require('proxyrequire'); +const { match, stub, resetHistory } = require('sinon'); +const { sequelize, Sequelize, makeMockModels } = require('sequelize-test-helpers'); + +describe('User Router Tests', function () { + const Database = proxyrequire('../src/db_interface', { + sequelize: Sequelize + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 2ff27eb..e06b6a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,13 +5,13 @@ "dependencies": { "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", - "@material-ui/core": "^5.0.0-alpha.36", + "@material-ui/core": "^5.0.0-beta.0", "@material-ui/icons": "^4.11.2", "@material-ui/styles": "^4.11.4", "@reduxjs/toolkit": "^1.6.0", - "@testing-library/jest-dom": "^5.11.4", - "@testing-library/react": "^11.1.0", - "@testing-library/user-event": "^12.1.10", + "@testing-library/jest-dom": "^5.14.1", + "@testing-library/react": "^11.2.7", + "@testing-library/user-event": "^12.8.3", "axios": "^0.21.1", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -21,7 +21,7 @@ "react-scripts": "4.0.3", "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0", - "web-vitals": "^1.0.1" + "web-vitals": "^1.1.2" }, "scripts": { "start": "react-scripts start", diff --git a/frontend/public/config.json b/frontend/public/config.json index c30666f..b5b3508 100644 --- a/frontend/public/config.json +++ b/frontend/public/config.json @@ -1,11 +1,13 @@ { "hosts": { "localhost:3000": "LOCAL", + "dev.j4.pm": "test", "todo.j4.pm": "prod" }, "defaultConfig": {}, "configs": { "LOCAL": {}, - "prod": {} + "test": {"apiUrl": "https://dev.j4.pm/api"}, + "prod": {"apiUrl": "https://todo.j4.pm/api"} } } \ No newline at end of file diff --git a/frontend/src/App.js b/frontend/src/App.js index fd79436..c8968fc 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -3,6 +3,7 @@ import RootPage from './modules/Root'; import AboutPage from './modules/About'; import AccountPage from './modules/Account'; import LoginPage from './modules/Login'; +import OauthPage from './modules/Oauth'; import SignupPage from './modules/Signup'; import TodoPage from './modules/TodoList'; import { connect } from 'react-redux'; @@ -24,15 +25,25 @@ const App = (props) => { + + + - + + + + diff --git a/frontend/src/modules/Login/index.js b/frontend/src/modules/Login/index.js index dc4c78e..182f070 100644 --- a/frontend/src/modules/Login/index.js +++ b/frontend/src/modules/Login/index.js @@ -11,7 +11,7 @@ import VisibilityOff from '@material-ui/icons/VisibilityOff'; import { connect } from 'react-redux'; import { withStyles } from '@material-ui/styles'; -const styles = (theme) => {}; +const styles = (theme) => { }; const LoginPage = (props) => { const [email, setEmail] = useState(''); @@ -137,6 +137,17 @@ const LoginPage = (props) => { Continue + + + )} diff --git a/frontend/src/modules/Oauth/index.js b/frontend/src/modules/Oauth/index.js new file mode 100644 index 0000000..bf2ac69 --- /dev/null +++ b/frontend/src/modules/Oauth/index.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { withStyles } from '@material-ui/styles'; + +const styles = (theme) => { }; + +const OauthPage = (props) => { + return
test
+} + +export default connect( + (state) => { + return {}; + }, + (dispatch, props) => { + return {}; + } +)(withStyles(styles)(OauthPage)); diff --git a/frontend/src/modules/Signup/index.js b/frontend/src/modules/Signup/index.js index e5402a5..84247f7 100644 --- a/frontend/src/modules/Signup/index.js +++ b/frontend/src/modules/Signup/index.js @@ -14,7 +14,7 @@ import { withStyles } from '@material-ui/styles'; const styles = (theme) => {}; const SignupPage = (props) => { - const { classes } = props; + //const { classes } = props; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');