/** * Authentication Worker * * I admit JWT is unnecessary, but I did it anyway because it was interesting * and I wanted to try it out. */ class SimpleAuth { /** * initialise the authentication helper */ constructor() { this.token = localStorage.getItem('token'); this.user = JSON.parse(localStorage.getItem('user') || 'null'); this.loginAttempts = parseInt(localStorage.getItem('loginAttempts') || '0'); this.isValidating = false; this.validationPromise = null; // Generate a browser fingerprint this.browserFingerprint = this._generateFingerprint(); // Check if the stored fingerprint matches the current browser const storedFingerprint = localStorage.getItem('browserFingerprint'); if (this.token && (!storedFingerprint || storedFingerprint !== this.browserFingerprint)) { // Fingerprint mismatch - potential token theft console.warn('Browser fingerprint mismatch - clearing authentication'); this.logout(false); // Silent logout (no redirect) } } /** * Generate a simple browser fingerprint, super unnecessary and out of scope * but it was simple and hardens the authentication a bit. * @private * @returns {string} A fingerprint based on browser properties */ _generateFingerprint() { const components = [ navigator.userAgent, navigator.language, screen.colorDepth, screen.width + 'x' + screen.height, new Date().getTimezoneOffset() ]; // Create a hash of the components let hash = 0; const str = components.join('|'); for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash) + str.charCodeAt(i); hash |= 0; // Convert to 32bit integer } return hash.toString(16); } /** * Validate token on page load, this is to prevent XSS attacks. (During testing * copying the tokens and userdata, and setting the localStorage manually on a * new browser automatically logged me in.) * This should be called when the page loads to ensure the token is valid * @returns {Promise} True if token is valid, false otherwise */ async validateOnLoad() { // If already validating, return the existing promise if (this.isValidating) { return this.validationPromise; } // If no token, no need to validate since not logged in if (!this.token) { return false; } // Set validating flag and create promise this.isValidating = true; this.validationPromise = (async () => { try { const isValid = await this.validateToken(); if (!isValid) { // Token is invalid, try to refresh it const refreshed = await this.refreshToken(); if (!refreshed) { // Refresh failed, logout this.logout(false); // Silent logout (no redirect) return false; } return true; } return isValid; } catch (error) { console.error('Token validation error:', error); this.logout(false); // Silent logout (no redirect) return false; } finally { this.isValidating = false; this.validationPromise = null; } })(); return this.validationPromise; } /** * Parse a JWT token to extract its payload * @param {string} token - The JWT token to parse * @returns {object|null} The decoded payload or null if invalid */ parseJwt(token) { try { const base64Url = token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) { return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); }).join('')); return JSON.parse(jsonPayload); } catch (e) { console.error('Error parsing JWT token:', e); return null; } } /** * Generate a new CAPTCHA * @returns {Promise} The generated CAPTCHA code */ async generateCaptcha() { try { const response = await fetch('/auth.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'generateCaptcha' }) }); const data = await response.json(); if (data.captcha) { return data.captcha; } throw new Error('Failed to generate CAPTCHA'); } catch (error) { console.error('Error generating CAPTCHA:', error); throw error; } } /** * Check if CAPTCHA is needed for login * @returns {boolean} True if CAPTCHA is needed, false otherwise */ needsCaptcha() { return this.loginAttempts >= 3; } /** * Login a user based on credentials. * @param {object} credentials - The user credentials (username, password, captchaInput) * @returns {Promise} The login result */ async login(credentials) { try { const response = await fetch('/auth.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }); const data = await response.json(); if (!response.ok) { // If CAPTCHA is required, include it in the error if (data.captcha) { throw new Error(data.error || 'Login failed'); } else { throw new Error(data.error || 'Login failed'); } } // Store token and user data this.token = data.token; localStorage.setItem('token', data.token); // Store refresh token if available if (data.refreshToken) { localStorage.setItem('refreshToken', data.refreshToken); } // Reset login attempts this.loginAttempts = 0; localStorage.setItem('loginAttempts', '0'); // Store user data this.user = data.user; localStorage.setItem('user', JSON.stringify(data.user)); // Store browser fingerprint localStorage.setItem('browserFingerprint', this.browserFingerprint); return { success: true, user: this.user }; } catch (error) { console.error('Login error:', error); // Increment login attempts this.loginAttempts++; localStorage.setItem('loginAttempts', this.loginAttempts.toString()); return { success: false, error: error.message, captcha: error.captcha }; } } /** * Logout the current user * @param {boolean} redirect - Whether to redirect to home page after logout (default: true) */ logout(redirect = true) { this.token = null; this.user = null; localStorage.removeItem('token'); localStorage.removeItem('refreshToken'); localStorage.removeItem('user'); localStorage.removeItem('browserFingerprint'); // Redirect to home page if requested if (redirect) { window.location.href = '/'; } } /** * Check if the user is authenticated * @returns {boolean} True if authenticated, false otherwise */ isAuthenticated() { return !!this.token && !!this.user; } /** * Check if the user is an admin * @returns {boolean} True if admin, false otherwise */ isAdmin() { return this.isAuthenticated() && (this.user.accessLevel === 1 || this.user.accessLevel === 0); } /** * Get the current user * @returns {object|null} The current user or null if not authenticated */ getUser() { return this.user; } /** * Get the authentication token * @returns {string|null} The token or null if not authenticated */ getToken() { return this.token; } /** * Make an authenticated API request * @param {string} url - The URL to fetch * @param {object} options - Fetch options * @returns {Promise} The fetch response */ async fetchAuth(url, options = {}) { if (!this.token) { throw new Error('Not authenticated'); } const headers = { ...options.headers, 'Authorization': `Bearer ${this.token}` }; return fetch(url, { ...options, headers }); } /** * Validate the current token * @returns {Promise} True if token is valid, false otherwise */ async validateToken() { try { if (!this.token) { return false; } const response = await fetch('/auth.php', { method: 'GET', headers: { 'Authorization': `Bearer ${this.token}` } }); const data = await response.json(); return data.valid === true; } catch (error) { console.error('Token validation error:', error); return false; } } /** * Refresh the access token using the refresh token * @returns {Promise} True if token was refreshed, false otherwise */ async refreshToken() { try { const refreshToken = localStorage.getItem('refreshToken'); if (!refreshToken) { return false; } const response = await fetch('/auth.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'refresh', refreshToken }) }); const data = await response.json(); if (data.success && data.token) { this.token = data.token; localStorage.setItem('token', data.token); return true; } return false; } catch (error) { console.error('Token refresh error:', error); return false; } } } // Create a global instance and expose it window.simpleAuth = new SimpleAuth(); // Also create an alias for backward compatibility window.auth = window.simpleAuth; // Log that simpleAuth is ready console.log('SimpleAuth is ready and exposed to window'); // Dispatch a custom event to notify other scripts window.dispatchEvent(new Event('simpleAuthReady'));