fix(auth): detect HTTPS via X-Forwarded-Proto for SameSite=None cookies, fix AuthGuard null overwrite
- Cookie SameSite now dynamic: None;Secure when behind HTTPS proxy, Lax otherwise - AuthGuard useEffect no longer overwrites Zustand store with null from background refetch - AuthInitializer: add staleTime 30s Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -28,14 +28,26 @@ function hashToken(token: string) {
|
|||||||
return createHash('sha256').update(`${env.sessionSecret}:${token}`).digest('hex');
|
return createHash('sha256').update(`${env.sessionSecret}:${token}`).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionCookie(token: string) {
|
function isSecureRequest(headers?: { get(name: string): string | null }) {
|
||||||
|
if (env.secureCookies) return true;
|
||||||
|
// Detect HTTPS behind proxy (X-Forwarded-Proto)
|
||||||
|
const proto = headers?.get('x-forwarded-proto');
|
||||||
|
if (proto === 'https') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSameSite(headers?: { get(name: string): string | null }) {
|
||||||
|
return isSecureRequest(headers) ? 'SameSite=None; Secure' : 'SameSite=Lax';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionCookie(token: string, headers?: { get(name: string): string | null }) {
|
||||||
const maxAge = 60 * 60 * 24 * 30;
|
const maxAge = 60 * 60 * 24 * 30;
|
||||||
const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax';
|
const sameSite = buildSameSite(headers);
|
||||||
return `${sessionCookieName}=${token}; HttpOnly; Path=/; ${sameSite}; Max-Age=${maxAge}`;
|
return `${sessionCookieName}=${token}; HttpOnly; Path=/; ${sameSite}; Max-Age=${maxAge}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionCookie() {
|
export function clearSessionCookie(headers?: { get(name: string): string | null }) {
|
||||||
const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax';
|
const sameSite = buildSameSite(headers);
|
||||||
return `${sessionCookieName}=; HttpOnly; Path=/; ${sameSite}; Max-Age=0`;
|
return `${sessionCookieName}=; HttpOnly; Path=/; ${sameSite}; Max-Age=0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
features: getAuthFeatures(),
|
features: getAuthFeatures(),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.post('/register', async ({ body, set }) => {
|
.post('/register', async ({ body, set, request }) => {
|
||||||
const req = body as Partial<RegisterRequest> | undefined;
|
const req = body as Partial<RegisterRequest> | undefined;
|
||||||
const email = normalizeEmail(req?.email);
|
const email = normalizeEmail(req?.email);
|
||||||
const name = normalizeName(req?.name);
|
const name = normalizeName(req?.name);
|
||||||
@@ -62,7 +62,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
|
|
||||||
const user = inserted[0];
|
const user = inserted[0];
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
set.headers['Set-Cookie'] = createSessionCookie(token);
|
set.headers['Set-Cookie'] = createSessionCookie(token, request.headers);
|
||||||
|
|
||||||
authCounter.labels('register', 'true').inc();
|
authCounter.labels('register', 'true').inc();
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
return serviceUnavailable('Database unavailable');
|
return serviceUnavailable('Database unavailable');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.post('/login', async ({ body, set }) => {
|
.post('/login', async ({ body, set, request }) => {
|
||||||
const req = body as Partial<AuthRequest> | undefined;
|
const req = body as Partial<AuthRequest> | undefined;
|
||||||
const email = normalizeEmail(req?.email);
|
const email = normalizeEmail(req?.email);
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
set.headers['Set-Cookie'] = createSessionCookie(token);
|
set.headers['Set-Cookie'] = createSessionCookie(token, request.headers);
|
||||||
|
|
||||||
authCounter.labels('login', 'true').inc();
|
authCounter.labels('login', 'true').inc();
|
||||||
|
|
||||||
@@ -116,8 +116,9 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.post('/logout', async ({ request, set }) => {
|
.post('/logout', async ({ request, set }) => {
|
||||||
await deleteSession(readSessionToken(request.headers.get('cookie')));
|
const cookieHeader = request.headers.get('cookie');
|
||||||
set.headers['Set-Cookie'] = clearSessionCookie();
|
await deleteSession(readSessionToken(cookieHeader));
|
||||||
|
set.headers['Set-Cookie'] = clearSessionCookie(request.headers);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
})
|
})
|
||||||
.get('/google', ({ set }) => {
|
.get('/google', ({ set }) => {
|
||||||
|
|||||||
@@ -10,27 +10,32 @@ type AuthGuardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AuthGuard({ children, requireExpert = false }: AuthGuardProps) {
|
export function AuthGuard({ children, requireExpert = false }: AuthGuardProps) {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
const setUser = useAuthStore((state) => state.setUser);
|
const setUser = useAuthStore((state) => state.setUser);
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['auth', 'me'],
|
queryKey: ['auth', 'me'],
|
||||||
queryFn: () => apiClient.getMe(),
|
queryFn: () => apiClient.getMe(),
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.data) {
|
if (query.data?.user) {
|
||||||
setUser(query.data.user);
|
setUser(query.data.user);
|
||||||
}
|
}
|
||||||
}, [query.data, setUser]);
|
}, [query.data, setUser]);
|
||||||
|
|
||||||
if (query.isLoading) {
|
// Tunjukkan loading hanya jika belum ada user di store
|
||||||
|
if (query.isLoading && !user) {
|
||||||
return <main className="min-h-screen p-8 text-center text-muted-foreground">Memeriksa sesi...</main>;
|
return <main className="min-h-screen p-8 text-center text-muted-foreground">Memeriksa sesi...</main>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query.data?.user) {
|
// Cek store dulu, baru query — mencegah redirect saat refetch background
|
||||||
|
const currentUser = query.data?.user ?? user;
|
||||||
|
if (!currentUser) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireExpert && query.data.user.role !== 'expert') {
|
if (requireExpert && currentUser.role !== 'expert') {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function AuthInitializer() {
|
|||||||
queryKey: ['auth', 'me'],
|
queryKey: ['auth', 'me'],
|
||||||
queryFn: () => apiClient.getMe(),
|
queryFn: () => apiClient.getMe(),
|
||||||
retry: false,
|
retry: false,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user