Files
flyer-crawler.projectium.com/src/routes/passport.routes.ts
2025-12-21 23:39:13 -08:00

357 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';
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,
};
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}`);
res.status(403).json({ message: '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 there's an authentication error (e.g., malformed token), log it but don't block the request.
if (info) {
// The patch requested this specific error handling.
logger.info({ info: info.message || info.toString() }, 'Optional auth info:');
} // The patch requested this specific error handling.
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;