diff --git a/backend/app/controllers/oauth.controller.js b/backend/app/controllers/oauth.controller.js new file mode 100644 index 0000000..f6613f6 --- /dev/null +++ b/backend/app/controllers/oauth.controller.js @@ -0,0 +1,159 @@ +import passport from "passport"; +import { Strategy as GitHubStrategy } from "passport-github2"; +import { getClient } from "../utils/database.js"; +import jwt from "jsonwebtoken"; + +export async function login(req, res) { + passport.authenticate("github", { scope: ["user:email"] })(req, res); +} + +export async function callback(req, res, next) { + passport.authenticate("github", { failureRedirect: "https://localhost/login?error=oauth_failed" }, async (err, user) => { + if (err) { + console.error("OAuth error:", err); + return res.redirect("https://localhost/login?error=oauth_error"); + } + + if (!user) { + return res.redirect("https://localhost/login?error=oauth_cancelled"); + } + + try { + // Extract user information from GitHub profile + const githubId = user.id; + const username = user.username || user.login; + const email = user.emails[0].value || null; + const avatarUrl = user.photos && user.photos[0] ? user.photos[0].value : null; + const displayName = user.displayName || username; + + console.log(user); + + console.log("GitHub user info:", { + githubId, + username, + email, + displayName, + avatarUrl + }); + + const client = await getClient(); + + // Check if user already exists by GitHub ID + let existingUserQuery = `SELECT * FROM users WHERE github_id = $1`; + let existingUserResult = await client.query(existingUserQuery, [githubId]); + + let dbUser; + + if (existingUserResult.rows.length > 0) { + // User exists, update their information + dbUser = existingUserResult.rows[0]; + + const updateQuery = `UPDATE users SET + username = $1, + email = $2, + picture = $3, + is_verified = true, + updated_at = NOW() + WHERE github_id = $4 + RETURNING id, username, email, picture`; + + const updateResult = await client.query(updateQuery, [ + username, + email, + avatarUrl || "/api/media/profile/default.png", + githubId + ]); + + dbUser = updateResult.rows[0]; + } else { + // Check if username already exists + const usernameCheck = await client.query(`SELECT id FROM users WHERE username = $1`, [username]); + let finalUsername = username; + + if (usernameCheck.rows.length > 0) { + // Username exists, append GitHub ID to make it unique + finalUsername = `${username}_gh${githubId}`; + } + + // Create new user + const insertQuery = `INSERT INTO users ( + username, + email, + picture, + github_id, + password, + is_verified + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, username, email, picture`; + + const insertResult = await client.query(insertQuery, [ + finalUsername, + email, + avatarUrl || "/api/media/profile/default.png", + githubId, + null, // No password for OAuth users + true // OAuth users are automatically verified + ]); + + dbUser = insertResult.rows[0]; + + // Create default playlist for new user + const playlistQuery = `INSERT INTO playlists (name, owner) VALUES ('A regarder plus tard', $1)`; + await client.query(playlistQuery, [dbUser.id]); + } + + client.end(); + + // Generate JWT token + const payload = { + id: dbUser.id, + username: dbUser.username, + }; + + const token = jwt.sign(payload, process.env.JWT_SECRET); + + // Redirect to frontend with token and user info + const userData = encodeURIComponent(JSON.stringify({ + id: dbUser.id, + username: dbUser.username, + email: dbUser.email, + picture: dbUser.picture + })); + + res.redirect(`https://localhost/login/success?token=${token}&user=${userData}`); + + } catch (error) { + console.error("Error processing GitHub OAuth callback:", error); + res.redirect("https://localhost/login?error=processing_error"); + } + })(req, res, next); +} + +export async function getUserInfo(req, res) { + try { + // This endpoint can be used to get current user info from token + const token = req.headers.authorization?.split(" ")[1]; + + if (!token) { + return res.status(401).json({ error: "No token provided" }); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const client = await getClient(); + + const query = `SELECT id, username, email, picture, github_id, is_verified FROM users WHERE id = $1`; + const result = await client.query(query, [decoded.id]); + + if (!result.rows[0]) { + client.end(); + return res.status(404).json({ error: "User not found" }); + } + + client.end(); + res.status(200).json({ user: result.rows[0] }); + + } catch (error) { + console.error("Error getting user info:", error); + res.status(500).json({ error: "Internal server error" }); + } +} \ No newline at end of file diff --git a/backend/app/routes/oauth.route.js b/backend/app/routes/oauth.route.js new file mode 100644 index 0000000..2b2d2b3 --- /dev/null +++ b/backend/app/routes/oauth.route.js @@ -0,0 +1,15 @@ +import {Router} from 'express'; +import { callback, login, getUserInfo } from '../controllers/oauth.controller.js'; +import { isTokenValid } from '../middlewares/jwt.middleware.js'; +import { addLogger } from '../middlewares/logger.middleware.js'; + +const router = Router(); + +router.get('/github', login) + +router.get('/callback', callback) + +// Get current user info from token +router.get('/me', [addLogger, isTokenValid], getUserInfo) + +export default router; \ No newline at end of file diff --git a/backend/app/uploads/profiles/astria.png b/backend/app/uploads/profiles/astria.png index 388cdbe..f1a0670 100644 Binary files a/backend/app/uploads/profiles/astria.png and b/backend/app/uploads/profiles/astria.png differ diff --git a/backend/app/utils/database.js b/backend/app/utils/database.js index 315335f..ad85431 100644 --- a/backend/app/utils/database.js +++ b/backend/app/utils/database.js @@ -20,11 +20,14 @@ export async function initDb() { try { let query = `CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, - email VARCHAR(255) NOT NULL, + email VARCHAR(255), username VARCHAR(255) NOT NULL, - password VARCHAR(255) NOT NULL, + password VARCHAR(255), picture VARCHAR(255), - is_verified BOOLEAN NOT NULL DEFAULT FALSE + is_verified BOOLEAN NOT NULL DEFAULT FALSE, + github_id VARCHAR(255) UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() );`; await client.query(query); @@ -126,8 +129,21 @@ export async function initDb() { )`; await client.query(query); + // Add GitHub OAuth columns if they don't exist + try { + await client.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS github_id VARCHAR(255) UNIQUE`); + await client.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW()`); + await client.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW()`); + await client.query(`ALTER TABLE users ALTER COLUMN email DROP NOT NULL`); + await client.query(`ALTER TABLE users ALTER COLUMN password DROP NOT NULL`); + } catch (e) { + console.log("OAuth columns already exist or error adding them:", e.message); + } + } catch (e) { console.error("Error initializing database:", e); + } finally { + client.end(); } } \ No newline at end of file diff --git a/backend/logs/access.log b/backend/logs/access.log index 2b06713..d76fa21 100644 --- a/backend/logs/access.log +++ b/backend/logs/access.log @@ -8357,3 +8357,86 @@ [2025-08-20 19:34:43.088] [undefined] PUT(/:id/tags): try to add tags to video 20 [2025-08-20 19:34:43.096] [undefined] PUT(/:id/tags): Tag csgo already exists for video 20 with status 200 [2025-08-20 19:34:43.100] [undefined] PUT(/:id/tags): successfully added tags to video 20 with status 200 +[2025-08-22 11:41:01.973] [undefined] GET(/:id/history): try to retrieve history of user 1 +[2025-08-22 11:41:01.980] [undefined] GET(/:id/history): failed to retrieve history of user 1 because it doesn't exist with status 404 +[2025-08-22 11:41:01.985] [undefined] GET(/:id/channel): try to retrieve channel of user 1 +[2025-08-22 11:41:01.991] [undefined] GET(/:id/channel): successfully retrieved channel of user 1 with status 200 +[2025-08-22 11:41:02.003] [undefined] GET(/user/:id): Playlists retrieved for user with id 1 with status 200 +[2025-08-22 11:41:04.343] [undefined] GET(/:id): try to get channel with id 1 +[2025-08-22 11:41:04.355] [undefined] GET(/:id/stats): try to get stats +[2025-08-22 11:41:04.361] [undefined] GET(/:id): Successfully get channel with id 1 with status 200 +[2025-08-22 11:41:04.371] [undefined] GET(/:id/stats): Successfully get stats with status 200 +[2025-08-22 11:41:12.948] [undefined] POST(/login): try to login with username 'astria' +[2025-08-22 11:41:13.006] [undefined] POST(/login): failed to login with status 401 +[2025-08-22 11:41:19.893] [undefined] POST(/login): try to login with username 'astria' +[2025-08-22 11:41:19.950] [undefined] POST(/login): Successfully logged in with status 200 +[2025-08-22 16:21:17.273] [undefined] GET(/:id/channel): try to retrieve channel of user 4 +[2025-08-22 16:21:17.277] [undefined] GET(/:id/channel): failed to retrieve channel of user 4 because it doesn't exist with status 404 +[2025-08-22 16:21:17.282] [undefined] GET(/:id/history): try to retrieve history of user 4 +[2025-08-22 16:21:17.286] [undefined] GET(/:id/history): failed to retrieve history of user 4 because it doesn't exist with status 404 +[2025-08-22 16:21:17.294] [undefined] GET(/user/:id): Playlists retrieved for user with id 4 with status 200 +[2025-08-22 16:23:00.680] [undefined] POST(/login): failed due to invalid values with status 400 +[2025-08-22 16:23:08.955] [undefined] POST(/login): failed due to invalid values with status 400 +[2025-08-22 16:24:16.438] [undefined] POST(/login): failed due to invalid values with status 400 +[2025-08-22 16:33:03.791] [undefined] POST(/login): failed due to invalid values with status 400 +[2025-08-22 16:33:18.532] [undefined] POST(/login): failed due to invalid values with status 400 +[2025-08-22 16:33:23.707] [undefined] GET(/:id/channel): try to retrieve channel of user 4 +[2025-08-22 16:33:23.712] [undefined] GET(/:id/channel): failed to retrieve channel of user 4 because it doesn't exist with status 404 +[2025-08-22 16:33:23.718] [undefined] GET(/:id/history): try to retrieve history of user 4 +[2025-08-22 16:33:23.722] [undefined] GET(/:id/history): failed to retrieve history of user 4 because it doesn't exist with status 404 +[2025-08-22 16:33:23.735] [undefined] GET(/user/:id): Playlists retrieved for user with id 4 with status 200 +[2025-08-22 16:33:44.131] [undefined] POST(/login): failed due to invalid values with status 400 +[2025-08-22 16:34:21.678] [undefined] POST(/login): failed due to invalid values with status 400 +[2025-08-22 16:34:50.238] [undefined] POST(/login): failed due to invalid values with status 400 +[2025-08-22 16:35:02.341] [undefined] GET(/:id/channel): try to retrieve channel of user 4 +[2025-08-22 16:35:02.345] [undefined] GET(/:id/channel): failed to retrieve channel of user 4 because it doesn't exist with status 404 +[2025-08-22 16:35:02.353] [undefined] GET(/:id/history): try to retrieve history of user 4 +[2025-08-22 16:35:02.357] [undefined] GET(/:id/history): failed to retrieve history of user 4 because it doesn't exist with status 404 +[2025-08-22 16:35:02.368] [undefined] GET(/user/:id): Playlists retrieved for user with id 4 with status 200 +[2025-08-22 16:35:08.368] [undefined] POST(/): try to create new channel with owner 4 and name github +[2025-08-22 16:35:08.371] [undefined] POST(/): Successfully created new channel with name github with status 200 +[2025-08-22 16:35:08.389] [undefined] GET(/:id/channel): try to retrieve channel of user 4 +[2025-08-22 16:35:08.393] [undefined] GET(/:id/channel): successfully retrieved channel of user 4 with status 200 +[2025-08-22 16:35:09.953] [undefined] GET(/:id): try to get channel with id 2 +[2025-08-22 16:35:09.966] [undefined] GET(/:id/stats): try to get stats +[2025-08-22 16:35:09.970] [undefined] GET(/:id): Successfully get channel with id 2 with status 200 +[2025-08-22 16:35:09.977] [undefined] GET(/:id/stats): Successfully get stats with status 200 +[2025-08-22 16:35:14.046] [undefined] GET(/:id/history): try to retrieve history of user 4 +[2025-08-22 16:35:14.049] [undefined] GET(/:id/channel): try to retrieve channel of user 4 +[2025-08-22 16:35:14.057] [undefined] GET(/:id/history): failed to retrieve history of user 4 because it doesn't exist with status 404 +[2025-08-22 16:35:14.061] [undefined] GET(/:id/channel): successfully retrieved channel of user 4 with status 200 +[2025-08-22 16:35:14.066] [undefined] GET(/user/:id): Playlists retrieved for user with id 4 with status 200 +[2025-08-22 16:35:49.944] [undefined] GET(/:id/channel): try to retrieve channel of user 4 +[2025-08-22 16:35:49.948] [undefined] GET(/:id/channel): successfully retrieved channel of user 4 with status 200 +[2025-08-22 16:35:49.951] [undefined] GET(/:id/history): try to retrieve history of user 4 +[2025-08-22 16:35:49.955] [undefined] GET(/:id/history): failed to retrieve history of user 4 because it doesn't exist with status 404 +[2025-08-22 16:35:49.964] [undefined] GET(/user/:id): Playlists retrieved for user with id 4 with status 200 +[2025-08-22 16:38:32.507] [undefined] GET(/:id/channel): try to retrieve channel of user 4 +[2025-08-22 16:38:32.511] [undefined] GET(/:id/channel): successfully retrieved channel of user 4 with status 200 +[2025-08-22 16:38:32.519] [undefined] GET(/:id/history): try to retrieve history of user 4 +[2025-08-22 16:38:32.525] [undefined] GET(/:id/history): failed to retrieve history of user 4 because it doesn't exist with status 404 +[2025-08-22 16:38:32.535] [undefined] GET(/user/:id): Playlists retrieved for user with id 4 with status 200 +[2025-08-22 16:38:34.090] [undefined] GET(/:id): try to get channel with id 2 +[2025-08-22 16:38:34.101] [undefined] GET(/:id/stats): try to get stats +[2025-08-22 16:38:34.104] [undefined] GET(/:id): Successfully get channel with id 2 with status 200 +[2025-08-22 16:38:34.111] [undefined] GET(/:id/stats): Successfully get stats with status 200 +[2025-08-22 16:38:35.245] [undefined] GET(/:id/channel): try to retrieve channel of user 4 +[2025-08-22 16:38:35.248] [undefined] GET(/:id/channel): successfully retrieved channel of user 4 with status 200 +[2025-08-22 16:38:39.372] [undefined] GET(/search): try to search user by username a +[2025-08-22 16:38:39.376] [undefined] GET(/search): successfully found user with username a with status 200 +[2025-08-22 16:38:43.731] [undefined] GET(/search): try to search user by username as +[2025-08-22 16:38:43.738] [undefined] GET(/search): successfully found user with username as with status 200 +[2025-08-22 16:38:44.209] [undefined] GET(/search): try to search user by username a +[2025-08-22 16:38:44.212] [undefined] GET(/search): successfully found user with username a with status 200 +[2025-08-22 16:38:45.263] [undefined] GET(/search): try to search user by username s +[2025-08-22 16:38:45.267] [undefined] GET(/search): successfully found user with username s with status 200 +[2025-08-22 17:16:45.647] [undefined] GET(/:id/channel): try to retrieve channel of user 1 +[2025-08-22 17:16:45.652] [undefined] GET(/:id/channel): failed to retrieve channel of user 1 because it doesn't exist with status 404 +[2025-08-22 17:16:45.657] [undefined] GET(/:id/history): try to retrieve history of user 1 +[2025-08-22 17:16:45.662] [undefined] GET(/:id/history): failed to retrieve history of user 1 because it doesn't exist with status 404 +[2025-08-22 17:16:45.673] [undefined] GET(/user/:id): Playlists retrieved for user with id 1 with status 200 +[2025-08-22 17:20:25.391] [undefined] GET(/:id/channel): try to retrieve channel of user 1 +[2025-08-22 17:20:25.395] [undefined] GET(/:id/channel): failed to retrieve channel of user 1 because it doesn't exist with status 404 +[2025-08-22 17:20:25.400] [undefined] GET(/:id/history): try to retrieve history of user 1 +[2025-08-22 17:20:25.407] [undefined] GET(/:id/history): failed to retrieve history of user 1 because it doesn't exist with status 404 +[2025-08-22 17:20:25.418] [undefined] GET(/user/:id): Playlists retrieved for user with id 1 with status 200 diff --git a/backend/package-lock.json b/backend/package-lock.json index faa30e7..a7ab9bf 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,11 +14,14 @@ "cors": "^2.8.5", "dotenv": "^17.0.1", "express": "^5.1.0", + "express-session": "^1.18.2", "express-validator": "^7.2.1", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", "multer": "^2.0.1", "nodemailer": "^7.0.5", + "passport": "^0.7.0", + "passport-github2": "^0.1.12", "pg": "^8.16.3" }, "devDependencies": { @@ -1069,6 +1072,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -1925,6 +1937,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/express-validator": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", @@ -3153,6 +3205,12 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3186,6 +3244,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3243,6 +3310,63 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-github2": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz", + "integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3306,6 +3430,11 @@ "node": ">= 14.16" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -3534,6 +3663,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -4292,6 +4430,24 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -4321,6 +4477,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/validator": { "version": "13.12.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", diff --git a/backend/package.json b/backend/package.json index 28e7f10..c7247bd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,11 +18,14 @@ "cors": "^2.8.5", "dotenv": "^17.0.1", "express": "^5.1.0", + "express-session": "^1.18.2", "express-validator": "^7.2.1", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", "multer": "^2.0.1", "nodemailer": "^7.0.5", + "passport": "^0.7.0", + "passport-github2": "^0.1.12", "pg": "^8.16.3" }, "devDependencies": { diff --git a/backend/server.js b/backend/server.js index 6140769..ffd5df4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -11,6 +11,10 @@ import PlaylistRoute from "./app/routes/playlist.route.js"; import {initDb} from "./app/utils/database.js"; import MediaRoutes from "./app/routes/media.routes.js"; import SearchRoute from "./app/routes/search.route.js"; +import OAuthRoute from "./app/routes/oauth.route.js"; +import session from "express-session"; +import passport from "passport"; +import { Strategy as GitHubStrategy } from "passport-github2"; console.clear(); dotenv.config(); @@ -23,6 +27,39 @@ app.use(express.urlencoded({extended: true, limit: '500mb'})); app.use(express.json({limit: '500mb'})); app.use(cors()) +app.use(session({ + secret: "your-secret", + resave: false, + saveUninitialized: false, +})); + +// --- Passport setup --- +app.use(passport.initialize()); +app.use(passport.session()); + +// Serialize user -> session +passport.serializeUser((user, done) => { + done(null, user); +}); + +// Deserialize session -> user +passport.deserializeUser((obj, done) => { + done(null, obj); +}); + +// --- GitHub Strategy --- + passport.use(new GitHubStrategy({ + clientID: process.env.GITHUB_ID, + clientSecret: process.env.GITHUB_SECRET, + callbackURL: "https://localhost/api/oauth/callback", + scope: ["user:email"] + }, + (accessToken, refreshToken, profile, done) => { + + return done(null, profile); + } + )); + // ROUTES app.use("/api/users/", UserRoute); app.use("/api/channels/", ChannelRoute); @@ -32,6 +69,7 @@ app.use("/api/playlists", PlaylistRoute); app.use("/api/recommendations", RecommendationRoute); app.use("/api/media", MediaRoutes); app.use("/api/search", SearchRoute); +app.use("/api/oauth", OAuthRoute); const port = process.env.PORT; diff --git a/docker-compose.yaml b/docker-compose.yaml index 66550d8..1f2014d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -18,6 +18,8 @@ services: PORT: ${BACKEND_PORT} GMAIL_USER: ${GMAIL_USER} GMAIL_PASSWORD: ${GMAIL_PASSWORD} + GITHUB_ID: ${GITHUB_ID} + GITHUB_SECRET: ${GITHUB_SECRET} volumes: - ./backend/logs:/var/log/freetube - ./backend:/app diff --git a/frontend/src/components/GitHubLoginButton.jsx b/frontend/src/components/GitHubLoginButton.jsx new file mode 100644 index 0000000..466d102 --- /dev/null +++ b/frontend/src/components/GitHubLoginButton.jsx @@ -0,0 +1,23 @@ +import oauthService from '../services/oauthService'; + +export default function GitHubLoginButton({ className = "" }) { + const handleGitHubLogin = () => { + oauthService.loginWithGitHub(); + }; + + return ( + + ); +} diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 2bbf860..a12365f 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; const AuthContext = createContext(); @@ -25,7 +25,7 @@ export const AuthProvider = ({ children }) => { setLoading(false); }, []); - const login = async (username, password) => { + const login = useCallback(async (username, password) => { try { const response = await fetch('/api/users/login', { method: 'POST', @@ -50,9 +50,9 @@ export const AuthProvider = ({ children }) => { } catch (error) { throw error; } - }; + }, []); - const register = async (email, username, password, profileImage) => { + const register = useCallback(async (email, username, password, profileImage) => { try { const formData = new FormData(); formData.append('email', email); @@ -81,22 +81,31 @@ export const AuthProvider = ({ children }) => { } catch (error) { throw error; } - }; + }, []); + + const loginWithOAuth = useCallback((userData, token) => { + console.log('OAuth login called with:', userData); + // Store token and user data + localStorage.setItem('token', token); + localStorage.setItem('user', JSON.stringify(userData)); + setUser(userData); + }, []); - const logout = () => { + const logout = useCallback(() => { localStorage.removeItem('token'); localStorage.removeItem('user'); setUser(null); - }; + }, []); - const getAuthHeaders = () => { + const getAuthHeaders = useCallback(() => { const token = localStorage.getItem('token'); return token ? { Authorization: `Bearer ${token}` } : {}; - }; + }, []); const value = { user, login, + loginWithOAuth, register, logout, getAuthHeaders, diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index d0d5851..9e13cb5 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import Navbar from '../components/Navbar'; +import GitHubLoginButton from '../components/GitHubLoginButton'; export default function Login() { const [formData, setFormData] = useState({ @@ -104,6 +105,7 @@ export default function Login() { + +
diff --git a/frontend/src/pages/LoginSuccess.jsx b/frontend/src/pages/LoginSuccess.jsx new file mode 100644 index 0000000..a70848a --- /dev/null +++ b/frontend/src/pages/LoginSuccess.jsx @@ -0,0 +1,59 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; + +export default function LoginSuccess() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { loginWithOAuth } = useAuth(); + const hasProcessed = useRef(false); + + useEffect(() => { + // Prevent multiple executions + if (hasProcessed.current) return; + + const token = searchParams.get('token'); + const userParam = searchParams.get('user'); + + console.log('Processing OAuth callback:', { token: !!token, userParam: !!userParam }); + + if (token && userParam) { + try { + hasProcessed.current = true; + + const userData = JSON.parse(decodeURIComponent(userParam)); + console.log('Parsed user data:', userData); + + // Use the OAuth login method to update auth context + loginWithOAuth(userData, token); + + // Small delay before navigation to ensure state is updated + setTimeout(() => { + navigate('/', { replace: true }); + }, 100); + + } catch (error) { + console.error('Error processing OAuth login:', error); + hasProcessed.current = true; + setTimeout(() => { + navigate('/login?error=invalid_data', { replace: true }); + }, 100); + } + } else { + console.log('Missing token or user data'); + hasProcessed.current = true; + setTimeout(() => { + navigate('/login?error=missing_data', { replace: true }); + }, 100); + } + }, []); // Remove dependencies to prevent re-runs + + return ( +
+
+
+

Finalisation de la connexion...

+
+
+ ); +} diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 0ca5ff8..108081e 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -4,6 +4,7 @@ import { useAuth } from '../contexts/AuthContext'; import Navbar from '../components/Navbar'; import EmailVerificationModal from '../modals/EmailVerificationModal'; import { verifyEmail } from '../services/user.service'; +import GitHubLoginButton from '../components/GitHubLoginButton'; export default function Register() { const [formData, setFormData] = useState({ @@ -236,6 +237,8 @@ export default function Register() { + +

Déjà un compte ?{' '} diff --git a/frontend/src/routes/routes.jsx b/frontend/src/routes/routes.jsx index 28d1f6a..0cde932 100644 --- a/frontend/src/routes/routes.jsx +++ b/frontend/src/routes/routes.jsx @@ -10,6 +10,7 @@ import AddVideo from "../pages/AddVideo.jsx"; import Search from "../pages/Search.jsx"; import Channel from "../pages/Channel.jsx"; import Playlist from "../pages/Playlist.jsx"; +import LoginSuccess from '../pages/LoginSuccess.jsx' const routes = [ { path: "/", element: }, @@ -88,6 +89,12 @@ const routes = [ ) + }, + { + path: "/login/success", + element: ( + + ) } ] diff --git a/frontend/src/services/oauthService.js b/frontend/src/services/oauthService.js new file mode 100644 index 0000000..b8d02b3 --- /dev/null +++ b/frontend/src/services/oauthService.js @@ -0,0 +1,36 @@ +const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://localhost/api'; + +export const oauthService = { + // Start GitHub OAuth flow + loginWithGitHub() { + window.location.href = `${API_BASE_URL}/oauth/github`; + }, + + // Get current user info using token + async getCurrentUser(token) { + const response = await fetch(`${API_BASE_URL}/oauth/me`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to get user info'); + } + + return response.json(); + }, + + // Verify if token is still valid + async verifyToken(token) { + try { + const userInfo = await this.getCurrentUser(token); + return userInfo; + } catch (error) { + return null; + } + } +}; + +export default oauthService;