Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-50 text-gray-900`}
>
{children}
</body>
Expand Down
128 changes: 126 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,60 @@
import Header from '@/components/ui/Header';
import SearchForm from '@/components/ServerSearchForm';
import { useRouter } from 'next/navigation';
import KoreanStandardTime from '@/components/KoreanStandaradTime';
import React, { useEffect, useState } from 'react';
import KoreanStandardTime from '@/components/KoreanStandardTime';
import LoginModal from '@/components/auth/LoginModal';
import SignupModal from '@/components/auth/SignupModal';
import ConfirmModal from '@/components/ui/ConfirmModal';

export default function Home() {
const router = useRouter();
const [signupOpen, setSignupOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const [isAuthed, setIsAuthed] = useState(false);
const [userName, setUserName] = useState<string | undefined>(undefined);
const [confirmOpen, setConfirmOpen] = useState(false);

const handleSubmit = (url: string) => {
router.push(`/result?url=${encodeURIComponent(url)}`);
};

// 새로고침 시에도 로그인 유지
useEffect(() => {
const at = localStorage.getItem('accessToken');
const name = localStorage.getItem('userName') || undefined;
if (at) {
setIsAuthed(true);
setUserName(name);
}
}, []);
Comment on lines +24 to +32
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

토큰 만료‧무결성 검증 로직이 없습니다

로컬 스토리지에 토큰이 존재한다는 이유만으로 isAuthedtrue로 설정하면
만료·위조 토큰을 가진 사용자가 인증된 상태로 오인될 수 있습니다.
JWT 만료 확인 혹은 /auth/validate 같은 엔드포인트 호출을 통해 최초 마운트 시 토큰 유효성을 검증해 주세요.


🏁 Script executed:

#!/bin/bash
set -e
echo "----- src/app/page.tsx (lines 1-200) -----"
sed -n '1,200p' src/app/page.tsx

Length of output: 5395


토큰 유효성 검증 로직 추가 필요
로컬 스토리지에 토큰이 존재한다고 곧바로 isAuthedtrue로 설정하면, 만료되었거나 위조된 토큰도 인증된 상태로 오인될 수 있습니다.

• 검증 대상: src/app/page.tsx useEffect 내부 (24–32라인)
• 제안 사항:

  • JWT 만료 시간(exp) 확인
  • 또는 /api/auth/validate 같은 백엔드 엔드포인트에 토큰 검증 요청
  • 검증 실패 시 로컬 스토리지에서 토큰 제거 및 인증 상태(false) 처리

예시 코드 스니펫:

 useEffect(() => {
   const at = localStorage.getItem('accessToken');
   const name = localStorage.getItem('userName') || undefined;
   if (at) {
-    setIsAuthed(true);
-    setUserName(name);
+    // 1) 만료·무결성 검증
+    fetch(`${process.env.NEXT_PUBLIC_API_BASE}/api/auth/validate`, {
+      headers: { Authorization: `Bearer ${at}` },
+    })
+      .then(res => {
+        if (res.ok) {
+          setIsAuthed(true);
+          setUserName(name);
+        } else {
+          localStorage.removeItem('accessToken');
+          setIsAuthed(false);
+        }
+      });
   }
 }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 새로고침 시에도 로그인 유지
useEffect(() => {
const at = localStorage.getItem('accessToken');
const name = localStorage.getItem('userName') || undefined;
if (at) {
setIsAuthed(true);
setUserName(name);
}
}, []);
// 새로고침 시에도 로그인 유지
useEffect(() => {
const at = localStorage.getItem('accessToken');
const name = localStorage.getItem('userName') || undefined;
if (at) {
// 1) 만료·무결성 검증
fetch(`${process.env.NEXT_PUBLIC_API_BASE}/api/auth/validate`, {
headers: { Authorization: `Bearer ${at}` },
})
.then(res => {
if (res.ok) {
setIsAuthed(true);
setUserName(name);
} else {
localStorage.removeItem('accessToken');
setIsAuthed(false);
}
});
}
}, []);
🤖 Prompt for AI Agents
In src/app/page.tsx around lines 24 to 32, the current useEffect sets isAuthed
to true if an accessToken exists in localStorage without validating it, which
risks accepting expired or forged tokens. To fix this, add logic to verify the
token's validity by either decoding the JWT to check its expiration time or
sending the token to a backend validation endpoint like /api/auth/validate. If
the token is invalid or expired, remove it from localStorage and set isAuthed to
false to prevent unauthorized access.


const handleLogout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('userName');
setIsAuthed(false);
setUserName(undefined);
setConfirmOpen(false);

alert('로그아웃 되었습니다.');
router.push('/'); // 홈으로 리다이렉트
};

const headerProps: React.ComponentProps<typeof Header> = isAuthed
? {
isAuthed: true as const,
userName: userName!,
onLogoutClick: () => setConfirmOpen(true),
}
: {
onLoginClick: () => setLoginOpen(true),
};

return (
<div className="min-h-screen bg-gray-50 text-gray-900">
{/* Header */}
<Header />
<Header {...headerProps} />

{/* Hero */}
<section className="text-center py-16">
Expand All @@ -42,6 +83,89 @@ export default function Home() {
<section className="max-w-3xl mx-auto mb-20 p-10">
<KoreanStandardTime showToggle={false} />
</section>

{/* 로그인 모달 */}
<LoginModal
open={loginOpen}
onClose={() => setLoginOpen(false)}
onSignupClick={() => {
setLoginOpen(false);
setSignupOpen(true);
}}
onSubmit={async ({ email, password }) => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE}/api/auth/login`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
},
);

const data = await res.json();

if (!res.ok) throw new Error(data.error || '로그인 실패');

localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
Comment on lines +110 to +111
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Refresh Token을 localStorage 에 저장하면 XSS에 취약합니다

accessToken 뿐 아니라 장기 수명의 refreshToken 도 동일한 DOM 영역에 저장되고 있어,
스크립트 인젝션 하나로 영구 재발급 권한까지 탈취될 수 있습니다.
refreshTokenhttpOnly‧Secure‧SameSite 쿠키로 내려받도록 백엔드와 협의하시길 권장합니다.

🤖 Prompt for AI Agents
In src/app/page.tsx around lines 105 to 106, the refreshToken is being stored in
localStorage, which exposes it to XSS attacks. To fix this, remove the line that
stores refreshToken in localStorage and instead coordinate with the backend to
have the refreshToken set as an httpOnly, Secure, and SameSite cookie to protect
it from client-side script access.

if (data?.data?.user?.username) {
localStorage.setItem('userName', data.data.user.username);
setUserName(data.data.user.username);
}
setIsAuthed(true);
return true;
} catch (err) {
alert(err instanceof Error ? err.message : '로그인 중 오류 발생');
return false; // 실패 시 false 반환
}
}}
/>

{/* 회원가입 모달 */}
<SignupModal
open={signupOpen}
onClose={() => setSignupOpen(false)}
onLoginClick={() => {
setSignupOpen(false);
setLoginOpen(true);
}}
onSubmit={async ({ username, email, password }) => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE}/api/auth/register`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password }),
},
);
const data = await res.json();

if (!res.ok) {
throw new Error(data.error || '회원가입 실패');
}

console.log('회원가입 성공', data.data.user);
alert('회원가입이 완료되었습니다. 로그인 해주세요.');
setSignupOpen(false);
setLoginOpen(true); // 바로 로그인 유도
} catch (err) {
alert(err instanceof Error ? err.message : '회원가입 중 오류 발생');
}
}}
/>

{/* 로그아웃 확인 모달 */}
<ConfirmModal
open={confirmOpen}
title="로그아웃 확인"
message="정말 로그아웃하시겠습니까?"
confirmText="로그아웃"
cancelText="취소"
onConfirm={handleLogout}
onClose={() => setConfirmOpen(false)}
/>
</div>
);
}
4 changes: 2 additions & 2 deletions src/app/result/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React, { useState, useEffect } from 'react';
import { RefreshCw, Clock, Info } from 'lucide-react';
import AlarmModal, { AlarmData } from '@/components/AlarmModal';
import { useSearchParams } from 'next/navigation';
import KoreanStandardTime from '@/components/KoreanStandaradTime';
import KoreanStandardTime from '@/components/KoreanStandardTime';
import ServerSearchForm from '@/components/ServerSearchForm';
import AlarmCountdown from '@/components/AlarmCountdown';

Expand Down Expand Up @@ -349,7 +349,7 @@ export default function CheckTimeApp() {

// 1. /api/time/compare 엔드포인트 호출
const compareResponse = await fetch(
'http://localhost:3001/api/time/compare',
`${process.env.NEXT_PUBLIC_API_BASE}/api/time/compare`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,18 @@ export default function KoreanStandardTime({

const fetchTime = async () => {
try {
const res = await fetch('http://localhost:3001/api/time/current'); // 배포 시 서버 주소로 변경
const apiPath = `/api/time/current`;
const base = process.env.NEXT_PUBLIC_API_BASE;
const url = base ? new URL(apiPath, base).toString() : apiPath;

const res = await fetch(url, { cache: 'no-store' }); // 캐시를 사용하지 않도록 설정

if (!res.ok) {
throw new Error(
`시간 API 호출 실패: ${res.status} ${res.statusText}`,
);
}

const data = await res.json();

if (data.success && data.data.timestamp) {
Expand Down
174 changes: 174 additions & 0 deletions src/components/auth/LoginModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
'use client';

import { useEffect, useState } from 'react';

type Props = {
open: boolean;
onClose: () => void;
onSubmit?: (payload: {
email: string;
password: string;
}) => Promise<boolean> | boolean;
onSignupClick?: () => void; // 회원가입 열기
};

export default function LoginModal({
open,
onClose,
onSubmit,
onSignupClick,
}: Props) {
const [showPw, setShowPw] = useState(false);
const [loading, setLoading] = useState(false);

useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);

if (!open) return null;

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const email = String(fd.get('email') || '');
const password = String(fd.get('password') || '');
setLoading(true);
try {
const ok = await onSubmit?.({ email, password });
if (ok) onClose(); // 로그인 성공 시 모달 닫기
} finally {
setLoading(false);
}
}

return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[60] flex items-center justify-center"
>
{/* backdrop (배경 클릭으로 닫기) */}
<button
aria-label="닫기"
onClick={onClose}
className="absolute inset-0 bg-black/40"
/>

{/* panel */}
<div className="relative w-full max-w-md rounded-2xl bg-white p-12 shadow-lg border border-gray-200">
{/* 닫기 버튼 */}
<button
onClick={onClose}
aria-label="모달 닫기"
className="absolute right-3 top-3 rounded p-1 text-gray-500 hover:bg-gray-100"
>
</button>

{/* 로고 섹션 (logo-section) */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-4">
<div className="h-8 w-8 rounded-lg text-white flex items-center justify-center bg-gradient-to-br from-indigo-400 to-purple-500">
</div>
<div className="text-xl font-bold text-black">Check Time</div>
</div>
<p className="text-sm text-gray-500">계정에 로그인하세요</p>
</div>

{/* 로그인 폼 (login-form) */}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label
htmlFor="email"
className="mb-1 block text-sm font-medium text-gray-700"
>
이메일
</label>
<input
id="email"
name="email"
type="email"
required
placeholder="your@email.com"
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-500 focus:bg-white focus:ring-4 focus:ring-indigo-200/50"
/>
</div>

<div>
<label
htmlFor="password"
className="mb-1 block text-sm font-medium text-gray-700"
>
비밀번호
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPw ? 'text' : 'password'}
required
placeholder="비밀번호를 입력하세요"
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 pr-10 text-sm outline-none transition focus:border-indigo-500 focus:bg-white focus:ring-4 focus:ring-indigo-200/50"
/>
<button
type="button"
onClick={() => setShowPw((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="비밀번호 표시 전환"
>
{showPw ? '🙈' : '👁️'}
</button>
</div>
<div className="mt-2 text-right">
<button
type="button"
className="text-sm text-indigo-600 hover:underline"
>
비밀번호를 잊으셨나요?
</button>
</div>
</div>

<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-black px-4 py-3 text-sm font-medium text-white transition hover:bg-black/80 disabled:bg-gray-300"
>
{loading ? (
<span className="inline-flex items-center gap-2">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-transparent border-t-current" />
로그인 중…
</span>
) : (
'로그인'
)}
</button>
</form>

{/* 구분선 (divider) */}
<div className="my-6 flex items-center text-sm text-gray-400">
<span className="h-px flex-1 bg-gray-200" />
</div>

{/* 하단 링크 (bottom-link) */}
<p className="text-center text-sm text-gray-500">
아직 계정이 없으신가요?{' '}
<button
type="button"
onClick={() => {
onClose();
onSignupClick?.();
}}
className="font-medium text-indigo-600 hover:underline"
>
회원가입
</button>
</p>
</div>
</div>
);
}
Loading