diff --git a/server/config/env.js b/server/config/env.js index fd7f730..9fdf5a8 100644 --- a/server/config/env.js +++ b/server/config/env.js @@ -1,39 +1,39 @@ + import dotenv from "dotenv"; -dotenv.config(); +// FORCE load .env from correct path +dotenv.config({ path: "./.env" }); const requiredEnvVars = [ "PORT", "MONGO_URI", "JWT_SECRET", "JWT_EXPIRES_IN", - "GEMINI_API_KEY", "CLIENT_URL", "NODE_ENV", "SMTP_HOST", "SMTP_PORT", "SMTP_USER", - "SMTP_PASS" + "SMTP_PASS", + "GEMINI_API_KEY", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "GITHUB_CALLBACK_URL" ]; -const missingEnvVars = requiredEnvVars.filter(envVar => !process.env[envVar]); +const missingEnvVars = requiredEnvVars.filter( + (key) => !process.env[key] +); + +console.log("DEBUG ENV:", { + PORT: process.env.PORT, + MONGO_URI: process.env.MONGO_URI ? "OK" : "MISSING" +}); if (missingEnvVars.length > 0) { - throw new Error(`Missing required environment variables: ${missingEnvVars.join(", ")}`); + throw new Error( + `Missing required environment variables: ${missingEnvVars.join(", ")}` + ); } -export const env = { - PORT: process.env.PORT, - MONGO_URI: process.env.MONGO_URI, - JWT_SECRET: process.env.JWT_SECRET, - JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN, - GEMINI_API_KEY: process.env.GEMINI_API_KEY, - CLIENT_URL: process.env.CLIENT_URL, - NODE_ENV: process.env.NODE_ENV, - SMTP_HOST: process.env.SMTP_HOST, - SMTP_PORT: parseInt(process.env.SMTP_PORT, 10), - SMTP_USER: process.env.SMTP_USER, - SMTP_PASS: process.env.SMTP_PASS -}; - -export default env; +export default process.env; diff --git a/server/middlewares/rateLimiter.js b/server/middlewares/rateLimiter.js new file mode 100644 index 0000000..391771e --- /dev/null +++ b/server/middlewares/rateLimiter.js @@ -0,0 +1,15 @@ +import rateLimit from "express-rate-limit"; + +export const otpLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + + max: 10, + + message: { + success: false, + message: "Too many OTP requests. Please try again later.", + }, + + standardHeaders: true, + legacyHeaders: false, +}); \ No newline at end of file diff --git a/server/models/Otp.js b/server/models/Otp.js index ecff596..d368a50 100644 --- a/server/models/Otp.js +++ b/server/models/Otp.js @@ -1,10 +1,48 @@ + import mongoose from "mongoose"; const otpSchema = new mongoose.Schema({ - email: { type: String, required: true, lowercase: true, index: true }, - otp: { type: String, required: true }, // stored hashed - purpose: { type: String, enum: ["signup", "forgot-password"], required: true }, - createdAt: { type: Date, default: Date.now, expires: 600 } // TTL 10 minutes + email: { + type: String, + required: true, + lowercase: true, + index: true, + }, + + otp: { + type: String, + required: true, + }, + + purpose: { + type: String, + enum: ["signup", "forgot-password"], + required: true, + }, + + failedAttempts: { + type: Number, + default: 0, + }, + + lockUntil: { + type: Date, + default: null, + }, + + createdAt: { + type: Date, + default: Date.now, + expires: 600, + }, }); +otpSchema.index( + { email: 1, purpose: 1 }, + { unique: true } +); + export default mongoose.model("Otp", otpSchema); + + + diff --git a/server/modules/auth/repository.js b/server/modules/auth/repository.js index d4e0e38..a4d8bc7 100644 --- a/server/modules/auth/repository.js +++ b/server/modules/auth/repository.js @@ -1,7 +1,12 @@ + import User from "../../models/User.js"; import Otp from "../../models/Otp.js"; class AuthRepository { + // ========================= + // USER METHODS + // ========================= + static async createUser(userData) { const user = new User(userData); return await user.save(); @@ -27,7 +32,7 @@ class AuthRepository { return await User.findOneAndUpdate( { email }, { isVerified: true }, - { new: true } + { returnDocument: "after" } ); } @@ -35,42 +40,101 @@ class AuthRepository { return await User.findOneAndUpdate( { email }, { password: hashedPassword }, - { new: true } + { returnDocument: "after" } ); } static async updateUserGithubIdentity(userId, githubIdentity = {}) { const updateData = { - "oauth.github.id": githubIdentity.id, - "oauth.github.username": githubIdentity.username, - "oauth.github.profileUrl": githubIdentity.profileUrl, - "handles.github": githubIdentity.username, + "oauth.github.id": githubIdentity.id, + "oauth.github.username": githubIdentity.username, + "oauth.github.profileUrl": githubIdentity.profileUrl, + "handles.github": githubIdentity.username, }; if (githubIdentity.avatarUrl) { updateData["profile.avatar"] = githubIdentity.avatarUrl; } + if (githubIdentity.accessToken) { - updateData["oauth.github.accessToken"] = githubIdentity.accessToken; + updateData["oauth.github.accessToken"] = + githubIdentity.accessToken; } return await User.findByIdAndUpdate( userId, updateData, - { new: true, runValidators: true } + { + returnDocument: "after", + runValidators: true, + } ); } + // ========================= + // OTP METHODS + // ========================= + static async createOtp({ email, otp, purpose }) { - // Delete any existing OTPs for same email+purpose - await Otp.deleteMany({ email, purpose }); - - const otpRecord = new Otp({ email, otp, purpose }); - return await otpRecord.save(); + return await Otp.findOneAndUpdate( + { email, purpose }, + { + $set: { + otp, + failedAttempts: 0, + lockUntil: null, + createdAt: new Date(), + }, + }, + { + upsert: true, + new: true, + setDefaultsOnInsert: true, + } + ); } + // ========================= + // OTP FETCH + // ========================= + static async findOtp(email, purpose) { - return await Otp.findOne({ email, purpose }).sort({ createdAt: -1 }); + return await Otp.findOne({ email, purpose }); + } + + // ========================= + // OTP SAFETY METHODS + // ========================= + + static async incrementOtpFailure(email, purpose) { + return await Otp.findOneAndUpdate( + { email, purpose }, + { $inc: { failedAttempts: 1 } }, + { returnDocument: "after" } + ); + } + + static async lockOtp(email, purpose, lockMinutes = 15) { + return await Otp.findOneAndUpdate( + { email, purpose }, + { + lockUntil: new Date( + Date.now() + lockMinutes * 60 * 1000 + ), + }, + { returnDocument: "after" } + ); + } + + static async resetOtp(email, purpose) { + return await Otp.findOneAndUpdate( + { email, purpose }, + { + failedAttempts: 0, + lockUntil: null, + }, + { returnDocument: "after" } + ); } static async deleteOtp(email, purpose) { @@ -79,3 +143,4 @@ class AuthRepository { } export default AuthRepository; + diff --git a/server/modules/auth/routes.js b/server/modules/auth/routes.js index 2920816..0139bd5 100644 --- a/server/modules/auth/routes.js +++ b/server/modules/auth/routes.js @@ -1,4 +1,5 @@ import { Router } from "express"; + import { registerSchema, loginSchema, @@ -11,29 +12,92 @@ import { validate, validateQuery } from "./validation.js"; + import AuthController from "./controller.js"; + import authMiddleware from "../../middlewares/authMiddleware.js"; + +import { otpLimiter } from "../../middlewares/rateLimiter.js"; + import rateLimit from "express-rate-limit"; const router = Router(); + +// ========================= +// GitHub Connect Rate Limiter +// ========================= const githubConnectRateLimit = rateLimit({ windowMs: 60 * 1000, max: 20, + standardHeaders: true, legacyHeaders: false, + message: { success: false, message: "Too many GitHub connect attempts. Please try again shortly." } }); -router.post("/register", validate(registerSchema), AuthController.register); -router.post("/verify-otp", validate(verifyOtpSchema), AuthController.verifyOtp); -router.post("/login", validate(loginSchema), AuthController.login); -router.post("/forgot-password", validate(forgotPasswordSchema), AuthController.forgotPassword); -router.post("/reset-password", validate(resetPasswordSchema), AuthController.resetPassword); -router.post("/resend-otp", validate(resendOtpSchema), AuthController.resendOtp); -router.get("/github/start", validateQuery(githubStartSchema), AuthController.startGithubAuth); +// ========================= +// AUTH ROUTES +// ========================= + +// Register +router.post( + "/register", + validate(registerSchema), + AuthController.register +); + +// Verify OTP (Protected with rate limiter) +router.post( + "/verify-otp", + otpLimiter, + validate(verifyOtpSchema), + AuthController.verifyOtp +); + +// Login +router.post( + "/login", + validate(loginSchema), + AuthController.login +); + +// Forgot Password +router.post( + "/forgot-password", + validate(forgotPasswordSchema), + AuthController.forgotPassword +); + +// Reset Password +router.post( + "/reset-password", + validate(resetPasswordSchema), + AuthController.resetPassword +); + +// Resend OTP +router.post( + "/resend-otp", + validate(resendOtpSchema), + AuthController.resendOtp +); + +// ========================= +// GITHUB AUTH +// ========================= + +// Start GitHub OAuth +router.get( + "/github/start", + validateQuery(githubStartSchema), + AuthController.startGithubAuth +); + +// Connect GitHub Account router.get( "/github/connect", githubConnectRateLimit, @@ -41,6 +105,12 @@ router.get( validateQuery(githubStartSchema), AuthController.startGithubConnect ); -router.get("/github/callback", validateQuery(githubCallbackSchema), AuthController.githubCallback); -export default router; +// GitHub Callback +router.get( + "/github/callback", + validateQuery(githubCallbackSchema), + AuthController.githubCallback +); + +export default router; \ No newline at end of file diff --git a/server/modules/auth/service.js b/server/modules/auth/service.js index 2e71973..ac21e17 100644 --- a/server/modules/auth/service.js +++ b/server/modules/auth/service.js @@ -1,54 +1,91 @@ + import bcrypt from "bcryptjs"; -import crypto from "crypto"; import axios from "axios"; import jwt from "jsonwebtoken"; + import ApiError from "../../utils/ApiError.js"; import AuthRepository from "./repository.js"; + import { generateAccessToken } from "../../utils/tokenHelper.js"; import { generateOTP } from "../../utils/otpHelper.js"; -import { sendVerificationOTP, sendPasswordResetOTP } from "../../utils/emailService.js"; -const GITHUB_OAUTH_URL = "https://github.com/login/oauth/authorize"; -const GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; -const GITHUB_USER_URL = "https://api.github.com/user"; -const GITHUB_USER_EMAILS_URL = "https://api.github.com/user/emails"; -const GITHUB_SCOPE = "read:user user:email"; +import { + sendVerificationOTP, + sendPasswordResetOTP +} from "../../utils/emailService.js"; + +// ========================= +// CONSTANTS +// ========================= +const MAX_OTP_ATTEMPTS = 5; +const OTP_LOCK_MINUTES = 15; + +// ========================= +// GITHUB CONFIG +// ========================= +const GITHUB_OAUTH_URL = + "https://github.com/login/oauth/authorize"; + +const GITHUB_ACCESS_TOKEN_URL = + "https://github.com/login/oauth/access_token"; + +const GITHUB_USER_URL = + "https://api.github.com/user"; + +const GITHUB_SCOPE = + "read:user user:email"; class AuthService { + + // ========================= + // REGISTER + // ========================= static async register({ name, email, password }) { - // Check if user already exists - const existingUser = await AuthRepository.findUserByEmailWithoutPassword(email); + + const existingUser = + await AuthRepository.findUserByEmailWithoutPassword(email); + if (existingUser) { - throw new ApiError(409, "User already exists with this email"); + + if (!existingUser.isVerified) { + await existingUser.deleteOne(); + await AuthRepository.deleteOtp(email, "signup"); + } else { + throw new ApiError( + 409, + "User already exists with this email" + ); + } } - // Hash password - const hashedPassword = await bcrypt.hash(password, 10); + const hashedPassword = + await bcrypt.hash(password, 10); - // Create user - const user = await AuthRepository.createUser({ - name, - email, - password: hashedPassword, - isVerified: false - }); + const user = + await AuthRepository.createUser({ + name, + email, + password: hashedPassword, + isVerified: false + }); - // Generate OTP const plainOtp = generateOTP(); - const hashedOtp = await bcrypt.hash(plainOtp, 4); - // Store hashed OTP + const hashedOtp = + await bcrypt.hash(plainOtp, 4); + await AuthRepository.createOtp({ email, otp: hashedOtp, purpose: "signup" }); - // Send verification email with plain OTP await sendVerificationOTP(email, plainOtp); return { - message: "Registration successful. Please check your email for OTP verification.", + message: + "Registration successful. Please check your email for OTP verification.", + user: { id: user._id, name: user.name, @@ -58,36 +95,100 @@ class AuthService { }; } + // ========================= + // VERIFY OTP + // ========================= static async verifyOtp({ email, otp }) { - // Find OTP record - const otpRecord = await AuthRepository.findOtp(email, "signup"); + + const otpRecord = + await AuthRepository.findOtp(email, "signup"); + if (!otpRecord) { - throw new ApiError(400, "OTP expired or not found"); + throw new ApiError( + 400, + "OTP expired or not found" + ); } - // Compare OTP - const isValidOtp = await bcrypt.compare(otp, otpRecord.otp); + if ( + otpRecord.lockUntil && + otpRecord.lockUntil > new Date() + ) { + + throw new ApiError( + 429, + `Too many failed attempts. Try again after ${OTP_LOCK_MINUTES} minutes` + ); + } + + const isValidOtp = + await bcrypt.compare(otp, otpRecord.otp); + if (!isValidOtp) { - throw new ApiError(400, "Invalid OTP"); + + const updated = + await AuthRepository.incrementOtpFailure( + email, + "signup" + ); + + const attempts = + updated?.failedAttempts ?? 1; + + if (attempts >= MAX_OTP_ATTEMPTS) { + + await AuthRepository.lockOtp( + email, + "signup", + OTP_LOCK_MINUTES + ); + + throw new ApiError( + 429, + `Too many failed attempts. Try again after ${OTP_LOCK_MINUTES} minutes` + ); + } + + const remaining = + MAX_OTP_ATTEMPTS - attempts; + + throw new ApiError( + 400, + `Invalid OTP. ${remaining} attempts left` + ); } - // Mark user as verified - await AuthRepository.updateUserVerification(email); + await AuthRepository.resetOtp( + email, + "signup" + ); + + await AuthRepository.deleteOtp( + email, + "signup" + ); - // Delete OTP record - await AuthRepository.deleteOtp(email, "signup"); + await AuthRepository.updateUserVerification( + email + ); - // Get user and generate token - const user = await AuthRepository.findUserByEmailWithoutPassword(email); - const token = generateAccessToken({ - userId: user._id, - email: user.email, - role: user.role - }); + const user = + await AuthRepository.findUserByEmailWithoutPassword( + email + ); + + const token = + generateAccessToken({ + userId: user._id, + email: user.email, + role: user.role + }); return { message: "Email verified successfully", + token, + user: { id: user._id, name: user.name, @@ -98,134 +199,260 @@ class AuthService { }; } + // ========================= + // LOGIN + // ========================= static async login({ email, password }) { - // Find user with password - const user = await AuthRepository.findUserByEmail(email); + + const user = + await AuthRepository.findUserByEmail(email); + if (!user) { - throw new ApiError(401, "Invalid credentials"); + throw new ApiError( + 401, + "Invalid credentials" + ); } - // Compare password - const isValidPassword = await bcrypt.compare(password, user.password); - if (!isValidPassword) { - throw new ApiError(401, "Invalid credentials"); + const isValid = + await bcrypt.compare(password, user.password); + + if (!isValid) { + throw new ApiError( + 401, + "Invalid credentials" + ); } - // Check if verified if (!user.isVerified) { - throw new ApiError(403, "Please verify your email first"); + throw new ApiError( + 403, + "Please verify your email first" + ); } - // Generate token - const token = generateAccessToken({ - userId: user._id, - email: user.email, - role: user.role - }); + const token = + generateAccessToken({ + userId: user._id, + email: user.email, + role: user.role + }); - // Update last active user.activity.lastActive = new Date(); + await user.save(); return { message: "Login successful", token, - user: { - id: user._id, - name: user.name, - email: user.email, - role: user.role, - isVerified: user.isVerified, - profile: user.profile, - handles: user.handles - } + user }; } + // ========================= + // FORGOT PASSWORD + // ========================= static async forgotPassword({ email }) { - // Find user - const user = await AuthRepository.findUserByEmailWithoutPassword(email); + + const user = + await AuthRepository.findUserByEmailWithoutPassword(email); + if (!user) { - throw new ApiError(404, "User not found"); + throw new ApiError( + 404, + "User not found" + ); } - // Generate OTP const plainOtp = generateOTP(); - const hashedOtp = await bcrypt.hash(plainOtp, 4); - // Store hashed OTP + const hashedOtp = + await bcrypt.hash(plainOtp, 4); + await AuthRepository.createOtp({ email, otp: hashedOtp, purpose: "forgot-password" }); - // Send password reset email - await sendPasswordResetOTP(email, plainOtp); + await sendPasswordResetOTP( + email, + plainOtp + ); return { - message: "Password reset OTP sent to your email" + message: + "Password reset OTP sent to your email" }; } - static async resetPassword({ email, otp, newPassword }) { - // Find OTP record - const otpRecord = await AuthRepository.findOtp(email, "forgot-password"); + // ========================= + // RESET PASSWORD + // ========================= + static async resetPassword({ + email, + otp, + newPassword + }) { + + const otpRecord = + await AuthRepository.findOtp( + email, + "forgot-password" + ); + if (!otpRecord) { - throw new ApiError(400, "Invalid or expired OTP"); + throw new ApiError( + 400, + "Invalid or expired OTP" + ); + } + + // ========================= + // CHECK LOCK + // ========================= + if ( + otpRecord.lockUntil && + otpRecord.lockUntil > new Date() + ) { + throw new ApiError( + 429, + `Too many failed attempts. Try again after ${OTP_LOCK_MINUTES} minutes` + ); } - // Verify OTP - const isValidOtp = await bcrypt.compare(otp, otpRecord.otp); + const isValidOtp = + await bcrypt.compare( + otp, + otpRecord.otp + ); + + // ========================= + // WRONG OTP + // ========================= if (!isValidOtp) { - throw new ApiError(400, "Invalid or expired OTP"); + + const updated = + await AuthRepository.incrementOtpFailure( + email, + "forgot-password" + ); + + const attempts = + updated?.failedAttempts ?? 1; + + if (attempts >= MAX_OTP_ATTEMPTS) { + + await AuthRepository.lockOtp( + email, + "forgot-password", + OTP_LOCK_MINUTES + ); + + throw new ApiError( + 429, + `Too many failed attempts. Try again after ${OTP_LOCK_MINUTES} minutes` + ); + } + + throw new ApiError( + 400, + "Invalid or expired OTP" + ); } - // Hash new password - const hashedPassword = await bcrypt.hash(newPassword, 10); + // ========================= + // SUCCESS + // ========================= + await AuthRepository.resetOtp( + email, + "forgot-password" + ); + + const hashedPassword = + await bcrypt.hash(newPassword, 10); - // Update user password - await AuthRepository.updateUserPassword(email, hashedPassword); + await AuthRepository.updateUserPassword( + email, + hashedPassword + ); - // Delete OTP record - await AuthRepository.deleteOtp(email, "forgot-password"); + await AuthRepository.deleteOtp( + email, + "forgot-password" + ); return { - message: "Password reset successful" + message: + "Password reset successful" }; } - static async resendOtp({ email, purpose }) { - // Find user - const user = await AuthRepository.findUserByEmailWithoutPassword(email); + // ========================= + // RESEND OTP + // ========================= + static async resendOtp({ + email, + purpose + }) { + + const user = + await AuthRepository.findUserByEmailWithoutPassword(email); + if (!user) { - throw new ApiError(404, "User not found"); + throw new ApiError( + 404, + "User not found" + ); } - // Check if already verified for signup - if (purpose === "signup" && user.isVerified) { - throw new ApiError(400, "Already verified"); + if ( + purpose === "signup" && + user.isVerified + ) { + throw new ApiError( + 400, + "Already verified" + ); } - // Delete existing OTPs - await AuthRepository.deleteOtp(email, purpose); + const existingOtp = + await AuthRepository.findOtp( + email, + purpose + ); + + if ( + existingOtp?.lockUntil && + existingOtp.lockUntil > new Date() + ) { + throw new ApiError( + 429, + `Too many failed attempts. Try again after ${OTP_LOCK_MINUTES} minutes` + ); + } - // Generate new OTP const plainOtp = generateOTP(); - const hashedOtp = await bcrypt.hash(plainOtp, 4); - // Store hashed OTP + const hashedOtp = + await bcrypt.hash(plainOtp, 4); + await AuthRepository.createOtp({ email, otp: hashedOtp, purpose }); - // Send appropriate email if (purpose === "signup") { - await sendVerificationOTP(email, plainOtp); + await sendVerificationOTP( + email, + plainOtp + ); } else { - await sendPasswordResetOTP(email, plainOtp); + await sendPasswordResetOTP( + email, + plainOtp + ); } return { @@ -233,119 +460,67 @@ class AuthService { }; } - static getGithubAuthorizationUrl({ mode = "login", userId = null, redirectPath }) { - this.#assertGithubOAuthConfig(); - - const safeMode = mode === "connect" ? "connect" : "login"; - const defaultRedirectPath = safeMode === "connect" ? "/account-center" : "/login"; - const state = this.#buildGithubStateToken({ - mode: safeMode, - userId, - redirectPath: redirectPath || defaultRedirectPath - }); - - const query = new URLSearchParams({ - client_id: process.env.GITHUB_CLIENT_ID, - redirect_uri: process.env.GITHUB_CALLBACK_URL, - scope: GITHUB_SCOPE, - state, - allow_signup: "true" - }); - - return `${GITHUB_OAUTH_URL}?${query.toString()}`; - } + // ========================= + // GITHUB AUTH + // ========================= + static getGithubAuthorizationUrl({ + mode = "login", + userId = null, + redirectPath + }) { + + const safeMode = + mode === "connect" + ? "connect" + : "login"; + + const state = jwt.sign( + { + mode: safeMode, + userId, + redirectPath + }, + process.env.JWT_SECRET, + { expiresIn: "10m" } + ); - static async handleGithubCallback({ code, state }) { - this.#assertGithubOAuthConfig(); + const query = + new URLSearchParams({ + client_id: + process.env.GITHUB_CLIENT_ID, - const decodedState = this.#verifyGithubStateToken(state); - const githubToken = await this.#exchangeGithubCodeForToken(code); - const githubProfile = await this.#fetchGithubUserProfile(githubToken); - const githubEmail = await this.#resolveGithubEmail(githubToken, githubProfile?.email); + redirect_uri: + process.env.GITHUB_CALLBACK_URL, - if (decodedState.mode === "connect") { - const connectedUser = await this.#connectGithubForAuthenticatedUser({ - userId: decodedState.userId, - githubProfile, - githubToken + scope: GITHUB_SCOPE, + state }); - return { - message: "GitHub account connected successfully", - redirectUrl: this.#buildFrontendRedirectUrl( - decodedState.redirectPath || "/account-center", - { - githubStatus: "connected", - githubUsername: connectedUser?.oauth?.github?.username || githubProfile?.login || "" - } - ) - }; - } - - const user = await this.#findOrCreateGithubUser({ - githubProfile, - githubEmail, - githubToken - }); - - const token = generateAccessToken({ - userId: user._id, - email: user.email, - role: user.role - }); - - return { - message: "GitHub authentication successful", - token, - user: this.#sanitizeUser(user), - redirectUrl: this.#buildFrontendRedirectUrl( - decodedState.redirectPath || "/login", - { - authProvider: "github", - authStatus: "success" - }, - { - token - } - ) - }; + return `${GITHUB_OAUTH_URL}?${query}`; } - static #assertGithubOAuthConfig() { - const required = ["GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GITHUB_CALLBACK_URL", "CLIENT_URL"]; - const missing = required.filter((key) => !process.env[key]); + static async handleGithubCallback({ + code, + state + }) { - if (missing.length > 0) { - throw new ApiError( - 500, - `Missing GitHub OAuth environment variables: ${missing.join(", ")}` + const decoded = + jwt.verify( + state, + process.env.JWT_SECRET ); - } - } - static #buildGithubStateToken(payload) { - const secret = process.env.GITHUB_STATE_SECRET || process.env.JWT_SECRET; - return jwt.sign(payload, secret, { expiresIn: "10m" }); - } - - static #verifyGithubStateToken(token) { - try { - const secret = process.env.GITHUB_STATE_SECRET || process.env.JWT_SECRET; - return jwt.verify(token, secret); - } catch { - throw new ApiError(400, "Invalid or expired GitHub OAuth state"); - } - } - - static async #exchangeGithubCodeForToken(code) { - try { - const response = await axios.post( + const tokenRes = + await axios.post( GITHUB_ACCESS_TOKEN_URL, { - client_id: process.env.GITHUB_CLIENT_ID, - client_secret: process.env.GITHUB_CLIENT_SECRET, - code, - redirect_uri: process.env.GITHUB_CALLBACK_URL + client_id: + process.env.GITHUB_CLIENT_ID, + + client_secret: + process.env.GITHUB_CLIENT_SECRET, + + code }, { headers: { @@ -354,182 +529,53 @@ class AuthService { } ); - const accessToken = response?.data?.access_token; - if (!accessToken) { - throw new ApiError(400, "GitHub did not return an access token"); - } - return accessToken; - } catch (error) { - if (error instanceof ApiError) throw error; - throw new ApiError(400, "Failed to exchange GitHub code for access token"); - } - } + const accessToken = + tokenRes.data.access_token; - static async #fetchGithubUserProfile(accessToken) { - try { - const response = await axios.get(GITHUB_USER_URL, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github+json", - "User-Agent": "CodeLens-App" - } - }); - - return response.data; - } catch { - throw new ApiError(400, "Failed to fetch GitHub profile"); - } - } - - static async #resolveGithubEmail(accessToken, profileEmail) { - if (profileEmail) return profileEmail; - - try { - const response = await axios.get(GITHUB_USER_EMAILS_URL, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github+json", - "User-Agent": "CodeLens-App" + const profileRes = + await axios.get( + GITHUB_USER_URL, + { + headers: { + Authorization: + `Bearer ${accessToken}` + } } - }); - - const emails = Array.isArray(response.data) ? response.data : []; - const preferredEmail = - emails.find((entry) => entry.primary && entry.verified)?.email || - emails.find((entry) => entry.verified)?.email || - emails[0]?.email; - - return preferredEmail || null; - } catch { - return null; - } - } + ); - static async #connectGithubForAuthenticatedUser({ userId, githubProfile, githubToken }) { - if (!userId) { - throw new ApiError(400, "Missing user information in GitHub connect flow"); - } + const githubProfile = + profileRes.data; - const existingByGithub = await AuthRepository.findUserByGithubId(String(githubProfile.id)); - if (existingByGithub && String(existingByGithub._id) !== String(userId)) { - throw new ApiError(409, "This GitHub account is already linked to another user"); - } + const user = + await AuthRepository.findUserByGithubId( + githubProfile.id + ); - const user = await AuthRepository.findUserById(userId); if (!user) { - throw new ApiError(404, "User not found"); - } - - const linkedUser = await AuthRepository.updateUserGithubIdentity(userId, { - id: String(githubProfile.id), - username: githubProfile.login, - profileUrl: githubProfile.html_url, - avatarUrl: githubProfile.avatar_url, - accessToken: githubToken, - }); - - return linkedUser; - } - - static async #findOrCreateGithubUser({ githubProfile, githubEmail, githubToken }) { - const githubId = String(githubProfile.id); - const githubUsername = githubProfile.login; - const githubProfileUrl = githubProfile.html_url; - const githubAvatar = githubProfile.avatar_url; - - let user = await AuthRepository.findUserByGithubId(githubId); - if (user) { - user.activity.lastActive = new Date(); - await user.save(); - return user; - } - - if (githubEmail) { - const existingByEmail = await AuthRepository.findUserByEmailWithoutPassword(githubEmail); - if (existingByEmail) { - const linked = await AuthRepository.updateUserGithubIdentity(existingByEmail._id, { - id: githubId, - username: githubUsername, - profileUrl: githubProfileUrl, - avatarUrl: githubAvatar, - accessToken: githubToken, - }); - linked.activity.lastActive = new Date(); - await linked.save(); - return linked; - } - } - - if (!githubEmail) { throw new ApiError( - 422, - "GitHub account does not expose an email. Please make your email available or connect from an existing account." + 401, + "GitHub account not linked" ); } - const generatedPassword = crypto.randomBytes(32).toString("hex"); - const hashedPassword = await bcrypt.hash(generatedPassword, 10); - - user = await AuthRepository.createUser({ - name: githubProfile.name || githubUsername, - email: githubEmail, - password: hashedPassword, - isVerified: true, - authProvider: "github", - profile: { avatar: githubAvatar || "" }, - handles: { github: githubUsername }, - oauth: { - github: { - id: githubId, - username: githubUsername, - profileUrl: githubProfileUrl, - accessToken: githubToken, - } - }, - activity: { lastActive: new Date() } - }); - - return user; - } + const appToken = + generateAccessToken({ + userId: user._id, + email: user.email, + role: user.role + }); - static #sanitizeUser(user) { return { - id: user._id, - name: user.name, - email: user.email, - role: user.role, - isVerified: user.isVerified, - authProvider: user.authProvider, - profile: user.profile, - handles: user.handles, - oauth: user.oauth - }; - } - - static #buildFrontendRedirectUrl(path, query = {}, fragment = {}) { - const baseUrl = process.env.CLIENT_URL || "http://localhost:5173"; - const redirect = new URL(path || "/login", baseUrl); - - Object.entries(query).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - redirect.searchParams.set(key, String(value)); - } - }); - - const fragmentParams = new URLSearchParams(); - Object.entries(fragment).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - fragmentParams.set(key, String(value)); - } - }); - - const fragmentString = fragmentParams.toString(); - if (fragmentString) { - redirect.hash = fragmentString; - } + message: + decoded.mode === "connect" + ? "GitHub account connected successfully" + : "GitHub login successful", - return redirect.toString(); + token: appToken, + user + }; } } export default AuthService; + diff --git a/server/modules/auth/validation.js b/server/modules/auth/validation.js index e43e7e4..360adc9 100644 --- a/server/modules/auth/validation.js +++ b/server/modules/auth/validation.js @@ -1,3 +1,4 @@ + import { z } from "zod"; export const registerSchema = z.object({ @@ -43,20 +44,20 @@ export const githubCallbackSchema = z.object({ export const validate = (schema) => { return (req, res, next) => { const result = schema.safeParse(req.body); - + if (!result.success) { - const errors = result.error.errors.map(err => ({ + const errors = result.error.issues.map(err => ({ field: err.path.join("."), message: err.message })); - + return res.status(400).json({ success: false, message: "Validation failed", errors }); } - + req.validatedData = result.data; next(); }; @@ -67,7 +68,7 @@ export const validateQuery = (schema) => { const result = schema.safeParse(req.query); if (!result.success) { - const errors = result.error.errors.map(err => ({ + const errors = result.error.issues.map(err => ({ field: err.path.join("."), message: err.message })); @@ -83,3 +84,4 @@ export const validateQuery = (schema) => { next(); }; }; + diff --git a/server/package-lock.json b/server/package-lock.json index f315bd4..e5ea564 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -79,9 +79,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -91,13 +91,12 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -107,9 +106,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -125,9 +124,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@types/node": { @@ -202,16 +201,42 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -624,7 +649,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -744,9 +768,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -1480,9 +1504,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", - "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -1633,24 +1657,24 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", + "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -2102,9 +2126,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2127,7 +2151,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }