diff --git a/backend/package.json b/backend/package.json index 2f781be..556a3a5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,15 +23,19 @@ "dependencies": { "@prisma/client": "^6.16.1", "@vercel/node": "^3.0.31", + "bcryptjs": "^3.0.2", "cors": "^2.8.5", "dotenv": "^17.2.2", "express": "^5.1.0", "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1" }, "devDependencies": { + "@types/bcryptjs": "^3.0.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", "@types/node": "^24.5.0", "nodemon": "^3.1.10", diff --git a/backend/src/app.ts b/backend/src/app.ts index 24faacc..2b05ac4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -3,6 +3,7 @@ import cors from "cors"; import express, { type Express } from "express"; import helmet from "helmet"; import morgan from "morgan"; +import authRoutes from "@/routes/auth"; const app: Express = express(); @@ -33,6 +34,9 @@ app.get("/health", (_req, res) => { }); }); +// Auth routes +app.use("/auth", authRoutes); + // API routes app.use("/api", (_req, res) => { res.json({ message: "API is working" }); diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts new file mode 100644 index 0000000..78f145f --- /dev/null +++ b/backend/src/controllers/auth.ts @@ -0,0 +1,148 @@ +import { Request, Response } from 'express'; +import { PrismaClient } from '@prisma/client'; +import { generateToken, hashPassword, comparePassword } from '@/utils/auth'; +import { RegisterRequest, LoginRequest, AuthResponse } from '@/types/auth'; + +const prisma = new PrismaClient(); + +/** + * Register new user + * POST /auth/register + */ +export const register = async (req: Request, res: Response): Promise => { + try { + const { email, password }: RegisterRequest = req.body; + + // Validate input + if (!email || !password) { + res.status(400).json({ + error: 'Validation error', + message: 'Email and password are required' + }); + return; + } + + if (password.length < 6) { + res.status(400).json({ + error: 'Validation error', + message: 'Password must be at least 6 characters long' + }); + return; + } + + // Check if user already exists + const existingUser = await prisma.user.findUnique({ + where: { email } + }); + + if (existingUser) { + res.status(409).json({ + error: 'User already exists', + message: 'A user with this email already exists' + }); + return; + } + + // Hash password and create user + const hashedPassword = await hashPassword(password); + + const user = await prisma.user.create({ + data: { + email, + password: hashedPassword, + name: null + } + }); + + // Generate token + const token = generateToken({ + userId: user.id, + email: user.email + }); + + const response: AuthResponse = { + user: { + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt + }, + token + }; + + res.status(201).json(response); + } catch (error) { + console.error('Registration error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to create user' + }); + } +}; + +/** + * Login user + * POST /auth/login + */ +export const login = async (req: Request, res: Response): Promise => { + try { + const { email, password }: LoginRequest = req.body; + + // Validate input + if (!email || !password) { + res.status(400).json({ + error: 'Validation error', + message: 'Email and password are required' + }); + return; + } + + // Find user + const user = await prisma.user.findUnique({ + where: { email } + }); + + if (!user) { + res.status(401).json({ + error: 'Authentication failed', + message: 'Invalid email or password' + }); + return; + } + + // Verify password + const isValidPassword = await comparePassword(password, user.password); + + if (!isValidPassword) { + res.status(401).json({ + error: 'Authentication failed', + message: 'Invalid email or password' + }); + return; + } + + // Generate token + const token = generateToken({ + userId: user.id, + email: user.email + }); + + const response: AuthResponse = { + user: { + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt + }, + token + }; + + res.status(200).json(response); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to authenticate user' + }); + } +}; \ No newline at end of file diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..ed40d2c --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,65 @@ +import { Request, Response, NextFunction } from 'express'; +import { verifyToken, extractTokenFromHeader, JWTPayload } from '@/utils/auth'; + +// Extend Request interface to include user +declare global { + namespace Express { + interface Request { + user?: JWTPayload; + } + } +} + +/** + * Middleware to verify JWT token and authenticate user + */ +export const authenticateToken = ( + req: Request, + res: Response, + next: NextFunction +): void => { + try { + const token = extractTokenFromHeader(req.headers.authorization); + + if (!token) { + res.status(401).json({ + error: 'Authentication required', + message: 'No token provided' + }); + return; + } + + const decoded = verifyToken(token); + req.user = decoded; + + next(); + } catch (error) { + res.status(401).json({ + error: 'Authentication failed', + message: error instanceof Error ? error.message : 'Invalid token' + }); + } +}; + +/** + * Optional auth middleware - doesn't fail if no token + */ +export const optionalAuth = ( + req: Request, + res: Response, + next: NextFunction +): void => { + try { + const token = extractTokenFromHeader(req.headers.authorization); + + if (token) { + const decoded = verifyToken(token); + req.user = decoded; + } + + next(); + } catch (error) { + // Ignore auth errors in optional auth + next(); + } +}; \ No newline at end of file diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..3127301 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,12 @@ +import { login, register } from "@/controllers/auth"; +import { Router } from "express"; + +const router: Router = Router(); + +// POST /auth/register +router.post("/register", register); + +// POST /auth/login +router.post("/login", login); + +export default router; diff --git a/backend/src/types/auth.ts b/backend/src/types/auth.ts new file mode 100644 index 0000000..be819be --- /dev/null +++ b/backend/src/types/auth.ts @@ -0,0 +1,25 @@ +export interface RegisterRequest { + email: string; + password: string; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface AuthResponse { + user: { + id: number; + email: string; + name: string | null; + createdAt: Date; + }; + token: string; +} + +export interface ApiError { + error: string; + message: string; + details?: string[]; +} \ No newline at end of file diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts new file mode 100644 index 0000000..92422fc --- /dev/null +++ b/backend/src/utils/auth.ts @@ -0,0 +1,58 @@ +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; +import { config } from '@/config/config'; + +export interface JWTPayload { + userId: number; + email: string; +} + +/** + * Generate JWT token for user + */ +export const generateToken = (payload: JWTPayload): string => { + return jwt.sign(payload, config.jwtSecret, { + expiresIn: '24h', + }); +}; + +/** + * Verify and decode JWT token + */ +export const verifyToken = (token: string): JWTPayload => { + try { + const decoded = jwt.verify(token, config.jwtSecret) as JWTPayload; + return decoded; + } catch (error) { + throw new Error('Invalid or expired token'); + } +}; + +/** + * Hash password using bcrypt + */ +export const hashPassword = async (password: string): Promise => { + const saltRounds = 12; + return await bcrypt.hash(password, saltRounds); +}; + +/** + * Compare password with hash + */ +export const comparePassword = async ( + password: string, + hash: string +): Promise => { + return await bcrypt.compare(password, hash); +}; + +/** + * Extract token from Authorization header + */ +export const extractTokenFromHeader = (authHeader?: string): string | null => { + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null; + } + + return authHeader.substring(7); // Remove 'Bearer ' prefix +}; \ No newline at end of file