diff --git a/webapp/src/app/[locale]/(GlobalWrapper)/(Auth)/layout.tsx b/webapp/src/app/[locale]/(GlobalWrapper)/(Auth)/layout.tsx index b0839ba..62ec5cc 100644 --- a/webapp/src/app/[locale]/(GlobalWrapper)/(Auth)/layout.tsx +++ b/webapp/src/app/[locale]/(GlobalWrapper)/(Auth)/layout.tsx @@ -1,110 +1,63 @@ 'use client'; -import { useAppSelector } from "@/redux/store" -import { isLoggedIn, mountCheckIfValid, logOut } from "@/redux/features/auth-slice"; -import { useDispatch } from "react-redux" -import { AppDispatch } from '@/redux/store'; -import { useEffect, useCallback } from "react"; -import { useRouter } from 'next/navigation' -import { ReactNode } from 'react' -import FullScreenLoader from "@/components/common/fullScreenLoader"; -import { load } from '@/redux/features/settings-slice' +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAppDispatch, useAppSelector } from '@/redux/store'; +import { checkAuth, selectAuth } from '@/redux/features/auth-slice'; +import FullScreenLoader from '@/components/common/fullScreenLoader'; import ErrorBoundary from '@/components/common/ErrorBoundary'; -import axios from 'axios'; // Added axios import +import { AppDispatch } from '@/redux/store'; // HOC for auth pages (only accept auth user) -export default function LocaleLayout({ children }: { children: ReactNode }) { - const router = useRouter(); - const dispatch = useDispatch(); - - // Load states - const isValid = useAppSelector((state) => state.authReducer.value.isValid); - const checkIfValidMounted = useAppSelector((state) => state.authReducer.value.checkIfValidMounted); - const notAuthRedirectPage = useAppSelector((state) => state.settingsReducer.value.notAuthRedirectPage); - const isLoadingSettings = useAppSelector((state) => state.settingsReducer.value.isLoadingSettings); - const loadedFirstTime = useAppSelector((state) => state.settingsReducer.value.loadedFirstTime); +export default function LocaleLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { isValid, checkIfValidMounted } = useAppSelector(selectAuth); + const notAuthRedirectPage = '/login'; - // Handle not logged in state - const handleNotLoggedIn = useCallback(() => { - // Clear any invalid token from cookies - document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; - // Clear Redux state - dispatch(logOut()); - // Redirect to login page - router.push(notAuthRedirectPage); - }, [notAuthRedirectPage, router, dispatch]); + // Check authentication status on mount and periodically + useEffect(() => { + const checkAuthentication = async () => { + try { + await (dispatch as AppDispatch)(checkAuth()); + } catch (error) { + console.error('Authentication check failed:', error); + } + }; - // Initialize auth check - useEffect(() => { - if (checkIfValidMounted) return; - - const checkAuth = async () => { - try { - await dispatch(mountCheckIfValid()); - await dispatch(isLoggedIn({ NotLoggedInCallback: handleNotLoggedIn })); - } catch (error) { - console.error('Auth check failed:', error); - handleNotLoggedIn(); - } - }; + // Initial check + checkAuthentication(); - checkAuth(); - }, [checkIfValidMounted, dispatch, handleNotLoggedIn]); + // Set up periodic check (every 5 minutes) + const intervalId = setInterval(checkAuthentication, 5 * 60 * 1000); - // Load settings - useEffect(() => { - if (loadedFirstTime || isLoadingSettings) return; - - const loadSettings = async () => { - try { - await dispatch(load({ page: 1 })); - } catch (error) { - console.error('Failed to load settings:', error); - } - }; + // Cleanup interval on component unmount + return () => clearInterval(intervalId); + }, [dispatch]); - loadSettings(); - }, [loadedFirstTime, isLoadingSettings, dispatch]); - - // Add global error handler for API requests - useEffect(() => { - const responseInterceptor = (response: any) => response; - - const errorInterceptor = (error: any) => { - if (error.response?.status === 401) { - // Handle unauthorized (token expired/invalid) - handleNotLoggedIn(); - } - return Promise.reject(error); - }; - - // Add request interceptor - const requestInterceptor = axios.interceptors.request.use( - config => config, - error => Promise.reject(error) - ); - - // Add response interceptor - const responseIntercept = axios.interceptors.response.use( - responseInterceptor, - errorInterceptor - ); - - // Cleanup function - return () => { - axios.interceptors.request.eject(requestInterceptor); - axios.interceptors.response.eject(responseIntercept); - }; - }, [handleNotLoggedIn]); - - // Show loading state while checking auth - if (isValid === null) { - return ; + // Handle redirection based on auth status + useEffect(() => { + if (checkIfValidMounted && !isValid) { + // Clear any existing token + document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + + // Only redirect if not already on the login page + if (window.location.pathname !== notAuthRedirectPage) { + window.location.href = notAuthRedirectPage; + } } + }, [isValid, checkIfValidMounted]); - return ( - - {isValid ? children : } - - ); + // Show loading state while checking auth + if (!checkIfValidMounted) { + return ; + } + + // If not valid, we'll be redirected by the effect above + if (!isValid) { + return ; + } + + // If valid, render the protected content + return {children}; } diff --git a/webapp/src/app/api/auth/verify/route.ts b/webapp/src/app/api/auth/verify/route.ts new file mode 100644 index 0000000..307adff --- /dev/null +++ b/webapp/src/app/api/auth/verify/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import jwt from 'jsonwebtoken'; +import { dbConnect } from '@/lib/dbConnect'; +import User from '@/models/User'; + +export async function GET() { + try { + await dbConnect(); + + // Get the token from cookies + const cookieStore = cookies(); + const token = cookieStore.get('authToken')?.value; + + if (!token) { + return NextResponse.json( + { valid: false, message: 'No token provided' }, + { status: 401 } + ); + } + + try { + // Verify the token + const decoded = jwt.verify(token, process.env.JWT_SECRET!); + + // Check if user still exists + const user = await User.findById(decoded.userId).select('-password'); + if (!user) { + return NextResponse.json( + { valid: false, message: 'User not found' }, + { status: 401 } + ); + } + + return NextResponse.json({ valid: true }); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return NextResponse.json( + { valid: false, message: 'Token expired' }, + { status: 401 } + ); + } + + return NextResponse.json( + { valid: false, message: 'Invalid token' }, + { status: 401 } + ); + } + } catch (error) { + console.error('Token verification error:', error); + return NextResponse.json( + { valid: false, message: 'Server error' }, + { status: 500 } + ); + } +} diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts new file mode 100644 index 0000000..afe7ffc --- /dev/null +++ b/webapp/src/lib/api.ts @@ -0,0 +1,50 @@ +import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { store } from '@/redux/store'; +import { logOut } from '@/redux/features/auth-slice'; + +const api: AxiosInstance = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor to add auth token +api.interceptors.request.use( + (config) => { + const token = store.getState().authReducer.value.authToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor to handle 401 errors +api.interceptors.response.use( + (response: AxiosResponse) => response, + async (error: AxiosError) => { + const originalRequest = error.config as any; + + // If the error is 401 and we haven't tried to refresh yet + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + // Clear any existing auth state + store.dispatch(logOut()); + document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + + // Redirect to login page + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + } + + return Promise.reject(error); + } +); + +export default api; diff --git a/webapp/src/lib/auth.ts b/webapp/src/lib/auth.ts new file mode 100644 index 0000000..2ae9dba --- /dev/null +++ b/webapp/src/lib/auth.ts @@ -0,0 +1,47 @@ +import jwt from 'jsonwebtoken'; +import { cookies } from 'next/headers'; + +export async function verifyToken(token?: string): Promise { + if (!token) return false; + + try { + // Verify the token + const decoded = jwt.verify(token, process.env.JWT_SECRET!); + return !!decoded; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + console.log('Token expired'); + } else if (error instanceof jwt.JsonWebTokenError) { + console.log('Invalid token'); + } + return false; + } +} + +export function getAuthToken(): string | undefined { + const cookieStore = cookies(); + return cookieStore.get('authToken')?.value; +} + +export function clearAuthToken() { + document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; +} + +export function setAuthToken(token: string) { + const expires = new Date(); + expires.setTime(expires.getTime() + 3 * 60 * 60 * 1000); // 3 hours + + document.cookie = `authToken=${token}; Path=/; Expires=${expires.toUTCString()}; HttpOnly; SameSite=Lax`; +} + +// Helper to get user ID from token +export function getUserIdFromToken(token?: string): string | null { + if (!token) return null; + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string }; + return decoded.userId; + } catch (error) { + return null; + } +} diff --git a/webapp/src/middleware.ts b/webapp/src/middleware.ts index 99bc4cf..817f4c9 100644 --- a/webapp/src/middleware.ts +++ b/webapp/src/middleware.ts @@ -6,57 +6,92 @@ * * source : https://nextjs.org/docs/app/building-your-application/routing/middleware */ -import createIntlMiddleware from 'next-intl/middleware'; -import { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server' -import validateAuthToken from '@/middleware/validateAuthToken' +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { verifyToken } from '@/lib/auth'; -export default async function middleware(request: NextRequest) { +// Define protected API routes +const protectedApiRoutes = [ + '/api/user', + // Add other protected API routes here +]; - // log the request general informations - let ip = request.ip ?? request.headers.get('X-Forwarded-For')?.split(':')[3] - console.log("request to : " ,request.nextUrl.pathname , 'from ip :' , ip) +// Define public routes that don't require authentication +const publicRoutes = [ + '/login', + '/register', + '/forgot-password', + '/api/auth', + '/api/auth/verify', + // Add other public routes here +]; - // handle pages - if(!request.nextUrl.pathname.startsWith('/api')) - { - // handle next-intl Internationalization - const defaultLocale = request.headers.get('x-default-locale') || 'ar'; - const handleI18nRouting = createIntlMiddleware({ - locales: ['ar', 'en'], - defaultLocale - }); - const response = handleI18nRouting(request); - response.headers.set('x-default-locale', defaultLocale); - - return response; +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Skip middleware for public routes + if (publicRoutes.some(route => pathname.startsWith(route))) { + return NextResponse.next(); } - // handle api routes - // must be user routes + // Check if the request is for a protected API route + const isProtectedApiRoute = protectedApiRoutes.some(route => pathname.startsWith(route)); + + if (isProtectedApiRoute) { + try { + // Verify the token + const token = request.cookies.get('authToken')?.value; + + if (!token) { + return NextResponse.json( + { success: false, message: 'No token provided' }, + { status: 401 } + ); + } - // protect /api/user routes - if(request.nextUrl.pathname.startsWith('/api/user')) - { - let authToken : {name:string , value : string} | undefined = request.cookies.get('authToken') - let authValidation : boolean | undefined = await validateAuthToken(authToken?.value) + const isValid = await verifyToken(token); + + if (!isValid) { + return NextResponse.json( + { success: false, message: 'Invalid or expired token' }, + { status: 401 } + ); + } - if(!authValidation) - { // you are not auth you cant access this route - return new NextResponse( - JSON.stringify({ - success: false, - message: "notAllowed", - // @ts-ignore - } , {status : 405 , headers: { 'content-type': 'application/json'}}) - ) + // Token is valid, continue with the request + return NextResponse.next(); + } catch (error) { + console.error('Authentication error:', error); + return NextResponse.json( + { success: false, message: 'Authentication failed' }, + { status: 500 } + ); } - // you can access the api route - return NextResponse.next() } + + // For non-API routes, check if user is authenticated + const isAuthenticated = await verifyToken(request.cookies.get('authToken')?.value); + + // If not authenticated and trying to access a protected page, redirect to login + if (!isAuthenticated && !pathname.startsWith('/login')) { + const loginUrl = new URL('/login', request.url); + loginUrl.searchParams.set('from', pathname); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); } -// represent the routes that this middleware supposed to handle them +// Configure which routes should be processed by the middleware export const config = { - matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public folder + */ + '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], }; \ No newline at end of file diff --git a/webapp/src/redux/features/auth-slice.ts b/webapp/src/redux/features/auth-slice.ts index 54fc484..f2f8d08 100644 --- a/webapp/src/redux/features/auth-slice.ts +++ b/webapp/src/redux/features/auth-slice.ts @@ -5,156 +5,119 @@ * * https://redux-toolkit.js.org/api/createAsyncThunk */ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' -import axios from 'axios' -import Cookies from 'universal-cookie'; -import { fireAlert } from './alert-slice'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { RootState } from '@/redux/store'; +import api from '@/lib/api'; +import { setLoading } from './ui-slice'; -const cookies = new Cookies(); - -type IinitialState = { - value: AuthState; +interface AuthState { + authToken: string | null; + isValid: boolean | null; + checkIfValidMounted: boolean; + lastChecked: number | null; } -type AuthState = { - authToken: string | null; - isValid: boolean | null; - checkIfValidMounted: boolean; -} +const initialState: AuthState = { + authToken: null, + isValid: null, + checkIfValidMounted: false, + lastChecked: null, +}; -const initialState = { - value: { - // try to get the token from the browser cookies - authToken: cookies.get("authToken") || null, - isValid: null, - checkIfValidMounted: false, +export const checkAuth = createAsyncThunk( + 'auth/checkAuth', + async (_, { dispatch, getState }) => { + try { + const { authReducer } = getState() as { authReducer: AuthState }; + + // Skip if we've checked recently (within last 5 minutes) + if (authReducer.lastChecked && (Date.now() - authReducer.lastChecked < 5 * 60 * 1000)) { + return { isValid: authReducer.isValid }; + } + + if (!authReducer.authToken) { + return { isValid: false }; + } + + const response = await api.get('/auth/verify'); + return { isValid: response.data.valid }; + } catch (error) { + console.error('Auth check failed:', error); + return { isValid: false }; } -} as IinitialState + } +); -const logIn = createAsyncThunk( - 'auth/logInStatus', - async (actionPayload: { username: string, password: string, successCallback: any }, thunkAPI) => { - try { - let { data } = await axios.post('/api/auth', actionPayload) - if (data.success) { - actionPayload.successCallback() - // fire the success alert - thunkAPI.dispatch(fireAlert({ - success: true, - message: "loggedIn", - })) - return data - } - else { - thunkAPI.dispatch(fireAlert({ - success: false, - message: data.message - })) - return { success: false } - } - } catch (err) { - thunkAPI.dispatch(fireAlert({ - success: false, - message: "unkownError", - })) - return { success: false } - } +export const login = createAsyncThunk( + 'auth/login', + async (credentials: { username: string; password: string }, { dispatch }) => { + try { + dispatch(setLoading(true)); + const response = await api.post('/auth/login', credentials); + return { + authToken: response.data.token, + isValid: true + }; + } finally { + dispatch(setLoading(false)); } -) + } +); -const isLoggedIn = createAsyncThunk( - 'auth/isLoggedInStatus', - async (actionPayload: { LoggedInCallback?: () => void; NotLoggedInCallback?: () => void }, { dispatch, getState }) => { - try { - const state = getState() as RootState; - const { authToken } = state.authReducer.value; - - // If no token, immediately trigger not logged in - if (!authToken) { - if (actionPayload.NotLoggedInCallback) { - actionPayload.NotLoggedInCallback(); - } - return { isValid: false }; - } - - try { - const { data } = await axios.get('/api/auth?authToken=' + authToken); - - if (!data.success) { - // If token is invalid or expired, clear it - if (data.message === 'expiredToken' || data.message === 'invalidToken') { - dispatch(logOut()); - document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; - } - - if (actionPayload.NotLoggedInCallback) { - actionPayload.NotLoggedInCallback(); - } - } else if (actionPayload.LoggedInCallback) { - actionPayload.LoggedInCallback(); - } - - return { isValid: data.success }; - } catch (error: any) { - // Handle network errors or server issues - console.error('Token validation error:', error); - - // If it's an auth-related error, clear the token - if (error.response?.status === 401 || error.response?.data?.message === 'expiredToken') { - dispatch(logOut()); - document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; - } - - if (actionPayload.NotLoggedInCallback) { - actionPayload.NotLoggedInCallback(); - } - - return { isValid: false }; - } - } catch (error) { - console.error('Error in isLoggedIn:', error); - if (actionPayload.NotLoggedInCallback) { - actionPayload.NotLoggedInCallback(); - } - return { isValid: false }; - } +export const logout = createAsyncThunk( + 'auth/logout', + async (_, { dispatch }) => { + try { + await api.post('/auth/logout'); + } catch (error) { + console.error('Logout error:', error); + } finally { + // Clear token from cookies + document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + return { isValid: false, authToken: null }; } -) + } +); -export const auth = createSlice({ - name: "auth", - initialState, - reducers: { - logOut: (state) => { - // remove the authToken cookie - cookies.remove('authToken', { path: '/' }); - // reset the state to there initial value - state.value.isValid = null - state.value.authToken = null - }, - mountCheckIfValid: (state) => { - state.value.checkIfValidMounted = true - }, +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setAuthToken: (state, action) => { + state.authToken = action.payload; + state.isValid = !!action.payload; }, - extraReducers: (builder) => { - // logIn thunk reducer - builder.addCase(logIn.fulfilled, (state: IinitialState, action) => { - // set the state - if (action.payload.success) { - state.value.authToken = action.payload.authToken; - state.value.isValid = true - } - }) - // check if user authToken cookie is a valid one - builder.addCase(isLoggedIn.fulfilled, (state: IinitialState, action) => { - // set the state - state.value.isValid = action.payload.isValid; - state.value.checkIfValidMounted = true - }) + clearAuth: (state) => { + state.authToken = null; + state.isValid = false; + state.checkIfValidMounted = true; }, -}) + }, + extraReducers: (builder) => { + builder + .addCase(checkAuth.fulfilled, (state, action) => { + state.isValid = action.payload.isValid; + state.checkIfValidMounted = true; + state.lastChecked = Date.now(); + + if (!action.payload.isValid) { + state.authToken = null; + document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + } + }) + .addCase(login.fulfilled, (state, action) => { + state.authToken = action.payload.authToken; + state.isValid = action.payload.isValid; + state.lastChecked = Date.now(); + }) + .addCase(logout.fulfilled, (state) => { + state.authToken = null; + state.isValid = false; + state.lastChecked = null; + }); + }, +}); -// export the functions -export const { logOut, mountCheckIfValid } = auth.actions; -export { logIn, isLoggedIn }; -export default auth.reducer; \ No newline at end of file +export const { setAuthToken, clearAuth } = authSlice.actions; +export const selectAuth = (state: RootState) => state.auth; +export default authSlice.reducer; \ No newline at end of file