feat: improve authentication
All checks were successful
Build Test / build (pull_request) Successful in 43s
JS Unit Tests / test (pull_request) Successful in 41s
PHP Unit Tests / test (pull_request) Successful in 49s

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-02-19 23:03:09 -05:00
parent decda8becc
commit 99fa707eb3
7 changed files with 194 additions and 175 deletions

View File

@@ -225,7 +225,7 @@ class AuthenticationController extends ControllerAbstract
return $this->clearTokenCookies($httpResponse); return $this->clearTokenCookies($httpResponse);
} }
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']); $httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed', 'expires_in' => 900]);
if ($response->tokens && isset($response->tokens['access'])) { if ($response->tokens && isset($response->tokens['access'])) {
$httpResponse->headers->setCookie( $httpResponse->headers->setCookie(
@@ -242,6 +242,15 @@ class AuthenticationController extends ControllerAbstract
return $httpResponse; return $httpResponse;
} }
/**
* Session health check
*/
#[AuthenticatedRoute('/auth/ping', name: 'auth.ping', methods: ['GET'])]
public function ping(): JsonResponse
{
return new JsonResponse(['status' => 'ok']);
}
/** /**
* Logout current device * Logout current device
*/ */
@@ -281,14 +290,16 @@ class AuthenticationController extends ControllerAbstract
*/ */
private function buildJsonResponse(AuthenticationResponse $response): JsonResponse private function buildJsonResponse(AuthenticationResponse $response): JsonResponse
{ {
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus); $data = $response->toArray();
// Set token cookies if present // Set token cookies and expose expires_in if present
if ($response->hasTokens()) { if ($response->hasTokens()) {
$data['expires_in'] = 900;
$httpResponse = new JsonResponse($data, $response->httpStatus);
return $this->setTokenCookies($httpResponse, $response->tokens, true); return $this->setTokenCookies($httpResponse, $response->tokens, true);
} }
return $httpResponse; return new JsonResponse($data, $response->httpStatus);
} }
/** /**

View File

@@ -9,15 +9,12 @@ import { useTenantStore } from '@KTXC/stores/tenantStore'
import { useUserStore } from '@KTXC/stores/userStore' import { useUserStore } from '@KTXC/stores/userStore'
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper' import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'
import { initializeModules } from '@KTXC/utils/modules' import { initializeModules } from '@KTXC/utils/modules'
import { createSessionMonitor } from '@KTXC/services/authManager'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import vuetify from './plugins/vuetify/index' import vuetify from './plugins/vuetify/index'
import '@KTXC/scss/style.scss' import '@KTXC/scss/style.scss'
// Material Design Icons (Vuetify mdi icon set)
import '@mdi/font/css/materialdesignicons.min.css' import '@mdi/font/css/materialdesignicons.min.css'
// google-fonts
import '@fontsource/public-sans/index.css' import '@fontsource/public-sans/index.css'
const app = createApp(App) const app = createApp(App)
@@ -26,8 +23,6 @@ app.use(pinia)
app.use(PerfectScrollbarPlugin) app.use(PerfectScrollbarPlugin)
app.use(vuetify) app.use(vuetify)
// Note: Router is registered AFTER modules are loaded to prevent premature route matching
const globalWindow = window as typeof window & { const globalWindow = window as typeof window & {
[key: string]: unknown [key: string]: unknown
} }
@@ -37,7 +32,6 @@ globalWindow.vue = Vue
globalWindow.VueRouter = VueRouterLib globalWindow.VueRouter = VueRouterLib
globalWindow.Pinia = PiniaLib as unknown globalWindow.Pinia = PiniaLib as unknown
// Bootstrap initial private UI state (modules, tenant, user) before mounting
(async () => { (async () => {
const moduleStore = useModuleStore(); const moduleStore = useModuleStore();
const tenantStore = useTenantStore(); const tenantStore = useTenantStore();
@@ -48,23 +42,23 @@ globalWindow.Pinia = PiniaLib as unknown
moduleStore.init(payload?.modules ?? {}); moduleStore.init(payload?.modules ?? {});
tenantStore.init(payload?.tenant ?? null); tenantStore.init(payload?.tenant ?? null);
userStore.init(payload?.user ?? {}); userStore.init(payload?.user ?? {});
// Initialize auth session monitor
const sessionMonitor = createSessionMonitor({ onLogout: () => userStore.logout() });
sessionMonitor.start();
// Initialize registered modules (following reference app's bootstrap pattern) // Initialize registered modules
await initializeModules(app); await initializeModules(app);
// Add 404 catch-all route AFTER all modules are loaded // Register a catch-all and router
// This ensures module routes are registered before the catch-all
router.addRoute({ router.addRoute({
name: 'NotFound', name: 'NotFound',
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
component: () => import('@KTXC/views/pages/maintenance/error/Error404Page.vue') component: () => import('@KTXC/views/pages/maintenance/error/Error404Page.vue')
}); });
// Register router AFTER modules are loaded
app.use(router); app.use(router);
await router.isReady(); await router.isReady();
// Home redirect handled by router beforeEnter
app.mount('#app'); app.mount('#app');
} catch (e) { } catch (e) {
console.error('Bootstrap failed:', e); console.error('Bootstrap failed:', e);

View File

@@ -0,0 +1,82 @@
/**
* Authentication Manager
*/
const REFRESH_URL = '/auth/refresh';
const PING_URL = '/auth/ping';
const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
const PING_INTERVAL_MS = 60 * 1000;
let tokenExpiresAt: number | null = null;
let refreshPromise: Promise<boolean> | null = null;
export function recordExpiry(expiresIn: number | null | undefined): void {
tokenExpiresAt = expiresIn != null ? Date.now() + expiresIn * 1000 : null;
}
function isTokenExpiredOrExpiring(): boolean {
if (tokenExpiresAt === null) return false;
return Date.now() >= tokenExpiresAt - TOKEN_EXPIRY_BUFFER_MS;
}
async function refreshToken(): Promise<boolean> {
if (refreshPromise) return refreshPromise;
refreshPromise = fetch(REFRESH_URL, { method: 'POST', credentials: 'include' })
.then(async r => {
if (!r.ok) return false;
try {
const body = await r.clone().json();
recordExpiry(body?.expires_in);
} catch { /* ignore parse errors */ }
return true;
})
.catch(() => false)
.finally(() => { refreshPromise = null; });
return refreshPromise;
}
/**
* Ensures the token is fresh before a request fires.
*/
export async function ensureFreshToken(): Promise<void> {
if (isTokenExpiredOrExpiring()) {
await refreshToken();
}
}
export interface SessionMonitorOptions {
onLogout: () => void | Promise<void>;
}
export function createSessionMonitor(options: SessionMonitorOptions) {
let intervalId: ReturnType<typeof setInterval> | null = null;
async function ping(): Promise<void> {
// GET ping — no body, no CSRF needed, just confirm the session cookie is still valid
const response = await fetch(PING_URL, { credentials: 'include' }).catch(() => null);
if (!response?.ok) {
// Session may be expired — attempt a token refresh before giving up
const refreshed = await refreshToken();
if (!refreshed) {
stop();
await options.onLogout();
}
}
}
function start(): void {
if (intervalId !== null) return;
intervalId = setInterval(ping, PING_INTERVAL_MS);
}
function stop(): void {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
}
return { start, stop };
}

View File

@@ -6,7 +6,7 @@ export const authenticationService = {
* Initialize authentication - get session and available methods * Initialize authentication - get session and available methods
*/ */
async start(): Promise<StartResponse> { async start(): Promise<StartResponse> {
return fetchWrapper.get('/auth/start', undefined, { skipLogoutOnError: true }); return fetchWrapper.get('/auth/start');
}, },
/** /**
@@ -20,7 +20,7 @@ export const authenticationService = {
return fetchWrapper.post('/auth/identify', { return fetchWrapper.post('/auth/identify', {
session, session,
identity, identity,
}, { skipLogoutOnError: true }); });
}, },
/** /**
@@ -42,7 +42,7 @@ export const authenticationService = {
method, method,
response, response,
...(identity && { identity }), ...(identity && { identity }),
}, { autoRetry: false, skipLogoutOnError: true }); });
}, },
/** /**
@@ -57,7 +57,7 @@ export const authenticationService = {
session, session,
method, method,
return_url: returnUrl, return_url: returnUrl,
}, { skipLogoutOnError: true }); });
}, },
/** /**
@@ -67,14 +67,14 @@ export const authenticationService = {
return fetchWrapper.post('/auth/challenge', { return fetchWrapper.post('/auth/challenge', {
session, session,
method, method,
}, { skipLogoutOnError: true }); });
}, },
/** /**
* Get current session status * Get current session status
*/ */
async getStatus(session: string): Promise<SessionStatus> { async getStatus(session: string): Promise<SessionStatus> {
return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`, undefined, { skipLogoutOnError: true }); return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`);
}, },
/** /**

View File

@@ -1,161 +1,92 @@
/** /**
* Core fetch wrapper - reusable across modules * Core fetch wrapper - reusable across modules
* Does not depend on stores to avoid bundling issues in library builds
*/ */
export interface FetchWrapperOptions { export interface FetchWrapperOptions {
/** /** Additional headers merged into every request */
* Optional callback to handle logout on auth failure headers?: Record<string, string>;
* If not provided, only logs error without redirecting /** Override default Content-Type (default: application/json) */
*/ contentType?: string;
onLogout?: () => void | Promise<void>; /** Override default Accept type (default: application/json) */
/** accept?: string;
* Enable automatic retry of failed requests after token refresh /** Called before every request */
* @default true beforeRequest?: () => Promise<void>;
*/ /** Called after every successful non-stream response */
autoRetry?: boolean; afterResponse?: (data: any) => void;
} }
// Mutex to prevent multiple simultaneous refresh attempts
class RefreshMutex {
private promise: Promise<boolean> | null = null;
async acquire(): Promise<boolean> {
if (this.promise) {
return this.promise;
}
this.promise = this.performRefresh();
const result = await this.promise;
this.promise = null;
return result;
}
private async performRefresh(): Promise<boolean> {
try {
const response = await fetch('/security/refresh', {
method: 'POST',
credentials: 'include'
});
return response.ok;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}
}
const tokenRefreshMutex = new RefreshMutex();
export interface RequestCallOptions { export interface RequestCallOptions {
/** /** When set, the raw Response is forwarded to this handler instead of being JSON-parsed */
* Override autoRetry for this specific request onStream?: (response: Response) => Promise<void>;
* @default true /** Per-call header overrides — highest priority, wins over factory-level headers */
*/ headers?: Record<string, string>;
autoRetry?: boolean; }
/**
* Skip calling onLogout callback on 401/403 errors function getCsrfToken(): string | null {
* Useful for authentication endpoints where 401 means invalid credentials, not session expiry if (typeof document === 'undefined') return null;
* @default false const cookie = document.cookie.split('; ').find(r => r.startsWith('X-CSRF-TOKEN='));
*/ return cookie ? (cookie.split('=')[1] ?? null) : null;
skipLogoutOnError?: boolean;
} }
export function createFetchWrapper(options: FetchWrapperOptions = {}) { export function createFetchWrapper(options: FetchWrapperOptions = {}) {
const { autoRetry: defaultAutoRetry = true } = options; async function request(
method: string,
return { url: string,
get: request('GET', options, defaultAutoRetry), body?: object,
post: request('POST', options, defaultAutoRetry), callOptions?: RequestCallOptions
put: request('PUT', options, defaultAutoRetry), ): Promise<any> {
delete: request('DELETE', options, defaultAutoRetry) if (options.beforeRequest) {
}; await options.beforeRequest();
} }
interface RequestOptions { // Header priority: defaults < factory options.headers < callOptions.headers
method: string; const headers: Record<string, string> = {
headers: Record<string, string>; 'Content-Type': options.contentType ?? 'application/json',
body?: string; 'Accept': options.accept ?? 'application/json',
credentials: 'include'; ...options.headers,
} ...callOptions?.headers,
function request(method: string, options: FetchWrapperOptions, defaultAutoRetry: boolean) {
return async (url: string, body?: object, callOptions?: RequestCallOptions): Promise<any> => {
const autoRetry = callOptions?.autoRetry ?? defaultAutoRetry;
const requestOptions: RequestOptions = {
method,
headers: getHeaders(url),
credentials: 'include'
}; };
if (body) { const csrf = getCsrfToken();
requestOptions.headers['Content-Type'] = 'application/json'; if (csrf) headers['X-CSRF-TOKEN'] = csrf;
requestOptions.body = JSON.stringify(body);
const requestOptions: RequestInit = {
method,
headers,
credentials: 'include',
body: body ? JSON.stringify(body) : undefined,
};
const response = await fetch(url, requestOptions);
if (!response.ok) {
const text = await response.text();
const data = text ? JSON.parse(text) : null;
throw new Error(data?.message || response.statusText);
} }
try { if (callOptions?.onStream) {
const response = await fetch(url, requestOptions); return callOptions.onStream(response);
if (response.status === 401 && autoRetry) {
// Try to refresh the token
const refreshSuccess = await tokenRefreshMutex.acquire();
if (refreshSuccess) {
// Retry the original request with the new token
const retryResponse = await fetch(url, requestOptions);
return handleResponse(retryResponse, options, callOptions?.skipLogoutOnError);
}
}
return handleResponse(response, options, callOptions?.skipLogoutOnError);
} catch (error) {
console.error('API error:', error);
throw error;
} }
const text = await response.text();
const data = text ? JSON.parse(text) : null;
options.afterResponse?.(data);
return data;
}
return {
get: (url: string, callOptions?: RequestCallOptions) =>
request('GET', url, undefined, callOptions),
post: (url: string, body?: object, callOptions?: RequestCallOptions) =>
request('POST', url, body, callOptions),
put: (url: string, body?: object, callOptions?: RequestCallOptions) =>
request('PUT', url, body, callOptions),
patch: (url: string, body?: object, callOptions?: RequestCallOptions) =>
request('PATCH', url, body, callOptions),
delete: (url: string, callOptions?: RequestCallOptions) =>
request('DELETE', url, undefined, callOptions),
}; };
} }
function getHeaders(_url: string): Record<string, string> {
const headers: Record<string, string> = {};
// Add CSRF token if available
const csrfToken = getCsrfTokenFromCookie();
if (csrfToken) {
headers['X-CSRF-TOKEN'] = csrfToken;
}
return headers;
}
function getCsrfTokenFromCookie(): string | null {
if (typeof document === 'undefined') return null;
const csrfCookie = document.cookie
.split('; ')
.find(row => row.startsWith('X-CSRF-TOKEN='));
return csrfCookie ? csrfCookie.split('=')[1] : null;
}
async function handleResponse(response: Response, options: FetchWrapperOptions, skipLogoutOnError?: boolean): Promise<any> {
const text = await response.text();
const data = text && JSON.parse(text);
if (!response.ok) {
if ([401, 403].includes(response.status) && !skipLogoutOnError) {
// Call logout callback if provided
if (options.onLogout) {
await options.onLogout();
} else {
console.error('Authentication failed. Please log in again.');
}
}
const error: string = (data && data.message) || response.statusText;
throw new Error(error);
}
return data;
}

View File

@@ -1,10 +1,7 @@
import { useUserStore } from '@KTXC/stores/userStore';
import { createFetchWrapper } from './fetch-wrapper-core'; import { createFetchWrapper } from './fetch-wrapper-core';
import { ensureFreshToken, recordExpiry } from '@KTXC/services/authManager';
// Create fetch wrapper with user store logout callback
export const fetchWrapper = createFetchWrapper({ export const fetchWrapper = createFetchWrapper({
onLogout: () => { beforeRequest: ensureFreshToken,
const { logout } = useUserStore(); afterResponse: (data) => recordExpiry(data?.expires_in),
logout();
}
}); });

View File

@@ -3,4 +3,8 @@
* This file is built separately and exposed via import map * This file is built separately and exposed via import map
*/ */
export { createFetchWrapper, type FetchWrapperOptions } from './fetch-wrapper-core'; export {
createFetchWrapper,
type FetchWrapperOptions,
type RequestCallOptions,
} from './fetch-wrapper-core';