diff --git a/Backend/.env.example b/Backend/.env.example index 88ff480..c39bcfe 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -1 +1,4 @@ DATABASE_URL=postgres://username:password@localhost:5432/database_name +JWT_SECRET=replace_with_a_long_random_secret +JWT_EXPIRES_IN=7d +PORT=3000 diff --git a/Backend/README.md b/Backend/README.md index eecc946..e225fb7 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -18,6 +18,9 @@ Set: ```bash DATABASE_URL=postgres://username:password@localhost:5432/database_name +JWT_SECRET=replace_with_a_long_random_secret +JWT_EXPIRES_IN=7d +PORT=3000 ``` ## Run app @@ -45,3 +48,33 @@ Open Drizzle Studio: ```bash bun run db:studio ``` + +## Auth API + +Register: + +```bash +POST /auth/register +{ + "email": "user@example.com", + "password": "password123", + "name": "User Name" +} +``` + +Login: + +```bash +POST /auth/login +{ + "email": "user@example.com", + "password": "password123" +} +``` + +Get current user: + +```bash +GET /auth/me +Authorization: Bearer +``` diff --git a/Backend/db/schema.ts b/Backend/db/schema.ts index 531f33a..d3f1832 100644 --- a/Backend/db/schema.ts +++ b/Backend/db/schema.ts @@ -4,5 +4,6 @@ export const users = pgTable('users', { id: uuid('id').defaultRandom().primaryKey(), email: varchar('email', { length: 255 }).notNull().unique(), name: varchar('name', { length: 255 }).notNull(), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }); diff --git a/Backend/index.ts b/Backend/index.ts index f0d3e97..afc0d97 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -1,11 +1,25 @@ +import 'dotenv/config'; import express from 'express'; +import authRoutes from './routes/auth'; + const app = express(); -app.get('/', (req, res) => { - res.send('Hello World'); +app.use(express.json()); + +app.get('/', (_req, res) => { + res.send('API is running'); }); -app.listen(3000, () => { - console.log('Server is running on port 3000'); +app.use('/auth', authRoutes); + +app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error(err); + res.status(500).json({ message: 'Internal server error' }); +}); + +const port = Number(process.env.PORT ?? 3000); + +app.listen(port, () => { + console.log(`Server is running on port ${port}`); }); diff --git a/Backend/middleware/auth.ts b/Backend/middleware/auth.ts new file mode 100644 index 0000000..082393c --- /dev/null +++ b/Backend/middleware/auth.ts @@ -0,0 +1,22 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { verifyAccessToken } from '../utils/jwt'; + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + const authorization = req.headers.authorization; + + if (!authorization?.startsWith('Bearer ')) { + res.status(401).json({ message: 'Missing or invalid authorization header' }); + return; + } + + const token = authorization.slice(7); + + try { + const payload = verifyAccessToken(token); + req.user = payload; + next(); + } catch { + res.status(401).json({ message: 'Invalid or expired token' }); + } +} diff --git a/Backend/routes/auth.ts b/Backend/routes/auth.ts new file mode 100644 index 0000000..32d800b --- /dev/null +++ b/Backend/routes/auth.ts @@ -0,0 +1,134 @@ +import { Router } from 'express'; +import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; + +import { db } from '../db/client'; +import { users } from '../db/schema'; +import { requireAuth } from '../middleware/auth'; +import { signAccessToken } from '../utils/jwt'; +import { hashPassword, verifyPassword } from '../utils/password'; + +const router = Router(); + +const registerSchema = z.object({ + email: z.email().trim().toLowerCase(), + password: z.string().min(8), + name: z.string().trim().min(1).max(255), +}); + +const loginSchema = z.object({ + email: z.email().trim().toLowerCase(), + password: z.string().min(1), +}); + +router.post('/register', async (req, res) => { + const parsed = registerSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const { email, password, name } = parsed.data; + + const existingUser = await db.query.users.findFirst({ + where: eq(users.email, email), + }); + + if (existingUser) { + res.status(409).json({ message: 'Email already in use' }); + return; + } + + const passwordHash = await hashPassword(password); + + const [newUser] = await db + .insert(users) + .values({ + email, + name, + passwordHash, + }) + .returning({ + id: users.id, + email: users.email, + name: users.name, + createdAt: users.createdAt, + }); + + if (!newUser) { + res.status(500).json({ message: 'Failed to create user' }); + return; + } + + const token = signAccessToken({ userId: newUser.id, email: newUser.email }); + + res.status(201).json({ token, user: newUser }); +}); + +router.post('/login', async (req, res) => { + const parsed = loginSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const { email, password } = parsed.data; + + const user = await db.query.users.findFirst({ + where: eq(users.email, email), + }); + + if (!user) { + res.status(401).json({ message: 'Invalid email or password' }); + return; + } + + const isPasswordValid = await verifyPassword(password, user.passwordHash); + + if (!isPasswordValid) { + res.status(401).json({ message: 'Invalid email or password' }); + return; + } + + const token = signAccessToken({ userId: user.id, email: user.email }); + + res.json({ + token, + user: { + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt, + }, + }); +}); + +router.get('/me', requireAuth, async (req, res) => { + const authenticatedUser = req.user; + + if (!authenticatedUser) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const user = await db.query.users.findFirst({ + where: and(eq(users.id, authenticatedUser.userId), eq(users.email, authenticatedUser.email)), + columns: { + id: true, + email: true, + name: true, + createdAt: true, + }, + }); + + if (!user) { + res.status(404).json({ message: 'User not found' }); + return; + } + + res.json({ user }); +}); + +export default router; diff --git a/Backend/types/express.d.ts b/Backend/types/express.d.ts new file mode 100644 index 0000000..04a8cd5 --- /dev/null +++ b/Backend/types/express.d.ts @@ -0,0 +1,11 @@ +import type { JwtPayload } from '../utils/jwt'; + +declare global { + namespace Express { + interface Request { + user?: JwtPayload; + } + } +} + +export {}; diff --git a/Backend/utils/jwt.ts b/Backend/utils/jwt.ts new file mode 100644 index 0000000..60fea45 --- /dev/null +++ b/Backend/utils/jwt.ts @@ -0,0 +1,28 @@ +import jwt from 'jsonwebtoken'; + +const JWT_EXPIRES_IN = (process.env.JWT_EXPIRES_IN ?? '7d') as jwt.SignOptions['expiresIn']; + +function getJwtSecret(): string { + const secret = process.env.JWT_SECRET; + + if (!secret) { + throw new Error('JWT_SECRET is not set'); + } + + return secret; +} + +export type JwtPayload = { + userId: string; + email: string; +}; + +export function signAccessToken(payload: JwtPayload): string { + return jwt.sign(payload, getJwtSecret(), { + expiresIn: JWT_EXPIRES_IN, + }); +} + +export function verifyAccessToken(token: string): JwtPayload { + return jwt.verify(token, getJwtSecret()) as JwtPayload; +} diff --git a/Backend/utils/password.ts b/Backend/utils/password.ts new file mode 100644 index 0000000..fb6da05 --- /dev/null +++ b/Backend/utils/password.ts @@ -0,0 +1,11 @@ +import bcrypt from 'bcrypt'; + +const SALT_ROUNDS = 12; + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +}