feat(auth): validar sessão e logout

- Adiciona endpoint de logout no backend e libera na configuração de segurança
- Implementa validação e renovação automática dos tokens no cliente
- Integra botão de sair ao fluxo de logout e redireciona para login
master
Artur Oliveira 2025-12-16 15:36:02 -03:00
parent 4efdfc9970
commit d08e42732f
6 changed files with 149 additions and 10 deletions

View File

@ -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())

View File

@ -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<Void> 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<AuthResponse> buildResponse(AuthTokens tokens, HttpServletRequest request) {
boolean secure = request.isSecure();
ResponseCookie accessCookie = CookieUtils.buildCookie(

View File

@ -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<void> | 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";

View File

@ -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<ModalType>(null);
const [serverForm, setServerForm] = useState<ServerFormState>(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 (
<>
<header className={Styles.wrapper}>
@ -188,6 +204,7 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
onAddServer={() => openModal("addServer")}
onEditProfile={() => openModal("editProfile")}
onBulkCreate={() => openModal("bulkImport")}
onLogout={handleLogout}
/>
</header>

View File

@ -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<Server[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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<User>(`/api/users/username/${encodeURIComponent(username)}`);
@ -65,7 +70,7 @@ export const Dashboard = () => {
setUserError(message);
toast.error(message);
}
}, []);
}, [navigate]);
useEffect(() => {
fetchCurrentUser();

View File

@ -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 (
<div className="flex h-screen items-center justify-center text-text">
Validando sessão...
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}