33c64972f8
- 서비스 소개 페이지 디자인/스타일 대폭 개선 - 웹훅/OAuth2 개발 가이드 문구 및 텍스트 업데이트/템플릿화
328 lines
16 KiB
HTML
328 lines
16 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 & Title -->
|
|
<div class="login-header">
|
|
<div class="header-main-row">
|
|
<div class="login-lock-icon">
|
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#000"
|
|
stroke-width="2">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="login-title">로그인</h2>
|
|
</div>
|
|
<p class="login-message-sub">[[${brandName}]]에 오신걸 환영합니다</p>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
<div th:if="${param.pwFailExceeded}" class="login-alert alert-info">
|
|
<i class="fas fa-info-circle"></i>
|
|
<span>비밀번호 확인 5회 실패로 로그아웃되었습니다. 다시 로그인해 주세요.</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-with-icon">
|
|
<span class="input-icon">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#767e89"
|
|
stroke-width="2">
|
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
|
<circle cx="12" cy="7" r="4" />
|
|
</svg>
|
|
</span>
|
|
<input type="text" id="id" name="id" th:value="${loginId}" class="form-input"
|
|
placeholder="아이디를 입력해주세요" required>
|
|
</div>
|
|
|
|
<!-- Password Field -->
|
|
<div class="form-group input-with-icon">
|
|
<span class="input-icon">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#767e89"
|
|
stroke-width="2">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
</svg>
|
|
</span>
|
|
<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-container">
|
|
<a th:href="@{/account_recovery(tab='findId')}" class="link-btn first">아이디 찾기</a>
|
|
<a th:href="@{/account_recovery(tab='resetPassword')}" class="link-btn middle">비밀번호 초기화</a>
|
|
<a th:href="@{/signup}" class="link-btn last">회원가입</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(); }
|
|
});
|
|
}
|
|
|
|
// 동시 접속 확인은 비밀번호 검증(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() {
|
|
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 () {
|
|
form.submit();
|
|
});
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
});
|
|
|
|
// 1차 인증(비밀번호) 통과 후 흐름: [동시 접속 확인] → [2FA] 순서.
|
|
var twoFactorPending = [[${twoFactorPending}]];
|
|
var duplicateConfirmPending = [[${duplicateConfirmPending}]];
|
|
var duplicateInfo = [[${duplicateInfo}]];
|
|
|
|
function openLoginTwoFactor() {
|
|
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('추가 인증이 취소되었습니다. 다시 로그인해주세요.');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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>
|
|
</th:block>
|
|
|
|
</html> |