diff --git a/backend/src/app.js b/backend/src/app.js index d747dae..f617761 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -16,6 +16,7 @@ app.use(helmet()); app.use( cors({ origin: env.corsOrigin, + credentials: true, }) ); app.use(morgan(env.nodeEnv === 'production' ? 'combined' : 'dev')); diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 6251f74..29e45dc 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -3,6 +3,40 @@ const dotenv = require('dotenv'); 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 = { port: Number(process.env.PORT) || 4000, nodeEnv: process.env.NODE_ENV || 'development', @@ -50,6 +84,7 @@ const env = { }, frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173', + refreshCookieName: process.env.REFRESH_COOKIE_NAME || 'linkedsign_refresh', androidApkPath: process.env.ANDROID_APK_PATH || path.resolve(process.cwd(), '..', 'android', 'apk', 'LinkedSign-debug.apk'), }; diff --git a/backend/src/modules/auth/auth.controller.js b/backend/src/modules/auth/auth.controller.js index 819a45f..6e13713 100644 --- a/backend/src/modules/auth/auth.controller.js +++ b/backend/src/modules/auth/auth.controller.js @@ -1,12 +1,41 @@ const authService = require('./auth.service'); const usersService = require('../users/users.service'); const { sendSuccess } = require('../../utils/apiResponse'); +const env = require('../../config/env'); const getContext = (req) => ({ ip: req.ip?.replace('::ffff:', '') || 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) => { try { if (!req.file) { @@ -33,6 +62,7 @@ const register = async (req, res, next) => { const login = async (req, res, next) => { try { const result = await authService.login(req.body, getContext(req)); + res.cookie(env.refreshCookieName, result.refreshToken, getRefreshCookieOptions()); return sendSuccess(res, result, 200); } catch (err) { return next(err); @@ -41,7 +71,8 @@ const login = async (req, res, next) => { const refresh = async (req, res, next) => { 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); } catch (err) { return next(err); @@ -50,7 +81,8 @@ const refresh = async (req, res, next) => { const logout = async (req, res, next) => { try { - await authService.logout(req.body.refreshToken); + await authService.logout(readRefreshToken(req)); + res.clearCookie(env.refreshCookieName, getRefreshCookieOptions()); return sendSuccess(res, { message: 'Logged out.' }, 200); } catch (err) { return next(err); diff --git a/backend/src/modules/auth/auth.repository.js b/backend/src/modules/auth/auth.repository.js index 6881ccf..a61e365 100644 --- a/backend/src/modules/auth/auth.repository.js +++ b/backend/src/modules/auth/auth.repository.js @@ -1,4 +1,7 @@ const db = require('../../config/db'); +const crypto = require('crypto'); + +const hashRefreshToken = (token) => crypto.createHash('sha256').update(String(token)).digest('hex'); const ensureSchema = async () => { const columns = [ @@ -144,22 +147,25 @@ const updateUnlockToken = async (userId, unlockToken, unlockTokenExpires) => { }; const saveRefreshToken = async (userId, token, expiresAt) => { + const hashedToken = hashRefreshToken(token); await db.execute( 'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)', - [userId, token, expiresAt] + [userId, hashedToken, expiresAt] ); }; const findRefreshToken = async (token) => { + const hashedToken = hashRefreshToken(token); 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()', - [token] + '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()', + [hashedToken, token] ); return rows[0] || null; }; 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) => { diff --git a/backend/src/modules/auth/auth.service.js b/backend/src/modules/auth/auth.service.js index d65ddfc..f163c7a 100644 --- a/backend/src/modules/auth/auth.service.js +++ b/backend/src/modules/auth/auth.service.js @@ -267,6 +267,12 @@ const login = async ({ email, password }, context = {}) => { }; 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); if (!record) { const err = new Error('Invalid or expired refresh token.'); @@ -293,6 +299,7 @@ const refresh = async (token) => { }; const logout = async (token) => { + if (!token) return; await authRepository.deleteRefreshToken(token); }; diff --git a/backend/src/modules/auth/auth.validator.js b/backend/src/modules/auth/auth.validator.js index 24e5bed..27b266c 100644 --- a/backend/src/modules/auth/auth.validator.js +++ b/backend/src/modules/auth/auth.validator.js @@ -35,7 +35,7 @@ const loginSchema = Joi.object({ }); const refreshSchema = Joi.object({ - refreshToken: Joi.string().required(), + refreshToken: Joi.string().optional(), }); const changePasswordSchema = Joi.object({ diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index dce091e..74e0d3a 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -6,7 +6,7 @@ export const register = (data) => client.post('/auth/register', 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 verifyEmail = (token) => client.get(`/auth/verify/${token}`); export const changePassword = (data) => client.patch('/auth/change-password', data); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index c3b8411..6b6384e 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -5,6 +5,7 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000'; const client = axios.create({ baseURL: API_URL, headers: { 'Content-Type': 'application/json' }, + withCredentials: true, }); client.interceptors.request.use((config) => { @@ -19,18 +20,16 @@ client.interceptors.response.use( const original = error.config; if (error.response?.status === 401 && !original._retry) { original._retry = true; - const refreshToken = localStorage.getItem('refreshToken'); - if (refreshToken) { + const hadAccessToken = Boolean(localStorage.getItem('accessToken')); + if (hadAccessToken) { try { - const { data } = await axios.post(`${API_URL}/auth/refresh`, { refreshToken }); - const { accessToken, refreshToken: newRefresh } = data.data; + const { data } = await axios.post(`${API_URL}/auth/refresh`, {}, { withCredentials: true }); + const { accessToken } = data.data; localStorage.setItem('accessToken', accessToken); - localStorage.setItem('refreshToken', newRefresh); original.headers.Authorization = `Bearer ${accessToken}`; return client(original); } catch { localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); window.location.href = '/login'; } } diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 9643ed6..8aa0384 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -15,7 +15,6 @@ export function AuthProvider({ children }) { .then(({ data }) => setUser(data.data.user)) .catch(() => { localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); }) .finally(() => setLoading(false)); } else { @@ -25,18 +24,15 @@ export function AuthProvider({ children }) { const login = useCallback(async (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('refreshToken', refreshToken); setUser(userData); return userData; }, []); const logout = useCallback(async () => { - const refreshToken = localStorage.getItem('refreshToken'); - try { await apiLogout(refreshToken); } catch { /* ignore */ } + try { await apiLogout(); } catch { /* ignore */ } localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); setUser(null); }, []);