save tokens localstorage, fix refresh sql query, added permissions
This commit is contained in:
@@ -1,7 +1,3 @@
|
|||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-shadow: 1px 1px 20px 1px rgba(68, 68, 68, 0.5);
|
|
||||||
border-radius: 40px;
|
|
||||||
border: 1px solid #444;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,16 @@ import './App.css'
|
|||||||
import { Route, Routes } from "react-router-dom";
|
import { Route, Routes } from "react-router-dom";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { ForgotPasswordPage } from "./pages/ForgotPasswordPage";
|
import { ForgotPasswordPage } from "./pages/ForgotPasswordPage";
|
||||||
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
|
import { RequirePermission } from "./components/RequirePermission";
|
||||||
|
import { RedirectIfAuthenticated } from "./components/RedirectIfAuthenticated";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LoginPage />} />
|
<Route path="/" element={<RedirectIfAuthenticated><LoginPage /></RedirectIfAuthenticated>} />
|
||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<RedirectIfAuthenticated><ForgotPasswordPage /></RedirectIfAuthenticated>} />
|
||||||
|
<Route path="/dashboard" element={<RequirePermission><DashboardPage /></RequirePermission>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { API_URL, setAccessToken } from "../token";
|
import { API_URL, authFetch, setAccessToken } from "../token";
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
const body = new URLSearchParams({ username: email, password });
|
const body = new URLSearchParams({ username: email, password });
|
||||||
@@ -12,3 +12,7 @@ export async function login(email: string, password: string) {
|
|||||||
setAccessToken(data.access_token);
|
setAccessToken(data.access_token);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
return authFetch("/protected/logout");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { UserOut } from "../../types/UserTypes";
|
||||||
|
import { authFetch } from "../token";
|
||||||
|
|
||||||
|
|
||||||
|
export async function fetchCurrentUser(): Promise<UserOut> {
|
||||||
|
const res = await authFetch("/protected/me");
|
||||||
|
if (!res.ok) throw new Error("Не удалось получить данные пользователя");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -10,8 +10,58 @@ export function getAccessToken() {
|
|||||||
return accessToken;
|
return accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestRefresh(): Promise<{ access_token: string } | null> {
|
||||||
|
const res = await fetch(`${API_URL}/protected/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshInFlight: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
export function refreshAccessToken(): Promise<boolean> {
|
||||||
|
if (refreshInFlight) return refreshInFlight;
|
||||||
|
|
||||||
|
refreshInFlight = (async () => {
|
||||||
|
try {
|
||||||
|
// refresh_token в cookie одноразовый (ротация): если другая вкладка
|
||||||
|
// или повторный вызов уже успели обменять его на новый ровно перед
|
||||||
|
// нашим запросом, наш "старый" токен уже отозван и сервер ответит
|
||||||
|
// 404/401, хотя сессия на самом деле жива. Одна короткая повторная
|
||||||
|
// попытка подхватит уже обновлённую браузером cookie и не даст
|
||||||
|
// разлогинить валидного пользователя из-за проигранной гонки.
|
||||||
|
let data = await requestRefresh();
|
||||||
|
if (!data) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
data = await requestRefresh();
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
setAccessToken(null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setAccessToken(data.access_token);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
export async function authFetch(path: string, init: RequestInit = {}) {
|
export async function authFetch(path: string, init: RequestInit = {}) {
|
||||||
const headers = new Headers(init.headers);
|
const headers = new Headers(init.headers);
|
||||||
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
return fetch(`${API_URL}${path}`, { ...init, headers, credentials: "include" });
|
|
||||||
|
const res = await fetch(`${API_URL}${path}`, { ...init, headers, credentials: "include" });
|
||||||
|
if (res.status !== 401) return res;
|
||||||
|
|
||||||
|
const refreshed = await refreshAccessToken();
|
||||||
|
if (!refreshed) return res;
|
||||||
|
|
||||||
|
const retryHeaders = new Headers(init.headers);
|
||||||
|
if (accessToken) retryHeaders.set("Authorization", `Bearer ${accessToken}`);
|
||||||
|
return fetch(`${API_URL}${path}`, { ...init, headers: retryHeaders, credentials: "include" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
|
export function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
if (user) return <Navigate to="/dashboard" replace />;
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
|
export function RequirePermission({ permission, children }: { permission?: string; children: ReactNode }) {
|
||||||
|
const { user, permissions, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
if (!user) return <Navigate to="/" replace />;
|
||||||
|
if (permission && !permissions.has(permission)) return <Navigate to="/dashboard" replace />;
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import type { UserOut } from "../types/UserTypes";
|
||||||
|
|
||||||
|
export type AuthState = {
|
||||||
|
user: UserOut | null;
|
||||||
|
permissions: Set<string>;
|
||||||
|
loading: boolean;
|
||||||
|
refreshUser: () => Promise<Set<string>>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthState | null>(null);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { fetchCurrentUser } from "../api/UserAPI/User";
|
||||||
|
import { logout as apiLogout } from "../api/LoginAPI/Auth";
|
||||||
|
import { refreshAccessToken, setAccessToken } from "../api/token";
|
||||||
|
import { getEffectivePermissions } from "../utils/permissions/Permissions";
|
||||||
|
import { getRememberMe, setRememberMe } from "../utils/rememberMe";
|
||||||
|
import { AuthContext, type AuthState } from "./AuthContext";
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, setState] = useState<Omit<AuthState, "refreshUser" | "logout">>({
|
||||||
|
user: null,
|
||||||
|
permissions: new Set(),
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshUser = useCallback(async () => {
|
||||||
|
setState((s) => ({ ...s, loading: true }));
|
||||||
|
try {
|
||||||
|
const user = await fetchCurrentUser();
|
||||||
|
const permissions = getEffectivePermissions(user);
|
||||||
|
setState({ user, permissions, loading: false });
|
||||||
|
return permissions;
|
||||||
|
} catch {
|
||||||
|
setState({ user: null, permissions: new Set(), loading: false });
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await apiLogout();
|
||||||
|
} catch {
|
||||||
|
// ошибку сети/сервера игнорируем — состояние всё равно чистим ниже
|
||||||
|
} finally {
|
||||||
|
setAccessToken(null);
|
||||||
|
setRememberMe(false);
|
||||||
|
setState({ user: null, permissions: new Set(), loading: false });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const started = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (started.current) return;
|
||||||
|
started.current = true;
|
||||||
|
|
||||||
|
if (!getRememberMe()) {
|
||||||
|
setState({ user: null, permissions: new Set(), loading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const ok = await refreshAccessToken();
|
||||||
|
if (ok) {
|
||||||
|
await refreshUser();
|
||||||
|
} else {
|
||||||
|
setState({ user: null, permissions: new Set(), loading: false });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [refreshUser]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ ...state, refreshUser, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState, type SubmitEvent, type ReactElement } from "react";
|
import { useState, type SubmitEvent, type ReactElement } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import styles from "./AuthComponent.module.css";
|
import styles from "./AuthComponent.module.css";
|
||||||
import { login } from "../../../api/LoginAPI/Auth";
|
import { login } from "../../../api/LoginAPI/Auth";
|
||||||
|
import { useAuth } from "../../../hooks/useAuth";
|
||||||
|
import { setRememberMe as persistRememberMe } from "../../../utils/rememberMe";
|
||||||
|
|
||||||
export function Auth():ReactElement {
|
export function Auth():ReactElement {
|
||||||
|
const { refreshUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
@@ -26,7 +30,9 @@ export function Auth():ReactElement {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
window.location.href = "http://localhost:8000/docs";
|
persistRememberMe(rememberMe);
|
||||||
|
await refreshUser();
|
||||||
|
navigate("/dashboard");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setErrors({ password: "Неверный email или пароль" });
|
setErrors({ password: "Неверный email или пароль" });
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { AuthContext } from "../context/AuthContext";
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
box-shadow: 1px 1px 20px 1px rgba(68, 68, 68, 0.5);
|
||||||
|
border-radius: 40px;
|
||||||
|
border: 1px solid #444;
|
||||||
}
|
}
|
||||||
.left_side {
|
.left_side {
|
||||||
width: 60%;
|
width: 60%;
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { AuthProvider } from './context/AuthProvider.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { permissions, logout } = useAuth();
|
||||||
|
const isAdmin = permissions.has("admin");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<p>User</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={() => { logout(); }}>Выйти</button>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div>
|
||||||
|
<p>Admin</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { UserOut } from "../../types/UserTypes";
|
||||||
|
|
||||||
|
|
||||||
|
export function getEffectivePermissions(user: UserOut): Set<string> {
|
||||||
|
const direct = user.direct_permissions.map(p => p.permission);
|
||||||
|
const fromGroups = user.group.flatMap(g => g.permissions.map(p => p.permission));
|
||||||
|
return new Set([...direct, ...fromGroups]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const REMEMBER_ME_KEY = "rememberMe";
|
||||||
|
|
||||||
|
export function setRememberMe(value: boolean) {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem(REMEMBER_ME_KEY, "1");
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(REMEMBER_ME_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRememberMe(): boolean {
|
||||||
|
return localStorage.getItem(REMEMBER_ME_KEY) === "1";
|
||||||
|
}
|
||||||
@@ -36,20 +36,23 @@ class JwtCrudActions:
|
|||||||
|
|
||||||
async def create_and_update_token(self, data: dict, old_jti: UUID, new_jti: UUID) -> bool:
|
async def create_and_update_token(self, data: dict, old_jti: UUID, new_jti: UUID) -> bool:
|
||||||
async with self.Session() as session, session.begin():
|
async with self.Session() as session, session.begin():
|
||||||
new_token = RefreshTokens(**data)
|
revoke_query = (
|
||||||
|
|
||||||
query = (
|
|
||||||
update(RefreshTokens)
|
update(RefreshTokens)
|
||||||
.where(RefreshTokens.id == old_jti, RefreshTokens.is_revoked.is_(False))
|
.where(RefreshTokens.id == old_jti, RefreshTokens.is_revoked.is_(False))
|
||||||
.values(is_revoked=True, replaced_by=new_jti)
|
.values(is_revoked=True)
|
||||||
.returning(RefreshTokens.id)
|
.returning(RefreshTokens.id)
|
||||||
)
|
)
|
||||||
updated_id = (await session.execute(query)).scalar_one_or_none()
|
updated_id = (await session.execute(revoke_query)).scalar_one_or_none()
|
||||||
|
|
||||||
if updated_id is None:
|
if updated_id is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
session.add(new_token)
|
session.add(RefreshTokens(**data))
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(RefreshTokens).where(RefreshTokens.id == old_jti).values(replaced_by=new_jti)
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,6 @@ async def reset(request: Request,
|
|||||||
return {"detail":"New password was sent"}
|
return {"detail":"New password was sent"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("/me")
|
||||||
async def protected(current_user:UserOut=Depends(require_permissions()))->dict:
|
async def protected(current_user:UserOut=Depends(require_permissions()))->UserOut:
|
||||||
return {"protected router": "Hello, this is a protected router"}
|
return current_user
|
||||||
|
|||||||
Reference in New Issue
Block a user