diff --git a/backend/src/main/java/com/hitcommunications/servermanager/config/security/SecurityConfig.java b/backend/src/main/java/com/hitcommunications/servermanager/config/security/SecurityConfig.java index eddb0d5..53e5fed 100644 --- a/backend/src/main/java/com/hitcommunications/servermanager/config/security/SecurityConfig.java +++ b/backend/src/main/java/com/hitcommunications/servermanager/config/security/SecurityConfig.java @@ -40,7 +40,7 @@ public class SecurityConfig { .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/api/auth/login", "/api/auth/refresh").permitAll() + .requestMatchers("/", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout").permitAll() .anyRequest().authenticated() ) .authenticationProvider(authenticationProvider()) diff --git a/backend/src/main/java/com/hitcommunications/servermanager/controllers/AuthController.java b/backend/src/main/java/com/hitcommunications/servermanager/controllers/AuthController.java index e4fe68f..36794c5 100644 --- a/backend/src/main/java/com/hitcommunications/servermanager/controllers/AuthController.java +++ b/backend/src/main/java/com/hitcommunications/servermanager/controllers/AuthController.java @@ -20,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.time.Duration; + @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -49,6 +51,29 @@ public class AuthController { return ResponseEntity.ok(user); } + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request) { + boolean secure = request.isSecure(); + ResponseCookie expiredAccessCookie = CookieUtils.buildCookie( + JwtAuthenticationFilter.ACCESS_TOKEN_COOKIE, + "", + Duration.ZERO, + false, + secure + ); + ResponseCookie expiredRefreshCookie = CookieUtils.buildCookie( + REFRESH_TOKEN_COOKIE, + "", + Duration.ZERO, + true, + secure + ); + + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, expiredAccessCookie.toString(), expiredRefreshCookie.toString()) + .build(); + } + private ResponseEntity buildResponse(AuthTokens tokens, HttpServletRequest request) { boolean secure = request.isSecure(); ResponseCookie accessCookie = CookieUtils.buildCookie( diff --git a/frontend/src/Api.ts b/frontend/src/Api.ts index e126c59..42ba3f2 100644 --- a/frontend/src/Api.ts +++ b/frontend/src/Api.ts @@ -2,6 +2,7 @@ import axios from "axios"; const baseURL = (import.meta.env.VITE_BACKEND_URL as string) || "http://localhost:8080"; const REFRESH_ENDPOINT = "/api/auth/refresh"; +const LOGOUT_ENDPOINT = "/api/auth/logout"; const ACCESS_TOKEN_COOKIE = "access_token"; const REFRESH_THRESHOLD_MS = 60_000; @@ -73,7 +74,19 @@ export const setAuthToken = (token?: string) => { let refreshPromise: Promise | null = null; -const refreshAccessToken = async () => { +const clearAccessTokenCookie = () => { + if (typeof document === "undefined") { + return; + } + document.cookie = `${ACCESS_TOKEN_COOKIE}=; Max-Age=0; path=/`; +}; + +const resetAuthState = () => { + clearAccessTokenCookie(); + setAuthToken(undefined); +}; + +export const refreshAccessToken = async () => { if (!refreshPromise) { refreshPromise = api .post(REFRESH_ENDPOINT) @@ -92,6 +105,56 @@ const refreshAccessToken = async () => { return refreshPromise; }; +const ensureTokenFromRefresh = async () => { + await refreshAccessToken(); + const updatedToken = getAccessToken(); + if (!updatedToken) { + throw new Error("Token indisponível após atualização."); + } + setAuthToken(updatedToken); + return updatedToken; +}; + +export const validateSession = async () => { + try { + const token = getAccessToken(); + if (!token) { + await ensureTokenFromRefresh(); + return true; + } + + const payload = decodeJwtPayload(token); + if (!payload?.sub) { + await ensureTokenFromRefresh(); + return true; + } + + if (payload.exp && payload.exp * 1000 <= Date.now()) { + await ensureTokenFromRefresh(); + return true; + } + + setAuthToken(token); + + if (isTokenExpiringSoon(token)) { + await ensureTokenFromRefresh(); + } + + return true; + } catch { + resetAuthState(); + return false; + } +}; + +export const logout = async () => { + try { + await api.post(LOGOUT_ENDPOINT); + } finally { + resetAuthState(); + } +}; + api.interceptors.request.use(async (config) => { const isRefreshCall = config.url?.includes(REFRESH_ENDPOINT); @@ -120,7 +183,7 @@ api.interceptors.response.use( (response) => response, (error) => { if (error?.response?.status === 401) { - setAuthToken(undefined); + resetAuthState(); const currentPath = window.location.pathname; if (currentPath !== "/login") { window.location.href = "/login"; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 9c5ed23..2eafa09 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,6 +1,6 @@ import { type ChangeEvent, type FormEvent, useEffect, useState } from "react"; import toast from "react-hot-toast"; -import api from "../Api"; +import api, { logout as requestLogout } from "../Api"; import { HeaderActions } from "./header/HeaderActions"; import { HeaderBrand } from "./header/HeaderBrand"; import { ProfileModal } from "./header/ProfileModal"; @@ -10,6 +10,7 @@ import type { Applications, DatabaseType, ServersType } from "../types/enums"; import type { User } from "../types/User"; import type { ProfileFormState, ServerFormState } from "./header/types"; import type { BulkImportResult } from "../types/BulkImport"; +import { useNavigate } from "react-router-dom"; type ModalType = "addServer" | "editProfile" | "bulkImport" | null; @@ -43,6 +44,7 @@ const applicationOptions: Applications[] = ["ASTERISK", "HITMANAGER", "HITMANAGE const databaseOptions: DatabaseType[] = ["MYSQL", "POSTGRESQL", "SQLSERVER", "ORACLE", "REDIS", "MONGODB", "MARIADB", "NONE"]; export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdated }: HeaderProps) => { + const navigate = useNavigate(); const [isMenuOpen, setMenuOpen] = useState(false); const [activeModal, setActiveModal] = useState(null); const [serverForm, setServerForm] = useState(defaultServerForm); @@ -178,6 +180,20 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat URL.revokeObjectURL(url); }; + const handleLogout = async () => { + setMenuOpen(false); + setActiveModal(null); + try { + await requestLogout(); + toast.success("Sessão encerrada com sucesso."); + } catch (err: any) { + const message = err?.response?.data?.message || "Não foi possível encerrar a sessão."; + toast.error(message); + } finally { + navigate("/login", { replace: true }); + } + }; + return ( <>
@@ -188,6 +204,7 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat onAddServer={() => openModal("addServer")} onEditProfile={() => openModal("editProfile")} onBulkCreate={() => openModal("bulkImport")} + onLogout={handleLogout} />
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index de89e4f..174d2d6 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import api, { getCurrentUsername } from "../Api"; import { Layout } from "../components/Layout"; import { Header } from "../components/Header"; @@ -11,6 +12,7 @@ import type { Applications, DatabaseType, ServersType } from "../types/enums"; import toast from "react-hot-toast"; export const Dashboard = () => { + const navigate = useNavigate(); const [servers, setServers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -54,7 +56,10 @@ export const Dashboard = () => { try { const username = getCurrentUsername(); if (!username) { - setUserError("Não foi possível identificar o usuário logado."); + const message = "Não foi possível identificar o usuário logado."; + setUserError(message); + toast.error(message); + navigate("/login", { replace: true }); return; } const { data } = await api.get(`/api/users/username/${encodeURIComponent(username)}`); @@ -65,7 +70,7 @@ export const Dashboard = () => { setUserError(message); toast.error(message); } - }, []); + }, [navigate]); useEffect(() => { fetchCurrentUser(); diff --git a/frontend/src/routes/ProtectedRoute.tsx b/frontend/src/routes/ProtectedRoute.tsx index 9ec1a97..88c4994 100644 --- a/frontend/src/routes/ProtectedRoute.tsx +++ b/frontend/src/routes/ProtectedRoute.tsx @@ -1,16 +1,45 @@ -import type { ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { Navigate, useLocation } from "react-router-dom"; -import { getAccessToken } from "../Api"; +import { validateSession } from "../Api"; interface ProtectedRouteProps { children: ReactNode; } export const ProtectedRoute = ({ children }: ProtectedRouteProps) => { - const token = getAccessToken(); const location = useLocation(); + const [checkingAuth, setCheckingAuth] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); - if (!token) { + useEffect(() => { + let active = true; + const verify = async () => { + try { + const valid = await validateSession(); + if (active) { + setIsAuthenticated(valid); + } + } finally { + if (active) { + setCheckingAuth(false); + } + } + }; + void verify(); + return () => { + active = false; + }; + }, []); + + if (checkingAuth) { + return ( +
+ Validando sessão... +
+ ); + } + + if (!isAuthenticated) { return ; }