feat: 다른 곳에서 로그인되면 전역에서 로그아웃시킨다
백엔드가 중복 로그인 감지를 넣으면서, 내 토큰이 밀리면 이후 모든 요청이 403 + code 905(만료된 토큰입니다)로 떨어진다(JwtAuthenticationFilter). 어느 화면·어느 API에서 걸리든 같은 결말이어야 해서 backendFetch 한 곳에서 처리한다. - 905/904를 받으면 /logout/revoked로 보낸다. 렌더 중에는 쿠키를 지울 수 없어 라우트 핸들러에서 세션을 파기하고 /login으로 안내와 함께 되돌린다. - redirect()는 예외로 던져지므로 Server Action의 catch가 삼키면 안 된다. 저장소를 호출하는 catch에 unstable_rethrow를 넣어 통과시킨다. - 그 경로는 쿠키가 이미 없어도 지나가야 안내가 유지돼 proxy 공개 경로에 넣는다. Co-Authored-By: Claude Opus 5
@f36ef317b4491c3df5c9358e40f5a6f9ae0f820c
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import { BackendRequestError } from '@/lib/http/backend-fetch';
|
... | ... | @@ -48,6 +49,8 @@ |
| 48 | 49 |
await write(); |
| 49 | 50 |
return null; |
| 50 | 51 |
} catch (error) {
|
| 52 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 53 |
+ unstable_rethrow(error); |
|
| 51 | 54 |
if (error instanceof BackendRequestError) {
|
| 52 | 55 |
return { status: 'error', message: error.message };
|
| 53 | 56 |
} |
... | ... | @@ -166,6 +169,7 @@ |
| 166 | 169 |
return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
|
| 167 | 170 |
} |
| 168 | 171 |
} catch (error) {
|
| 172 |
+ unstable_rethrow(error); |
|
| 169 | 173 |
if (error instanceof BackendRequestError) {
|
| 170 | 174 |
return { status: 'failed', message: error.message };
|
| 171 | 175 |
} |
--- app/(protected)/(basic)/boards/_actions.ts
+++ app/(protected)/(basic)/boards/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
... | ... | @@ -80,6 +81,8 @@ |
| 80 | 81 |
await write(); |
| 81 | 82 |
return null; |
| 82 | 83 |
} catch (error) {
|
| 84 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 85 |
+ unstable_rethrow(error); |
|
| 83 | 86 |
return {
|
| 84 | 87 |
status: 'error', |
| 85 | 88 |
message: error instanceof Error ? error.message : fallback, |
... | ... | @@ -161,6 +164,7 @@ |
| 161 | 164 |
try {
|
| 162 | 165 |
attachmentId = await resolveAttachmentId(formData); |
| 163 | 166 |
} catch (error) {
|
| 167 |
+ unstable_rethrow(error); |
|
| 164 | 168 |
return {
|
| 165 | 169 |
status: 'error', |
| 166 | 170 |
message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, |
... | ... | @@ -208,6 +212,7 @@ |
| 208 | 212 |
try {
|
| 209 | 213 |
attachmentId = await resolveAttachmentId(formData); |
| 210 | 214 |
} catch (error) {
|
| 215 |
+ unstable_rethrow(error); |
|
| 211 | 216 |
return {
|
| 212 | 217 |
status: 'error', |
| 213 | 218 |
message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, |
... | ... | @@ -254,6 +259,7 @@ |
| 254 | 259 |
try {
|
| 255 | 260 |
attachmentId = await resolveAttachmentId(formData); |
| 256 | 261 |
} catch (error) {
|
| 262 |
+ unstable_rethrow(error); |
|
| 257 | 263 |
return {
|
| 258 | 264 |
status: 'error', |
| 259 | 265 |
message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, |
... | ... | @@ -312,7 +318,8 @@ |
| 312 | 318 |
return { status: 'error', message: GONE_MESSAGE };
|
| 313 | 319 |
} |
| 314 | 320 |
return { status: 'success', post };
|
| 315 |
- } catch {
|
|
| 321 |
+ } catch (error) {
|
|
| 322 |
+ unstable_rethrow(error); |
|
| 316 | 323 |
return { status: 'error', message: LOAD_FAILED_MESSAGE };
|
| 317 | 324 |
} |
| 318 | 325 |
} |
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 | 3 |
import { revalidatePath } from 'next/cache';
|
| 4 |
-import { redirect } from 'next/navigation';
|
|
| 4 |
+import { redirect, unstable_rethrow } from 'next/navigation';
|
|
| 5 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 6 |
import { BackendRequestError } from '@/lib/http/backend-fetch';
|
| 7 | 7 |
import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
|
... | ... | @@ -122,6 +122,8 @@ |
| 122 | 122 |
try {
|
| 123 | 123 |
return await run(); |
| 124 | 124 |
} catch (error) {
|
| 125 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 126 |
+ unstable_rethrow(error); |
|
| 125 | 127 |
// 진짜 원인은 여기에만 남는다 — 화면에는 사람이 고칠 수 있는 사유만 나간다. |
| 126 | 128 |
console.error('[contents] 저장 실패', error);
|
| 127 | 129 |
return failure(error, SAVE_FAILED_MESSAGE); |
... | ... | @@ -448,6 +450,7 @@ |
| 448 | 450 |
await deleteContent(kind, id); |
| 449 | 451 |
} |
| 450 | 452 |
} catch (error) {
|
| 453 |
+ unstable_rethrow(error); |
|
| 451 | 454 |
return failure(error, DELETE_FAILED_MESSAGE); |
| 452 | 455 |
} |
| 453 | 456 |
|
--- app/(protected)/(basic)/decoration-items/_actions.ts
+++ app/(protected)/(basic)/decoration-items/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import {
|
... | ... | @@ -154,13 +155,16 @@ |
| 154 | 155 |
try {
|
| 155 | 156 |
imageFileId = await uploadDecorationItemImage(newFile); |
| 156 | 157 |
} catch (error) {
|
| 158 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 159 |
+ unstable_rethrow(error); |
|
| 157 | 160 |
return { status: 'error', errors: { imageFileId: describeUploadFailure(error) } };
|
| 158 | 161 |
} |
| 159 | 162 |
} |
| 160 | 163 |
|
| 161 | 164 |
try {
|
| 162 | 165 |
await createDecorationItem({ ...validation.values, imageFileId });
|
| 163 |
- } catch {
|
|
| 166 |
+ } catch (error) {
|
|
| 167 |
+ unstable_rethrow(error); |
|
| 164 | 168 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 165 | 169 |
} |
| 166 | 170 |
|
... | ... | @@ -204,13 +208,15 @@ |
| 204 | 208 |
try {
|
| 205 | 209 |
imageFileId = await uploadDecorationItemImage(newFile); |
| 206 | 210 |
} catch (error) {
|
| 211 |
+ unstable_rethrow(error); |
|
| 207 | 212 |
return { status: 'error', errors: { imageFileId: describeUploadFailure(error) } };
|
| 208 | 213 |
} |
| 209 | 214 |
} |
| 210 | 215 |
|
| 211 | 216 |
try {
|
| 212 | 217 |
await updateDecorationItem(itemSn, { ...validation.values, imageFileId });
|
| 213 |
- } catch {
|
|
| 218 |
+ } catch (error) {
|
|
| 219 |
+ unstable_rethrow(error); |
|
| 214 | 220 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 215 | 221 |
} |
| 216 | 222 |
|
... | ... | @@ -236,7 +242,8 @@ |
| 236 | 242 |
|
| 237 | 243 |
try {
|
| 238 | 244 |
await deleteDecorationItem(itemSn); |
| 239 |
- } catch {
|
|
| 245 |
+ } catch (error) {
|
|
| 246 |
+ unstable_rethrow(error); |
|
| 240 | 247 |
return { status: 'error', message: DELETE_FAILED_MESSAGE };
|
| 241 | 248 |
} |
| 242 | 249 |
|
--- app/(protected)/(basic)/points/standards/_actions.ts
+++ app/(protected)/(basic)/points/standards/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
... | ... | @@ -60,7 +61,9 @@ |
| 60 | 61 |
|
| 61 | 62 |
try {
|
| 62 | 63 |
await createPointStandard(validation.values); |
| 63 |
- } catch {
|
|
| 64 |
+ } catch (error) {
|
|
| 65 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 66 |
+ unstable_rethrow(error); |
|
| 64 | 67 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 65 | 68 |
} |
| 66 | 69 |
|
... | ... | @@ -84,7 +87,8 @@ |
| 84 | 87 |
|
| 85 | 88 |
try {
|
| 86 | 89 |
await updatePointStandard(validation.values); |
| 87 |
- } catch {
|
|
| 90 |
+ } catch (error) {
|
|
| 91 |
+ unstable_rethrow(error); |
|
| 88 | 92 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 89 | 93 |
} |
| 90 | 94 |
|
... | ... | @@ -99,7 +103,8 @@ |
| 99 | 103 |
|
| 100 | 104 |
try {
|
| 101 | 105 |
await deletePointStandard(id); |
| 102 |
- } catch {
|
|
| 106 |
+ } catch (error) {
|
|
| 107 |
+ unstable_rethrow(error); |
|
| 103 | 108 |
return { status: 'error', message: DELETE_FAILED_MESSAGE };
|
| 104 | 109 |
} |
| 105 | 110 |
|
--- app/(protected)/(basic)/system/banned-words/_actions.ts
+++ app/(protected)/(basic)/system/banned-words/_actions.ts
... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 | 3 |
import { revalidatePath } from 'next/cache';
|
| 4 |
-import { redirect } from 'next/navigation';
|
|
| 4 |
+import { redirect, unstable_rethrow } from 'next/navigation';
|
|
| 5 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 6 |
import { BackendRequestError } from '@/lib/http/backend-fetch';
|
| 7 | 7 |
import {
|
... | ... | @@ -47,6 +47,8 @@ |
| 47 | 47 |
try {
|
| 48 | 48 |
result = await createBannedWord(validation.value); |
| 49 | 49 |
} catch (error) {
|
| 50 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 51 |
+ unstable_rethrow(error); |
|
| 50 | 52 |
return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
|
| 51 | 53 |
} |
| 52 | 54 |
|
... | ... | @@ -66,6 +68,7 @@ |
| 66 | 68 |
try {
|
| 67 | 69 |
await deleteBannedWord(id); |
| 68 | 70 |
} catch (error) {
|
| 71 |
+ unstable_rethrow(error); |
|
| 69 | 72 |
return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
|
| 70 | 73 |
} |
| 71 | 74 |
|
... | ... | @@ -90,6 +93,7 @@ |
| 90 | 93 |
try {
|
| 91 | 94 |
result = await uploadBannedWordExcel(file); |
| 92 | 95 |
} catch (error) {
|
| 96 |
+ unstable_rethrow(error); |
|
| 93 | 97 |
return { status: 'error', message: failureMessage(error, UPLOAD_FAILED_MESSAGE) };
|
| 94 | 98 |
} |
| 95 | 99 |
|
--- app/(protected)/(basic)/system/codes/_actions.ts
+++ app/(protected)/(basic)/system/codes/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import {
|
... | ... | @@ -68,7 +69,9 @@ |
| 68 | 69 |
|
| 69 | 70 |
try {
|
| 70 | 71 |
await createCodeGroup(validation.values); |
| 71 |
- } catch {
|
|
| 72 |
+ } catch (error) {
|
|
| 73 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 74 |
+ unstable_rethrow(error); |
|
| 72 | 75 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 73 | 76 |
} |
| 74 | 77 |
|
... | ... | @@ -99,7 +102,8 @@ |
| 99 | 102 |
|
| 100 | 103 |
try {
|
| 101 | 104 |
await updateCodeGroup(targetComCd, validation.values); |
| 102 |
- } catch {
|
|
| 105 |
+ } catch (error) {
|
|
| 106 |
+ unstable_rethrow(error); |
|
| 103 | 107 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 104 | 108 |
} |
| 105 | 109 |
|
... | ... | @@ -124,7 +128,8 @@ |
| 124 | 128 |
|
| 125 | 129 |
try {
|
| 126 | 130 |
await deleteCodeGroup(comCd); |
| 127 |
- } catch {
|
|
| 131 |
+ } catch (error) {
|
|
| 132 |
+ unstable_rethrow(error); |
|
| 128 | 133 |
return { status: 'error', message: DELETE_FAILED_MESSAGE };
|
| 129 | 134 |
} |
| 130 | 135 |
|
... | ... | @@ -154,7 +159,8 @@ |
| 154 | 159 |
|
| 155 | 160 |
try {
|
| 156 | 161 |
await createCodeDetail(validation.values); |
| 157 |
- } catch {
|
|
| 162 |
+ } catch (error) {
|
|
| 163 |
+ unstable_rethrow(error); |
|
| 158 | 164 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 159 | 165 |
} |
| 160 | 166 |
|
... | ... | @@ -188,7 +194,8 @@ |
| 188 | 194 |
|
| 189 | 195 |
try {
|
| 190 | 196 |
await updateCodeDetail(targetComCd, targetComDtlCd, validation.values); |
| 191 |
- } catch {
|
|
| 197 |
+ } catch (error) {
|
|
| 198 |
+ unstable_rethrow(error); |
|
| 192 | 199 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 193 | 200 |
} |
| 194 | 201 |
|
... | ... | @@ -208,7 +215,8 @@ |
| 208 | 215 |
|
| 209 | 216 |
try {
|
| 210 | 217 |
await deleteCodeDetail(comCd, comDtlCd); |
| 211 |
- } catch {
|
|
| 218 |
+ } catch (error) {
|
|
| 219 |
+ unstable_rethrow(error); |
|
| 212 | 220 |
return { status: 'error', message: DELETE_FAILED_MESSAGE };
|
| 213 | 221 |
} |
| 214 | 222 |
|
--- app/(protected)/(basic)/system/merchants/_actions.ts
+++ app/(protected)/(basic)/system/merchants/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import { createMerchant } from '@/lib/data/repositories/merchant-repository';
|
... | ... | @@ -45,7 +46,9 @@ |
| 45 | 46 |
// TODO(백엔드): 수정 API가 없다(`/api/v1/mngr/voc`는 목록·등록 둘뿐). 생기면 여기서 가른다. |
| 46 | 47 |
try {
|
| 47 | 48 |
await createMerchant(validation.values); |
| 48 |
- } catch {
|
|
| 49 |
+ } catch (error) {
|
|
| 50 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 51 |
+ unstable_rethrow(error); |
|
| 49 | 52 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 50 | 53 |
} |
| 51 | 54 |
|
--- app/(public)/login/_components/logout-notice.tsx
+++ app/(public)/login/_components/logout-notice.tsx
... | ... | @@ -7,10 +7,14 @@ |
| 7 | 7 |
interface LogoutNoticeProps {
|
| 8 | 8 |
/** 로그아웃 Server Action이 백엔드 호출 실패를 알리려고 실어 보낸 쿼리 파라미터가 있었는가. */ |
| 9 | 9 |
showError: boolean; |
| 10 |
+ /** 다른 곳에서 로그인되어 세션이 밀렸는가. */ |
|
| 11 |
+ showSessionRevoked: boolean; |
|
| 10 | 12 |
} |
| 11 | 13 |
|
| 12 | 14 |
const LOGOUT_ERROR_MESSAGE = |
| 13 | 15 |
'로그아웃 처리 중 일부 오류가 있었지만 로그아웃되었습니다.'; |
| 16 |
+const SESSION_REVOKED_MESSAGE = |
|
| 17 |
+ '다른 곳에서 로그인되어 로그아웃되었습니다. 다시 로그인해 주세요.'; |
|
| 14 | 18 |
|
| 15 | 19 |
/** |
| 16 | 20 |
* 화면에 그릴 UI가 없는 알림 트리거 컴포넌트 — `app/(protected)/_actions.ts`의 `logout()`이 |
... | ... | @@ -31,21 +35,29 @@ |
| 31 | 35 |
* 그대로 true이기 때문이다. 프로덕션은 원래 1회지만, 사용자에게 실제로 보이는 부수효과(알림)는 |
| 32 | 36 |
* Strict Mode 유무와 무관하게 항상 1회여야 한다. |
| 33 | 37 |
*/ |
| 34 |
-export function LogoutNotice({ showError }: LogoutNoticeProps) {
|
|
| 38 |
+export function LogoutNotice({
|
|
| 39 |
+ showError, |
|
| 40 |
+ showSessionRevoked, |
|
| 41 |
+}: LogoutNoticeProps) {
|
|
| 35 | 42 |
const router = useRouter(); |
| 36 | 43 |
const { showToast } = useFeedback();
|
| 37 | 44 |
const hasNotifiedRef = useRef(false); |
| 38 | 45 |
|
| 39 | 46 |
useEffect(() => {
|
| 40 |
- if (!showError || hasNotifiedRef.current) {
|
|
| 47 |
+ if ((!showError && !showSessionRevoked) || hasNotifiedRef.current) {
|
|
| 41 | 48 |
return; |
| 42 | 49 |
} |
| 43 | 50 |
hasNotifiedRef.current = true; |
| 44 | 51 |
|
| 45 | 52 |
// 내부 정보(백엔드 message 원문 등)는 노출하지 않는다 — 항상 이 고정 문구만 보여준다. |
| 46 |
- showToast({ variant: 'warning', message: LOGOUT_ERROR_MESSAGE });
|
|
| 53 |
+ showToast({
|
|
| 54 |
+ variant: 'warning', |
|
| 55 |
+ message: showSessionRevoked |
|
| 56 |
+ ? SESSION_REVOKED_MESSAGE |
|
| 57 |
+ : LOGOUT_ERROR_MESSAGE, |
|
| 58 |
+ }); |
|
| 47 | 59 |
router.replace('/login');
|
| 48 |
- }, [showError, showToast, router]); |
|
| 60 |
+ }, [showError, showSessionRevoked, showToast, router]); |
|
| 49 | 61 |
|
| 50 | 62 |
return null; |
| 51 | 63 |
} |
--- app/(public)/login/page.tsx
+++ app/(public)/login/page.tsx
... | ... | @@ -1,5 +1,8 @@ |
| 1 | 1 |
import { getSessionAdmin } from '@/lib/auth/dal';
|
| 2 |
-import { LOGOUT_ERROR_QUERY_PARAM } from '@/lib/constants/logout';
|
|
| 2 |
+import {
|
|
| 3 |
+ LOGOUT_ERROR_QUERY_PARAM, |
|
| 4 |
+ SESSION_REVOKED_QUERY_PARAM, |
|
| 5 |
+} from '@/lib/constants/logout'; |
|
| 3 | 6 |
import { redirect } from 'next/navigation';
|
| 4 | 7 |
import styles from './login.module.scss'; |
| 5 | 8 |
import { LoginForm } from './_components/login-form';
|
... | ... | @@ -22,10 +25,16 @@ |
| 22 | 25 |
const resolvedSearchParams = await searchParams; |
| 23 | 26 |
const logoutError = resolvedSearchParams[LOGOUT_ERROR_QUERY_PARAM]; |
| 24 | 27 |
const showLogoutError = typeof logoutError === 'string' && logoutError.length > 0; |
| 28 |
+ const sessionRevoked = resolvedSearchParams[SESSION_REVOKED_QUERY_PARAM]; |
|
| 29 |
+ const showSessionRevoked = |
|
| 30 |
+ typeof sessionRevoked === 'string' && sessionRevoked.length > 0; |
|
| 25 | 31 |
|
| 26 | 32 |
return ( |
| 27 | 33 |
<div className={styles.page}>
|
| 28 |
- <LogoutNotice showError={showLogoutError} />
|
|
| 34 |
+ <LogoutNotice |
|
| 35 |
+ showError={showLogoutError}
|
|
| 36 |
+ showSessionRevoked={showSessionRevoked}
|
|
| 37 |
+ /> |
|
| 29 | 38 |
<div className={styles.card}>
|
| 30 | 39 |
<h1 className={styles.title}>관리자 로그인</h1>
|
| 31 | 40 |
<LoginForm /> |
+++ app/logout/revoked/route.ts
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +import { redirect } from 'next/navigation'; | |
| 2 | +import { deleteSession } from '@/lib/auth/session'; | |
| 3 | +import { SESSION_REVOKED_QUERY_PARAM } from '@/lib/constants/logout'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 다른 곳에서 로그인되어 세션이 밀렸을 때 도착하는 곳 — 쿠키를 지우고 `/login`으로 보낸다. | |
| 7 | + * 렌더 중에는 쿠키를 지울 수 없어 라우트 핸들러로 분리했다(lib/auth/session-revoked.ts 참고). | |
| 8 | + */ | |
| 9 | +export async function GET(): Promise<never> { | |
| 10 | + await deleteSession(); | |
| 11 | + redirect(`/login?${SESSION_REVOKED_QUERY_PARAM}=1`); | |
| 12 | +} |
+++ lib/auth/session-revoked.ts
... | ... | @@ -0,0 +1,19 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { redirect } from 'next/navigation'; | |
| 3 | +import { SESSION_REVOKED_PATH } from '@/lib/constants/logout'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 다른 곳에서 로그인되면 백엔드가 저장한 refreshToken이 바뀌고, 그 뒤로 이 세션의 accessToken은 | |
| 7 | + * 403 + XPIRED_TOKEN(905)으로 거절된다(edupay-backend JwtAuthenticationFilter). 904는 같은 뜻의 | |
| 8 | + * 예비 코드다. | |
| 9 | + * | |
| 10 | + * 쿠키 삭제는 렌더 중에 할 수 없어(Next.js 제약) 라우트 핸들러로 넘긴다 — 거기서 세션을 지우고 | |
| 11 | + * `/login`으로 안내와 함께 되돌린다. | |
| 12 | + */ | |
| 13 | +const SESSION_REVOKED_CODES: ReadonlySet<number> = new Set([904, 905]); | |
| 14 | + | |
| 15 | +export function redirectIfSessionRevoked(code: number): void { | |
| 16 | + if (SESSION_REVOKED_CODES.has(code)) { | |
| 17 | + redirect(SESSION_REVOKED_PATH); | |
| 18 | + } | |
| 19 | +} |
--- lib/constants/logout.ts
+++ lib/constants/logout.ts
... | ... | @@ -11,3 +11,12 @@ |
| 11 | 11 |
* 하는 설계, app/(protected)/_actions.ts 참고). |
| 12 | 12 |
*/ |
| 13 | 13 |
export const LOGOUT_ERROR_QUERY_PARAM = 'logoutError'; |
| 14 |
+ |
|
| 15 |
+/** |
|
| 16 |
+ * 다른 곳에서 로그인되어 이 세션이 밀렸을 때 `/login` 화면에 알릴 쿼리 파라미터 이름. |
|
| 17 |
+ * LOGOUT_ERROR_QUERY_PARAM과 같은 이유로 상수로 둔다. |
|
| 18 |
+ */ |
|
| 19 |
+export const SESSION_REVOKED_QUERY_PARAM = 'sessionRevoked'; |
|
| 20 |
+ |
|
| 21 |
+/** 세션이 밀린 것을 감지했을 때 보내는 경로 — 쿠키를 지우고 `/login`으로 넘긴다. */ |
|
| 22 |
+export const SESSION_REVOKED_PATH = '/logout/revoked'; |
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 | 2 |
import { getApiBaseUrl } from '@/lib/env';
|
| 3 |
+import { redirectIfSessionRevoked } from '@/lib/auth/session-revoked';
|
|
| 3 | 4 |
|
| 4 | 5 |
/** |
| 5 | 6 |
* 백엔드(edupay-backend) REST 호출 공용 클라이언트 — baseURL 결합, 타임아웃, JSON 헤더, |
... | ... | @@ -331,6 +332,7 @@ |
| 331 | 332 |
logResponse(call, response.status, raw); |
| 332 | 333 |
const envelope = parseEnvelope(raw); |
| 333 | 334 |
if (envelope && typeof envelope.code === 'number') {
|
| 335 |
+ redirectIfSessionRevoked(envelope.code); |
|
| 334 | 336 |
return {
|
| 335 | 337 |
ok: false, |
| 336 | 338 |
code: envelope.code, |
... | ... | @@ -444,6 +446,7 @@ |
| 444 | 446 |
// 호출부가 구분할 수 없으므로, 봉투에 code가 실려 있으면 그것을 살려서 내려준다. |
| 445 | 447 |
const envelope = parseEnvelope(raw); |
| 446 | 448 |
if (envelope && typeof envelope.code === 'number') {
|
| 449 |
+ redirectIfSessionRevoked(envelope.code); |
|
| 447 | 450 |
return {
|
| 448 | 451 |
ok: false, |
| 449 | 452 |
code: envelope.code, |
... | ... | @@ -544,6 +547,7 @@ |
| 544 | 547 |
|
| 545 | 548 |
if (!response.ok || !envelope || envelope.success !== true) {
|
| 546 | 549 |
if (envelope && typeof envelope.code === 'number') {
|
| 550 |
+ redirectIfSessionRevoked(envelope.code); |
|
| 547 | 551 |
return {
|
| 548 | 552 |
ok: false, |
| 549 | 553 |
code: envelope.code, |
--- proxy.ts
+++ proxy.ts
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { NextResponse } from 'next/server';
|
| 2 | 2 |
import type { NextRequest } from 'next/server';
|
| 3 | 3 |
import { SESSION_COOKIE_NAME } from '@/lib/auth/session-cookie';
|
| 4 |
+import { SESSION_REVOKED_PATH } from '@/lib/constants/logout';
|
|
| 4 | 5 |
|
| 5 | 6 |
/** |
| 6 | 7 |
* 낙관적 체크 전용 — 세션 쿠키의 "존재 여부"만 확인한다. 최종 판단(서명·만료 검증)은 |
... | ... | @@ -16,7 +17,7 @@ |
| 16 | 17 |
*/ |
| 17 | 18 |
// 이슈: `/dev-test/design`은 모든 환경에서 세션 없이 열린다(사용자 요청). 실데이터를 그리지 |
| 18 | 19 |
// 않고 @fox 컴포넌트만 보여주는 화면이지만, 운영에도 노출되는 경로다. |
| 19 |
-const PUBLIC_PATHS = ['/login', '/dev-test/design']; |
|
| 20 |
+const PUBLIC_PATHS = ['/login', '/dev-test/design', SESSION_REVOKED_PATH]; |
|
| 20 | 21 |
|
| 21 | 22 |
function isPublicPath(pathname: string): boolean {
|
| 22 | 23 |
return PUBLIC_PATHS.includes(pathname); |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?