Skip to content
Merged

Feat #78

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'no-console': 'warn',
},
ignorePatterns: ['dist/', 'node_modules/', '*.js'],
};
9 changes: 9 additions & 0 deletions backend/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
extends: ['../../.eslintrc.js'],
env: {
jest: true,
},
rules: {
// Backend specific rules
},
};
5 changes: 5 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env

/src/generated/prisma
19 changes: 14 additions & 5 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,50 @@
"main": "src/index.ts",
"scripts": {
"start": "ts-node src/index.ts",
"dev": "ts-node --watch src/index.ts",
"dev": "ts-node src/index.ts",
"build": "tsc",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"test": "jest",
"lint": "eslint src/**/*.ts"
},
"dependencies": {
"@chenaikit/core": "workspace:*",
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"express": "^4.18.0",
"express-rate-limit": "^8.1.0",
"helmet": "^7.0.0",
<<<<<<< HEAD
=======

"jsonwebtoken": "^9.0.2",
"zod": "^4.1.11",

"winston": "^3.14.2",
"uuid": "^9.0.1",
"prom-client": "^15.1.3",
"@sentry/node": "^8.30.0",
"@opentelemetry/sdk-node": "^0.54.0",
"@opentelemetry/auto-instrumentations-node": "^0.64.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.54.0",
>>>>>>> 4b2e5da5b0789837c58861e705de63b87b64ae63

"ioredis": "^5.8.0",
"reflect-metadata": "^0.2.2",
"typeorm": "^0.3.27"

},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cors": "^2.8.0",
"@types/node": "^20.0.0",
"@types/uuid": "^9.0.7",
"typescript": "^5.0.0",
"ts-node": "^10.9.0",
"jest": "^29.0.0",
"@types/express": "^4.17.0",
"@types/express-rate-limit": "^6.0.2",
"@types/jest": "^29.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
Expand All @@ -48,4 +57,4 @@
"ts-node": "^10.9.0",
"typescript": "^5.0.0"
}
}
}
11 changes: 11 additions & 0 deletions backend/prisma/migrations/20251004160526_init/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'user',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
12 changes: 12 additions & 0 deletions backend/prisma/migrations/20251004161955_init_v2/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"token" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);

-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
Warnings:

- You are about to drop the column `token` on the `RefreshToken` table. All the data in the column will be lost.
- Added the required column `tokenHash` to the `RefreshToken` table without a default value. This is not possible if the table is not empty.

*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_RefreshToken" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"tokenHash" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_RefreshToken" ("createdAt", "expiresAt", "id", "userId") SELECT "createdAt", "expiresAt", "id", "userId" FROM "RefreshToken";
DROP TABLE "RefreshToken";
ALTER TABLE "new_RefreshToken" RENAME TO "RefreshToken";
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
3 changes: 3 additions & 0 deletions backend/prisma/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
34 changes: 34 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}

datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}

enum Role {
user
admin
}

model User {
id String @id @default(cuid())
email String @unique
password String
role Role @default(user)
createdAt DateTime @default(now())
refreshTokens RefreshToken[]
}


model RefreshToken {
id Int @id @default(autoincrement())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
expiresAt DateTime
}

94 changes: 94 additions & 0 deletions backend/src/controllers/authController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Request, Response } from 'express';
import { hashPassword, comparePassword } from '../utils/password';
import { generateAccessToken } from '../utils/jwt';
import { prisma } from '../prisma/client';
import { UserPayload } from '../types/auth';
import crypto from 'crypto';
import { z } from 'zod';

const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['user', 'admin']).optional(),
});

const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});

export class AuthController {
async register(req: Request, res: Response) {
try {
const { email, password, role } = registerSchema.parse(req.body);
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) return res.status(400).json({ message: 'Email already registered' });

const hashed = await hashPassword(password);
const user = await prisma.user.create({
data: { email, password: hashed, role: role || 'user' },
});

res.status(201).json({ message: 'User registered', userId: user.id });
} catch (err: any) {
res.status(400).json({ message: err.message || 'Registration failed' });
}
}

async login(req: Request, res: Response) {
try {
const { email, password } = loginSchema.parse(req.body);
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(400).json({ message: 'Invalid credentials' });

const valid = await comparePassword(password, user.password);
if (!valid) return res.status(400).json({ message: 'Invalid credentials' });

const payload: UserPayload = { id: user.id, email: user.email, role: user.role };
const accessToken = generateAccessToken(payload);
const refreshTokenRaw = crypto.randomBytes(64).toString('hex');
const refreshTokenHash = await hashPassword(refreshTokenRaw);

await prisma.refreshToken.create({
data: {
tokenHash: refreshTokenHash,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});

res.json({ accessToken, refreshToken: refreshTokenRaw });
} catch (err: any) {
res.status(400).json({ message: err.message || 'Login failed' });
}
}

async refreshToken(req: Request, res: Response) {
try {
const { token } = req.body;
if (!token) return res.status(401).json({ message: 'Refresh token missing' });

const tokens = await prisma.refreshToken.findMany({ include: { user: true } });
let matched = null;
for (const t of tokens) {
if (await comparePassword(token, t.tokenHash)) {
matched = t;
break;
}
}

if (!matched) return res.status(403).json({ message: 'Invalid refresh token' });
if (matched.expiresAt < new Date()) return res.status(403).json({ message: 'Refresh token expired' });

const payload: UserPayload = {
id: matched.user.id,
email: matched.user.email,
role: matched.user.role,
};
const accessToken = generateAccessToken(payload);
res.json({ accessToken });
} catch (err: any) {
res.status(400).json({ message: err.message || 'Token refresh failed' });
}
}
}
6 changes: 5 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import dotenv from 'dotenv';
import authRoutes from './routes/auth';
import { ensureRedisConnection } from './config/redis';
import { cacheMiddleware } from './middleware/cache';
import { CacheKeys } from './utils/cacheKeys';
Expand All @@ -26,7 +27,7 @@ dotenv.config();
validateEnvironment();

const app: express.Application = express();
const PORT = process.env.PORT || 3000;
const PORT = process.env.PORT || 5000;

// Initialize monitoring
initializeMonitoring().finally(() => {
Expand Down Expand Up @@ -170,6 +171,9 @@ app.use((error: Error, req: express.Request, res: express.Response, next: expres
});
});


app.use('/api/auth', authRoutes);

// Start server
app.listen(PORT, async () => {
console.log(`🚀 ChenAIKit Backend running on port ${PORT}`);
Expand Down
34 changes: 34 additions & 0 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt';
import { UserPayload } from '../types/auth';

declare global {
namespace Express {
interface Request {
user?: UserPayload;
}
}
}

export const authenticate = (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers['authorization'];
const token = authHeader?.split(' ')[1];

if (!token) return res.status(401).json({ message: 'Access token missing' });

try {
req.user = verifyAccessToken(token);
next();
} catch {
return res.status(403).json({ message: 'Invalid or expired token' });
}
};

export const authorize = (roles: Array<'user' | 'admin'>) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ message: 'Forbidden' });
}
next();
};
};
7 changes: 7 additions & 0 deletions backend/src/prisma/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PrismaClient } from '../generated/prisma';

export const prisma = new PrismaClient();

process.on('beforeExit', async () => {
await prisma.$disconnect();
});
19 changes: 19 additions & 0 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { AuthController } from '../controllers/authController';

const router = Router();
const controller = new AuthController();


const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { message: 'Too many requests, try again later.' },
});

router.post('/register', authLimiter, controller.register.bind(controller));
router.post('/login', authLimiter, controller.login.bind(controller));
router.post('/refresh', authLimiter, controller.refreshToken.bind(controller));

export default router;
5 changes: 5 additions & 0 deletions backend/src/types/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface UserPayload {
id: string;
email: string;
role: 'user' | 'admin';
}
Loading
Loading