Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 59s
370 lines
15 KiB
TypeScript
370 lines
15 KiB
TypeScript
// src/routes/passport.routes.ts
|
|
import passport from 'passport';
|
|
import { Strategy as LocalStrategy } from 'passport-local';
|
|
//import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
|
//import { Strategy as GitHubStrategy } from 'passport-github2';
|
|
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { Request, Response, NextFunction } from 'express';
|
|
|
|
import * as db from '../services/db/index.db';
|
|
import { logger } from '../services/logger.server';
|
|
import { UserProfile } from '../types';
|
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
|
import { ForbiddenError } from '../services/db/errors.db';
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET!;
|
|
|
|
const MAX_FAILED_ATTEMPTS = 5;
|
|
const LOCKOUT_DURATION_MINUTES = 15;
|
|
|
|
/**
|
|
* A type guard to check if an object is a UserProfile.
|
|
* This is useful for safely accessing properties on `req.user`.
|
|
* @param user The user object to check.
|
|
* @returns True if the object is a UserProfile, false otherwise.
|
|
*/
|
|
function isUserProfile(user: unknown): user is UserProfile {
|
|
return (
|
|
typeof user === 'object' &&
|
|
user !== null &&
|
|
'role' in user &&
|
|
'user' in user &&
|
|
typeof (user as { user: unknown }).user === 'object' &&
|
|
(user as { user: unknown }).user !== null &&
|
|
'user_id' in ((user as { user: unknown }).user as object)
|
|
);
|
|
}
|
|
|
|
// --- Passport Local Strategy (for email/password login) ---
|
|
passport.use(
|
|
new LocalStrategy(
|
|
{
|
|
usernameField: 'email',
|
|
passReqToCallback: true, // Pass the request object to the callback
|
|
},
|
|
async (req: Request, email, password, done) => {
|
|
try {
|
|
// 1. Find the user by email, including their profile data for the JWT payload.
|
|
const userprofile = await db.userRepo.findUserWithProfileByEmail(email, req.log);
|
|
|
|
if (!userprofile) {
|
|
// User not found
|
|
logger.warn(`Login attempt failed for non-existent user: ${email}`);
|
|
return done(null, false, { message: 'Incorrect email or password.' });
|
|
}
|
|
|
|
// Check if the account is currently locked.
|
|
if (
|
|
userprofile.failed_login_attempts >= MAX_FAILED_ATTEMPTS &&
|
|
userprofile.last_failed_login
|
|
) {
|
|
const lockoutTime = new Date(userprofile.last_failed_login).getTime();
|
|
const timeSinceLockout = Date.now() - lockoutTime;
|
|
const lockoutDurationMs = LOCKOUT_DURATION_MINUTES * 60 * 1000;
|
|
|
|
if (timeSinceLockout < lockoutDurationMs) {
|
|
logger.warn(`Login attempt for locked account: ${email}`);
|
|
// Refresh the lockout timestamp on each attempt to prevent probing.
|
|
await db.adminRepo.incrementFailedLoginAttempts(userprofile.user.user_id, req.log);
|
|
return done(null, false, {
|
|
message: `Account is temporarily locked. Please try again in ${LOCKOUT_DURATION_MINUTES} minutes.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!userprofile.password_hash) {
|
|
// User exists but signed up via OAuth, so they don't have a password.
|
|
logger.warn(`Password login attempt for OAuth user: ${email}`);
|
|
return done(null, false, {
|
|
message:
|
|
'This account was created using a social login. Please use Google or GitHub to sign in.',
|
|
});
|
|
}
|
|
|
|
// 2. Compare the submitted password with the hashed password in your DB.
|
|
logger.debug(
|
|
`[Passport] Verifying password for ${email}. Hash length: ${userprofile.password_hash.length}`,
|
|
);
|
|
const isMatch = await bcrypt.compare(password, userprofile.password_hash);
|
|
logger.debug(`[Passport] Password match result: ${isMatch}`);
|
|
|
|
if (!isMatch) {
|
|
// Password does not match
|
|
logger.warn(`Login attempt failed for user ${email} due to incorrect password.`);
|
|
// Increment failed attempts and get the new count.
|
|
const newAttemptCount = await db.adminRepo.incrementFailedLoginAttempts(
|
|
userprofile.user.user_id,
|
|
req.log,
|
|
);
|
|
|
|
// Log this security event.
|
|
await db.adminRepo.logActivity(
|
|
{
|
|
userId: userprofile.user.user_id,
|
|
action: 'login_failed_password',
|
|
displayText: `Failed login attempt for user ${userprofile.user.email}.`,
|
|
icon: 'shield-alert',
|
|
details: { source_ip: req.ip ?? null, new_attempt_count: newAttemptCount }, // The user.email is correct here as it's part of the Omit type
|
|
},
|
|
req.log,
|
|
);
|
|
|
|
// If this attempt just locked the account, inform the user immediately.
|
|
if (newAttemptCount >= MAX_FAILED_ATTEMPTS) {
|
|
return done(null, false, {
|
|
message: `Account is temporarily locked. Please try again in ${LOCKOUT_DURATION_MINUTES} minutes.`,
|
|
});
|
|
}
|
|
|
|
return done(null, false, { message: 'Incorrect email or password.' });
|
|
}
|
|
|
|
// 3. Success! Return the user object (without password_hash for security).
|
|
// Reset failed login attempts upon successful login.
|
|
await db.adminRepo.resetFailedLoginAttempts(
|
|
userprofile.user.user_id,
|
|
req.ip ?? 'unknown',
|
|
req.log,
|
|
);
|
|
|
|
logger.info(`User successfully authenticated: ${email}`);
|
|
|
|
// The `user` object from `findUserWithProfileByEmail` is now a fully formed
|
|
// UserProfile object with additional authentication fields. We must strip these
|
|
// sensitive fields before passing the profile to the session.
|
|
// The `...userProfile` rest parameter will contain the clean UserProfile object,
|
|
// which no longer has a top-level email property.
|
|
const {
|
|
password_hash,
|
|
failed_login_attempts,
|
|
last_failed_login,
|
|
refresh_token,
|
|
...cleanUserProfile
|
|
} = userprofile;
|
|
return done(null, cleanUserProfile);
|
|
} catch (err: unknown) {
|
|
req.log.error({ error: err }, 'Error during local authentication strategy:');
|
|
return done(err);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
// --- Passport Google OAuth 2.0 Strategy ---
|
|
// passport.use(new GoogleStrategy({
|
|
// clientID: process.env.GOOGLE_CLIENT_ID!,
|
|
// clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
// callbackURL: '/api/auth/google/callback', // Must match the one in Google Cloud Console
|
|
// scope: ['profile', 'email']
|
|
// },
|
|
// async (accessToken, refreshToken, profile, done) => {
|
|
// try {
|
|
// const email = profile.emails?.[0]?.value;
|
|
// if (!email) {
|
|
// return done(new Error("No email found in Google profile."), false);
|
|
// }
|
|
|
|
// // Check if user already exists in our database
|
|
// const user = await db.findUserByEmail(email); // Changed to const as 'user' is not reassigned
|
|
|
|
// if (user) {
|
|
// // User exists, proceed to log them in.
|
|
// logger.info(`Google OAuth successful for existing user: ${email}`);
|
|
// // The password_hash is intentionally destructured and discarded for security.
|
|
// const { password_hash, ...userWithoutHash } = user;
|
|
// return done(null, userWithoutHash);
|
|
// } else {
|
|
// // User does not exist, create a new account for them.
|
|
// logger.info(`Google OAuth: creating new user for email: ${email}`);
|
|
|
|
// // Since this is an OAuth user, they don't have a password.
|
|
// // We pass `null` for the password hash.
|
|
// const newUser = await db.createUser(email, null, {
|
|
// full_name: profile.displayName,
|
|
// avatar_url: profile.photos?.[0]?.value
|
|
// });
|
|
|
|
// // Send a welcome email to the new user
|
|
// try {
|
|
// await sendWelcomeEmail(email, profile.displayName);
|
|
// } catch (emailError) {
|
|
// logger.error(`Failed to send welcome email to new Google user ${email}`, { error: emailError });
|
|
// // Don't block the login flow if email fails.
|
|
// }
|
|
|
|
// // The `createUser` function returns the user object without the password hash.
|
|
// return done(null, newUser);
|
|
// }
|
|
// } catch (err) {
|
|
// logger.error('Error during Google authentication strategy:', { error: err });
|
|
// return done(err, false);
|
|
// }
|
|
// }
|
|
// ));
|
|
|
|
// --- Passport GitHub OAuth 2.0 Strategy ---
|
|
// passport.use(new GitHubStrategy({
|
|
// clientID: process.env.GITHUB_CLIENT_ID!,
|
|
// clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
// callbackURL: '/api/auth/github/callback', // Must match the one in GitHub OAuth App settings
|
|
// scope: ['user:email'] // Request email access
|
|
// },
|
|
// async (accessToken, refreshToken, profile, done) => {
|
|
// try {
|
|
// const email = profile.emails?.[0]?.value;
|
|
// if (!email) {
|
|
// return done(new Error("No public email found in GitHub profile. Please ensure your primary email is public or add one."), false);
|
|
// }
|
|
|
|
// // Check if user already exists in our database
|
|
// const user = await db.findUserByEmail(email); // Changed to const as 'user' is not reassigned
|
|
|
|
// if (user) {
|
|
// // User exists, proceed to log them in.
|
|
// logger.info(`GitHub OAuth successful for existing user: ${email}`);
|
|
// // The password_hash is intentionally destructured and discarded for security.
|
|
// const { password_hash, ...userWithoutHash } = user;
|
|
// return done(null, userWithoutHash);
|
|
// } else {
|
|
// // User does not exist, create a new account for them.
|
|
// logger.info(`GitHub OAuth: creating new user for email: ${email}`);
|
|
|
|
// // Since this is an OAuth user, they don't have a password.
|
|
// // We pass `null` for the password hash.
|
|
// const newUser = await db.createUser(email, null, {
|
|
// full_name: profile.displayName || profile.username, // GitHub profile might not have displayName
|
|
// avatar_url: profile.photos?.[0]?.value
|
|
// });
|
|
|
|
// // Send a welcome email to the new user
|
|
// try {
|
|
// await sendWelcomeEmail(email, profile.displayName || profile.username);
|
|
// } catch (emailError) {
|
|
// logger.error(`Failed to send welcome email to new GitHub user ${email}`, { error: emailError });
|
|
// // Don't block the login flow if email fails.
|
|
// }
|
|
|
|
// // The `createUser` function returns the user object without the password hash.
|
|
// return done(null, newUser);
|
|
// }
|
|
// } catch (err) {
|
|
// logger.error('Error during GitHub authentication strategy:', { error: err });
|
|
// return done(err, false);
|
|
// }
|
|
// }
|
|
// ));
|
|
|
|
// --- Passport JWT Strategy (for protecting API routes) ---
|
|
const jwtOptions = {
|
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Expect JWT in 'Authorization: Bearer <token>' header
|
|
secretOrKey: JWT_SECRET,
|
|
};
|
|
|
|
// --- DEBUG LOGGING FOR JWT SECRET ---
|
|
if (!JWT_SECRET) {
|
|
logger.fatal('[Passport] CRITICAL: JWT_SECRET is missing or empty in environment variables! JwtStrategy will fail.');
|
|
} else {
|
|
logger.info(`[Passport] JWT_SECRET loaded successfully (length: ${JWT_SECRET.length}).`);
|
|
}
|
|
|
|
passport.use(
|
|
new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
|
|
logger.debug(
|
|
{ jwt_payload: jwt_payload ? { user_id: jwt_payload.user_id } : 'null' },
|
|
'[JWT Strategy] Verifying token payload:',
|
|
);
|
|
try {
|
|
// The jwt_payload contains the data you put into the token during login (e.g., { user_id: user.user_id, email: user.email }).
|
|
// We re-fetch the user from the database here to ensure they are still active and valid.
|
|
const userProfile = await db.userRepo.findUserProfileById(jwt_payload.user_id, logger);
|
|
|
|
// --- JWT STRATEGY DEBUG LOGGING ---
|
|
logger.debug(
|
|
`[JWT Strategy] DB lookup for user ID ${jwt_payload.user_id} result: ${userProfile ? 'FOUND' : 'NOT FOUND'}`,
|
|
);
|
|
|
|
if (userProfile) {
|
|
return done(null, userProfile); // User profile object will be available as req.user in protected routes
|
|
} else {
|
|
logger.warn(`JWT authentication failed: user with ID ${jwt_payload.user_id} not found.`);
|
|
return done(null, false); // User not found or invalid token
|
|
}
|
|
} catch (err: unknown) {
|
|
logger.error({ error: err }, 'Error during JWT authentication strategy:');
|
|
return done(err, false);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// --- Middleware for Admin Role Check ---
|
|
export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
|
|
// Use the type guard for safer access to req.user
|
|
const userProfile = req.user;
|
|
|
|
if (isUserProfile(userProfile) && userProfile.role === 'admin') {
|
|
next();
|
|
} else {
|
|
// Check if userProfile is a valid UserProfile before accessing its properties for logging.
|
|
const userIdForLog = isUserProfile(userProfile) ? userProfile.user.user_id : 'unknown';
|
|
logger.warn(`Admin access denied for user: ${userIdForLog}`);
|
|
next(new ForbiddenError('Forbidden: Administrator access required.'));
|
|
}
|
|
};
|
|
|
|
/**
|
|
* A flexible authentication middleware. It attempts to authenticate via JWT but does NOT
|
|
* reject the request if authentication fails. This allows routes to handle both
|
|
* authenticated and anonymous users. If a valid token is present, `req.user` will be
|
|
* populated; otherwise, it will be undefined, and the request proceeds.
|
|
*/
|
|
export const optionalAuth = (req: Request, res: Response, next: NextFunction) => {
|
|
// The custom callback for passport.authenticate gives us access to `err`, `user`, and `info`.
|
|
passport.authenticate(
|
|
'jwt',
|
|
{ session: false },
|
|
(err: Error | null, user: Express.User | false, info: { message: string } | Error) => {
|
|
if (err) {
|
|
// An actual error occurred during authentication (e.g., malformed token).
|
|
// For optional auth, we log this but still proceed without a user.
|
|
logger.warn({ error: err }, 'Optional auth encountered an error, proceeding anonymously.');
|
|
return next();
|
|
}
|
|
if (info) {
|
|
// The patch requested this specific error handling.
|
|
logger.info({ info: info.message || info.toString() }, 'Optional auth info:');
|
|
}
|
|
if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds.
|
|
|
|
next(); // Always proceed to the next middleware
|
|
},
|
|
)(req, res, next);
|
|
};
|
|
|
|
/**
|
|
* Mock Authentication Middleware for Testing
|
|
*
|
|
* This middleware is ONLY active when `process.env.NODE_ENV` is 'test'.
|
|
* It bypasses the entire JWT authentication flow and directly injects a
|
|
* mock user object into `req.user`. This is essential for integration tests,
|
|
* allowing protected routes to be tested without needing to generate valid JWTs
|
|
* or mock the passport strategy.
|
|
*
|
|
* In any environment other than 'test', it does nothing and immediately passes
|
|
* control to the next middleware.
|
|
*/
|
|
export const mockAuth = (req: Request, res: Response, next: NextFunction) => {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
// In a test environment, attach a mock user to the request.
|
|
// We use the mock factory to create a consistent, type-safe user profile.
|
|
// We override the default role to 'admin' for broad access in tests.
|
|
req.user = createMockUserProfile({
|
|
role: 'admin',
|
|
});
|
|
}
|
|
// In production or development, this middleware does nothing.
|
|
next();
|
|
};
|
|
|
|
export default passport;
|