b54fedc280
- SuccessHandler 충돌 해결: LoginFinalizer 리팩터 버전 채택 - LoginFinalizer 로그 마스킹 재적용(design 변경 보존) - CSS 생성물 충돌은 SASS 재컴파일로 해소(.tfa- 포함) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
275 lines
12 KiB
HTML
275 lines
12 KiB
HTML
<!doctype html>
|
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
|
|
<body>
|
|
<th:block layout:fragment="contentFragment">
|
|
<div class="login-page">
|
|
<div class="login-container">
|
|
<div class="login-card">
|
|
<!-- Logo -->
|
|
<div class="login-logo">
|
|
<img th:src="@{/img/logo/logo_box.png}" alt="DJBank">
|
|
</div>
|
|
|
|
<!-- Header Message -->
|
|
<p class="login-message">로그인 하시면 더 많은 서비스를 이용하실 수 있습니다.</p>
|
|
|
|
<!-- Alert Messages -->
|
|
<div th:if="${param.logout}" class="login-alert alert-info">
|
|
<i class="fas fa-info-circle"></i>
|
|
<span>로그아웃되었습니다.</span>
|
|
</div>
|
|
<div th:if="${param.expired}" class="login-alert alert-info">
|
|
<i class="fas fa-info-circle"></i>
|
|
<span>장시간 미사용으로 세션이 만료되어 로그아웃되었습니다. 다시 로그인해주세요.</span>
|
|
</div>
|
|
<div th:if="${param.forceLogout}" class="login-alert alert-info">
|
|
<i class="fas fa-info-circle"></i>
|
|
<span>다른 기기 또는 브라우저에서 로그인되어 현재 세션이 종료되었습니다.</span>
|
|
</div>
|
|
<div th:if="${param.reason == 'auth'}" class="login-alert alert-info">
|
|
<i class="fas fa-info-circle"></i>
|
|
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
|
|
</div>
|
|
|
|
<!-- Login Form -->
|
|
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="login-form">
|
|
<input type="hidden" id="message" name="message" th:value="${loginMessage}">
|
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
|
<input type="hidden" id="loginId" th:value="${loginId}">
|
|
|
|
<!-- ID Field -->
|
|
<div class="form-group">
|
|
<input type="text"
|
|
id="id"
|
|
name="id"
|
|
th:value="${loginId}"
|
|
class="form-input"
|
|
placeholder="아이디"
|
|
required>
|
|
</div>
|
|
|
|
<!-- Password Field -->
|
|
<div class="form-group">
|
|
<input type="password"
|
|
id="password"
|
|
name="password"
|
|
class="form-input"
|
|
placeholder="비밀번호"
|
|
required>
|
|
</div>
|
|
|
|
<!-- Remember Me Checkbox -->
|
|
<div class="form-checkbox">
|
|
<input id="checkId" name="checkId" type="checkbox">
|
|
<label for="checkId">아이디저장</label>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Login Button -->
|
|
<button type="button" class="login-button" id="actionLogin">
|
|
로그인
|
|
</button>
|
|
|
|
<!-- Additional Links -->
|
|
<div class="login-links">
|
|
<a th:href="@{/account_recovery(tab='findId')}">아이디 찾기</a>
|
|
<span class="link-separator">|</span>
|
|
<a th:href="@{/account_recovery(tab='resetPassword')}">비밀번호 초기화</a>
|
|
<span class="link-separator">|</span>
|
|
<a th:href="@{/signup}">회원가입</a>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div class="login-loading" id="loginLoading">
|
|
<div class="spinner"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</th:block>
|
|
</body>
|
|
<th:block layout:fragment="contentScript">
|
|
<script th:inline="javascript">
|
|
function setCookie(name, value, expires) {
|
|
let cookie = `${name}=${encodeURIComponent(value)}; path=/`;
|
|
|
|
if (expires instanceof Date) {
|
|
cookie += `; expires=${expires.toUTCString()}`;
|
|
}
|
|
document.cookie = cookie;
|
|
}
|
|
|
|
function getCookie(name) {
|
|
let decodedCookie = decodeURIComponent(document.cookie);
|
|
let ca = decodedCookie.split(';');
|
|
for (let i = 0; i < ca.length; i++) {
|
|
let c = ca[i];
|
|
while (c.charAt(0) === ' ') {
|
|
c = c.substring(1);
|
|
}
|
|
if (c.indexOf(name) === 0) {
|
|
return c.substring(name.length + 1, c.length);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getId(form) {
|
|
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
|
|
}
|
|
|
|
// CSRF 토큰 재발급 안전망: keepalive ping이 끊긴 경우(절전·네트워크 단절 등)
|
|
// 세션이 만료됐어도 제출 직전 새 토큰을 받아 403(만료) 대신 정상 로그인되게 한다.
|
|
// 재발급 실패 시에는 기존 토큰으로 그대로 제출한다.
|
|
function refreshCsrfAndThen(form, next) {
|
|
$.ajax({
|
|
url: /*[[@{/api/session/csrf}]]*/ '/api/session/csrf',
|
|
type: 'GET',
|
|
success: function (data) {
|
|
if (data && data.token) {
|
|
if (form['_csrf']) { form['_csrf'].value = data.token; }
|
|
var meta = document.querySelector('meta[name="_csrf"]');
|
|
if (meta) { meta.content = data.token; }
|
|
}
|
|
},
|
|
complete: function () { next(); }
|
|
});
|
|
}
|
|
|
|
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
|
|
function fnInit() {
|
|
let form = document.getElementById('loginForm');
|
|
if (form) {
|
|
getId(form);
|
|
}
|
|
if (document.loginForm.message && document.loginForm.message.value) {
|
|
customPopups.showAlert(document.loginForm.message.value);
|
|
}
|
|
}
|
|
|
|
$(function () {
|
|
|
|
// 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
|
|
// 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
|
|
|
|
var successMsg = [[${success}]];
|
|
console.log("Success message:", successMsg);
|
|
if (successMsg) {
|
|
console.log("Showing alert");
|
|
customPopups.showAlert(successMsg);
|
|
}
|
|
|
|
// 비밀번호 만료 체크 관련 코드 추가
|
|
var successMsg = [[${session.success}]];
|
|
var passwordExpired = [[${session.passwordExpired}]];
|
|
var emailVerificationRequired = [[${session.emailVerificationRequired}]];
|
|
var redirectUrl = [[${session.redirectUrl}]];
|
|
|
|
if (successMsg) {
|
|
customPopups.showAlert(successMsg, function() {
|
|
// 비밀번호 만료·미인증(이메일 인증 필요) 시 전용 화면으로 이동
|
|
if ((passwordExpired || emailVerificationRequired) && redirectUrl) {
|
|
window.location.href = redirectUrl;
|
|
}
|
|
});
|
|
}
|
|
|
|
let actionLogin = function (event) {
|
|
let form = document.loginForm;
|
|
|
|
// Show loading state
|
|
$('#loginLoading').addClass('active');
|
|
|
|
// 이메일(아이디) 소문자 변환 적용
|
|
form.id.value = form.id.value.toLowerCase();
|
|
|
|
if ($('#checkId').is(':checked')) {
|
|
setCookie('saveid', $('#id').val(), new Date(new Date().getTime() + 1000 * 3600 * 24 * 30));
|
|
} else {
|
|
setCookie('saveid', $('#id').val(), new Date(new Date().getTime() - 1));
|
|
}
|
|
|
|
if (!form.checkValidity()) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
$('#loginLoading').removeClass('active');
|
|
customPopups.showAlert('입력 정보 확인');
|
|
} else {
|
|
if (form.password.value.length < 8) {
|
|
$('#loginLoading').removeClass('active');
|
|
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
|
} else {
|
|
refreshCsrfAndThen(form, function () {
|
|
checkDuplicateAndLogin(form);
|
|
});
|
|
}
|
|
}
|
|
form.classList.add('was-validated');
|
|
};
|
|
|
|
$('#actionLogin').on('click', function(event) {
|
|
event.preventDefault();
|
|
actionLogin(event);
|
|
});
|
|
$('#password').on('keydown', function (event) {
|
|
if (event.key === 'Enter' || event.keyCode === 13 || event.code === 'Enter') {
|
|
event.preventDefault();
|
|
actionLogin(event);
|
|
}
|
|
});
|
|
|
|
// 로그인 2FA: 1차 인증 통과 후 pending 상태면 추가 인증 팝업 자동 오픈
|
|
var twoFactorPending = [[${twoFactorPending}]];
|
|
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
|
|
TwoFactorAuth.open({
|
|
mode: 'login',
|
|
onSuccess: function (res) {
|
|
window.location.href = (res && res.redirect) ? res.redirect : /*[[@{/}]]*/ '/';
|
|
},
|
|
onCancel: function (reason) {
|
|
// 팝업 닫기/타임아웃/실패 = 2차 인증 실패 → 로그인 페이지 유지 후 재로그인 안내
|
|
if (reason === 'TIMEOUT') {
|
|
customPopups.showAlert('인증 시간이 초과되어 로그인이 취소되었습니다. 다시 로그인해주세요.');
|
|
} else if (reason !== 'FAILED') {
|
|
customPopups.showAlert('추가 인증이 취소되었습니다. 다시 로그인해주세요.');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fnInit();
|
|
});
|
|
</script>
|
|
</th:block>
|
|
</html>
|