중복 로그인 확인 및 처리 로직 추가:
- 동시 접속 확인 API(DuplicateLoginController) 및 서비스 구현 - 중복 세션 확인 후 로그인 확정/취소 처리 로직 추가 - 2FA 흐름과 연계된 프론트엔드 수정 및 UI 개선
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user