diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..74279fc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# dependencies +/node_modules + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local +.env + +/.idea + +/server/logs + +/build + +package-lock.json diff --git a/package.json b/package.json index bb991aa6..fe5a48f9 100644 --- a/package.json +++ b/package.json @@ -14,17 +14,23 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^13.1.1", "@testing-library/user-event": "^13.5.0", + "@types/bcrypt": "^5.0.2", + "@types/csurf": "^1.11.5", "@types/jest": "^27.4.1", + "@types/jsonwebtoken": "^9.0.7", "@types/node": "^16.11.27", "@types/react": "^18.0.5", "@types/react-dom": "^18.0.1", "@types/uuid": "^8.3.4", "@wbr-project/wbr-interpret": "^0.9.3-marketa.1", "axios": "^0.26.0", + "bcrypt": "^5.1.1", "buffer": "^6.0.3", "bullmq": "^5.12.15", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "cross-fetch": "^4.0.0", + "csurf": "^1.11.0", "dotenv": "^16.0.0", "express": "^4.17.2", "fortawesome": "^0.0.1-security", @@ -32,10 +38,12 @@ "googleapis": "^144.0.0", "ioredis": "^5.4.1", "joi": "^17.6.0", + "jsonwebtoken": "^9.0.2", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", "moment-timezone": "^0.5.45", "node-cron": "^3.0.3", + "pg": "^8.13.0", "playwright": "^1.20.1", "playwright-extra": "^4.3.6", "prismjs": "^1.28.0", @@ -47,6 +55,8 @@ "react-scripts": "5.0.1", "react-simple-code-editor": "^0.11.2", "react-transition-group": "^4.4.2", + "sequelize": "^6.37.3", + "sequelize-typescript": "^2.1.6", "socket.io": "^4.4.1", "socket.io-client": "^4.4.1", "styled-components": "^5.3.3", @@ -85,6 +95,7 @@ ] }, "devDependencies": { + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.13", "@types/loglevel": "^1.6.3", "@types/node": "^17.0.15", diff --git a/server/src/db/config.ts b/server/src/db/config.ts new file mode 100644 index 00000000..9b2221f2 --- /dev/null +++ b/server/src/db/config.ts @@ -0,0 +1,36 @@ +import { Sequelize } from 'sequelize'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const sequelize = new Sequelize( + process.env.DB_NAME as string, + process.env.DB_USER as string, + process.env.DB_PASSWORD as string, + { + host: process.env.DB_HOST, + dialect: 'postgres', + logging: false, + } +); + +export const connectDB = async () => { + try { + await sequelize.authenticate(); + console.log('Database connected successfully'); + } catch (error) { + console.error('Unable to connect to the database:', error); + } +}; + +export const syncDB = async () => { + try { + await sequelize.sync({ force: false }); // force: true will drop and recreate tables on every run + console.log('Database synced successfully!'); + } catch (error) { + console.error('Failed to sync database:', error); + } +}; + + +export default sequelize; \ No newline at end of file diff --git a/server/src/middlewares/auth.ts b/server/src/middlewares/auth.ts new file mode 100644 index 00000000..98143c08 --- /dev/null +++ b/server/src/middlewares/auth.ts @@ -0,0 +1,29 @@ +import { Request, Response } from "express"; +import { verify } from "jsonwebtoken"; + +declare module "express-serve-static-core" { + interface Request { + user?: any; + } +} + +export const requireSignIn = (req: Request, res: Response, next: any) => { + const token = req.cookies && req.cookies.token ? req.cookies.token : null; + + if (token === null) return res.sendStatus(401); + + const secret = process.env.JWT_SECRET; + if (!secret) { + return res.sendStatus(500); // Internal Server Error if secret is not defined + } + + verify(token, secret, (err: any, user: any) => { + console.log(err) + + if (err) return res.sendStatus(403) + + req.user = user; + + next() + }) +}; diff --git a/server/src/models/User.ts b/server/src/models/User.ts new file mode 100644 index 00000000..67b944b9 --- /dev/null +++ b/server/src/models/User.ts @@ -0,0 +1,57 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import sequelize from '../db/config'; +import { hashPassword, comparePassword } from '../utils/auth'; + +interface UserAttributes { + id: number; + email: string; + password: string; +} + +// Optional fields for creating a new user +interface UserCreationAttributes extends Optional { } + +class User extends Model implements UserAttributes { + public id!: number; + public email!: string; + public password!: string; + + public async isValidPassword(password: string): Promise { + return comparePassword(password, this.password); + } +} + +User.init( + { + id: { + type: DataTypes.INTEGER.UNSIGNED, + autoIncrement: true, + primaryKey: true, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + validate: { + isEmail: true, + }, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + sequelize, + tableName: 'user', + hooks: { + beforeCreate: async (user: User) => { + if (user.password) { + user.password = await hashPassword(user.password) as string; + } + }, + }, + } +); + +export default User; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 00f51aa2..e8d376e0 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,92 +1,79 @@ -import { Router } from 'express';; -import { google, sheets_v4 } from "googleapis"; -import { OAuth2Client } from 'google-auth-library' +import { Router, Request, Response } from 'express'; +import User from '../models/User'; +import jwt from 'jsonwebtoken'; +export const router = Router(); -export const router = Router() +interface AuthenticatedRequest extends Request { + user?: { id: string }; +} -const oauth2Client = new OAuth2Client( - '_CLIENT_ID', - '_CLIENT_SECRET', - '_REDIRECT_URI' -); - -// initialize Google OAuth 2.0 flow -router.get('/auth/google', (req, res) => { - const url = oauth2Client.generateAuthUrl({ - access_type: 'offline', - scope: [ - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/spreadsheets' - ] - }); - res.redirect(url); -}); - -// Callback route for Google OAuth 2.0 -router.get('/auth/google/callback', async (req, res) => { - const code = req.query.code; - if (typeof code !== 'string') { - res.status(400).send('Invalid authorization code'); - return; - } +router.post('/register', async (req, res) => { try { - const { tokens } = await oauth2Client.getToken(code); - oauth2Client.setCredentials(tokens); - // TODO: Store tokens securely (e.g., in a database) - res.send('Authentication successful'); - } catch (error) { - console.error('Error during authentication:', error); - res.status(500).send('Authentication failed'); - } - }); + const { email, password } = req.body -router.get('/sheets', async (req, res) => { + if (!email) return res.status(400).send('Email is required') + if (!password || password.length < 6) return res.status(400).send('Password is required and must be at least 6 characters') + + let userExist = await User.findOne({ where: { email } }); + if (userExist) return res.status(400).send('User already exists') + + const user = await User.create({ email, password }); + + const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { expiresIn: '1h' }); + user.password = undefined as unknown as string + res.cookie('token', token, { + httpOnly: true + }) + res.json(user) + } catch (error: any) { + res.status(500).send(`Could not register user - ${error.message}`) + } +}) + +router.post('/login', async (req, res) => { try { - const drive = google.drive({ version: 'v3', auth: oauth2Client }); - const response = await drive.files.list({ - q: "mimeType='application/vnd.google-apps.spreadsheet'", - fields: 'files(id, name)' + const { email, password } = req.body; + if (!email || !password) return res.status(400).send('Email and password are required') + if (password.length < 6) return res.status(400).send('Password must be at least 6 characters') + + let user = await User.findOne({ where: { email } }); + const match = await user?.isValidPassword(password); + if (!match) return res.status(400).send('Invalid email or password') + + const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string, { expiresIn: '1h' }); + + // return user and token to client, exclude hashed password + if (user) { + user.password = undefined as unknown as string; + } + res.cookie('token', token, { + httpOnly: true + }) + res.json(user) + } catch (error: any) { + res.status(400).send(`Could not login user - ${error.message}`) + } +}) + +router.get('/logout', async (req, res) => { + try { + res.clearCookie('token') + return res.json({ message: 'Logout successful' }) + } catch (error: any) { + res.status(500).send(`Could not logout user - ${error.message}`) + } +}) + +router.get('/current-user', async (req: AuthenticatedRequest, res) => { + try { + if (!req.user) { + return res.status(401).send('Unauthorized'); + } + const user = await User.findByPk(req.user.id, { + attributes: { exclude: ['password'] }, }); - res.json(response.data.files); - } catch (error) { - console.error('Error listing sheets:', error); - res.status(500).send('Failed to list sheets'); + return res.status(200).json({ ok: true }); + } catch (error: any) { + return res.status(500).send(`Could not fetch current user : ${error.message}.`); } -}); - -router.get('/sheets/:sheetId', async (req, res) => { - try { - const sheets = google.sheets({ version: 'v4', auth: oauth2Client }); - const response = await sheets.spreadsheets.values.get({ - spreadsheetId: req.params.sheetId, - range: 'Sheet1', // Adjust range as needed - }); - res.json(response.data.values); - } catch (error) { - console.error('Error reading sheet:', error); - res.status(500).send('Failed to read sheet'); - } -}); - -router.post('/sheets/:sheetId', async (req, res) => { - try { - const sheets = google.sheets({ version: 'v4', auth: oauth2Client }); - - const request: sheets_v4.Params$Resource$Spreadsheets$Values$Append = { - spreadsheetId: req.params.sheetId, - range: 'Sheet1', // Adjust range as needed - valueInputOption: 'USER_ENTERED', - requestBody: { - values: [req.body.values], // Expect an array of values in the request body - }, - }; - - const response = await sheets.spreadsheets.values.append(request); - - res.json(response.data); - } catch (error) { - console.error('Error writing to sheet:', error); - res.status(500).send('Failed to write to sheet'); - } - }); \ No newline at end of file +}); \ No newline at end of file diff --git a/server/src/server.ts b/server/src/server.ts index 3e553367..849ef834 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -4,10 +4,15 @@ import cors from 'cors'; import 'dotenv/config'; import { record, workflow, storage, auth, integration } from './routes'; import { BrowserPool } from "./browser-management/classes/BrowserPool"; -import logger from './logger' +import logger from './logger'; +import { connectDB, syncDB } from './db/config'; +import cookieParser from 'cookie-parser'; +import csrf from 'csurf'; import { SERVER_PORT } from "./constants/config"; import { Server } from "socket.io"; +const csrfProtection = csrf({ cookie: true }) + const app = express(); app.use(cors()); app.use(express.json()); @@ -25,6 +30,10 @@ export const io = new Server(server); */ export const browserPool = new BrowserPool(); +// parse cookies - "cookie" is true in csrfProtection +app.use(cookieParser()) +app.use(csrfProtection) + app.use('/record', record); app.use('/workflow', workflow); app.use('/storage', storage); @@ -35,4 +44,12 @@ app.get('/', function (req, res) { return res.send('Maxun server started 🚀'); }); -server.listen(SERVER_PORT, () => logger.log('info', `Server listening on port ${SERVER_PORT}`)); +app.get('/csrf-token', (req, res) => { + res.json({ csrfToken: req.csrfToken() }) + }) + +server.listen(SERVER_PORT, async () => { + await connectDB(); + await syncDB(); + logger.log('info', `Server listening on port ${SERVER_PORT}`); +}); diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts new file mode 100644 index 00000000..4cc45644 --- /dev/null +++ b/server/src/utils/auth.ts @@ -0,0 +1,22 @@ +import bcrypt from "bcrypt"; + +export const hashPassword = (password: any) => { + return new Promise((resolve, reject) => { + bcrypt.genSalt(12, (err, salt) => { + if (err) { + reject(err) + } + bcrypt.hash(password, salt, (err, hash) => { + if (err) { + reject(err) + } + resolve(hash) + }) + }) + }) +} + +// password from frontend and hash from database +export const comparePassword = (password: any, hash: any) => { + return bcrypt.compare(password, hash) +} \ No newline at end of file diff --git a/src/context/auth.tsx b/src/context/auth.tsx new file mode 100644 index 00000000..1d410f0c --- /dev/null +++ b/src/context/auth.tsx @@ -0,0 +1,103 @@ +import { useReducer, createContext, useEffect } from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; + +interface ProviderProps { + children: React.ReactNode; +} + +interface ActionType { + type: 'LOGIN' | 'LOGOUT'; + payload?: any; +} + +type InitialStateType = { + user: any; +}; + +const initialState = { + user: null, +}; + +const Context = createContext<{ + state: InitialStateType; + dispatch: any; +}>({ + state: initialState, + dispatch: () => null, +}); + +const reducer = (state: InitialStateType, action: ActionType) => { + switch (action.type) { + case 'LOGIN': + return { + ...state, + user: action.payload, + }; + case 'LOGOUT': + return { + ...state, + user: null, + }; + default: + return state; + } +}; + +const Provider = ({ children }: ProviderProps) => { + const [state, dispatch] = useReducer(reducer, initialState); + + const navigate = useNavigate(); + + // get user info from local storage + useEffect(() => { + dispatch({ + type: 'LOGIN', + payload: JSON.parse(window.localStorage.getItem('user') || 'null'), + }); + }, []); + + axios.interceptors.response.use( + function (response) { + // any status code that lies within the range of 2XX causes this function to trigger + return response; + }, + function (error) { + // any status codes that fall outside the range of 2XX cause this function to trigger + let res = error.response; + if (res.status === 401 && res.config && !res.config.__isRetryRequest) { + return new Promise((resolve, reject) => { + axios + .get('http://localhost:8080/auth/logout') + .then((data) => { + console.log('/401 error > logout'); + dispatch({ type: 'LOGOUT' }); + window.localStorage.removeItem('user'); + navigate('/login'); // Replace router.push with navigate + }) + .catch((err) => { + console.log('AXIOS INTERCEPTORS ERROR:', err); + reject(error); + }); + }); + } + return Promise.reject(error); + } + ); + + // csrf - include tokens in the axios header every time a request is made + useEffect(() => { + const getCsrfToken = async () => { + const { data } = await axios.get('http://localhost:8080/csrf-token'); + console.log('CSRFFFFF =>>>>', data); + (axios.defaults.headers as any)['X-CSRF-TOKEN'] = data.getCsrfToken; + }; + getCsrfToken(); + }, []); + + return ( + {children} + ); +}; + +export { Context, Provider };