Harden web session handling and production config

This commit is contained in:
2026-04-07 19:48:48 -05:00
parent 890f3fff2b
commit 0292b2e85a
9 changed files with 96 additions and 20 deletions
+1
View File
@@ -16,6 +16,7 @@ app.use(helmet());
app.use( app.use(
cors({ cors({
origin: env.corsOrigin, origin: env.corsOrigin,
credentials: true,
}) })
); );
app.use(morgan(env.nodeEnv === 'production' ? 'combined' : 'dev')); app.use(morgan(env.nodeEnv === 'production' ? 'combined' : 'dev'));
+35
View File
@@ -3,6 +3,40 @@ const dotenv = require('dotenv');
dotenv.config({ path: path.resolve(process.cwd(), '.env') }); dotenv.config({ path: path.resolve(process.cwd(), '.env') });
const requireEnv = (name) => {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
};
if (process.env.NODE_ENV === 'production') {
[
'JWT_SECRET',
'CORS_ORIGIN',
'FRONTEND_URL',
'DB_HOST',
'DB_NAME',
'DB_USER',
'DB_PASSWORD',
'STORAGE_PATH',
'MAIL_HOST',
'MAIL_PORT',
'MAIL_USER',
'MAIL_PASS',
'MAIL_FROM',
].forEach(requireEnv);
if (process.env.JWT_SECRET === 'change_me') {
throw new Error('JWT_SECRET cannot use the default insecure value in production.');
}
if ((process.env.FRONTEND_URL || '').includes('localhost')) {
throw new Error('FRONTEND_URL must not point to localhost in production.');
}
}
const env = { const env = {
port: Number(process.env.PORT) || 4000, port: Number(process.env.PORT) || 4000,
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || 'development',
@@ -50,6 +84,7 @@ const env = {
}, },
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173', frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
refreshCookieName: process.env.REFRESH_COOKIE_NAME || 'linkedsign_refresh',
androidApkPath: androidApkPath:
process.env.ANDROID_APK_PATH || path.resolve(process.cwd(), '..', 'android', 'apk', 'LinkedSign-debug.apk'), process.env.ANDROID_APK_PATH || path.resolve(process.cwd(), '..', 'android', 'apk', 'LinkedSign-debug.apk'),
}; };
+34 -2
View File
@@ -1,12 +1,41 @@
const authService = require('./auth.service'); const authService = require('./auth.service');
const usersService = require('../users/users.service'); const usersService = require('../users/users.service');
const { sendSuccess } = require('../../utils/apiResponse'); const { sendSuccess } = require('../../utils/apiResponse');
const env = require('../../config/env');
const getContext = (req) => ({ const getContext = (req) => ({
ip: req.ip?.replace('::ffff:', '') || null, ip: req.ip?.replace('::ffff:', '') || null,
userAgent: req.headers['user-agent'] || null, userAgent: req.headers['user-agent'] || null,
}); });
const getRefreshCookieOptions = () => ({
httpOnly: true,
secure: env.nodeEnv === 'production',
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
const readRefreshToken = (req) => {
const cookieHeader = req.headers.cookie || '';
const cookies = Object.fromEntries(
cookieHeader
.split(';')
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const separatorIndex = entry.indexOf('=');
if (separatorIndex === -1) return [entry, ''];
return [
decodeURIComponent(entry.slice(0, separatorIndex)),
decodeURIComponent(entry.slice(separatorIndex + 1)),
];
})
);
return cookies[env.refreshCookieName] || req.body?.refreshToken || null;
};
const register = async (req, res, next) => { const register = async (req, res, next) => {
try { try {
if (!req.file) { if (!req.file) {
@@ -33,6 +62,7 @@ const register = async (req, res, next) => {
const login = async (req, res, next) => { const login = async (req, res, next) => {
try { try {
const result = await authService.login(req.body, getContext(req)); const result = await authService.login(req.body, getContext(req));
res.cookie(env.refreshCookieName, result.refreshToken, getRefreshCookieOptions());
return sendSuccess(res, result, 200); return sendSuccess(res, result, 200);
} catch (err) { } catch (err) {
return next(err); return next(err);
@@ -41,7 +71,8 @@ const login = async (req, res, next) => {
const refresh = async (req, res, next) => { const refresh = async (req, res, next) => {
try { try {
const result = await authService.refresh(req.body.refreshToken); const result = await authService.refresh(readRefreshToken(req));
res.cookie(env.refreshCookieName, result.refreshToken, getRefreshCookieOptions());
return sendSuccess(res, result, 200); return sendSuccess(res, result, 200);
} catch (err) { } catch (err) {
return next(err); return next(err);
@@ -50,7 +81,8 @@ const refresh = async (req, res, next) => {
const logout = async (req, res, next) => { const logout = async (req, res, next) => {
try { try {
await authService.logout(req.body.refreshToken); await authService.logout(readRefreshToken(req));
res.clearCookie(env.refreshCookieName, getRefreshCookieOptions());
return sendSuccess(res, { message: 'Logged out.' }, 200); return sendSuccess(res, { message: 'Logged out.' }, 200);
} catch (err) { } catch (err) {
return next(err); return next(err);
+10 -4
View File
@@ -1,4 +1,7 @@
const db = require('../../config/db'); const db = require('../../config/db');
const crypto = require('crypto');
const hashRefreshToken = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
const ensureSchema = async () => { const ensureSchema = async () => {
const columns = [ const columns = [
@@ -144,22 +147,25 @@ const updateUnlockToken = async (userId, unlockToken, unlockTokenExpires) => {
}; };
const saveRefreshToken = async (userId, token, expiresAt) => { const saveRefreshToken = async (userId, token, expiresAt) => {
const hashedToken = hashRefreshToken(token);
await db.execute( await db.execute(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)', 'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)',
[userId, token, expiresAt] [userId, hashedToken, expiresAt]
); );
}; };
const findRefreshToken = async (token) => { const findRefreshToken = async (token) => {
const hashedToken = hashRefreshToken(token);
const [rows] = await db.execute( const [rows] = await db.execute(
'SELECT rt.*, u.id as uid, u.email, u.name, u.role, u.is_verified, u.is_active FROM refresh_tokens rt JOIN users u ON rt.user_id = u.id WHERE rt.token = ? AND rt.expires_at > NOW()', 'SELECT rt.*, u.id as uid, u.email, u.name, u.role, u.is_verified, u.is_active FROM refresh_tokens rt JOIN users u ON rt.user_id = u.id WHERE rt.token IN (?, ?) AND rt.expires_at > NOW()',
[token] [hashedToken, token]
); );
return rows[0] || null; return rows[0] || null;
}; };
const deleteRefreshToken = async (token) => { const deleteRefreshToken = async (token) => {
await db.execute('DELETE FROM refresh_tokens WHERE token = ?', [token]); const hashedToken = hashRefreshToken(token);
await db.execute('DELETE FROM refresh_tokens WHERE token IN (?, ?)', [hashedToken, token]);
}; };
const deleteUserRefreshTokens = async (userId) => { const deleteUserRefreshTokens = async (userId) => {
+7
View File
@@ -267,6 +267,12 @@ const login = async ({ email, password }, context = {}) => {
}; };
const refresh = async (token) => { const refresh = async (token) => {
if (!token) {
const err = new Error('Invalid or expired refresh token.');
err.statusCode = 401;
throw err;
}
const record = await authRepository.findRefreshToken(token); const record = await authRepository.findRefreshToken(token);
if (!record) { if (!record) {
const err = new Error('Invalid or expired refresh token.'); const err = new Error('Invalid or expired refresh token.');
@@ -293,6 +299,7 @@ const refresh = async (token) => {
}; };
const logout = async (token) => { const logout = async (token) => {
if (!token) return;
await authRepository.deleteRefreshToken(token); await authRepository.deleteRefreshToken(token);
}; };
+1 -1
View File
@@ -35,7 +35,7 @@ const loginSchema = Joi.object({
}); });
const refreshSchema = Joi.object({ const refreshSchema = Joi.object({
refreshToken: Joi.string().required(), refreshToken: Joi.string().optional(),
}); });
const changePasswordSchema = Joi.object({ const changePasswordSchema = Joi.object({
+1 -1
View File
@@ -6,7 +6,7 @@ export const register = (data) => client.post('/auth/register', data, {
}, },
}); });
export const login = (data) => client.post('/auth/login', data); export const login = (data) => client.post('/auth/login', data);
export const logout = (refreshToken) => client.post('/auth/logout', { refreshToken }); export const logout = () => client.post('/auth/logout', {});
export const getMe = () => client.get('/auth/me'); export const getMe = () => client.get('/auth/me');
export const verifyEmail = (token) => client.get(`/auth/verify/${token}`); export const verifyEmail = (token) => client.get(`/auth/verify/${token}`);
export const changePassword = (data) => client.patch('/auth/change-password', data); export const changePassword = (data) => client.patch('/auth/change-password', data);
+5 -6
View File
@@ -5,6 +5,7 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
const client = axios.create({ const client = axios.create({
baseURL: API_URL, baseURL: API_URL,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
withCredentials: true,
}); });
client.interceptors.request.use((config) => { client.interceptors.request.use((config) => {
@@ -19,18 +20,16 @@ client.interceptors.response.use(
const original = error.config; const original = error.config;
if (error.response?.status === 401 && !original._retry) { if (error.response?.status === 401 && !original._retry) {
original._retry = true; original._retry = true;
const refreshToken = localStorage.getItem('refreshToken'); const hadAccessToken = Boolean(localStorage.getItem('accessToken'));
if (refreshToken) { if (hadAccessToken) {
try { try {
const { data } = await axios.post(`${API_URL}/auth/refresh`, { refreshToken }); const { data } = await axios.post(`${API_URL}/auth/refresh`, {}, { withCredentials: true });
const { accessToken, refreshToken: newRefresh } = data.data; const { accessToken } = data.data;
localStorage.setItem('accessToken', accessToken); localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefresh);
original.headers.Authorization = `Bearer ${accessToken}`; original.headers.Authorization = `Bearer ${accessToken}`;
return client(original); return client(original);
} catch { } catch {
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login'; window.location.href = '/login';
} }
} }
+2 -6
View File
@@ -15,7 +15,6 @@ export function AuthProvider({ children }) {
.then(({ data }) => setUser(data.data.user)) .then(({ data }) => setUser(data.data.user))
.catch(() => { .catch(() => {
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} else { } else {
@@ -25,18 +24,15 @@ export function AuthProvider({ children }) {
const login = useCallback(async (email, password) => { const login = useCallback(async (email, password) => {
const { data } = await apiLogin({ email, password }); const { data } = await apiLogin({ email, password });
const { accessToken, refreshToken, user: userData } = data.data; const { accessToken, user: userData } = data.data;
localStorage.setItem('accessToken', accessToken); localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(userData); setUser(userData);
return userData; return userData;
}, []); }, []);
const logout = useCallback(async () => { const logout = useCallback(async () => {
const refreshToken = localStorage.getItem('refreshToken'); try { await apiLogout(); } catch { /* ignore */ }
try { await apiLogout(refreshToken); } catch { /* ignore */ }
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null); setUser(null);
}, []); }, []);