중복 로그인 확인 및 처리 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 동시 접속 확인 API(DuplicateLoginController) 및 서비스 구현
- 중복 세션 확인 후 로그인 확정/취소 처리 로직 추가
- 2FA 흐름과 연계된 프론트엔드 수정 및 UI 개선
This commit is contained in:
Rinjae
2026-07-28 17:00:10 +09:00
parent d4d5d66727
commit 929980c089
17 changed files with 446 additions and 103 deletions
@@ -672,9 +672,36 @@ const customPopups = {
$('body').css('overflow', 'hidden');
// 열 때마다 사유/에러 초기화
$('#withdrawalReasonInput').val('');
$('#withdrawalReasonError').hide();
$('#withdrawalReasonInput').off('input.withdrawal').on('input.withdrawal', function () {
$('#withdrawalReasonError').hide();
});
$('#withdrawalPopupConfirmButton').off('click').on('click', function () {
// 탈퇴 사유 필수 입력
var reason = $.trim($('#withdrawalReasonInput').val() || '');
if (!reason) {
$('#withdrawalReasonError').show();
$('#withdrawalReasonInput').focus();
return;
}
$('#withdrawalReasonHidden').val(reason);
customPopups.hideWithdrawal();
$('#withdrawalForm').submit();
// step-up 2FA 활성 시: 인증 성공 후에만 제출
var twofaRequired = $('#withdrawalPopup').attr('data-twofa-required') === 'true';
if (twofaRequired && typeof TwoFactorAuth !== 'undefined') {
TwoFactorAuth.open({
purpose: '/withdraw',
onSuccess: function () { $('#withdrawalForm').submit(); },
onCancel: function () { /* 사용자 취소 — 탈퇴 중단 */ }
});
} else {
$('#withdrawalForm').submit();
}
});
$('#withdrawalPopupCancelButton').off('click').on('click', function () {
@@ -32,7 +32,7 @@
<div class="found-users-list">
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
<div class="user-info">
<span class="user-email" th:text="${user.maskedEmailAddr}">te***@example.com</span>
<span class="user-email" th:text="${user.loginId}">test@example.com</span>
<span class="user-date">
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
</span>
@@ -149,34 +149,21 @@
});
}
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
function checkDuplicateAndLogin(form) {
var loginId = form.id.value;
$.ajax({
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate',
type: 'POST',
data: { loginId: loginId },
success: function (data) {
if (data && data.duplicateSession) {
$('#loginLoading').removeClass('active');
var msg = '해당 계정은 이미 다른 곳(' + data.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + data.loginTime + '<br><br>'
+ '기존 접속을 해제하고 로그인하시겠습니까?';
customPopups.showConfirm(msg, function (confirmed) {
if (confirmed) {
$('#loginLoading').addClass('active');
form.submit();
}
});
} else {
form.submit();
}
},
error: function () {
// 중복 체크 실패 시 로그인은 그대로 진행
form.submit();
}
});
// 동시 접속 확인은 비밀번호 검증(1차 인증) 통과 후 서버가 안내한다
// (2FA pending 또는 /login?duplicate=1 대기 상태에서 duplicateInfo 모델로 수신).
function duplicateConfirmMessage(info) {
return '해당 계정은 이미 다른 곳(' + info.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + info.loginTime + '<br><br>'
+ '기존 접속을 해제하고 로그인하시겠습니까?';
}
// 세션 CSRF 헤더 (meta[name=_csrf]) — 인증 후 확인/취소 POST 용
function csrfHeaders() {
var t = document.querySelector('meta[name="_csrf"]');
var h = document.querySelector('meta[name="_csrf_header"]');
var headers = {};
headers[h ? h.getAttribute('content') : 'X-XSRF-TOKEN'] = t ? t.getAttribute('content') : '';
return headers;
}
function fnInit() {
@@ -242,7 +229,7 @@
customPopups.showAlert('[[#{login.passLengthShort}]]');
} else {
refreshCsrfAndThen(form, function () {
checkDuplicateAndLogin(form);
form.submit();
});
}
}
@@ -260,9 +247,12 @@
}
});
// 로그인 2FA: 1차 인증 통과 후 pending 상태면 추가 인증 팝업 자동 오픈
// 1차 인증(비밀번호) 통과 후 흐름: [동시 접속 확인] → [2FA] 순서.
var twoFactorPending = [[${twoFactorPending}]];
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
var duplicateConfirmPending = [[${duplicateConfirmPending}]];
var duplicateInfo = [[${duplicateInfo}]];
function openLoginTwoFactor() {
TwoFactorAuth.open({
mode: 'login',
onSuccess: function (res) {
@@ -279,6 +269,53 @@
});
}
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
// 로그인 2FA pending: 동시 접속이 있으면 확인 후 2FA 팝업, 취소 시 로그인 포기
if (duplicateInfo) {
customPopups.showConfirm(duplicateConfirmMessage(duplicateInfo), function (confirmed) {
if (confirmed) {
openLoginTwoFactor();
} else {
$.ajax({
url: /*[[@{/auth/2fa/cancel}]]*/ '/auth/2fa/cancel',
type: 'POST',
headers: csrfHeaders(),
data: { reason: 'CANCELLED' }
});
customPopups.showAlert('로그인이 취소되었습니다.');
}
});
} else {
openLoginTwoFactor();
}
} else if (duplicateConfirmPending && duplicateInfo) {
// 2FA off + 동시 접속: 확인 시 서버가 로그인 확정(기존 접속 해제), 취소 시 포기
customPopups.showConfirm(duplicateConfirmMessage(duplicateInfo), function (confirmed) {
var url = confirmed
? /*[[@{/login/duplicate/confirm}]]*/ '/login/duplicate/confirm'
: /*[[@{/login/duplicate/cancel}]]*/ '/login/duplicate/cancel';
$.ajax({
url: url,
type: 'POST',
headers: csrfHeaders(),
success: function (res) {
if (confirmed) {
if (res && res.valid && res.redirect) {
window.location.href = res.redirect;
} else {
customPopups.showAlert((res && res.message) || '로그인 확인이 만료되었습니다. 다시 로그인해주세요.');
}
}
},
error: function () {
if (confirmed) {
customPopups.showAlert('로그인 처리 중 오류가 발생했습니다. 다시 로그인해주세요.');
}
}
});
});
}
fnInit();
});
</script>
@@ -29,7 +29,8 @@
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!</p>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br>
비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p>
</div>
<div class="form-row">
@@ -1,5 +1,6 @@
<!-- views/fragment/popup/withdrawalPopup.html -->
<div th:fragment="withdrawalPopup" id="withdrawalPopup" style="display: none;">
<div th:fragment="withdrawalPopup" id="withdrawalPopup" style="display: none;"
th:attr="data-twofa-required=${withdrawTwofaRequired}">
<!-- Modal Backdrop -->
<div class="modal-backdrop" id="withdrawalModalBackdrop"></div>
@@ -24,15 +25,25 @@
<li>회원 탈퇴는 API Portal 사이트의 회원 탈퇴입니다.</li>
<li>사용중인 API 서비스의 중지는 제휴 담당자를 통해 문의 주시기 바랍니다.</li>
</ul>
<div style="margin-top: 16px;">
<label for="withdrawalReasonInput" style="display: block; margin-bottom: 6px; font-size: 14px; font-weight: 600; color: #334155;">
탈퇴 사유 <span style="color: #d63a3a;">(필수)</span>
</label>
<textarea id="withdrawalReasonInput" maxlength="200" rows="3"
placeholder="탈퇴 사유를 입력해 주세요. (최대 200자)"
style="width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #CBD5E1; border-radius: 6px; font-size: 14px; line-height: 1.5; resize: vertical;"></textarea>
<p id="withdrawalReasonError" style="display: none; margin: 6px 0 0 0; font-size: 13px; color: #d63a3a;">탈퇴 사유를 입력해 주세요.</p>
</div>
<form id="withdrawalForm" th:action="@{/withdraw}" method="POST" style="display:none;">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" name="withdrawReason" id="withdrawalReasonHidden"/>
</form>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="withdrawalPopupCancelButton">취소</button>
<button type="button" class="btn btn-primary" id="withdrawalPopupConfirmButton">탈퇴신청</button>
<button type="button" class="btn btn-primary" id="withdrawalPopupConfirmButton">탈퇴하기</button>
</div>
</div>