Files
panel/includes/auth.js
2025-06-16 14:25:25 -04:00

282 lines
11 KiB
JavaScript

import unirest from 'unirest';
import { randomBytes, createHmac } from 'crypto';
import { RateLimiterMemory } from 'rate-limiter-flexible';
import sanitizeHtml from 'sanitize-html';
import helmet from 'helmet';
import csurf from 'csurf';
// Environment variable validation
const requiredEnvVars = [
'ADMIN_SECRET_KEY',
'AUTH_ENDPOINT',
'AUTH_PASSWORD',
'AUTO_LOGIN_LINK_PREFIX',
'AUTO_LOGIN_REDIRECT_URL',
'LINK_ID_BYTES',
'LINK_EXPIRY_SECONDS',
'TEMP_LINKS_CLEANUP_INTERVAL_MS'
];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
console.log(`Missing required environment variable: ${envVar}`);
process.exit(1);
}
}
// Initialize security middleware
const csrfProtection = csurf({ cookie: { secure: true, httpOnly: true, sameSite: 'strict' } });
const rateLimiter = new RateLimiterMemory({
points: 10,
duration: 60 // 10 requests per minute
});
const temporaryLinks = new Map();
// Secure cleanup interval
const cleanupInterval = Math.max(60000, parseInt(process.env.TEMP_LINKS_CLEANUP_INTERVAL_MS, 10));
setInterval(() => {
const now = Date.now();
for (const [linkId, linkData] of temporaryLinks.entries()) {
if (linkData.expiresAt < now) {
temporaryLinks.delete(linkId);
console.log(`Cleaned up expired link: ${linkId}`);
}
}
}, cleanupInterval);
// Input sanitization and validation
const sanitizeInput = (input) => {
if (typeof input !== 'string') {
console.log(`Invalid input type: expected string, got ${typeof input}`);
return null;
}
// Allow alphanumeric characters and underscores
if (!/^[a-zA-Z0-9_]+$/.test(input)) {
console.log(`Invalid input format: ${input}`);
return null;
}
return sanitizeHtml(input);
};
// Generate secure token
const generateSecureToken = (bytes) => {
const buffer = randomBytes(Math.max(16, parseInt(bytes, 10)));
const hmac = createHmac('sha256', process.env.ADMIN_SECRET_KEY);
return hmac.update(buffer).digest('hex');
};
// Check if IP is local (for development)
const isLocalIp = (ip) => {
return (
ip === '127.0.0.1' ||
ip === '::1' ||
ip.startsWith('192.168.') ||
ip.startsWith('10.')
);
};
// Generate nonce for CSP
const generateNonce = () => {
return randomBytes(16).toString('base64');
};
export async function generateLoginLink(req, res) {
// Apply security headers
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"]
}
},
xFrameOptions: { action: 'deny' }
})(req, res, async () => {
try {
// Rate limiting
await rateLimiter.consume(req.ip);
// CSRF protection
csrfProtection(req, res, async () => {
const { secretKey, username } = req.body;
// Validate inputs
if (!sanitizeInput(secretKey) || secretKey !== process.env.ADMIN_SECRET_KEY) {
console.log(`Invalid secret key attempt from IP: ${req.ip}`);
return res.status(401).json({ error: 'Unauthorized' });
}
const sanitizedUsername = sanitizeInput(username);
if (!sanitizedUsername) {
console.log(`Invalid username attempt from IP: ${req.ip}, username: ${username}`);
return res.status(400).json({ error: 'Invalid username' });
}
// Secure API request
const tokenResponse = await unirest
.post(process.env.AUTH_ENDPOINT)
.headers({
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Request-ID': generateSecureToken(16)
})
.send({ username: sanitizedUsername, password: process.env.AUTH_PASSWORD })
.timeout(5000);
if (!tokenResponse.body.token) {
console.log(`Failed to generate API key for username: ${sanitizedUsername}`);
return res.status(500).json({ error: 'Authentication service error' });
}
const apiKey = tokenResponse.body.token;
const linkId = generateSecureToken(process.env.LINK_ID_BYTES);
const loginLink = `${process.env.AUTO_LOGIN_LINK_PREFIX}${linkId}`;
// Store link data with additional security metadata
temporaryLinks.set(linkId, {
apiKey,
username: sanitizedUsername,
expiresAt: Date.now() + Math.min(3600000, parseInt(process.env.LINK_EXPIRY_SECONDS, 10) * 1000),
ip: req.ip,
userAgent: req.get('User-Agent') || 'Unknown'
});
// Secure timeout
setTimeout(() => {
temporaryLinks.delete(linkId);
console.log(`Expired link removed: ${linkId}`);
}, Math.min(3600000, parseInt(process.env.LINK_EXPIRY_SECONDS, 10) * 1000));
console.log(`Generated login link for username: ${sanitizedUsername} from IP: ${req.ip}, userAgent: ${req.get('User-Agent') || 'Unknown'}`);
res.json({ loginLink });
});
} catch (error) {
console.log(`Error generating login link: ${error.message}`);
res.status(500).json({ error: 'Server error' });
}
});
}
export function handleAutoLogin(req, res) {
// Generate nonce for CSP
const nonce = generateNonce();
// Apply security headers with nonce
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'", `'nonce-${nonce}'`]
}
},
xFrameOptions: { action: 'deny' }
})(req, res, () => {
const { linkId } = req.params;
const sanitizedLinkId = sanitizeInput(linkId);
const linkData = temporaryLinks.get(sanitizedLinkId);
if (!linkData || linkData.expiresAt < Date.now()) {
temporaryLinks.delete(sanitizedLinkId);
console.log(`Expired or invalid login attempt for link: ${sanitizedLinkId} from IP: ${req.ip}`);
return res.send(`
<html>
<head>
<meta http-equiv="refresh" content="3;url=${encodeURI(process.env.AUTO_LOGIN_REDIRECT_URL)}">
<meta name="robots" content="noindex">
<style>
body { display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #111827; font-family: 'Arial', sans-serif; }
.notification { background-color: #1f2937; color: white; padding: 16px; border-radius: 8px; display: flex; flex-direction: column; align-items: center; gap: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); max-width: 400px; width: 100%; }
h1 { font-size: 2.25em; color: white; text-align: center; margin: 0; }
.spinner { border: 4px solid rgba(255, 255, 255, 0.3); border-top: 4px solid #ffffff; border-radius: 50%; width: 24px; height: 24px; animation: spin 1s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="notification">
<span class="spinner"></span>
<h1>Login Expired</h1>
<h1>Redirecting...</h1>
</div>
</body>
</html>
`);
}
// Verify client consistency
const isIpMatch = linkData.ip === req.ip;
const isUserAgentMatch = linkData.userAgent === (req.get('User-Agent') || 'Unknown');
const isLocal = isLocalIp(req.ip) && isLocalIp(linkData.ip);
const strictUserAgentCheck = process.env.STRICT_USER_AGENT_CHECK === 'true';
if (strictUserAgentCheck && !isUserAgentMatch && !isLocal) {
temporaryLinks.delete(sanitizedLinkId);
console.log(
`Suspicious login attempt for link: ${sanitizedLinkId} from IP: ${req.ip}, ` +
`expected IP: ${linkData.ip}, isLocal: ${isLocal}, ` +
`userAgentMatch: ${isUserAgentMatch}, ` +
`expectedUserAgent: ${linkData.userAgent}, ` +
`actualUserAgent: ${req.get('User-Agent') || 'Unknown'}`
);
return res.status(403).json({ error: 'Invalid session' });
}
if (!isUserAgentMatch) {
console.log(
`Non-critical user-agent mismatch for link: ${sanitizedLinkId} from IP: ${req.ip}, ` +
`expectedUserAgent: ${linkData.userAgent}, ` +
`actualUserAgent: ${req.get('User-Agent') || 'Unknown'}`
);
}
temporaryLinks.delete(sanitizedLinkId);
console.log(`Successful auto-login for username: ${linkData.username} from IP: ${req.ip}, userAgent: ${req.get('User-Agent') || 'Unknown'}`);
// Secure API key storage with additional client-side security and debugging
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Secure Auto Login</title>
<meta name="robots" content="noindex">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-${nonce}'">
<meta http-equiv="refresh" content="5;url=/">
<style>
body { display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #111827; font-family: 'Arial', sans-serif; }
.notification { background-color: #1f2937; color: white; padding: 16px; border-radius: 8px; display: flex; flex-direction: column; align-items: center; gap: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); max-width: 400px; width: 100%; }
h1 { font-size: 2.25em; color: white; text-align: center; margin: 0; }
.spinner { border: 4px solid rgba(255, 255, 255, 0.3); border-top: 4px solid #ffffff; border-radius: 50%; width: 24px; height: 24px; animation: spin 1s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="notification">
<span class="spinner"></span>
<h1>Securely logging in...</h1>
</div>
<script nonce="${nonce}">
(function() {
console.log('Auto-login script started');
const apiKey = '${sanitizeHtml(linkData.apiKey)}';
console.log('API key retrieved');
try {
localStorage.setItem('apiKey', apiKey);
console.log('API key stored in localStorage');
sessionStorage.setItem('sessionTimestamp', Date.now());
console.log('Session timestamp stored');
window.location.href = '/';
console.log('Redirect initiated to /');
} catch (e) {
console.error('Storage error:', e.message);
window.location.href = '${encodeURI(process.env.AUTO_LOGIN_REDIRECT_URL)}';
console.log('Fallback redirect initiated to AUTO_LOGIN_REDIRECT_URL');
}
})();
</script>
</body>
</html>
`);
});
}