File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
09-17
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<AdminLoginOutcome> {
const result = await backendFetch<AdminLoginResponse>(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<AdminAccessTokenClaims>(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<boolean> {
const result = await backendFetch<null>(LOGOUT_PATH, {
method: 'POST',
accessToken,
cache: 'no-store',
canHaveNullData: true,
});
return result.ok;
}