feat: implement secure 2-token authentication with rolling sessions
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m43s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m43s
- Refactored POST /auth/login to issue a 15-minute Access Token and a 30-day Refresh Token. - Added POST /auth/refresh endpoint to automatically issue new Access Tokens and extend the Refresh Token's lifespan by 30 days upon use (Sliding Expiration). - Built an HTTP interceptor wrapper (apiFetch) in dataService.ts that automatically catches 401 Unauthorized errors, calls the refresh endpoint, updates localStorage, and silently retries the original request without logging the user out.
This commit is contained in:
@@ -236,8 +236,78 @@ apiRouter.post('/auth/login', async (req, res) => {
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' });
|
||||
|
||||
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '24h' });
|
||||
res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } });
|
||||
// Generate Access Token (short-lived)
|
||||
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '15m' });
|
||||
|
||||
// Generate Refresh Token (long-lived)
|
||||
const refreshToken = crypto.randomBytes(40).toString('hex');
|
||||
const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`;
|
||||
|
||||
// Store Refresh Token in database (expires in 30 days)
|
||||
await pool.query(
|
||||
'INSERT INTO refresh_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
||||
[refreshId, user.id, refreshToken]
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
refreshToken,
|
||||
user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug }
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh Token
|
||||
apiRouter.post('/auth/refresh', async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' });
|
||||
|
||||
try {
|
||||
// Verifies if the token exists and hasn't expired
|
||||
const [tokens] = await pool.query(
|
||||
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE r.token = ? AND r.expires_at > NOW()',
|
||||
[refreshToken]
|
||||
);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
// If invalid, optionally delete the bad token if it's just expired
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
|
||||
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
|
||||
}
|
||||
|
||||
const user = tokens[0];
|
||||
|
||||
if (user.status !== 'active') {
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
|
||||
return res.status(403).json({ error: 'Sua conta está inativa.' });
|
||||
}
|
||||
|
||||
// Sliding Expiration: Extend the refresh token's life by another 30 days
|
||||
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token = ?', [refreshToken]);
|
||||
|
||||
// Issue a new short-lived access token
|
||||
const newAccessToken = jwt.sign(
|
||||
{ id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
res.json({ token: newAccessToken });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout (Revoke Refresh Token)
|
||||
apiRouter.post('/auth/logout', async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
|
||||
}
|
||||
res.json({ message: 'Logout bem-sucedido.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -1562,6 +1632,20 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// Create refresh_tokens table for persistent sessions
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id varchar(36) NOT NULL,
|
||||
user_id varchar(36) NOT NULL,
|
||||
token varchar(255) NOT NULL,
|
||||
expires_at timestamp NOT NULL,
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY token (token),
|
||||
KEY user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// Add funnel_id to teams
|
||||
try {
|
||||
await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL");
|
||||
|
||||
Reference in New Issue
Block a user