Harden web session handling and production config
This commit is contained in:
@@ -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'));
|
||||||
|
|||||||
@@ -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'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 +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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user