diff --git a/.env.example b/.env.example index 4c1eeec..a7e801a 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,7 @@ NODE_ENV=development # Database DATABASE_URL="mysql://db_username:db_pass@db_host:3306/internify_lms_db" + +# Authentication Config +JWT_SECRET="your_jwt_secret_key_here" +JWT_EXPIRES_IN="7d" diff --git a/package-lock.json b/package-lock.json index a2c8b4c..f3b744d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,19 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.19.3", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.3", "morgan": "^1.10.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.9", "@types/node": "^20.12.12", "prisma": "^6.19.3", @@ -660,6 +664,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -724,6 +735,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -741,12 +763,20 @@ "@types/node": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -929,6 +959,15 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -989,6 +1028,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1313,6 +1358,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1915,6 +1969,97 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -2303,6 +2448,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -2480,6 +2626,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -3076,6 +3234,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index a448a6f..351f0e0 100644 --- a/package.json +++ b/package.json @@ -18,15 +18,19 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.19.3", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.3", "morgan": "^1.10.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.9", "@types/node": "^20.12.12", "prisma": "^6.19.3", diff --git a/prisma.config.ts b/prisma.config.ts index 04e0d11..ae97446 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -2,7 +2,7 @@ // This file was generated by Prisma and assumes you have installed the following: // npm install --save-dev prisma dotenv import 'dotenv/config'; -import { defineConfig, env } from 'prisma/config'; +import { defineConfig } from 'prisma/config'; export default defineConfig({ schema: 'prisma/schema.prisma', @@ -10,8 +10,4 @@ export default defineConfig({ path: 'prisma/migrations', seed: 'tsx prisma/seed.ts', }, - engine: 'classic', - datasource: { - url: env('DATABASE_URL'), - }, }); diff --git a/prisma/seed.ts b/prisma/seed.ts index c430a1a..5ec787f 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,21 +1,48 @@ import 'dotenv/config'; import { PrismaClient } from '../src/generated/prisma/client'; +import bcrypt from 'bcryptjs'; const prisma = new PrismaClient(); async function main() { - console.log('Start seeding Internify database...'); + console.log('Start seeding Internify database...'); - // Seeding process ... + const adminEmail = 'admin@internify.com'; + const adminPassword = 'AdminInternify123!'; + const passwordHash = await bcrypt.hash(adminPassword, 10); - console.log('Seeding finished'); + // Cek Apakah ada si Admin + const existingAdmin = await prisma.user.findUnique({ + where: { email: adminEmail } + }); + + if (!existingAdmin) { + const adminUser = await prisma.user.create({ + data: { + email: adminEmail, + passwordHash: passwordHash, + role: 'ADMIN', + admin: { + create: { + namaDepan: 'Super', + namaBelakang: 'Admin', + } + } + } + }); + console.log(`Admin user created: ${adminEmail} (password: ${adminPassword})`); + } else { + console.log(`Admin user already exists: ${adminEmail}`); + } + + console.log('Seeding finished'); } main() - .catch((error) => { - console.error('Seeding failed:', error); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); + .catch((error) => { + console.error('Seeding failed:', error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..0270799 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '../generated/prisma/client'; + +const prisma = new PrismaClient(); + +export default prisma; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..d38fad4 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,67 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import prisma from '../lib/prisma'; +import { CustomError } from './errorHandler'; + +export interface AuthenticatedRequest extends Request { + user?: { + id: string; + email: string; + role: 'ADMIN' | 'STUDENT'; + }; +} + +export const protect = async (req: AuthenticatedRequest, _res: Response, next: NextFunction) => { + let token: string | undefined; + + if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { + token = req.headers.authorization.split(' ')[1]; + } + + if (!token) { + const error: CustomError = new Error('Not authorized to access this route, token is missing'); + error.statusCode = 401; + return next(error); + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'internify_secret_key_2026_dev') as { + id: string; + email: string; + role: 'ADMIN' | 'STUDENT'; + }; + + const user = await prisma.user.findUnique({ + where: { id: decoded.id }, + select: { + id: true, + email: true, + role: true, + }, + }); + + if (!user) { + const error: CustomError = new Error('User belonging to this token no longer exists'); + error.statusCode = 401; + return next(error); + } + + req.user = user as { id: string; email: string; role: 'ADMIN' | 'STUDENT' }; + next(); + } catch (err) { + const error: CustomError = new Error('Not authorized to access this route, token is invalid or expired'); + error.statusCode = 401; + return next(error); + } +}; + +export const restrictTo = (...roles: ('ADMIN' | 'STUDENT')[]) => { + return (req: AuthenticatedRequest, _res: Response, next: NextFunction) => { + if (!req.user || !roles.includes(req.user.role)) { + const error: CustomError = new Error('You do not have permission to perform this action'); + error.statusCode = 403; + return next(error); + } + next(); + }; +}; diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..b827721 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,200 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import prisma from '../lib/prisma'; +import { protect, AuthenticatedRequest } from '../middleware/auth'; +import { CustomError } from '../middleware/errorHandler'; + +const router = Router(); + +const generateToken = (payload: { id: string; email: string; role: string }) => { + return jwt.sign( + payload, + process.env.JWT_SECRET || 'internify_secret_key_2026_dev', + { expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as any } + ); +}; + +/** + * @route POST /api/auth/register + * @desc Mendaftarkan user baru dengan role STUDENT (Mahasiswa) + * @access Public + */ +router.post('/register', async (req: Request, res: Response, next: NextFunction) => { + try { + const { + email, + password, + namaDepan, + namaBelakang, + kontak, + jurusan, + universitas, + negara, + cvPath, + portofolioPath, + motivasi, + relevantSkills, + } = req.body; + + if (!email || !password || !namaDepan || !kontak || !jurusan || !universitas || !negara) { + const error: CustomError = new Error('Mohon lengkapi semua field wajib yang diperlukan'); + error.statusCode = 400; + return next(error); + } + + const existingUser = await prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + const error: CustomError = new Error('Email sudah terdaftar'); + error.statusCode = 400; + return next(error); + } + + const salt = await bcrypt.genSalt(10); + const passwordHash = await bcrypt.hash(password, salt); + + const newUser = await prisma.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + email, + passwordHash, + role: 'STUDENT', + }, + }); + + await tx.mahasiswa.create({ + data: { + idUser: user.id, + namaDepan, + namaBelakang: namaBelakang || null, + kontak, + jurusan, + universitas, + negara, + cvPath: cvPath || '', + portofolioPath: portofolioPath || '', + motivasi: motivasi || '', + relevantSkills: relevantSkills || '', + }, + }); + + return user; + }); + + res.status(201).json({ + success: true, + message: 'Registrasi mahasiswa berhasil', + data: { + id: newUser.id, + email: newUser.email, + role: newUser.role, + createdAt: newUser.createdAt, + }, + }); + } catch (error) { + next(error); + } +}); + +/** + * @route POST /api/auth/login + * @desc Autentikasi user dan dapatin token + * @access Public + */ +router.post('/login', async (req: Request, res: Response, next: NextFunction) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + const error: CustomError = new Error('Mohon masukkan email dan password'); + error.statusCode = 400; + return next(error); + } + + const user = await prisma.user.findUnique({ + where: { email }, + include: { + admin: true, + mahasiswa: true, + }, + }); + + if (!user) { + const error: CustomError = new Error('Email atau password salah'); + error.statusCode = 401; + return next(error); + } + + const isMatch = await bcrypt.compare(password, user.passwordHash); + if (!isMatch) { + const error: CustomError = new Error('Email atau password salah'); + error.statusCode = 401; + return next(error); + } + + const profile = user.role === 'ADMIN' ? user.admin : user.mahasiswa; + + const token = generateToken({ + id: user.id, + email: user.email, + role: user.role, + }); + + res.status(200).json({ + success: true, + token, + user: { + id: user.id, + email: user.email, + role: user.role, + profile, + }, + }); + } catch (error) { + next(error); + } +}); + +/** + * @route GET /api/auth/profile + * @desc Dapatin profile user yang sedang login + * @access Private + */ +router.get('/profile', protect, async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const userId = req.user?.id; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + admin: true, + mahasiswa: true, + }, + }); + + if (!user) { + const error: CustomError = new Error('User tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + const profile = user.role === 'ADMIN' ? user.admin : user.mahasiswa; + + res.status(200).json({ + success: true, + user: { + id: user.id, + email: user.email, + role: user.role, + profile, + }, + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index ad94aae..5374951 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,7 +1,11 @@ import { Router, Request, Response, NextFunction } from 'express'; +import prisma from '../lib/prisma'; +import authRouter from './auth'; const router = Router(); +router.use('/auth', authRouter); + /** * @route GET /api/health * @desc Endpoint untuk memeriksa kesehatan/status server @@ -15,6 +19,31 @@ router.get('/health', (_req: Request, res: Response) => { }); }); +/** + * @route GET /api/users + * @desc Mendapatkan daftar semua user dari database menggunakan Prisma + * @access Public + */ +router.get('/users', async (_req: Request, res: Response, next: NextFunction) => { + try { + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + role: true, + createdAt: true, + }, + }); + res.status(200).json({ + success: true, + count: users.length, + data: users, + }); + } catch (error) { + next(error); + } +}); + /** * @route GET /api/error-test * @desc Mensimulasikan error server internal untuk memverifikasi middleware penanganan error global