start on migrating from Supabase to local Postgress, and passport.js for auth because CORS
This commit is contained in:
354
server.ts
Normal file
354
server.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import passport from 'passport';
|
||||
import { Strategy as LocalStrategy } from 'passport-local';
|
||||
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import dotenv from 'dotenv';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs/promises';
|
||||
import { findUserByEmail, createUser, findUserById, findUserProfileById, updateUserPreferences, updateUserPassword, deleteUserById, checkTablesExist, getPoolStatus, saveRefreshToken, findUserByRefreshToken } from './src/services/db';
|
||||
import { logger } from './src/services/logger';
|
||||
|
||||
// Load environment variables from a .env file at the root of your project
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json()); // Middleware to parse JSON request bodies
|
||||
app.use(cookieParser()); // Middleware to parse cookies
|
||||
app.use(passport.initialize()); // Initialize Passport
|
||||
|
||||
// --- Configuration ---
|
||||
// IMPORTANT: Use a strong, randomly generated secret key and store it securely
|
||||
// in your .env file, not hardcoded here.
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_jwt_key_change_this';
|
||||
if (JWT_SECRET === 'your_super_secret_jwt_key_change_this') {
|
||||
logger.warn('Security Warning: JWT_SECRET is using a default, insecure value. Please set a strong secret in your .env file.');
|
||||
}
|
||||
|
||||
// --- Passport Local Strategy (for email/password login) ---
|
||||
passport.use(new LocalStrategy(
|
||||
{ usernameField: 'email' }, // Tell Passport to expect 'email' instead of 'username'
|
||||
async (email, password, done) => {
|
||||
try {
|
||||
// 1. Find the user in your PostgreSQL database by email.
|
||||
const user = await findUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
// User not found
|
||||
logger.warn(`Login attempt failed for non-existent user: ${email}`);
|
||||
return done(null, false, { message: 'Incorrect email or password.' });
|
||||
}
|
||||
|
||||
// 2. Compare the submitted password with the hashed password in your DB.
|
||||
const isMatch = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isMatch) {
|
||||
// Password does not match
|
||||
logger.warn(`Login attempt failed for user ${email} due to incorrect password.`);
|
||||
return done(null, false, { message: 'Incorrect email or password.' });
|
||||
}
|
||||
|
||||
// 3. Success! Return the user object (without password_hash for security).
|
||||
const { password_hash, ...userWithoutHash } = user;
|
||||
logger.info(`User successfully authenticated: ${email}`);
|
||||
return done(null, userWithoutHash);
|
||||
} catch (err) {
|
||||
logger.error('Error during local authentication strategy:', { error: err });
|
||||
return done(err);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// --- 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) => {
|
||||
try {
|
||||
// The jwt_payload contains the data you put into the token during login (e.g., { id: user.id, email: user.email }).
|
||||
// We re-fetch the user from the database here to ensure they are still active and valid.
|
||||
const user = await findUserById(jwt_payload.id);
|
||||
|
||||
if (user) {
|
||||
return done(null, user); // User object will be available as req.user in protected routes
|
||||
} else {
|
||||
logger.warn(`JWT authentication failed: user with ID ${jwt_payload.id} not found.`);
|
||||
return done(null, false); // User not found or invalid token
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error during JWT authentication strategy:', { error: err });
|
||||
return done(err, false);
|
||||
}
|
||||
}));
|
||||
|
||||
// --- API Routes ---
|
||||
|
||||
// --- Health & System Check Routes ---
|
||||
app.get('/api/health/ping', (req: Request, res: Response) => {
|
||||
res.status(200).send('pong');
|
||||
});
|
||||
|
||||
app.get('/api/health/db-schema', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
|
||||
const missingTables = await checkTablesExist(requiredTables);
|
||||
|
||||
if (missingTables.length > 0) {
|
||||
return res.status(500).json({ success: false, message: `Database schema check failed. Missing tables: ${missingTables.join(', ')}.` });
|
||||
}
|
||||
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
|
||||
} catch (error) {
|
||||
logger.error('Error during DB schema check:', { error });
|
||||
return res.status(500).json({ success: false, message: 'An error occurred while checking the database schema.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/health/storage', async (req: Request, res: Response) => {
|
||||
// This path should be an absolute path on your server, configured via an environment variable.
|
||||
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
|
||||
try {
|
||||
await fs.access(storagePath, fs.constants.W_OK); // Check for write access
|
||||
return res.status(200).json({ success: true, message: `Storage directory '${storagePath}' is accessible and writable.` });
|
||||
} catch (error) {
|
||||
logger.error(`Storage check failed for path: ${storagePath}`, { error });
|
||||
return res.status(500).json({ success: false, message: `Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.` });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/health/db-pool', (req: Request, res: Response) => {
|
||||
try {
|
||||
const status = getPoolStatus();
|
||||
// A healthy pool should ideally have 0 waiting clients. A small number might be acceptable under load.
|
||||
const isHealthy = status.waitingCount < 5; // Arbitrary threshold for "healthy"
|
||||
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
|
||||
|
||||
if (isHealthy) {
|
||||
return res.status(200).json({ success: true, message });
|
||||
} else {
|
||||
logger.warn(`Database pool health check shows high waiting count: ${status.waitingCount}`);
|
||||
return res.status(500).json({ success: false, message: `Pool may be under stress. ${message}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during DB pool health check:', { error });
|
||||
return res.status(500).json({ success: false, message: 'An error occurred while checking the database pool status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Authentication Routes ---
|
||||
|
||||
// Registration Route
|
||||
app.post('/api/auth/register', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ message: 'Email and password are required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const existingUser = await findUserByEmail(email);
|
||||
if (existingUser) {
|
||||
logger.warn(`Registration attempt for existing email: ${email}`);
|
||||
return res.status(409).json({ message: 'User with that email already exists.' });
|
||||
}
|
||||
|
||||
// Hash the password before storing it
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
logger.info(`Hashing password for new user: ${email}`);
|
||||
|
||||
const newUser = await createUser(email, hashedPassword);
|
||||
logger.info(`Successfully created new user in DB: ${newUser.email} (ID: ${newUser.id})`);
|
||||
|
||||
// Immediately log in the user by issuing a JWT
|
||||
const payload = { id: newUser.id, email: newUser.email };
|
||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); // Token expires in 1 hour
|
||||
|
||||
// Generate and save a refresh token
|
||||
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||
await saveRefreshToken(newUser.id, refreshToken);
|
||||
|
||||
// Send the refresh token in a secure, HttpOnly cookie
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true, // The cookie is not accessible via client-side script
|
||||
secure: process.env.NODE_ENV === 'production', // Only send over HTTPS in production
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
return res.status(201).json({ message: 'User registered successfully!', user: payload, token });
|
||||
} catch (err) {
|
||||
logger.error('Error during /register route handling:', { error: err });
|
||||
return next(err); // Pass error to Express error handler
|
||||
}
|
||||
});
|
||||
|
||||
// Login Route
|
||||
app.post('/api/auth/login', (req: Request, res: Response, next: NextFunction) => {
|
||||
// Use passport.authenticate with the 'local' strategy
|
||||
// { session: false } because we're using JWTs, not server-side sessions
|
||||
passport.authenticate('local', { session: false }, (err: Error, user: Express.User | false, info: { message: string }) => {
|
||||
if (err) {
|
||||
logger.error('Login authentication error in /login route:', { error: err });
|
||||
return next(err); // Pass server errors to the error handler
|
||||
}
|
||||
if (!user) {
|
||||
// Authentication failed (e.g., incorrect credentials)
|
||||
return res.status(401).json({ message: info ? info.message : 'Login failed' });
|
||||
}
|
||||
|
||||
// User is authenticated, create and sign a JWT
|
||||
// The user object here is what was returned from the LocalStrategy's `done` callback
|
||||
const typedUser = user as { id: string; email: string };
|
||||
const payload = { id: typedUser.id, email: typedUser.email };
|
||||
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); // Short-lived access token
|
||||
|
||||
// Generate and save a refresh token
|
||||
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||
saveRefreshToken(typedUser.id, refreshToken).then(() => {
|
||||
logger.info(`JWT and refresh token issued for user: ${typedUser.email}`);
|
||||
|
||||
// Send the refresh token in a secure, HttpOnly cookie
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
return res.json({ user: payload, token: accessToken });
|
||||
}).catch(tokenErr => {
|
||||
logger.error('Failed to save refresh token during login:', { error: tokenErr });
|
||||
return next(tokenErr);
|
||||
});
|
||||
})(req, res, next); // This is crucial for Passport middleware to work correctly
|
||||
});
|
||||
|
||||
// New Route to refresh the access token
|
||||
app.post('/api/auth/refresh-token', async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.cookies;
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({ message: 'Refresh token not found.' });
|
||||
}
|
||||
|
||||
const user = await findUserByRefreshToken(refreshToken);
|
||||
if (!user) {
|
||||
return res.status(403).json({ message: 'Invalid refresh token.' });
|
||||
}
|
||||
|
||||
const payload = { id: user.id, email: user.email };
|
||||
const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||
|
||||
res.json({ token: newAccessToken });
|
||||
});
|
||||
|
||||
// Example Protected Route to get user profile
|
||||
// The frontend can call this route on startup to validate a stored JWT
|
||||
app.get('/api/users/profile', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => {
|
||||
// If JWT authentication is successful, req.user will contain the user object
|
||||
// from the JwtStrategy's `done` callback.
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
logger.info(`Profile requested for user: ${authenticatedUser.email}`);
|
||||
|
||||
try {
|
||||
const profile = await findUserProfileById(authenticatedUser.id);
|
||||
if (!profile) {
|
||||
logger.warn(`No profile found for authenticated user ID: ${authenticatedUser.id}`);
|
||||
return res.status(404).json({ message: 'Profile not found for this user.' });
|
||||
}
|
||||
res.json(profile); // Return the full profile object
|
||||
} catch (error) {
|
||||
logger.error('Error fetching profile in /api/users/profile:', { error });
|
||||
res.status(500).json({ message: 'Failed to retrieve user profile.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Protected Route to update user preferences
|
||||
app.put('/api/users/profile/preferences', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => {
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
const newPreferences = req.body;
|
||||
|
||||
if (!newPreferences || typeof newPreferences !== 'object') {
|
||||
return res.status(400).json({ message: 'Invalid preferences format. Body must be a JSON object.' });
|
||||
}
|
||||
|
||||
logger.info(`Preferences update requested for user: ${authenticatedUser.email}`, { newPreferences });
|
||||
|
||||
try {
|
||||
const updatedProfile = await updateUserPreferences(authenticatedUser.id, newPreferences);
|
||||
res.json(updatedProfile);
|
||||
} catch (error) {
|
||||
logger.error('Error updating preferences in /api/users/profile/preferences:', { error });
|
||||
res.status(500).json({ message: 'Failed to update user preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Protected Route to update user password
|
||||
app.put('/api/users/profile/password', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => {
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
const { newPassword } = req.body;
|
||||
|
||||
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 6) {
|
||||
return res.status(400).json({ message: 'Password must be a string of at least 6 characters.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||
logger.info(`Hashing new password for user: ${authenticatedUser.email}`);
|
||||
|
||||
await updateUserPassword(authenticatedUser.id, hashedPassword);
|
||||
|
||||
logger.info(`Successfully updated password for user: ${authenticatedUser.email}`);
|
||||
res.status(200).json({ message: 'Password updated successfully.' });
|
||||
} catch (error) {
|
||||
logger.error('Error during password update:', { error });
|
||||
res.status(500).json({ message: 'Failed to update password.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Protected Route to delete a user account
|
||||
app.delete('/api/users/account', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => {
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
const { password } = req.body;
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({ message: 'Password is required for account deletion.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Fetch the user from DB to get their current password hash for verification
|
||||
const userWithHash = await findUserByEmail(authenticatedUser.email);
|
||||
if (!userWithHash) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
|
||||
// 2. Compare the submitted password with the stored hash
|
||||
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
|
||||
if (!isMatch) {
|
||||
logger.warn(`Account deletion failed for user ${authenticatedUser.email} due to incorrect password.`);
|
||||
return res.status(403).json({ message: 'Incorrect password.' });
|
||||
}
|
||||
|
||||
// 3. If password matches, delete the user. The `ON DELETE CASCADE` in your schema will clean up related data.
|
||||
await deleteUserById(authenticatedUser.id);
|
||||
logger.warn(`User account deleted successfully: ${authenticatedUser.email}`);
|
||||
res.status(200).json({ message: 'Account deleted successfully.' });
|
||||
} catch (error) {
|
||||
logger.error('Error during account deletion:', { error });
|
||||
res.status(500).json({ message: 'Failed to delete account.' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Error Handling and Server Startup ---
|
||||
|
||||
// Basic error handling middleware
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
logger.error('Unhandled application error:', { error: err.stack });
|
||||
res.status(500).send('Something broke!');
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Authentication server started on port ${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user