import 'server-only'; import { backendFetch } from '@/lib/http/backend-fetch'; import { decodeJwtPayload } from '@/lib/auth/decode-jwt-payload'; import type { AdminUser } from '@/lib/domain/admin-user'; /** * 관리자 인증 Repository — `POST /api/v1/mngr/auth/login`, `POST /api/v1/mngr/auth/logout`. * * 이슈: 로그인은 성공(200)이면서도 토큰 없이 최근 접속 정보만 돌아올 수 있다 — 다른 곳에 * 살아 있는 세션이 있는데 아직 [확인]을 누르지 않은 경우다. 이때 화면이 중복 로그인 안내를 * 띄우고 `forceLogin`으로 재요청해야 실제 토큰이 발급된다(백엔드 MngrAuthServiceImpl). * * 이슈: accessToken 클레임에 `loginId`가 없다(EgovJwtTokenUtil#generateAccessToken은 userId· * userNm·userType·roleId·tokenVersion만 싣는다). 그래서 loginId는 입력값을 그대로 세션에 * 보관한다 — 백엔드가 그 아이디로 계정을 찾아 인증에 성공한 값이라 신뢰할 수 있다. */ const LOGIN_PATH = '/api/v1/mngr/auth/login'; const LOGOUT_PATH = '/api/v1/mngr/auth/logout'; /** 백엔드 ResponseCode.NOT_FOUND — "로그인 계정이 없습니다". */ const ACCOUNT_NOT_FOUND_CODE = 404; const GENERIC_LOGIN_ERROR = '아이디 또는 비밀번호가 올바르지 않습니다.'; export type AdminCredentials = { loginId: string; password: string; /** 중복 로그인 안내에서 [확인]을 누른 재시도인가. */ forceLogin?: boolean; }; export type AdminLoginSuccess = { status: 'success'; admin: AdminUser; accessToken: string; refreshToken: string; /** 백엔드 accessToken의 `exp` 클레임(unix seconds) — 세션 만료를 이 값에 정렬시킨다. */ accessTokenExpiresAt: number; }; /** 다른 곳에 살아 있는 세션이 있을 때 화면이 보여줄 최근 접속 정보. */ export type DuplicateLoginNotice = { lastLoginAt: string | null; lastLoginIp: string | null; currentIp: string | null; }; export type AdminLoginOutcome = | AdminLoginSuccess | { status: 'duplicate'; notice: DuplicateLoginNotice } | { status: 'failure'; message: string }; type AdminAccessTokenClaims = { userId: string; userNm: string; userType: string; roleId: string; exp: number; }; type AdminLoginResponse = { accessToken?: string | null; refreshToken?: string | null; lastLgnDt?: string | null; lastLgnIpAddr?: string | null; currentIpAddr?: string | null; }; function readNullableString(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null; } export async function requestAdminLogin( credentials: AdminCredentials ): Promise { const result = await backendFetch(LOGIN_PATH, { method: 'POST', body: { loginId: credentials.loginId, loginPw: credentials.password, isForceLogin: credentials.forceLogin === true, }, cache: 'no-store', }); if (!result.ok) { // 계정 존재 여부는 드러내지 않는다. 잠김·탈퇴·실패횟수 안내는 백엔드 문구를 그대로 쓴다. return { status: 'failure', message: result.code === ACCOUNT_NOT_FOUND_CODE || !result.message ? GENERIC_LOGIN_ERROR : result.message, }; } const accessToken = readNullableString(result.data.accessToken); const refreshToken = readNullableString(result.data.refreshToken); if (!accessToken || !refreshToken) { return { status: 'duplicate', notice: { lastLoginAt: readNullableString(result.data.lastLgnDt), lastLoginIp: readNullableString(result.data.lastLgnIpAddr), currentIp: readNullableString(result.data.currentIpAddr), }, }; } const claims = decodeJwtPayload(accessToken); if ( !claims || typeof claims.userId !== 'string' || !claims.userId || typeof claims.exp !== 'number' ) { return { status: 'failure', message: GENERIC_LOGIN_ERROR }; } const admin: AdminUser = { id: claims.userId, name: typeof claims.userNm === 'string' && claims.userNm ? claims.userNm : credentials.loginId, loginId: credentials.loginId, roleCode: typeof claims.roleId === 'string' ? claims.roleId : '', }; return { status: 'success', admin, accessToken, refreshToken, accessTokenExpiresAt: claims.exp, }; } /** * 로그아웃 — 백엔드가 저장된 refreshToken을 NULL로 만든다. * * 이슈: 이 호출이 accessToken 자체를 무효화하지는 않는다. JwtAuthenticationFilter의 중복 로그인 * 판정이 `dbRefreshToken != null && !equals(clientTokenVersion)`이라, NULL이 된 뒤에는 기존 * accessToken이 만료(24h)까지 계속 통과한다(edupay-backend 61d8197 확인). 백엔드 보완 필요. * * 실패해도 로컬 세션은 반드시 파기해야 하므로 예외 대신 boolean을 돌려준다. */ export async function requestAdminLogout(accessToken: string): Promise { const result = await backendFetch(LOGOUT_PATH, { method: 'POST', accessToken, cache: 'no-store', canHaveNullData: true, }); return result.ok; }