From 262307f17d0aebb783e2e6db6186fd905ffdf0f6 Mon Sep 17 00:00:00 2001 From: shankar Date: Mon, 9 Dec 2024 17:06:01 +0530 Subject: [PATCH] Added code to validate if user authenticated then redirect to dashboard else login page. Added code to handle role based navebar tab displaying. Added code to handle access token or refresh token expiration and then redirect to login page. --- db-scripts/01-init.sql | 2 - router-dashboard/package.json | 1 + router-dashboard/src/App.tsx | 37 ++-- router-dashboard/src/components/Login.tsx | 93 ++++++++-- .../components/dashboard/DashboardLayout.tsx | 58 +++++- .../src/components/dashboard/Header.tsx | 6 +- .../src/components/dashboard/Navbar.tsx | 83 +++------ router-dashboard/src/contexts/AuthContext.tsx | 41 +++++ router-dashboard/src/main.tsx | 20 ++- router-dashboard/src/services/api.service.ts | 165 +++++++++++++++++- router-dashboard/src/types/user.ts | 21 +++ .../src/controllers/AuthController.ts | 9 +- .../src/repositories/RouterRepository.ts | 1 - .../src/repositories/UserRepository.ts | 5 +- ve-router-backend/src/services/AuthService.ts | 13 +- .../src/services/SetupService.ts | 4 +- ve-router-backend/src/types/user.ts | 3 - 17 files changed, 432 insertions(+), 130 deletions(-) create mode 100644 router-dashboard/src/contexts/AuthContext.tsx create mode 100644 router-dashboard/src/types/user.ts diff --git a/db-scripts/01-init.sql b/db-scripts/01-init.sql index 5b3be98..55bddfa 100644 --- a/db-scripts/01-init.sql +++ b/db-scripts/01-init.sql @@ -106,7 +106,6 @@ CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS user_sessions ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, - session_token VARCHAR(255) UNIQUE NOT NULL, refresh_token VARCHAR(255) NOT NULL, ip_address VARCHAR(45) NOT NULL, user_agent TEXT, @@ -115,7 +114,6 @@ CREATE TABLE IF NOT EXISTS user_sessions ( last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_user_session_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT unique_session_token UNIQUE(session_token), CONSTRAINT unique_refresh_token UNIQUE(refresh_token) ); diff --git a/router-dashboard/package.json b/router-dashboard/package.json index d1e9f4d..62a2521 100644 --- a/router-dashboard/package.json +++ b/router-dashboard/package.json @@ -13,6 +13,7 @@ "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.14.1", "router-dashboard": "file:" }, "devDependencies": { diff --git a/router-dashboard/src/App.tsx b/router-dashboard/src/App.tsx index aab7bd7..9990489 100644 --- a/router-dashboard/src/App.tsx +++ b/router-dashboard/src/App.tsx @@ -1,20 +1,35 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; import Login from './components/Login'; import DashboardLayout from './components/dashboard/DashboardLayout'; +import { useAuth } from './contexts/AuthContext'; function App() { - const [isLoggedIn, setIsLoggedIn] = useState(false); - - // Simulating checking if the user is logged in (e.g., using localStorage or an API call) - useEffect(() => { - const userLoggedIn = localStorage.getItem('isLoggedIn') === 'true'; // Example logic - setIsLoggedIn(userLoggedIn); - }, []); + const { isAuthenticated } = useAuth(); return ( - <> - {isLoggedIn ? : } - + + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + ); } diff --git a/router-dashboard/src/components/Login.tsx b/router-dashboard/src/components/Login.tsx index fb72f82..eaf9682 100644 --- a/router-dashboard/src/components/Login.tsx +++ b/router-dashboard/src/components/Login.tsx @@ -1,33 +1,83 @@ -import React, { useState } from 'react'; -import Header from './dashboard/Header'; +import React, { useState, useEffect } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { apiService } from '../services/api.service'; +import { useNavigate } from 'react-router-dom'; const Login: React.FC = () => { + const navigate = useNavigate(); + const { setIsAuthenticated } = useAuth(); // Access the setIsAuthenticated function + const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [error, setError] = useState(''); + const [usernameError, setUsernameError] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [generalError, setGeneralError] = useState(''); - const handleLogin = (event: React.FormEvent) => { + const [sessionExpiredMessage, setSessionExpiredMessage] = useState(null); // State to store the session expired message + + useEffect(() => { + // Check if the URL has the sessionExpired query parameter + const urlParams = new URLSearchParams(window.location.search); + const sessionExpired = urlParams.get('sessionExpired'); + if (sessionExpired) { + setSessionExpiredMessage('Session expired or something went wrong. Please log in again.'); + } + }, []); // Empty dependency array means this will run only on component mount + + const handleLogin = async (event: React.FormEvent) => { event.preventDefault(); - - // Mock authentication logic - if (username === 'admin' && password === 'password') { - localStorage.setItem('user', JSON.stringify({ accessToken: 'fake-token' })); - window.location.href = '/dashboard'; + + let hasError = false; + + // Validate username and password + if (!username.trim()) { + setUsernameError('Username is required'); + hasError = true; } else { - setError('Invalid username or password'); + setUsernameError(''); + } + + if (!password.trim()) { + setPasswordError('Password is required'); + hasError = true; + } else { + setPasswordError(''); + } + + // Stop execution if there are validation errors + if (hasError) return; + + try { + const result = await apiService.login(username, password); + if (result) { + // Save tokens and user details in localStorage + localStorage.setItem('accessToken', result.accessToken); + localStorage.setItem('refreshToken', result.refreshToken); + localStorage.setItem('user', JSON.stringify(result.user)); + localStorage.setItem('isLoggedIn', 'true'); + + // Update the authentication state in the context + setIsAuthenticated(true); + + // Redirect to Dashboard + //navigate('/'); + window.location.href = '/'; + } else { + setGeneralError('Invalid username or password'); + } + } catch (error: any) { + setGeneralError(error.message || 'An unexpected error occurred'); } }; - + return (
- {/* Header */} -
{}} /> - {/* Login Content */}

Login

+ {/* Username Field */}
+ + {/* Password Field */}
- {error && ( -
{error}
- )} + + {/* General Error */} + {generalError &&
{generalError}
} + + {/* Display the session expired message */} + {sessionExpiredMessage &&
{sessionExpiredMessage}
} + + {/* Login Button */} )} @@ -99,18 +107,14 @@ const Navbar: React.FC = ({ activeTab, onTabChange }) => { className="p-1 rounded hover:bg-gray-700 transition-colors" title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'} > - {isCollapsed ? ( - - ) : ( - - )} + {isCollapsed ? : }
{/* Navigation Items */} - - {/* Expanded overlay when collapsed and hovered */} - {isHovered && isCollapsed && !isPinned && ( -
-
-

Router Management

-

System

-
- -
- )}
); }; diff --git a/router-dashboard/src/contexts/AuthContext.tsx b/router-dashboard/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..e7b0730 --- /dev/null +++ b/router-dashboard/src/contexts/AuthContext.tsx @@ -0,0 +1,41 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +interface AuthContextType { + isAuthenticated: boolean; + setIsAuthenticated: (value: boolean) => void; +} + +const AuthContext = createContext(undefined); + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; + +export const AuthProvider: React.FC = ({ children }) => { + const [isAuthenticated, setIsAuthenticated] = useState(() => { + // Check localStorage on initial load + return localStorage.getItem('isLoggedIn') === 'true'; + }); + + useEffect(() => { + // Sync authentication state with localStorage + if (isAuthenticated) { + localStorage.setItem('isLoggedIn', 'true'); + } else { + localStorage.removeItem('isLoggedIn'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + } + }, [isAuthenticated]); + + return ( + + {children} + + ); +}; diff --git a/router-dashboard/src/main.tsx b/router-dashboard/src/main.tsx index 953c388..9af0b00 100644 --- a/router-dashboard/src/main.tsx +++ b/router-dashboard/src/main.tsx @@ -1,11 +1,17 @@ // File: src/main.tsx -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter as Router } from 'react-router-dom'; // Import BrowserRouter for routing +import App from './App.tsx'; // Ensure App is properly imported +import './index.css'; +import { AuthProvider } from './contexts/AuthContext'; // Import the AuthProvider ReactDOM.createRoot(document.getElementById('root')!).render( - - , -) \ No newline at end of file + {/* Wrap the App with AuthProvider to enable authentication context */} + {/* Wrap the App with Router to enable routing */} + + + + +); diff --git a/router-dashboard/src/services/api.service.ts b/router-dashboard/src/services/api.service.ts index 6c69f00..25f0103 100644 --- a/router-dashboard/src/services/api.service.ts +++ b/router-dashboard/src/services/api.service.ts @@ -11,6 +11,35 @@ const DEFAULT_OPTIONS = { } }; +// Helper function to get Authorization header +const getAuthHeaders = () => { + const accessToken = localStorage.getItem('accessToken'); + if (!accessToken) { + console.error('No access token found in localStorage'); + return {}; + } + return { + Authorization: `Bearer ${accessToken}`, + }; +}; + +// Helper function to remove tokens from localStorage +const removeTokens = () => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + localStorage.removeItem('isLoggedIn'); +}; + +const redirectToLogin = (logMessage: string) => { + console.log(logMessage); + removeTokens(); // Remove all tokens if refresh token expired + + // Redirect to login page with a query parameter indicating session expiry + window.location.href = '/login?sessionExpired=true'; // Redirect with the query parameter +} + + // Helper function to log API responses in development const logResponse = (prefix: string, data: any) => { if (process.env.NODE_ENV === 'development') { @@ -19,9 +48,131 @@ const logResponse = (prefix: string, data: any) => { }; class ApiService { + async login(username: string, password: string): Promise<{ accessToken: string; refreshToken: string; user: any } | null> { + try { + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: 'POST', + ...DEFAULT_OPTIONS, + body: JSON.stringify({ username, password }), + }); + + if (!response.ok) { + throw new Error('Invalid username or password'); + } + + const data = await response.json(); + return { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + user: data.user, + }; + } catch (error) { + console.error('Login error:', error); + throw error; + } + } + + // Refresh access token using the refresh token + async refreshToken(): Promise { + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) { + console.error('No refresh token found in localStorage'); + return false; + } + + try { + const response = await fetch(`${API_BASE_URL}/auth/refresh-token`, { + method: 'POST', + ...DEFAULT_OPTIONS, + body: JSON.stringify({ refreshToken }), + }); + + if (!response.ok) { + console.log('Failed to refresh token: Expired or invalid token'); + return false; + } + + const data = await response.json(); + // Save the new access and refresh tokens + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + return true; + } catch (error) { + console.error('Error refreshing token:', error); + return false; + } + } + + // API call with token handling for expired access token or refresh token + async fetchWithAuth(url: string, options: RequestInit = {}): Promise { + // Get the Authorization headers dynamically + const authOptions = getAuthHeaders(); + + // Merge the passed options with the Authorization header inside the headers object + const finalOptions = { + ...options, // Include user-specified options like method, body, etc. + headers: { + ...(options.headers || {}), // Include existing headers from the passed options + ...authOptions, // Add Authorization header dynamically + }, + }; + + // Print the request details before sending it + //console.log('Request Details:'); + //console.log('URL:', url); + //console.log('Options:', JSON.stringify(finalOptions, null, 2)); // Pretty print the options + + const response = await fetch(url, { ...finalOptions }); + + if (response.status === 401) { + const errorData = await response.json(); + // Check for Access token expired + if (errorData.message === 'Access token expired') { + console.log("Access token is expired, initiating to re generate") + const refreshed = await this.refreshToken(); + if (refreshed) { + // Get the Authorization headers dynamically + const authOptions = getAuthHeaders(); + + // Retry the original request with new access token + return fetch(url, { ...options, ...authOptions }); + } else { + redirectToLogin("Refresh token is expired, removing all stored session data"); + } + } + + // Check for Refresh token expired + if (errorData.message === 'Refresh token expired') { + redirectToLogin("Refresh token is expired, removing all stored session data"); + } + + redirectToLogin(errorData.message); + } + return response; + } + + async logout(refreshToken: string): Promise { + try { + const response = await this.fetchWithAuth(`${API_BASE_URL}/auth/logout`, { + method: 'POST', + ...DEFAULT_OPTIONS, + body: JSON.stringify({ refreshToken }), + }); + + if (!response.ok) { + throw new Error('Failed to logout'); + } + + await response.json(); + } catch (error) { + console.error('Logout error:', error); + throw error; + } + } + async getAllRouters(filter: FilterType = 'all'): Promise { try { - const response = await fetch(`${API_BASE_URL}/routers?filter=${filter}`, { + const response = await this.fetchWithAuth(`${API_BASE_URL}/routers?filter=${filter}`, { method: 'GET', ...DEFAULT_OPTIONS }); @@ -112,7 +263,7 @@ class ApiService { async getRouterById(id: number): Promise { try { - const response = await fetch(`${API_BASE_URL}/routers/${id}`, { + const response = await this.fetchWithAuth(`${API_BASE_URL}/routers/${id}`, { ...DEFAULT_OPTIONS }); if (!response.ok) { @@ -142,7 +293,7 @@ class ApiService { last_seen: new Date().toISOString(), // Set current timestamp }; - const response = await fetch(`${API_BASE_URL}/routers`, { + const response = await this.fetchWithAuth(`${API_BASE_URL}/routers`, { method: 'POST', ...DEFAULT_OPTIONS, body: JSON.stringify(backendData), @@ -174,7 +325,7 @@ class ApiService { last_seen: new Date().toISOString() // Update timestamp on changes }; - const response = await fetch(`${API_BASE_URL}/routers/${id}`, { + const response = await this.fetchWithAuth(`${API_BASE_URL}/routers/${id}`, { method: 'PUT', ...DEFAULT_OPTIONS, body: JSON.stringify(backendData), @@ -193,7 +344,7 @@ class ApiService { async deleteRouter(id: number): Promise { try { - const response = await fetch(`${API_BASE_URL}/routers/${id}`, { + const response = await this.fetchWithAuth(`${API_BASE_URL}/routers/${id}`, { method: 'DELETE', ...DEFAULT_OPTIONS }); @@ -209,7 +360,7 @@ class ApiService { async getRoutersByFacility(facility: string): Promise { try { - const response = await fetch( + const response = await this.fetchWithAuth( `${API_BASE_URL}/routers/facility/${encodeURIComponent(facility)}`, { ...DEFAULT_OPTIONS } ); @@ -226,7 +377,7 @@ class ApiService { async checkApiStatus(): Promise { try { - const response = await fetch(`${API_BASE_URL}/routers`, { + const response = await this.fetchWithAuth(`${API_BASE_URL}/routers`, { method: 'GET', ...DEFAULT_OPTIONS }); diff --git a/router-dashboard/src/types/user.ts b/router-dashboard/src/types/user.ts new file mode 100644 index 0000000..5ac8d04 --- /dev/null +++ b/router-dashboard/src/types/user.ts @@ -0,0 +1,21 @@ +// User Role enum +export type UserRole = 'admin' | 'operator' | 'viewer' | 'api'; + +// User Status enum +export type UserStatus = 'active' | 'locked' | 'disabled'; + +// User Interface +export interface User { + id: number; + name: string; + username: string; + email: string; + password_hash: string; + role: UserRole; + status: UserStatus; + failed_login_attempts: number; + last_login: Date | null; + password_changed_at: Date; + created_at: Date; + updated_at: Date; +} \ No newline at end of file diff --git a/ve-router-backend/src/controllers/AuthController.ts b/ve-router-backend/src/controllers/AuthController.ts index 53b0820..0b67ab3 100644 --- a/ve-router-backend/src/controllers/AuthController.ts +++ b/ve-router-backend/src/controllers/AuthController.ts @@ -19,7 +19,7 @@ export class AuthController { const user = await this.service.validateUser(username, password); - const { accessToken, refreshToken, sessionToken } = this.service.generateTokens(user); + const { accessToken, refreshToken} = this.service.generateTokens(user); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days expiration // Check for an active session for this user @@ -48,7 +48,6 @@ export class AuthController { logger.info('Updating expired session.'); await this.service.updateUserSession(existingSession.refresh_token, { refresh_token: refreshToken, - session_token: sessionToken, expires_at: expiresAt }); res.json({ @@ -69,7 +68,6 @@ export class AuthController { // No session matches, Create a new user sessions const userSessionDTO: Partial = { user_id : user.id, - session_token : sessionToken, refresh_token : refreshToken, ip_address : req.ip, user_agent : req.headers['user-agent'], @@ -135,12 +133,11 @@ export class AuthController { role: userData.role as UserRole, }; - const { accessToken, refreshToken: newRefreshToken, sessionToken: newSessionToken } = + const { accessToken, refreshToken: newRefreshToken} = this.service.generateTokens(user); const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days expiration const userSessionData = { - session_token: newSessionToken, refresh_token: newRefreshToken, expires_at: newExpiresAt } @@ -176,7 +173,7 @@ export class AuthController { return res.status(400).json({ message: "Refresh Token is required" }); } - res.json({ message: 'Logged out successfully' }); + res.status(200).json({ message: 'Logged out successfully' }); } catch (err) { const error = err as Error; logger.error('Logout error:', { message: error.message, stack: error.stack }); diff --git a/ve-router-backend/src/repositories/RouterRepository.ts b/ve-router-backend/src/repositories/RouterRepository.ts index e8231d9..aa50422 100644 --- a/ve-router-backend/src/repositories/RouterRepository.ts +++ b/ve-router-backend/src/repositories/RouterRepository.ts @@ -141,7 +141,6 @@ export class RouterRepository { [routerId] ); - logger.info(`Containers for router ${routerId}:`, rows); return rows as Container[]; } catch (error) { logger.error(`Error fetching Containers for router ${routerId}:`, error); diff --git a/ve-router-backend/src/repositories/UserRepository.ts b/ve-router-backend/src/repositories/UserRepository.ts index 6c3c761..bd5dbdb 100644 --- a/ve-router-backend/src/repositories/UserRepository.ts +++ b/ve-router-backend/src/repositories/UserRepository.ts @@ -144,12 +144,11 @@ export class UserRepository { const [result] = await pool.query( `INSERT INTO user_sessions ( - user_id, session_token, refresh_token, ip_address, + user_id, refresh_token, ip_address, user_agent, expires_at, created_at, last_activity - ) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())`, + ) VALUES (?, ?, ?, ?, ?, NOW(), NOW())`, [ userSession.user_id, - userSession.session_token, userSession.refresh_token, userSession.ip_address, userSession.user_agent, diff --git a/ve-router-backend/src/services/AuthService.ts b/ve-router-backend/src/services/AuthService.ts index fa2c3d5..21508f5 100644 --- a/ve-router-backend/src/services/AuthService.ts +++ b/ve-router-backend/src/services/AuthService.ts @@ -19,7 +19,8 @@ export class AuthService { return jwt.sign( { userId: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET as string, - { expiresIn: '15m' } + //{ expiresIn: '30m' } + { expiresIn: '1m' } ); }; @@ -28,18 +29,18 @@ export class AuthService { const accessToken = jwt.sign( { userId: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET as string, - { expiresIn: '15m' } + //{ expiresIn: '30m' } + { expiresIn: '1m' } ); const refreshToken = jwt.sign( { userId: user.id, username: user.username, role: user.role, type: 'refresh' }, // Include a claim to distinguish token types process.env.JWT_SECRET as string, { expiresIn: '7d' } // Longer expiry for refresh token + //{ expiresIn: '1m' } ); - const sessionToken = crypto.randomBytes(40).toString('hex'); - - return { accessToken, refreshToken, sessionToken }; + return { accessToken, refreshToken }; } // Validate the user by username and password @@ -64,7 +65,6 @@ export class AuthService { async createUserSession (userSessionData: Partial) { const requiredFields = [ 'user_id', - 'session_token', 'refresh_token', 'ip_address', 'user_agent', @@ -103,7 +103,6 @@ export class AuthService { async updateUserSession (refreshToken:string, userSessionData: Partial) { const requiredFields = [ - 'session_token', 'refresh_token', 'expires_at' ]; diff --git a/ve-router-backend/src/services/SetupService.ts b/ve-router-backend/src/services/SetupService.ts index 9fd1b09..7eeb55a 100644 --- a/ve-router-backend/src/services/SetupService.ts +++ b/ve-router-backend/src/services/SetupService.ts @@ -16,9 +16,9 @@ export class SetupService { const defaultUsers = [ { name: 'API User', username: 'api_user', email: 'apiuser@ve.com', password: 'api_user@@124', role: 'api' }, { name: 'Administrator', username: 'admin', email: 'admin@ve.com', password: 'admin@@007', role: 'admin' }, + { name: 'Maqbool Patel', username: 'maqbool', email: 'maqbool@ve.com', password: 'maqbool@@210', role: 'admin' }, { name: 'Kavya Raghunath', username: 'kavya', email: 'kavya@ve.com', password: 'kavya@@124', role: 'viewer' }, - { name: 'Reid McKenzie', username: 'reid', email: 'reid@ve.com', password: 'reid@@321', role: 'viewer' }, - { name: 'Maqbool Patel', username: 'maqbool', email: 'maqbool@ve.com', password: 'maqbool@@210', role: 'viewer' }, + { name: 'Reid McKenzie', username: 'reid', email: 'reid@ve.com', password: 'reid@@321', role: 'viewer' } ]; const createdUsers = []; diff --git a/ve-router-backend/src/types/user.ts b/ve-router-backend/src/types/user.ts index 55c48b2..d9091c6 100644 --- a/ve-router-backend/src/types/user.ts +++ b/ve-router-backend/src/types/user.ts @@ -38,7 +38,6 @@ export interface UpdateUser { export interface UserSession { id: number; user_id: number; - session_token: string; refresh_token: string; ip_address: string; user_agent: string | null; @@ -50,7 +49,6 @@ export interface UserSession { // Create User Session Interface export interface CreateUserSessionDTO { user_id: number; - session_token: string; refresh_token: string; ip_address: string; user_agent: string | null; @@ -75,7 +73,6 @@ export interface UserWithSession { }; session: { id: number; - session_token: string; refresh_token: string; ip_address: string; user_agent: string | null;