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())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/", "/api/auth/login", "/api/auth/refresh").permitAll()
|
.requestMatchers("/", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.authenticationProvider(authenticationProvider())
|
.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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
|
@ -49,6 +51,29 @@ public class AuthController {
|
||||||
return ResponseEntity.ok(user);
|
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) {
|
private ResponseEntity<AuthResponse> buildResponse(AuthTokens tokens, HttpServletRequest request) {
|
||||||
boolean secure = request.isSecure();
|
boolean secure = request.isSecure();
|
||||||
ResponseCookie accessCookie = CookieUtils.buildCookie(
|
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 baseURL = (import.meta.env.VITE_BACKEND_URL as string) || "http://localhost:8080";
|
||||||
const REFRESH_ENDPOINT = "/api/auth/refresh";
|
const REFRESH_ENDPOINT = "/api/auth/refresh";
|
||||||
|
const LOGOUT_ENDPOINT = "/api/auth/logout";
|
||||||
const ACCESS_TOKEN_COOKIE = "access_token";
|
const ACCESS_TOKEN_COOKIE = "access_token";
|
||||||
const REFRESH_THRESHOLD_MS = 60_000;
|
const REFRESH_THRESHOLD_MS = 60_000;
|
||||||
|
|
||||||
|
|
@ -73,7 +74,19 @@ export const setAuthToken = (token?: string) => {
|
||||||
|
|
||||||
let refreshPromise: Promise<void> | null = null;
|
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) {
|
if (!refreshPromise) {
|
||||||
refreshPromise = api
|
refreshPromise = api
|
||||||
.post(REFRESH_ENDPOINT)
|
.post(REFRESH_ENDPOINT)
|
||||||
|
|
@ -92,6 +105,56 @@ const refreshAccessToken = async () => {
|
||||||
return refreshPromise;
|
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) => {
|
api.interceptors.request.use(async (config) => {
|
||||||
const isRefreshCall = config.url?.includes(REFRESH_ENDPOINT);
|
const isRefreshCall = config.url?.includes(REFRESH_ENDPOINT);
|
||||||
|
|
||||||
|
|
@ -120,7 +183,7 @@ api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error?.response?.status === 401) {
|
if (error?.response?.status === 401) {
|
||||||
setAuthToken(undefined);
|
resetAuthState();
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
if (currentPath !== "/login") {
|
if (currentPath !== "/login") {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { type ChangeEvent, type FormEvent, useEffect, useState } from "react";
|
import { type ChangeEvent, type FormEvent, useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import api from "../Api";
|
import api, { logout as requestLogout } from "../Api";
|
||||||
import { HeaderActions } from "./header/HeaderActions";
|
import { HeaderActions } from "./header/HeaderActions";
|
||||||
import { HeaderBrand } from "./header/HeaderBrand";
|
import { HeaderBrand } from "./header/HeaderBrand";
|
||||||
import { ProfileModal } from "./header/ProfileModal";
|
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 { User } from "../types/User";
|
||||||
import type { ProfileFormState, ServerFormState } from "./header/types";
|
import type { ProfileFormState, ServerFormState } from "./header/types";
|
||||||
import type { BulkImportResult } from "../types/BulkImport";
|
import type { BulkImportResult } from "../types/BulkImport";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type ModalType = "addServer" | "editProfile" | "bulkImport" | null;
|
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"];
|
const databaseOptions: DatabaseType[] = ["MYSQL", "POSTGRESQL", "SQLSERVER", "ORACLE", "REDIS", "MONGODB", "MARIADB", "NONE"];
|
||||||
|
|
||||||
export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdated }: HeaderProps) => {
|
export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdated }: HeaderProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [isMenuOpen, setMenuOpen] = useState(false);
|
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||||
const [activeModal, setActiveModal] = useState<ModalType>(null);
|
const [activeModal, setActiveModal] = useState<ModalType>(null);
|
||||||
const [serverForm, setServerForm] = useState<ServerFormState>(defaultServerForm);
|
const [serverForm, setServerForm] = useState<ServerFormState>(defaultServerForm);
|
||||||
|
|
@ -178,6 +180,20 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
|
||||||
URL.revokeObjectURL(url);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className={Styles.wrapper}>
|
<header className={Styles.wrapper}>
|
||||||
|
|
@ -188,6 +204,7 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
|
||||||
onAddServer={() => openModal("addServer")}
|
onAddServer={() => openModal("addServer")}
|
||||||
onEditProfile={() => openModal("editProfile")}
|
onEditProfile={() => openModal("editProfile")}
|
||||||
onBulkCreate={() => openModal("bulkImport")}
|
onBulkCreate={() => openModal("bulkImport")}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import api, { getCurrentUsername } from "../Api";
|
import api, { getCurrentUsername } from "../Api";
|
||||||
import { Layout } from "../components/Layout";
|
import { Layout } from "../components/Layout";
|
||||||
import { Header } from "../components/Header";
|
import { Header } from "../components/Header";
|
||||||
|
|
@ -11,6 +12,7 @@ import type { Applications, DatabaseType, ServersType } from "../types/enums";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -54,7 +56,10 @@ export const Dashboard = () => {
|
||||||
try {
|
try {
|
||||||
const username = getCurrentUsername();
|
const username = getCurrentUsername();
|
||||||
if (!username) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const { data } = await api.get<User>(`/api/users/username/${encodeURIComponent(username)}`);
|
const { data } = await api.get<User>(`/api/users/username/${encodeURIComponent(username)}`);
|
||||||
|
|
@ -65,7 +70,7 @@ export const Dashboard = () => {
|
||||||
setUserError(message);
|
setUserError(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCurrentUser();
|
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 { Navigate, useLocation } from "react-router-dom";
|
||||||
import { getAccessToken } from "../Api";
|
import { validateSession } from "../Api";
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||||
const token = getAccessToken();
|
|
||||||
const location = useLocation();
|
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 />;
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue