mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: enhance authentication and session management
- Added NODE_ENV variable for development in docker-compose.override.yml.example. - Changed default NODE_ENV to development in Dockerfile. - Implemented fetchWsToken function to retrieve short-lived WebSocket tokens for secure authentication in TerminalPanel. - Updated connect function to use wsToken for WebSocket connections when API key is not available. - Introduced verifySession function to validate session status after login and on app load, ensuring session integrity. - Modified RootLayoutContent to verify session cookie validity and redirect to login if the session is invalid or expired. These changes improve the security and reliability of the authentication process.
This commit is contained in:
@@ -102,7 +102,6 @@ RUN git config --system --add safe.directory '*' && \
|
|||||||
USER automaker
|
USER automaker
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3008
|
ENV PORT=3008
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
} from '@/config/terminal-themes';
|
} from '@/config/terminal-themes';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { getApiKey } from '@/lib/http-api-client';
|
import { getApiKey, getSessionToken } from '@/lib/http-api-client';
|
||||||
|
|
||||||
// Font size constraints
|
// Font size constraints
|
||||||
const MIN_FONT_SIZE = 8;
|
const MIN_FONT_SIZE = 8;
|
||||||
@@ -486,6 +486,40 @@ export function TerminalPanel({
|
|||||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||||
const wsUrl = serverUrl.replace(/^http/, 'ws');
|
const wsUrl = serverUrl.replace(/^http/, 'ws');
|
||||||
|
|
||||||
|
// Fetch a short-lived WebSocket token for secure authentication
|
||||||
|
const fetchWsToken = useCallback(async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionToken = getSessionToken();
|
||||||
|
if (sessionToken) {
|
||||||
|
headers['X-Session-Token'] = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${serverUrl}/api/auth/token`, {
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('[Terminal] Failed to fetch wsToken:', response.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.token) {
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Terminal] Error fetching wsToken:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [serverUrl]);
|
||||||
|
|
||||||
// Draggable - only the drag handle triggers drag
|
// Draggable - only the drag handle triggers drag
|
||||||
const {
|
const {
|
||||||
attributes: dragAttributes,
|
attributes: dragAttributes,
|
||||||
@@ -940,7 +974,7 @@ export function TerminalPanel({
|
|||||||
const terminal = xtermRef.current;
|
const terminal = xtermRef.current;
|
||||||
if (!terminal) return;
|
if (!terminal) return;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = async () => {
|
||||||
// Build WebSocket URL with auth params
|
// Build WebSocket URL with auth params
|
||||||
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
|
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
|
||||||
|
|
||||||
@@ -948,8 +982,14 @@ export function TerminalPanel({
|
|||||||
const apiKey = getApiKey();
|
const apiKey = getApiKey();
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
url += `&apiKey=${encodeURIComponent(apiKey)}`;
|
url += `&apiKey=${encodeURIComponent(apiKey)}`;
|
||||||
|
} else {
|
||||||
|
// In web mode, fetch a short-lived wsToken for secure authentication
|
||||||
|
const wsToken = await fetchWsToken();
|
||||||
|
if (wsToken) {
|
||||||
|
url += `&wsToken=${encodeURIComponent(wsToken)}`;
|
||||||
|
}
|
||||||
|
// Cookies are also sent automatically with same-origin WebSocket
|
||||||
}
|
}
|
||||||
// In web mode, cookies are sent automatically with same-origin WebSocket
|
|
||||||
|
|
||||||
// Add terminal password token if required
|
// Add terminal password token if required
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
@@ -1164,7 +1204,7 @@ export function TerminalPanel({
|
|||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [sessionId, authToken, wsUrl, isTerminalReady]);
|
}, [sessionId, authToken, wsUrl, isTerminalReady, fetchWsToken]);
|
||||||
|
|
||||||
// Handle resize with debouncing
|
// Handle resize with debouncing
|
||||||
const handleResize = useCallback(() => {
|
const handleResize = useCallback(() => {
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export const checkAuthStatus = async (): Promise<{
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Login with API key (for web mode)
|
* Login with API key (for web mode)
|
||||||
|
* After login succeeds, verifies the session is actually working by making
|
||||||
|
* a request to an authenticated endpoint.
|
||||||
*/
|
*/
|
||||||
export const login = async (
|
export const login = async (
|
||||||
apiKey: string
|
apiKey: string
|
||||||
@@ -141,6 +143,17 @@ export const login = async (
|
|||||||
if (data.success && data.token) {
|
if (data.success && data.token) {
|
||||||
setSessionToken(data.token);
|
setSessionToken(data.token);
|
||||||
console.log('[HTTP Client] Session token stored after login');
|
console.log('[HTTP Client] Session token stored after login');
|
||||||
|
|
||||||
|
// Verify the session is actually working by making a request to an authenticated endpoint
|
||||||
|
const verified = await verifySession();
|
||||||
|
if (!verified) {
|
||||||
|
console.error('[HTTP Client] Login appeared successful but session verification failed');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Session verification failed. Please try again.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
console.log('[HTTP Client] Login verified successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -151,30 +164,34 @@ export const login = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch session token from server (for page refresh when cookie exists)
|
* Check if the session cookie is still valid by making a request to an authenticated endpoint.
|
||||||
* This retrieves the session token so it can be used for explicit header-based auth.
|
* Note: This does NOT retrieve the session token - on page refresh we rely on cookies alone.
|
||||||
|
* The session token is only available after a fresh login.
|
||||||
*/
|
*/
|
||||||
export const fetchSessionToken = async (): Promise<boolean> => {
|
export const fetchSessionToken = async (): Promise<boolean> => {
|
||||||
|
// On page refresh, we can't retrieve the session token (it's stored in HTTP-only cookie).
|
||||||
|
// We just verify the cookie is valid by checking auth status.
|
||||||
|
// The session token is only stored in memory after a fresh login.
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getServerUrl()}/api/auth/token`, {
|
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||||
credentials: 'include', // Send the session cookie
|
credentials: 'include', // Send the session cookie
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.log('[HTTP Client] No valid session to get token from');
|
console.log('[HTTP Client] Failed to check auth status');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.token) {
|
if (data.success && data.authenticated) {
|
||||||
setSessionToken(data.token);
|
console.log('[HTTP Client] Session cookie is valid');
|
||||||
console.log('[HTTP Client] Session token retrieved from cookie session');
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[HTTP Client] Session cookie is not authenticated');
|
||||||
return false;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[HTTP Client] Failed to fetch session token:', error);
|
console.error('[HTTP Client] Failed to check session:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -200,6 +217,58 @@ export const logout = async (): Promise<{ success: boolean }> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that the current session is still valid by making a request to an authenticated endpoint.
|
||||||
|
* If the session has expired or is invalid, clears the session and returns false.
|
||||||
|
* This should be called:
|
||||||
|
* 1. After login to verify the cookie was set correctly
|
||||||
|
* 2. On app load to verify the session hasn't expired
|
||||||
|
*/
|
||||||
|
export const verifySession = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add session token header if available
|
||||||
|
const sessionToken = getSessionToken();
|
||||||
|
if (sessionToken) {
|
||||||
|
headers['X-Session-Token'] = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a request to an authenticated endpoint to verify the session
|
||||||
|
// We use /api/settings/status as it requires authentication and is lightweight
|
||||||
|
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for authentication errors
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
console.warn('[HTTP Client] Session verification failed - session expired or invalid');
|
||||||
|
// Clear the session since it's no longer valid
|
||||||
|
clearSessionToken();
|
||||||
|
// Try to clear the cookie via logout (fire and forget)
|
||||||
|
fetch(`${getServerUrl()}/api/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
}).catch(() => {});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('[HTTP Client] Session verification failed with status:', response.status);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[HTTP Client] Session verified successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HTTP Client] Session verification error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
type EventType =
|
type EventType =
|
||||||
| 'agent:stream'
|
| 'agent:stream'
|
||||||
| 'auto-mode:event'
|
| 'auto-mode:event'
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ import {
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import {
|
import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client';
|
||||||
initApiKey,
|
|
||||||
checkAuthStatus,
|
|
||||||
isElectronMode,
|
|
||||||
fetchSessionToken,
|
|
||||||
} from '@/lib/http-api-client';
|
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||||
|
|
||||||
@@ -80,7 +75,7 @@ function RootLayoutContent() {
|
|||||||
|
|
||||||
// Initialize authentication
|
// Initialize authentication
|
||||||
// - Electron mode: Uses API key from IPC (header-based auth)
|
// - Electron mode: Uses API key from IPC (header-based auth)
|
||||||
// - Web mode: Uses session token (fetched from cookie session for explicit header auth)
|
// - Web mode: Uses HTTP-only session cookie
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -94,29 +89,31 @@ function RootLayoutContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In web mode, try to fetch session token (works if cookie is valid)
|
// In web mode, verify the session cookie is still valid
|
||||||
// This allows explicit header-based auth which works better cross-origin
|
// by making a request to an authenticated endpoint
|
||||||
const tokenFetched = await fetchSessionToken();
|
const isValid = await verifySession();
|
||||||
|
|
||||||
if (tokenFetched) {
|
if (isValid) {
|
||||||
// We have a valid session - token is now stored in memory
|
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
setAuthChecked(true);
|
setAuthChecked(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: check auth status via cookie
|
// Session is invalid or expired - redirect to login
|
||||||
const status = await checkAuthStatus();
|
console.log('Session invalid or expired - redirecting to login');
|
||||||
setIsAuthenticated(status.authenticated);
|
setIsAuthenticated(false);
|
||||||
setAuthChecked(true);
|
setAuthChecked(true);
|
||||||
|
|
||||||
// Redirect to login if not authenticated and not already on login page
|
if (location.pathname !== '/login') {
|
||||||
if (!status.authenticated && location.pathname !== '/login') {
|
|
||||||
navigate({ to: '/login' });
|
navigate({ to: '/login' });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize auth:', error);
|
console.error('Failed to initialize auth:', error);
|
||||||
setAuthChecked(true);
|
setAuthChecked(true);
|
||||||
|
// On error, redirect to login to be safe
|
||||||
|
if (location.pathname !== '/login') {
|
||||||
|
navigate({ to: '/login' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ services:
|
|||||||
# Set root directory for all projects and file operations
|
# Set root directory for all projects and file operations
|
||||||
# Users can only create/open projects within this directory
|
# Users can only create/open projects within this directory
|
||||||
- ALLOWED_ROOT_DIRECTORY=/projects
|
- ALLOWED_ROOT_DIRECTORY=/projects
|
||||||
|
- NODE_ENV=development
|
||||||
|
|||||||
Reference in New Issue
Block a user