feat: add authentication routes, update environment variables, and enhance error handling
This commit is contained in:
@@ -1 +1,4 @@
|
|||||||
DATABASE_URL=postgres://username:password@localhost:5432/database_name
|
DATABASE_URL=postgres://username:password@localhost:5432/database_name
|
||||||
|
JWT_SECRET=replace_with_a_long_random_secret
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
PORT=3000
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ Set:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL=postgres://username:password@localhost:5432/database_name
|
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
|
## Run app
|
||||||
@@ -45,3 +48,33 @@ Open Drizzle Studio:
|
|||||||
```bash
|
```bash
|
||||||
bun run db:studio
|
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 <token>
|
||||||
|
```
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export const users = pgTable('users', {
|
|||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||||
name: varchar('name', { length: 255 }).notNull(),
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
|
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.use(express.json());
|
||||||
res.send('Hello World');
|
|
||||||
|
app.get('/', (_req, res) => {
|
||||||
|
res.send('API is running');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(3000, () => {
|
app.use('/auth', authRoutes);
|
||||||
console.log('Server is running on port 3000');
|
|
||||||
|
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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
22
Backend/middleware/auth.ts
Normal file
22
Backend/middleware/auth.ts
Normal file
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
134
Backend/routes/auth.ts
Normal file
134
Backend/routes/auth.ts
Normal file
@@ -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;
|
||||||
11
Backend/types/express.d.ts
vendored
Normal file
11
Backend/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { JwtPayload } from '../utils/jwt';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: JwtPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
28
Backend/utils/jwt.ts
Normal file
28
Backend/utils/jwt.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
11
Backend/utils/password.ts
Normal file
11
Backend/utils/password.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user