login and tokens - test
This commit is contained in:
parent
99c0ea9752
commit
c75dc399d5
8 changed files with 210 additions and 9 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -46,3 +46,4 @@ vitest.config.*.timestamp*
|
|||
|
||||
# Env Files
|
||||
.env
|
||||
.env.local
|
||||
|
|
|
|||
|
|
@ -21,19 +21,31 @@ const app = express();
|
|||
app.use(express.json());
|
||||
|
||||
app.use(cookieParser());
|
||||
app.use(cors({ origin: 'http://localhost:3000', credentials: true })); // TODO: for dev purposes because it will be the same server on production?
|
||||
app.use(cors({ origin: 'http://localhost:4200', credentials: true })); // TODO: for dev purposes because it will be the same server on production?
|
||||
app.use(express.json());
|
||||
|
||||
const refreshTokens = new Set<string>();
|
||||
const refreshTokens = new Set<string>(); // TODO: ideally store in DB
|
||||
|
||||
// TODO: simplify
|
||||
const generateAccessToken = (user: User): string => {
|
||||
const cleanUser = { ...user, hashed_password: undefined };
|
||||
return jwt.sign(cleanUser, ACCESS_SECRET, { expiresIn: '15s' });
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
},
|
||||
ACCESS_SECRET,
|
||||
{ expiresIn: '15s' }
|
||||
);
|
||||
};
|
||||
const generateRefreshToken = (user: User): string => {
|
||||
const cleanUser = { ...user, hashed_password: undefined };
|
||||
return jwt.sign(cleanUser, REFRESH_SECRET, { expiresIn: '7d' });
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
},
|
||||
REFRESH_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: organize in services folder
|
||||
|
|
@ -75,13 +87,17 @@ app.post('/login', async (req: Request, res: Response) => {
|
|||
|
||||
app.post('/refresh', (req: Request, res: Response) => {
|
||||
const token = req.cookies?.refreshToken;
|
||||
if (!token || !refreshTokens.has(token)) return res.sendStatus(403);
|
||||
|
||||
// TODO: voltar a verificaço abaixo, quando refresh tokens estiver em DB
|
||||
// estudar melhor (exemplo, verificar expiração de refresh token etc)
|
||||
if (!token /*|| !refreshTokens.has(token)*/) return res.sendStatus(403);
|
||||
|
||||
try {
|
||||
const user = jwt.verify(token, REFRESH_SECRET) as User;
|
||||
const accessToken = generateAccessToken(user);
|
||||
res.json({ accessToken });
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
res.sendStatus(403);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
103
apps/frontend/src/App.tsx
Normal file
103
apps/frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from './auth/authStore';
|
||||
import { refreshToken, logout } from './auth/auth-utils';
|
||||
import api from './auth/axiosInstance';
|
||||
|
||||
export default function App() {
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await refreshToken();
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Hybrid Auth Flow</h1>
|
||||
{!accessToken ? (
|
||||
<>
|
||||
<LoginArea />
|
||||
<Protected />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogoutButton />
|
||||
<Protected />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginArea() {
|
||||
const setAccessToken = useAuthStore((s) => s.setAccessToken);
|
||||
|
||||
const [email, _email] = useState('');
|
||||
const [password, _password] = useState('');
|
||||
|
||||
const login = async () => {
|
||||
const res = await fetch(`${import.meta.env.VITE_SERVER}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setAccessToken(data.accessToken);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={(e) => _email(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => _password(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button onClick={login}>Login</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutButton() {
|
||||
return <button onClick={logout}>Logout</button>;
|
||||
}
|
||||
|
||||
function Protected() {
|
||||
const callAPI = async () => {
|
||||
try {
|
||||
const res = await api.get('/protected');
|
||||
alert(res.data.message);
|
||||
} catch {
|
||||
alert('Unauthorized');
|
||||
}
|
||||
};
|
||||
|
||||
return <button onClick={callAPI}>Call Protected Route</button>;
|
||||
}
|
||||
32
apps/frontend/src/auth/auth-utils.ts
Normal file
32
apps/frontend/src/auth/auth-utils.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useAuthStore } from './authStore';
|
||||
|
||||
// TODO: remover?
|
||||
export function getAccessToken(): string | null {
|
||||
return useAuthStore.getState().accessToken;
|
||||
}
|
||||
|
||||
// TODO: precisa exportar?
|
||||
export function setAccessToken(token: string | null) {
|
||||
useAuthStore.getState().setAccessToken(token);
|
||||
}
|
||||
|
||||
export async function refreshToken(): Promise<string> {
|
||||
const res = await fetch(`${import.meta.env.VITE_SERVER}/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Unable to refresh token');
|
||||
|
||||
const data = await res.json();
|
||||
setAccessToken(data.accessToken);
|
||||
return data.accessToken;
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await fetch(`${import.meta.env.VITE_SERVER}/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
setAccessToken(null);
|
||||
}
|
||||
11
apps/frontend/src/auth/authStore.ts
Normal file
11
apps/frontend/src/auth/authStore.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
type AuthState = {
|
||||
accessToken: string | null;
|
||||
setAccessToken: (token: string | null) => void;
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
accessToken: null,
|
||||
setAccessToken: (token) => set({ accessToken: token }),
|
||||
}));
|
||||
38
apps/frontend/src/auth/axiosInstance.ts
Normal file
38
apps/frontend/src/auth/axiosInstance.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import axios, { AxiosError } from 'axios';
|
||||
import { getAccessToken, refreshToken } from './auth-utils';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_SERVER,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (err: AxiosError) => {
|
||||
const originalRequest = err.config as any;
|
||||
if (
|
||||
(err.response?.status === 401 || err.response?.status === 403) &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return api(originalRequest);
|
||||
} catch {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import App from './app/app';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
|
|
|
|||
Reference in a new issue