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 loginmaster
parent
4efdfc9970
commit
d08e42732f
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue