diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..ba9af03 --- /dev/null +++ b/next.config.js @@ -0,0 +1,9 @@ +module.exports = { + // Lab 07 memakai URL /auth/login dan /auth/callback (tanpa prefix /api). + rewrites() { + return [ + { source: '/auth/login', destination: '/api/auth/login' }, + { source: '/auth/callback', destination: '/api/auth/callback' }, + ]; + }, +}; diff --git a/package-lock.json b/package-lock.json index 2652fad..2153cf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { - "name": "week-4", + "name": "week-7", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "week-4", + "name": "week-7", "version": "1.0.0", "license": "ISC", "dependencies": { "@apollo/server": "^5.5.1", "@as-integrations/next": "^4.1.0", - "cors": "^2.8.6", "dotenv": "^17.4.2", "graphql": "^16.14.2", + "jsonwebtoken": "^9.0.3", "next": "^16.3.4", "pg": "^8.23.0", "react": "^19.2.8", @@ -1198,6 +1198,12 @@ "url": "https://opencollective.com/express" } }, + "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/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1397,6 +1403,15 @@ "node": ">= 0.4" } }, + "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", @@ -1677,6 +1692,91 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "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/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/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -2175,7 +2275,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index d1e9126..a0f1227 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "week-4", + "name": "week-7", "version": "1.0.0", "description": "", "main": "index.js", @@ -15,9 +15,9 @@ "dependencies": { "@apollo/server": "^5.5.1", "@as-integrations/next": "^4.1.0", - "cors": "^2.8.6", "dotenv": "^17.4.2", "graphql": "^16.14.2", + "jsonwebtoken": "^9.0.3", "next": "^16.3.4", "pg": "^8.23.0", "react": "^19.2.8", diff --git a/pages/api/auth/callback.js b/pages/api/auth/callback.js new file mode 100644 index 0000000..1b79981 --- /dev/null +++ b/pages/api/auth/callback.js @@ -0,0 +1,57 @@ +const jwt = require('jsonwebtoken'); + +// Langkah 3-5 OAuth2: tukar code dengan access token, ambil profil, terbitkan JWT sendiri. +module.exports = async function handler(req, res) { + const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, JWT_SECRET } = process.env; + if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET || !JWT_SECRET) { + res.status(500).json({ error: 'GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET / JWT_SECRET belum diatur di environment' }); + return; + } + + const { code, state } = req.query; + + // state sekali pakai: hapus cookie apa pun hasilnya. + const expectedState = req.cookies.oauth_state; + res.setHeader('Set-Cookie', 'oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0'); + + if (!code) { + res.status(400).json({ error: 'Parameter code tidak ada' }); + return; + } + if (!state || !expectedState || state !== expectedState) { + res.status(400).json({ error: 'State tidak valid, ulangi login lewat /auth/login' }); + return; + } + + try { + const tokenRes = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + client_secret: GITHUB_CLIENT_SECRET, + code, + }), + }); + const { access_token, error_description } = await tokenRes.json(); + if (!access_token) { + res.status(401).json({ error: error_description || 'Gagal menukar code dengan access token' }); + return; + } + + const userRes = await fetch('https://api.github.com/user', { + headers: { Authorization: `Bearer ${access_token}`, Accept: 'application/json' }, + }); + if (!userRes.ok) { + res.status(502).json({ error: 'Gagal mengambil profil dari GitHub' }); + return; + } + const githubUser = await userRes.json(); + + const token = jwt.sign({ username: githubUser.login }, JWT_SECRET, { expiresIn: '1h' }); + res.json({ token }); + } catch (err) { + console.error('[OAuth error] /auth/callback:', err); + res.status(500).json({ error: 'Terjadi kesalahan pada server, silakan coba lagi' }); + } +}; diff --git a/pages/api/auth/login.js b/pages/api/auth/login.js new file mode 100644 index 0000000..6cf64cd --- /dev/null +++ b/pages/api/auth/login.js @@ -0,0 +1,32 @@ +const crypto = require('crypto'); + +// Langkah 1 OAuth2: arahkan pengguna ke halaman login GitHub. +module.exports = function handler(req, res) { + const { GITHUB_CLIENT_ID, CALLBACK_URL } = process.env; + if (!GITHUB_CLIENT_ID || !CALLBACK_URL) { + res.status(500).json({ error: 'GITHUB_CLIENT_ID / CALLBACK_URL belum diatur di environment' }); + return; + } + + // Cookie state hanya terkirim ke host yang sama dengan CALLBACK_URL, jadi login harus dimulai di host itu. + const callbackUrl = new URL(CALLBACK_URL); + if (req.headers.host !== callbackUrl.host) { + res.redirect(`${callbackUrl.origin}/auth/login`); + return; + } + + // state acak disimpan di cookie lalu dicocokkan di /auth/callback (anti login-CSRF). + const state = crypto.randomBytes(16).toString('hex'); + const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''; + res.setHeader( + 'Set-Cookie', + `oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600${secure}` + ); + + const params = new URLSearchParams({ + client_id: GITHUB_CLIENT_ID, + redirect_uri: CALLBACK_URL, + state, + }); + res.redirect(`https://github.com/login/oauth/authorize?${params}`); +}; diff --git a/pages/api/graphql.js b/pages/api/graphql.js index ba410b9..ca34541 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -1,6 +1,7 @@ const { ApolloServer } = require('@apollo/server'); const { startServerAndCreateNextHandler } = require('@as-integrations/next'); const { GraphQLError } = require('graphql'); +const jwt = require('jsonwebtoken'); const { Pool } = require('pg'); // 1. Inisialisasi Pool PostgreSQL untuk Serverless @@ -114,6 +115,37 @@ function validatePrice(price) { return price; } +// Ambil user dari header "Authorization: Bearer ". Token hilang/salah/kedaluwarsa -> null. +function getUserFromRequest(req) { + const token = (req.headers.authorization || '').replace('Bearer ', ''); + try { + return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); + } catch { + return null; + } +} + +// Dipakai di resolver mutation: tolak request tanpa JWT valid. +function requireAuth(contextValue) { + if (!contextValue.user) { + throw new GraphQLError('Unauthorized: silakan login terlebih dahulu', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + } +} + +// Cari (atau buat) baris users untuk pengguna GitHub, supaya products.created_by bisa terisi. +// users.email wajib unik & NOT NULL, sedangkan email GitHub bisa privat -> pakai alamat noreply. +async function getOrCreateUserId(username) { + const result = await pool.query( + `INSERT INTO users (username, email) VALUES ($1, $2) + ON CONFLICT (username) DO UPDATE SET username = EXCLUDED.username + RETURNING id`, + [username, `${username}@users.noreply.github.com`] + ); + return result.rows[0].id; +} + // Bungkus error tak terduga dari DB supaya tidak bocor ke client, // tapi biarkan GraphQLError yang sudah kita buat sendiri lewat apa adanya. function handleUnexpectedError(err, context) { @@ -180,7 +212,8 @@ const resolvers = { }, Mutation: { - createProduct: async (_, { input }) => { + createProduct: async (_, { input }, contextValue) => { + requireAuth(contextValue); try { const title = validateTitle(input.title); const price = validatePrice(input.price); @@ -194,9 +227,11 @@ const resolvers = { }); } + const userId = await getOrCreateUserId(contextValue.user.username); + const result = await pool.query( - 'INSERT INTO products (title, price, category_id) VALUES ($1, $2, $3) RETURNING id, title, price::float, category_id, created_by', - [title, price, categoryId] + 'INSERT INTO products (title, price, category_id, created_by) VALUES ($1, $2, $3, $4) RETURNING id, title, price::float, category_id, created_by', + [title, price, categoryId, userId] ); return result.rows[0]; } catch (err) { @@ -210,7 +245,8 @@ const resolvers = { } }, - updateProduct: async (_, { id, input }) => { + updateProduct: async (_, { id, input }, contextValue) => { + requireAuth(contextValue); try { const productId = parsePositiveIntId(id, 'id'); @@ -246,7 +282,8 @@ const resolvers = { } }, - deleteProduct: async (_, { id }) => { + deleteProduct: async (_, { id }, contextValue) => { + requireAuth(contextValue); try { const productId = parsePositiveIntId(id, 'id'); const result = await pool.query('DELETE FROM products WHERE id = $1 RETURNING id', [productId]); @@ -304,7 +341,8 @@ const server = new ApolloServer({ // 6. Inisialisasi Handler dengan Context Per-Request const apolloHandler = startServerAndCreateNextHandler(server, { - context: async () => ({ + context: async (req) => ({ + user: getUserFromRequest(req), metrics: { categoryProductsCalls: 0, callLogs: [], diff --git a/pages/index.js b/pages/index.js index e1d27c1..fe7a3b7 100644 --- a/pages/index.js +++ b/pages/index.js @@ -15,6 +15,8 @@ function Home() { target: '#embedded-sandbox', initialEndpoint: `${window.location.origin}/api/graphql`, initialState: { + // Ganti dengan JWT dari /auth/login supaya mutation diizinkan. + headers: { Authorization: 'Bearer ' }, document: `query TestCategoriesWithProducts { categories { id @@ -44,7 +46,7 @@ function Home() { React.createElement( Head, null, - React.createElement('title', null, 'GraphQL Sandbox - Lab 04') + React.createElement('title', null, 'GraphQL Sandbox') ), React.createElement('div', { id: 'embedded-sandbox',