Auth: add proactive JWT refresh in frontend and clock-skew leeway in backend; improve 401 handling
This commit is contained in:
@@ -38,7 +38,10 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
|||||||
else:
|
else:
|
||||||
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({
|
||||||
|
"exp": expire,
|
||||||
|
"iat": datetime.utcnow(),
|
||||||
|
})
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
||||||
@@ -46,7 +49,12 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
|||||||
def verify_token(token: str) -> Optional[str]:
|
def verify_token(token: str) -> Optional[str]:
|
||||||
"""Verify JWT token and return username"""
|
"""Verify JWT token and return username"""
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.secret_key,
|
||||||
|
algorithms=[settings.algorithm],
|
||||||
|
leeway=30 # allow small clock skew
|
||||||
|
)
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
if username is None:
|
if username is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
const app = {
|
const app = {
|
||||||
token: localStorage.getItem('auth_token'),
|
token: localStorage.getItem('auth_token'),
|
||||||
user: null,
|
user: null,
|
||||||
initialized: false
|
initialized: false,
|
||||||
|
refreshTimerId: null,
|
||||||
|
refreshInProgress: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize application
|
// Initialize application
|
||||||
@@ -20,7 +22,7 @@ async function initializeApp() {
|
|||||||
window.keyboardShortcuts.initialize();
|
window.keyboardShortcuts.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove Bootstrap-dependent tooltips/popovers; use native title/tooltips if needed
|
// Tooltips/popovers handled via native browser features as needed
|
||||||
|
|
||||||
// Add form validation classes
|
// Add form validation classes
|
||||||
initializeFormValidation();
|
initializeFormValidation();
|
||||||
@@ -34,7 +36,7 @@ async function initializeApp() {
|
|||||||
|
|
||||||
// Form validation
|
// Form validation
|
||||||
function initializeFormValidation() {
|
function initializeFormValidation() {
|
||||||
// Native validation handling without Bootstrap classes
|
// Native validation handling
|
||||||
const forms = document.querySelectorAll('form');
|
const forms = document.querySelectorAll('form');
|
||||||
forms.forEach(form => {
|
forms.forEach(form => {
|
||||||
form.addEventListener('submit', function(event) {
|
form.addEventListener('submit', function(event) {
|
||||||
@@ -72,20 +74,50 @@ function setupAPIHelpers() {
|
|||||||
if (app.token) {
|
if (app.token) {
|
||||||
window.apiHeaders['Authorization'] = `Bearer ${app.token}`;
|
window.apiHeaders['Authorization'] = `Bearer ${app.token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start proactive refresh scheduling when a token is present
|
||||||
|
if (app.token) {
|
||||||
|
scheduleTokenRefresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// API utility functions
|
// API utility functions
|
||||||
async function apiCall(url, options = {}) {
|
async function apiCall(url, options = {}) {
|
||||||
|
// Proactively refresh token if close to expiry
|
||||||
|
if (app.token) {
|
||||||
|
const msLeft = getMsUntilExpiry(app.token);
|
||||||
|
if (msLeft !== null && msLeft < 5 * 60 * 1000) { // < 5 minutes
|
||||||
|
try {
|
||||||
|
await refreshToken();
|
||||||
|
} catch (e) {
|
||||||
|
logout();
|
||||||
|
throw new Error('Authentication required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
headers: { ...window.apiHeaders, ...options.headers },
|
headers: { ...window.apiHeaders, ...options.headers },
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, config);
|
let response = await fetch(url, config);
|
||||||
|
|
||||||
|
if (response.status === 401 && app.token) {
|
||||||
|
// Attempt one refresh then retry once
|
||||||
|
try {
|
||||||
|
await refreshToken();
|
||||||
|
const retryConfig = {
|
||||||
|
headers: { ...window.apiHeaders, ...options.headers },
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
response = await fetch(url, retryConfig);
|
||||||
|
} catch (_) {
|
||||||
|
// fall through to logout below
|
||||||
|
}
|
||||||
|
}
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// Token expired or invalid
|
|
||||||
logout();
|
logout();
|
||||||
throw new Error('Authentication required');
|
throw new Error('Authentication required');
|
||||||
}
|
}
|
||||||
@@ -130,9 +162,17 @@ function setAuthToken(token) {
|
|||||||
app.token = token;
|
app.token = token;
|
||||||
localStorage.setItem('auth_token', token);
|
localStorage.setItem('auth_token', token);
|
||||||
window.apiHeaders['Authorization'] = `Bearer ${token}`;
|
window.apiHeaders['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
// Reschedule refresh on token update
|
||||||
|
scheduleTokenRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
|
if (app.refreshTimerId) {
|
||||||
|
clearTimeout(app.refreshTimerId);
|
||||||
|
app.refreshTimerId = null;
|
||||||
|
}
|
||||||
|
app.refreshInProgress = false;
|
||||||
app.token = null;
|
app.token = null;
|
||||||
app.user = null;
|
app.user = null;
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
@@ -364,3 +404,67 @@ window.apiPut = apiPut;
|
|||||||
window.apiDelete = apiDelete;
|
window.apiDelete = apiDelete;
|
||||||
window.formatCurrency = formatCurrency;
|
window.formatCurrency = formatCurrency;
|
||||||
window.formatDate = formatDate;
|
window.formatDate = formatDate;
|
||||||
|
|
||||||
|
// JWT utilities and refresh handling
|
||||||
|
function decodeJwt(token) {
|
||||||
|
try {
|
||||||
|
const payload = token.split('.')[1];
|
||||||
|
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
return JSON.parse(json);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMsUntilExpiry(token) {
|
||||||
|
const decoded = decodeJwt(token);
|
||||||
|
if (!decoded || !decoded.exp) return null;
|
||||||
|
const expiryMs = decoded.exp * 1000;
|
||||||
|
return expiryMs - Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTokenRefresh() {
|
||||||
|
if (!app.token) return;
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (app.refreshTimerId) {
|
||||||
|
clearTimeout(app.refreshTimerId);
|
||||||
|
app.refreshTimerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msLeft = getMsUntilExpiry(app.token);
|
||||||
|
if (msLeft === null) return;
|
||||||
|
|
||||||
|
// Refresh 5 minutes before expiry, but not sooner than 30 seconds from now
|
||||||
|
const leadTimeMs = 5 * 60 * 1000;
|
||||||
|
const refreshInMs = Math.max(msLeft - leadTimeMs, 30 * 1000);
|
||||||
|
|
||||||
|
app.refreshTimerId = setTimeout(() => {
|
||||||
|
// Fire and forget; apiCall path also guards per-request
|
||||||
|
refreshToken().catch(() => {
|
||||||
|
logout();
|
||||||
|
});
|
||||||
|
}, refreshInMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshToken() {
|
||||||
|
if (!app.token) throw new Error('No token to refresh');
|
||||||
|
if (app.refreshInProgress) return; // Avoid parallel refreshes
|
||||||
|
app.refreshInProgress = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...window.apiHeaders }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Refresh failed');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data || !data.access_token) {
|
||||||
|
throw new Error('Invalid refresh response');
|
||||||
|
}
|
||||||
|
setAuthToken(data.access_token);
|
||||||
|
} finally {
|
||||||
|
app.refreshInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user