Merge pull request 'feat/improve-authentication' (#28) from feat/improve-authentication into main
Some checks failed
Renovate / renovate (push) Failing after 1h13m58s
Some checks failed
Renovate / renovate (push) Failing after 1h13m58s
Reviewed-on: #28
This commit was merged in pull request #28.
This commit is contained in:
@@ -225,7 +225,7 @@ class AuthenticationController extends ControllerAbstract
|
||||
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'])) {
|
||||
$httpResponse->headers->setCookie(
|
||||
@@ -242,6 +242,15 @@ class AuthenticationController extends ControllerAbstract
|
||||
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
|
||||
*/
|
||||
@@ -281,14 +290,16 @@ class AuthenticationController extends ControllerAbstract
|
||||
*/
|
||||
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()) {
|
||||
$data['expires_in'] = 900;
|
||||
$httpResponse = new JsonResponse($data, $response->httpStatus);
|
||||
return $this->setTokenCookies($httpResponse, $response->tokens, true);
|
||||
}
|
||||
|
||||
return $httpResponse;
|
||||
return new JsonResponse($data, $response->httpStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,4 +16,9 @@ class TenantService
|
||||
return $this->store->fetchByDomain($domain);
|
||||
}
|
||||
|
||||
public function fetchById(string $identifier): ?TenantObject
|
||||
{
|
||||
return $this->store->fetch($identifier);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -37,6 +37,26 @@ class SessionTenant
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the tenant by its identifier (for console / CLI usage).
|
||||
*/
|
||||
public function configureById(string $identifier): void
|
||||
{
|
||||
if ($this->configured) {
|
||||
return;
|
||||
}
|
||||
$tenant = $this->tenantService->fetchById($identifier);
|
||||
if ($tenant) {
|
||||
$this->domain = $identifier;
|
||||
$this->tenant = $tenant;
|
||||
$this->configured = true;
|
||||
} else {
|
||||
$this->domain = null;
|
||||
$this->tenant = null;
|
||||
$this->configured = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the tenant configured
|
||||
*/
|
||||
|
||||
@@ -9,15 +9,12 @@ import { useTenantStore } from '@KTXC/stores/tenantStore'
|
||||
import { useUserStore } from '@KTXC/stores/userStore'
|
||||
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'
|
||||
import { initializeModules } from '@KTXC/utils/modules'
|
||||
import { createSessionMonitor } from '@KTXC/services/authManager'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import vuetify from './plugins/vuetify/index'
|
||||
import '@KTXC/scss/style.scss'
|
||||
|
||||
// Material Design Icons (Vuetify mdi icon set)
|
||||
import '@mdi/font/css/materialdesignicons.min.css'
|
||||
|
||||
// google-fonts
|
||||
import '@fontsource/public-sans/index.css'
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -26,8 +23,6 @@ app.use(pinia)
|
||||
app.use(PerfectScrollbarPlugin)
|
||||
app.use(vuetify)
|
||||
|
||||
// Note: Router is registered AFTER modules are loaded to prevent premature route matching
|
||||
|
||||
const globalWindow = window as typeof window & {
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -37,7 +32,6 @@ globalWindow.vue = Vue
|
||||
globalWindow.VueRouter = VueRouterLib
|
||||
globalWindow.Pinia = PiniaLib as unknown
|
||||
|
||||
// Bootstrap initial private UI state (modules, tenant, user) before mounting
|
||||
(async () => {
|
||||
const moduleStore = useModuleStore();
|
||||
const tenantStore = useTenantStore();
|
||||
@@ -48,23 +42,23 @@ globalWindow.Pinia = PiniaLib as unknown
|
||||
moduleStore.init(payload?.modules ?? {});
|
||||
tenantStore.init(payload?.tenant ?? null);
|
||||
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);
|
||||
|
||||
// Add 404 catch-all route AFTER all modules are loaded
|
||||
// This ensures module routes are registered before the catch-all
|
||||
// Register a catch-all and router
|
||||
router.addRoute({
|
||||
name: 'NotFound',
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: () => import('@KTXC/views/pages/maintenance/error/Error404Page.vue')
|
||||
});
|
||||
|
||||
// Register router AFTER modules are loaded
|
||||
app.use(router);
|
||||
|
||||
await router.isReady();
|
||||
// Home redirect handled by router beforeEnter
|
||||
|
||||
app.mount('#app');
|
||||
} catch (e) {
|
||||
console.error('Bootstrap failed:', e);
|
||||
|
||||
82
core/src/services/authManager.ts
Normal file
82
core/src/services/authManager.ts
Normal 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 };
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const authenticationService = {
|
||||
* Initialize authentication - get session and available methods
|
||||
*/
|
||||
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', {
|
||||
session,
|
||||
identity,
|
||||
}, { skipLogoutOnError: true });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ export const authenticationService = {
|
||||
method,
|
||||
response,
|
||||
...(identity && { identity }),
|
||||
}, { autoRetry: false, skipLogoutOnError: true });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -57,7 +57,7 @@ export const authenticationService = {
|
||||
session,
|
||||
method,
|
||||
return_url: returnUrl,
|
||||
}, { skipLogoutOnError: true });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -67,14 +67,14 @@ export const authenticationService = {
|
||||
return fetchWrapper.post('/auth/challenge', {
|
||||
session,
|
||||
method,
|
||||
}, { skipLogoutOnError: true });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current session status
|
||||
*/
|
||||
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)}`);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,161 +1,92 @@
|
||||
/**
|
||||
* Core fetch wrapper - reusable across modules
|
||||
* Does not depend on stores to avoid bundling issues in library builds
|
||||
*/
|
||||
|
||||
export interface FetchWrapperOptions {
|
||||
/**
|
||||
* Optional callback to handle logout on auth failure
|
||||
* If not provided, only logs error without redirecting
|
||||
*/
|
||||
onLogout?: () => void | Promise<void>;
|
||||
/**
|
||||
* Enable automatic retry of failed requests after token refresh
|
||||
* @default true
|
||||
*/
|
||||
autoRetry?: boolean;
|
||||
/** Additional headers merged into every request */
|
||||
headers?: Record<string, string>;
|
||||
/** Override default Content-Type (default: application/json) */
|
||||
contentType?: string;
|
||||
/** Override default Accept type (default: application/json) */
|
||||
accept?: string;
|
||||
/** Called before every request */
|
||||
beforeRequest?: () => Promise<void>;
|
||||
/** Called after every successful non-stream response */
|
||||
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 {
|
||||
/**
|
||||
* Override autoRetry for this specific request
|
||||
* @default true
|
||||
*/
|
||||
autoRetry?: boolean;
|
||||
/**
|
||||
* Skip calling onLogout callback on 401/403 errors
|
||||
* Useful for authentication endpoints where 401 means invalid credentials, not session expiry
|
||||
* @default false
|
||||
*/
|
||||
skipLogoutOnError?: boolean;
|
||||
/** When set, the raw Response is forwarded to this handler instead of being JSON-parsed */
|
||||
onStream?: (response: Response) => Promise<void>;
|
||||
/** Per-call header overrides — highest priority, wins over factory-level headers */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const cookie = document.cookie.split('; ').find(r => r.startsWith('X-CSRF-TOKEN='));
|
||||
return cookie ? (cookie.split('=')[1] ?? null) : null;
|
||||
}
|
||||
|
||||
export function createFetchWrapper(options: FetchWrapperOptions = {}) {
|
||||
const { autoRetry: defaultAutoRetry = true } = options;
|
||||
|
||||
return {
|
||||
get: request('GET', options, defaultAutoRetry),
|
||||
post: request('POST', options, defaultAutoRetry),
|
||||
put: request('PUT', options, defaultAutoRetry),
|
||||
delete: request('DELETE', options, defaultAutoRetry)
|
||||
};
|
||||
}
|
||||
async function request(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: object,
|
||||
callOptions?: RequestCallOptions
|
||||
): Promise<any> {
|
||||
if (options.beforeRequest) {
|
||||
await options.beforeRequest();
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
credentials: 'include';
|
||||
}
|
||||
|
||||
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'
|
||||
// Header priority: defaults < factory options.headers < callOptions.headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': options.contentType ?? 'application/json',
|
||||
'Accept': options.accept ?? 'application/json',
|
||||
...options.headers,
|
||||
...callOptions?.headers,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
requestOptions.headers['Content-Type'] = 'application/json';
|
||||
requestOptions.body = JSON.stringify(body);
|
||||
|
||||
const csrf = getCsrfToken();
|
||||
if (csrf) headers['X-CSRF-TOKEN'] = csrf;
|
||||
|
||||
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 {
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
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;
|
||||
|
||||
if (callOptions?.onStream) {
|
||||
return callOptions.onStream(response);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useUserStore } from '@KTXC/stores/userStore';
|
||||
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({
|
||||
onLogout: () => {
|
||||
const { logout } = useUserStore();
|
||||
logout();
|
||||
}
|
||||
beforeRequest: ensureFreshToken,
|
||||
afterResponse: (data) => recordExpiry(data?.expires_in),
|
||||
});
|
||||
|
||||
@@ -3,4 +3,8 @@
|
||||
* 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';
|
||||
|
||||
Reference in New Issue
Block a user