Files
Nexus/web/app/api.js
T
Your Name 95aa8a610f security: move refresh token to HttpOnly cookie
- Refresh token stored in HttpOnly + Secure + SameSite=Lax cookie
  instead of localStorage (XSS can no longer steal it)
- Login sets cookie, refresh reads from cookie, logout clears cookie
- Access token remains in localStorage (short-lived, 30min)
- Clear pendingPassword from memory after TOTP success/back
- Remove "admin" from username placeholder
- Cookie path scoped to /api/auth (minimal exposure)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-25 20:22:34 +08:00

193 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Nexus — Shared API auth module (JWT Bearer + auto-refresh via HttpOnly cookie)
// Include before page-specific scripts: <script src="/app/api.js"></script>
const API = window.location.origin + '/api';
function _getTokens() {
return {
access: localStorage.getItem('access_token') || '',
// Refresh token is in HttpOnly cookie — not accessible from JS
expires: parseInt(localStorage.getItem('token_expires') || '0'),
};
}
function _isTokenExpiring() {
// Refresh if token expires within 2 minutes
return Date.now() > _getTokens().expires - 120000;
}
async function _refreshTokens() {
try {
// Refresh token is auto-sent as HttpOnly cookie by browser (same-origin)
const res = await fetch(API + '/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) return false;
const data = await res.json();
if (!data.success) return false;
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('token_expires', String(Date.now() + (data.expires_in || 1800) * 1000));
return true;
} catch {
return false;
}
}
function apiHeaders() {
const { access } = _getTokens();
return { 'Authorization': 'Bearer ' + access };
}
function apiHeadersJSON() {
return { ...apiHeaders(), 'Content-Type': 'application/json' };
}
async function apiFetch(url, options = {}) {
// Auto-refresh if token is about to expire
if (_isTokenExpiring()) {
const refreshed = await _refreshTokens();
if (!refreshed) {
_logout();
return null;
}
}
// Add auth headers
options.headers = { ...apiHeaders(), ...(options.headers || {}) };
const res = await fetch(url, options);
// Record session activity on successful API calls
if (res.status !== 401) {
_recordActivity();
}
// If 401, try refresh once
if (res.status === 401) {
const refreshed = await _refreshTokens();
if (refreshed) {
options.headers = { ...apiHeaders(), ...(options.headers || {}) };
return fetch(url, options);
}
_logout();
return null;
}
return res;
}
function _logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('token_expires');
localStorage.removeItem('admin');
localStorage.removeItem('last_activity');
window.location.href = '/app/login.html';
}
function doLogout() {
// Notify backend (best-effort, don't block)
// Refresh token is in HttpOnly cookie — auto-sent by browser
const { access } = _getTokens();
if (access) {
try {
fetch(API + '/auth/logout', {
method: 'POST',
keepalive: true,
credentials: 'same-origin',
});
} catch {}
}
_logout();
}
// ── Session Inactivity Timeout ──
// Auto-logout after 8 hours of no API activity
const SESSION_MAX_AGE_MS = 8 * 60 * 60 * 1000; // 8 hours
function _checkSessionAge() {
const lastActivity = parseInt(localStorage.getItem('last_activity') || '0');
if (lastActivity && Date.now() - lastActivity > SESSION_MAX_AGE_MS) {
_logout();
return false;
}
return true;
}
function _recordActivity() {
localStorage.setItem('last_activity', String(Date.now()));
}
// Backward-compatible alias — try refresh cookie if no access token
const token = localStorage.getItem('access_token') || '';
if (!token) {
// No access token — try refresh via HttpOnly cookie (auto-sent by browser)
_refreshTokens().then(ok => {
if (!ok) _logout();
else _recordActivity();
});
} else if (!_checkSessionAge()) {
// Session expired — will redirect to login via _checkSessionAge → _logout
} else {
_recordActivity();
}
// ── Global Toast Notification System ──
// Call toast('success', '操作成功') or toast('error', '操作失败') from any page.
let _toastContainer = null;
let _toastId = 0;
function _ensureToastContainer() {
if (_toastContainer) return;
_toastContainer = document.createElement('div');
_toastContainer.id = 'toastContainer';
_toastContainer.className = 'fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none';
_toastContainer.style.maxWidth = '380px';
document.body.appendChild(_toastContainer);
}
function toast(type, message, duration) {
_ensureToastContainer();
duration = duration || 3000;
const id = ++_toastId;
const colors = {
success: 'bg-green-900/90 border-green-700/50 text-green-200',
error: 'bg-red-900/90 border-red-700/50 text-red-200',
warning: 'bg-yellow-900/90 border-yellow-700/50 text-yellow-200',
info: 'bg-blue-900/90 border-blue-700/50 text-blue-200',
};
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
const el = document.createElement('div');
el.id = 'toast-' + id;
el.className = `pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-xl border shadow-lg text-sm backdrop-blur-sm transition-all duration-300 opacity-0 translate-x-4 ${colors[type] || colors.info}`;
el.innerHTML = `<span class="text-base">${icons[type] || ''}</span><span class="flex-1">${_toastEsc(message)}</span><button onclick="dismissToast(${id})" class="text-current opacity-50 hover:opacity-100 text-xs ml-2">✕</button>`;
_toastContainer.appendChild(el);
// Animate in
requestAnimationFrame(() => {
el.classList.remove('opacity-0', 'translate-x-4');
el.classList.add('opacity-100', 'translate-x-0');
});
// Auto-dismiss
if (duration > 0) {
setTimeout(() => dismissToast(id), duration);
}
return id;
}
function dismissToast(id) {
const el = document.getElementById('toast-' + id);
if (!el) return;
el.classList.add('opacity-0', 'translate-x-4');
el.classList.remove('opacity-100', 'translate-x-0');
setTimeout(() => el.remove(), 300);
}
function _toastEsc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }