From 42f0587cf68d739b8baa6618437e5a7be57441ab Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Wed, 22 Apr 2026 08:11:54 +1000 Subject: [PATCH] Add JWT local auth with protected API routes - User model with bcrypt password hashing - Register, login, logout, and /me endpoints - authenticate middleware applied to all API routes - JWT_SECRET configurable via environment variable Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.deploy.yml | 1 + server/src/index.ts | 11 ++--- server/src/middleware/authenticate.ts | 22 ++++++++++ server/src/models/User.ts | 27 ++++++++++++ server/src/routes/auth.ts | 62 ++++++++++++++++++++++----- 5 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 server/src/middleware/authenticate.ts create mode 100644 server/src/models/User.ts diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 727027f..3d00157 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -36,6 +36,7 @@ services: - PORT=8080 - CLIENT_URL=http://nginx - MONGODB_URI=mongodb://mongodb:27017/etsy-tracker + - JWT_SECRET=${JWT_SECRET:-changeme} volumes: - etsy_data:/app/data - etsy_uploads:/app/uploads diff --git a/server/src/index.ts b/server/src/index.ts index d46a79c..02e15f1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,6 +14,7 @@ import orderRoutes from './routes/orders'; import customerRoutes from './routes/customers'; import expenseRoutes from './routes/expenses'; import analyticsRoutes from './routes/analytics'; +import { authenticate } from './middleware/authenticate'; // Load environment variables dotenv.config(); @@ -60,11 +61,11 @@ mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/etsy-trac // Routes app.use('/api/auth', authRoutes); -app.use('/api/products', productRoutes); -app.use('/api/orders', orderRoutes); -app.use('/api/customers', customerRoutes); -app.use('/api/expenses', expenseRoutes); -app.use('/api/analytics', analyticsRoutes); +app.use('/api/products', authenticate, productRoutes); +app.use('/api/orders', authenticate, orderRoutes); +app.use('/api/customers', authenticate, customerRoutes); +app.use('/api/expenses', authenticate, expenseRoutes); +app.use('/api/analytics', authenticate, analyticsRoutes); // Health check endpoint app.get('/api/health', (req, res) => { diff --git a/server/src/middleware/authenticate.ts b/server/src/middleware/authenticate.ts new file mode 100644 index 0000000..400cdc8 --- /dev/null +++ b/server/src/middleware/authenticate.ts @@ -0,0 +1,22 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +export interface AuthRequest extends Request { + userId?: string; +} + +export function authenticate(req: AuthRequest, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ message: 'No token provided' }); + } + + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, process.env.JWT_SECRET || 'changeme') as { userId: string }; + req.userId = payload.userId; + next(); + } catch { + res.status(401).json({ message: 'Invalid or expired token' }); + } +} diff --git a/server/src/models/User.ts b/server/src/models/User.ts new file mode 100644 index 0000000..4ed1194 --- /dev/null +++ b/server/src/models/User.ts @@ -0,0 +1,27 @@ +import mongoose, { Document, Schema } from 'mongoose'; +import bcrypt from 'bcryptjs'; + +export interface IUser extends Document { + name: string; + email: string; + password: string; + comparePassword(candidate: string): Promise; +} + +const UserSchema: Schema = new Schema({ + name: { type: String, required: true, trim: true }, + email: { type: String, required: true, unique: true, trim: true, lowercase: true }, + password: { type: String, required: true }, +}, { timestamps: true }); + +UserSchema.pre('save', async function (next) { + if (!this.isModified('password')) return next(); + this.password = await bcrypt.hash(this.password as string, 12); + next(); +}); + +UserSchema.methods.comparePassword = function (candidate: string): Promise { + return bcrypt.compare(candidate, this.password); +}; + +export default mongoose.model('User', UserSchema); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index c61130b..f1ff784 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,20 +1,60 @@ -import { Router } from 'express'; +import { Router, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import User from '../models/User'; +import { authenticate, AuthRequest } from '../middleware/authenticate'; const router = Router(); +const JWT_SECRET = process.env.JWT_SECRET || 'changeme'; +const JWT_EXPIRES_IN = '7d'; -// POST /api/auth/login -router.post('/login', (req, res) => { - res.json({ message: 'Login endpoint - coming soon!' }); +router.post('/register', async (req: Request, res: Response) => { + try { + const { name, email, password } = req.body; + if (!name || !email || !password) { + return res.status(400).json({ message: 'Name, email and password are required' }); + } + const existing = await User.findOne({ email }); + if (existing) { + return res.status(409).json({ message: 'Email already in use' }); + } + const user = new User({ name, email, password }); + await user.save(); + const token = jwt.sign({ userId: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + res.status(201).json({ token, user: { id: user._id, name: user.name, email: user.email } }); + } catch (err) { + res.status(500).json({ message: 'Registration failed', error: err }); + } }); -// POST /api/auth/register -router.post('/register', (req, res) => { - res.json({ message: 'Register endpoint - coming soon!' }); +router.post('/login', async (req: Request, res: Response) => { + try { + const { email, password } = req.body; + if (!email || !password) { + return res.status(400).json({ message: 'Email and password are required' }); + } + const user = await User.findOne({ email }); + if (!user || !(await user.comparePassword(password))) { + return res.status(401).json({ message: 'Invalid email or password' }); + } + const token = jwt.sign({ userId: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + res.json({ token, user: { id: user._id, name: user.name, email: user.email } }); + } catch (err) { + res.status(500).json({ message: 'Login failed', error: err }); + } }); -// POST /api/auth/logout -router.post('/logout', (req, res) => { - res.json({ message: 'Logout endpoint - coming soon!' }); +router.get('/me', authenticate, async (req: AuthRequest, res: Response) => { + try { + const user = await User.findById(req.userId).select('-password'); + if (!user) return res.status(404).json({ message: 'User not found' }); + res.json(user); + } catch (err) { + res.status(500).json({ message: 'Failed to fetch user', error: err }); + } }); -export default router; \ No newline at end of file +router.post('/logout', (_req, res) => { + res.json({ message: 'Logged out' }); +}); + +export default router;