Merge pull request #42 from amhsirak/develop

feat: auth
This commit is contained in:
Karishma Shukla
2024-09-24 23:35:43 +05:30
committed by GitHub
9 changed files with 367 additions and 87 deletions

18
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -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",

36
server/src/db/config.ts Normal file
View File

@@ -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;

View File

@@ -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()
})
};

57
server/src/models/User.ts Normal file
View File

@@ -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<UserAttributes, 'id'> { }
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
public id!: number;
public email!: string;
public password!: string;
public async isValidPassword(password: string): Promise<boolean> {
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;

View File

@@ -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');
}
});
});

View File

@@ -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}`);
});

22
server/src/utils/auth.ts Normal file
View File

@@ -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)
}

103
src/context/auth.tsx Normal file
View File

@@ -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 (
<Context.Provider value={{ state, dispatch }}>{children}</Context.Provider>
);
};
export { Context, Provider };