2FA (추가 인증) 기능 추가:

- Step-up 인증 Interceptor 및 보호 경로 관리 로직 도입
- 2FA 팝업 모듈 및 스타일(SASS, JS) 추가
- 로그인 실패 사유 Enum 및 2FA 관련 처리 로직 공통화
This commit is contained in:
Rinjae
2026-07-27 20:12:13 +09:00
parent 41be827b81
commit b5ffa69eba
43 changed files with 2672 additions and 553 deletions
+117 -69
View File
@@ -711,75 +711,6 @@ hr {
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.design-survey-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
z-index: 400;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.design-survey-bar .survey-container {
display: flex;
align-items: center;
gap: 16px;
}
.design-survey-bar .survey-label {
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
.design-survey-bar .survey-buttons {
display: flex;
gap: 8px;
}
.design-survey-bar .survey-btn {
padding: 6px 16px;
border: 2px solid rgba(255, 255, 255, 0.5);
border-radius: 20px;
background: transparent;
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.design-survey-bar .survey-btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: #ffffff;
}
.design-survey-bar .survey-btn.active {
background: #ffffff;
color: #667eea;
border-color: #ffffff;
}
@media (max-width: 768px) {
.design-survey-bar {
height: 40px;
}
.design-survey-bar .survey-label {
display: none;
}
.design-survey-bar .survey-btn {
padding: 4px 12px;
font-size: 12px;
}
}
body.design-survey-active .global-header {
margin-top: 48px;
}
@media (max-width: 768px) {
body.design-survey-active .global-header {
margin-top: 40px;
}
}
.blind {
position: absolute;
width: 1px;
@@ -7634,6 +7565,123 @@ button.djb-comment-submit:disabled {
color: #888;
}
#tfaModal .modal-title {
display: flex;
align-items: center;
gap: 8px;
}
.tfa-subtitle {
text-align: center;
color: #64748B;
margin-bottom: 24px;
font-size: 14px;
}
.tfa-segment {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.tfa-segment .tfa-seg-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border: 1px solid #CBD5E1;
border-radius: 8px;
background: #F8FAFC;
color: #475569;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.tfa-segment .tfa-seg-btn:hover {
border-color: #0049B4;
}
.tfa-segment .tfa-seg-btn.is-active {
background: #0049B4;
border-color: #0049B4;
color: #FFFFFF;
}
.tfa-segment .tfa-seg-btn.is-active .tfa-seg-masked {
color: rgba(255, 255, 255, 0.85);
}
.tfa-segment .tfa-seg-masked {
font-size: 12px;
font-weight: 400;
color: #94A3B8;
}
.tfa-btn-block {
width: 100%;
margin-top: 8px;
}
.tfa-code-row {
position: relative;
display: flex;
align-items: center;
}
.tfa-code-row .tfa-code-input {
width: 100%;
padding: 14px 64px 14px 16px;
border: 1px solid #CBD5E1;
border-radius: 8px;
font-size: 15px;
letter-spacing: 2px;
}
.tfa-code-row .tfa-code-input:focus {
outline: none;
border-color: #0049B4;
}
.tfa-code-row .tfa-timer {
position: absolute;
right: 16px;
color: #E11D48;
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.tfa-message {
min-height: 18px;
margin-top: 8px;
font-size: 13px;
color: #64748B;
}
.tfa-message.is-error {
color: #E11D48;
}
.tfa-test-notice {
margin-top: 8px;
padding: 10px 14px;
border: 1px dashed #F59E0B;
border-radius: 8px;
background: #FFFBEB;
color: #B45309;
font-size: 13px;
text-align: center;
}
.tfa-actions {
display: flex;
justify-content: flex-end;
margin: 8px 0;
}
.tfa-actions .tfa-resend {
background: none;
border: none;
color: #0049B4;
font-size: 13px;
cursor: pointer;
text-decoration: underline;
}
.hero-carousel-section {
position: relative;
width: 100%;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,296 @@
/*
* 공통 2FA(추가 인증) 팝업 모듈.
*
* TwoFactorAuth.open({
* mode: 'login' | 'stepup', // 참고용(서버가 세션으로 판별). 로깅/분기용
* purpose: '/myapikey/...', // step-up 대상 보호 경로(로그인은 생략)
* onSuccess: function(res){}, // 검증 성공. login 이면 res.redirect 사용
* onCancel: function(reason){}// 닫기/타임아웃/실패로 종료
* });
*
* 수신처는 서버가 세션 대상 사용자로부터 결정한다(클라이언트는 채널만 선택).
* 모든 POST 는 세션 CSRF(meta[name=_csrf]) 헤더를 함께 보낸다.
*/
(function (global) {
'use strict';
var CTX = (function () {
var el = document.querySelector('base');
return (window.__contextPath !== undefined) ? window.__contextPath : '';
})();
function csrf() {
var t = document.querySelector('meta[name="_csrf"]');
var h = document.querySelector('meta[name="_csrf_header"]');
return {
header: h ? h.getAttribute('content') : 'X-XSRF-TOKEN',
token: t ? t.getAttribute('content') : ''
};
}
function url(path) {
return CTX + path;
}
function postForm(path, params) {
var c = csrf();
var headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' };
if (c.token) { headers[c.header] = c.token; }
var body = Object.keys(params || {}).map(function (k) {
return encodeURIComponent(k) + '=' + encodeURIComponent(params[k]);
}).join('&');
return fetch(url(path), {
method: 'POST',
headers: headers,
credentials: 'same-origin',
body: body
}).then(function (r) {
// cancel 은 본문이 없다
return r.status === 200 ? r.text().then(function (t) { return t ? JSON.parse(t) : {}; }) : Promise.reject(r);
});
}
function getJson(path) {
return fetch(url(path), { credentials: 'same-origin' }).then(function (r) { return r.json(); });
}
var TwoFactorAuth = {
_opts: null,
_timer: null,
_channel: null,
_closing: false,
open: function (options) {
var self = this;
self._opts = options || {};
self._closing = false;
var purpose = self._opts.purpose || '';
getJson('/auth/2fa/info' + (purpose ? ('?purpose=' + encodeURIComponent(purpose)) : ''))
.then(function (info) {
if (!info || !info.available) {
self._fail('인증 대상 정보가 없습니다. 다시 시도해주세요.');
return;
}
self._render(info);
self._show();
if (info.inProgress) {
self._confirmForce(info.message || '진행 중인 다른 인증 절차가 있습니다.');
}
})
.catch(function () {
self._fail('추가 인증을 시작할 수 없습니다.');
});
},
_render: function (info) {
var self = this;
self._ttl = info.ttlSeconds || 180;
self._testNotice = !!info.testNoticeEnabled;
var seg = document.getElementById('tfaSegment');
seg.innerHTML = '';
var channels = info.channels || [];
channels.forEach(function (ch, idx) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'tfa-seg-btn' + (idx === 0 ? ' is-active' : '');
btn.setAttribute('data-channel', ch.type);
btn.innerHTML = (ch.type === 'EMAIL' ? '이메일 인증' : '전화번호 인증')
+ '<span class="tfa-seg-masked">' + ch.masked + '</span>';
btn.addEventListener('click', function () { self._selectChannel(ch.type); });
seg.appendChild(btn);
});
self._channel = channels.length ? channels[0].type : null;
// 초기 상태: 발송 단계
document.getElementById('tfaSendStep').style.display = '';
document.getElementById('tfaVerifyStep').style.display = 'none';
document.getElementById('tfaMessage').textContent = '';
document.getElementById('tfaCodeInput').value = '';
var notice = document.getElementById('tfaTestNotice');
notice.style.display = 'none';
notice.textContent = '';
// 핸들러 바인딩
document.getElementById('tfaSendButton').onclick = function () { self._send(false); };
document.getElementById('tfaResendButton').onclick = function () { self._send(false); };
document.getElementById('tfaVerifyButton').onclick = function () { self._verify(); };
document.getElementById('tfaCloseButton').onclick = function () { self._cancel('CANCELLED'); };
document.getElementById('tfaBackdrop').onclick = function () { self._cancel('CANCELLED'); };
document.getElementById('tfaCodeInput').onkeydown = function (e) {
if (e.key === 'Enter') { self._verify(); }
};
},
_selectChannel: function (type) {
this._channel = type;
var btns = document.querySelectorAll('#tfaSegment .tfa-seg-btn');
Array.prototype.forEach.call(btns, function (b) {
b.classList.toggle('is-active', b.getAttribute('data-channel') === type);
});
},
_send: function (force) {
var self = this;
if (!self._channel) { return; }
var sendBtn = document.getElementById('tfaSendButton');
var resendBtn = document.getElementById('tfaResendButton');
sendBtn.disabled = true;
resendBtn.disabled = true;
postForm('/auth/2fa/send', {
channel: self._channel,
purpose: self._opts.purpose || '',
force: force ? 'true' : 'false'
}).then(function (res) {
sendBtn.disabled = false;
resendBtn.disabled = false;
if (res.inProgress) {
self._confirmForce(res.message || '진행 중인 다른 인증 절차가 있습니다.');
return;
}
if (!res.valid) {
self._setMessage(res.message || '인증번호 발송에 실패했습니다.', true);
return;
}
// 발송 성공 → 검증 단계 노출 + 타이머
document.getElementById('tfaSendStep').style.display = 'none';
document.getElementById('tfaVerifyStep').style.display = '';
self._setMessage('', false);
document.getElementById('tfaCodeInput').value = '';
document.getElementById('tfaCodeInput').focus();
if (self._testNotice && res.testAuthNumber) {
var notice = document.getElementById('tfaTestNotice');
notice.style.display = '';
notice.textContent = '[테스트] 인증번호: ' + res.testAuthNumber;
}
self._startTimer(res.ttlSeconds || self._ttl);
}).catch(function () {
sendBtn.disabled = false;
resendBtn.disabled = false;
self._setMessage('인증번호 발송 중 오류가 발생했습니다.', true);
});
},
_confirmForce: function (message) {
var self = this;
var ok = window.confirm(message + '\n강제 종료하고 새로 진행하시겠습니까?');
if (ok) {
self._send(true);
}
},
_verify: function () {
var self = this;
var code = (document.getElementById('tfaCodeInput').value || '').trim();
if (!/^[0-9]{6}$/.test(code)) {
self._setMessage('6자리 인증번호를 입력해주세요.', true);
return;
}
var btn = document.getElementById('tfaVerifyButton');
btn.disabled = true;
postForm('/auth/2fa/verify', { code: code }).then(function (res) {
btn.disabled = false;
if (res.valid) {
self._stopTimer();
self._closing = true;
self._hide();
if (typeof self._opts.onSuccess === 'function') {
self._opts.onSuccess(res);
}
return;
}
if (res.terminated) {
self._stopTimer();
self._fail(res.message || '인증에 실패했습니다. 처음부터 다시 진행해주세요.');
return;
}
self._setMessage(res.message || '인증번호가 일치하지 않습니다.', true);
}).catch(function () {
btn.disabled = false;
self._setMessage('인증 처리 중 오류가 발생했습니다.', true);
});
},
_cancel: function (reason) {
var self = this;
if (self._closing) { return; }
self._closing = true;
self._stopTimer();
postForm('/auth/2fa/cancel', { reason: reason }).catch(function () {}).then(function () {
self._hide();
if (typeof self._opts.onCancel === 'function') {
self._opts.onCancel(reason);
}
});
},
_startTimer: function (seconds) {
var self = this;
self._stopTimer();
var remaining = seconds;
var el = document.getElementById('tfaTimer');
function tick() {
if (remaining <= 0) {
self._stopTimer();
el.textContent = '00:00';
self._cancel('TIMEOUT');
return;
}
var m = Math.floor(remaining / 60);
var s = remaining % 60;
el.textContent = (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
remaining--;
}
tick();
self._timer = setInterval(tick, 1000);
},
_stopTimer: function () {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
},
_setMessage: function (msg, isError) {
var el = document.getElementById('tfaMessage');
el.textContent = msg || '';
el.className = 'tfa-message' + (isError ? ' is-error' : '');
},
_fail: function (message) {
var self = this;
self._stopTimer();
self._hide();
if (typeof window.customPopups !== 'undefined' && customPopups.showAlert) {
customPopups.showAlert(message);
} else if (message) {
window.alert(message);
}
if (typeof self._opts.onCancel === 'function') {
self._opts.onCancel('FAILED');
}
},
_show: function () {
// 모달은 컨테이너 display + backdrop/modal 의 .show 클래스(visibility/opacity) 둘 다 필요
document.getElementById('tfaPopup').style.display = 'block';
var bd = document.getElementById('tfaBackdrop');
var md = document.getElementById('tfaModal');
if (bd) bd.classList.add('show');
if (md) md.classList.add('show');
},
_hide: function () {
var bd = document.getElementById('tfaBackdrop');
var md = document.getElementById('tfaModal');
if (bd) bd.classList.remove('show');
if (md) md.classList.remove('show');
document.getElementById('tfaPopup').style.display = 'none';
}
};
global.TwoFactorAuth = TwoFactorAuth;
})(window);
@@ -0,0 +1,134 @@
@use '../abstracts/variables' as *;
@use '../abstracts/color-functions' as *;
@use '../abstracts/mixins' as *;
// 공통 2FA(추가 인증) 팝업. .modal / .modal-backdrop / .modal-dialog 는 _modals 재사용.
#tfaModal {
.modal-title {
display: flex;
align-items: center;
gap: 8px;
}
}
.tfa-subtitle {
text-align: center;
color: #64748B;
margin-bottom: 24px;
font-size: 14px;
}
.tfa-segment {
display: flex;
gap: 8px;
margin-bottom: 20px;
.tfa-seg-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border: 1px solid #CBD5E1;
border-radius: 8px;
background: #F8FAFC;
color: #475569;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: $primary-color;
}
&.is-active {
background: $primary-color;
border-color: $primary-color;
color: #FFFFFF;
.tfa-seg-masked {
color: rgba(255, 255, 255, 0.85);
}
}
}
.tfa-seg-masked {
font-size: 12px;
font-weight: 400;
color: #94A3B8;
}
}
.tfa-btn-block {
width: 100%;
margin-top: 8px;
}
.tfa-code-row {
position: relative;
display: flex;
align-items: center;
.tfa-code-input {
width: 100%;
padding: 14px 64px 14px 16px;
border: 1px solid #CBD5E1;
border-radius: 8px;
font-size: 15px;
letter-spacing: 2px;
&:focus {
outline: none;
border-color: $primary-color;
}
}
.tfa-timer {
position: absolute;
right: 16px;
color: #E11D48;
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
}
.tfa-message {
min-height: 18px;
margin-top: 8px;
font-size: 13px;
color: #64748B;
&.is-error {
color: #E11D48;
}
}
.tfa-test-notice {
margin-top: 8px;
padding: 10px 14px;
border: 1px dashed #F59E0B;
border-radius: 8px;
background: #FFFBEB;
color: #B45309;
font-size: 13px;
text-align: center;
}
.tfa-actions {
display: flex;
justify-content: flex-end;
margin: 8px 0;
.tfa-resend {
background: none;
border: none;
color: $primary-color;
font-size: 13px;
cursor: pointer;
text-decoration: underline;
}
}
@@ -44,92 +44,6 @@
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
// ===========================
// Design Survey Bar
// ===========================
.design-survey-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
z-index: 400;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.survey-container {
display: flex;
align-items: center;
gap: 16px;
}
.survey-label {
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
.survey-buttons {
display: flex;
gap: 8px;
}
.survey-btn {
padding: 6px 16px;
border: 2px solid rgba(255, 255, 255, 0.5);
border-radius: 20px;
background: transparent;
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: #ffffff;
}
&.active {
background: #ffffff;
color: #667eea;
border-color: #ffffff;
}
}
@media (max-width: 768px) {
height: 40px;
.survey-label {
display: none;
}
.survey-btn {
padding: 4px 12px;
font-size: 12px;
}
}
}
// Body offset when survey is active
body.design-survey-active {
.global-header {
margin-top: 48px;
}
@media (max-width: 768px) {
.global-header {
margin-top: 40px;
}
}
}
// 디자인 변형 스타일은 JavaScript에서 동적으로 적용됩니다.
// header_container.html의 DESIGN_OPTIONS 참조
// Blind text for screen readers
.blind {
position: absolute;
+1
View File
@@ -45,6 +45,7 @@
@use 'components/test-env-notice' as *;
@use 'components/djb-inquiry-comments' as *;
@use 'components/password-policy' as *;
@use 'components/two-factor' as *;
// 5. Page-specific styles
@use 'pages/index' as *;
@@ -0,0 +1,135 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_base_layout}">
<head>
<meta charset="utf-8"/>
<title>추가 인증</title>
</head>
<body>
<th:block layout:fragment="contentFragment">
<style>
/* Sticky footer: body 를 세로 플렉스로 만들어 콘텐츠가 남는 높이를 채우고
푸터가 항상 뷰포트 하단에 붙도록(푸터 아래 빈 공간 제거). 이 페이지에서만 적용. */
body { display: flex; flex-direction: column; min-height: 100vh; }
body > .container { flex: 1 0 auto; } /* 본문 컨테이너(헤더/푸터 내부 .container 아님) */
/* 안내는 상단에서부터 노출(세로 중앙정렬 X → 팝업 뒤에 가려지지 않게) */
.tfa-challenge {
padding: 40px 16px 48px; /* 상단 약간의 여백만 */
}
.tfa-challenge-card {
text-align: center;
max-width: 480px;
margin: 0 auto; /* 가로 중앙 */
}
.tfa-challenge-icon {
width: 88px;
height: 88px;
margin: 0 auto 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #E8F0FE 0%, #DCE7FB 100%);
box-shadow: 0 8px 24px rgba(0, 73, 180, 0.15);
}
.tfa-challenge-icon svg { width: 44px; height: 44px; color: #0049B4; }
.tfa-challenge-title {
font-size: 22px;
font-weight: 700;
color: #1E293B;
margin: 0 0 10px;
}
.tfa-challenge-desc {
font-size: 15px;
line-height: 1.6;
color: #64748B;
margin: 0;
}
.tfa-challenge-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 20px;
padding: 6px 14px;
border-radius: 999px;
background: #F1F5F9;
color: #475569;
font-size: 13px;
font-weight: 500;
}
.tfa-challenge-badge svg { width: 15px; height: 15px; }
@media (max-width: 640px) {
.tfa-challenge { padding: 32px 16px; }
.tfa-challenge-icon { width: 72px; height: 72px; }
.tfa-challenge-icon svg { width: 36px; height: 36px; }
.tfa-challenge-title { font-size: 19px; }
}
</style>
<div class="tfa-challenge">
<div class="tfa-challenge-card">
<div class="tfa-challenge-icon" aria-hidden="true">
<!-- shield-check -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
<path d="M9 12l2 2 4-4"></path>
</svg>
</div>
<h2 class="tfa-challenge-title">추가 인증이 필요합니다</h2>
<p class="tfa-challenge-desc">
회원님의 소중한 정보를 안전하게 보호하기 위해<br>
추가 인증을 진행해 주세요.
</p>
<span class="tfa-challenge-badge" aria-hidden="true">
<!-- lock -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
제주은행(DJBank) 보안 인증
</span>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
(function () {
var ctxRoot = /*[[@{/}]]*/ '/';
var contextPath = ctxRoot.replace(/\/$/, '');
var purpose = /*[[${purpose}]]*/ '';
var returnUrl = /*[[${returnUrl}]]*/ '';
function goReturn() {
window.location.href = contextPath + returnUrl;
}
function open() {
if (typeof TwoFactorAuth === 'undefined') {
setTimeout(open, 50);
return;
}
TwoFactorAuth.open({
mode: 'stepup',
purpose: purpose,
onSuccess: function () { goReturn(); },
onCancel: function () {
// 취소/실패 → 진입 이전(직전 페이지)으로. 없으면 홈으로.
if (document.referrer && document.referrer.indexOf(location.host) !== -1) {
window.location.href = document.referrer;
} else {
window.location.href = contextPath + '/';
}
}
});
}
document.addEventListener('DOMContentLoaded', open);
})();
</script>
</th:block>
</body>
</html>
@@ -244,6 +244,25 @@
}
});
// 로그인 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>
@@ -197,39 +197,30 @@
);
}
// step-up 2FA 응답(401 + stepUpRequired) 판별 및 처리 헬퍼
function isStepUpRequired(jqXHR) {
return jqXHR && jqXHR.status === 401 && jqXHR.responseJSON && jqXHR.responseJSON.stepUpRequired;
}
function requireStepUp(purpose, retry) {
if (typeof TwoFactorAuth === 'undefined') {
customPopups.showAlert('추가 인증이 필요합니다. 다시 시도해주세요.');
return;
}
TwoFactorAuth.open({
mode: 'stepup',
purpose: purpose,
onSuccess: function() { retry(); },
onCancel: function() { /* 취소: 원 작업 중단 */ }
});
}
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
function showPasswordPrompt() {
customPopups.showPasswordInput({
title: 'Client Secret 조회',
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
onConfirm: function(password) {
$.ajax({
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function(response) {
if (response.success) {
customPopups.hidePasswordInput();
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
customPopups.hidePasswordInput();
showLostKeyGuide();
} else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
}).fail(function() {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
});
doRevealSecret(password);
},
onCancel: function() {
// User cancelled - do nothing
@@ -237,6 +228,42 @@
});
}
// Client Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doRevealSecret(password) {
$.ajax({
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function(response) {
if (response.success) {
customPopups.hidePasswordInput();
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
customPopups.hidePasswordInput();
showLostKeyGuide();
} else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
}).fail(function(jqXHR) {
if (isStepUpRequired(jqXHR)) {
customPopups.hidePasswordInput();
requireStepUp('/myapikey/credential/reveal-secret', function() { doRevealSecret(password); });
return;
}
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
});
}
// Copy to clipboard function - called from button with data-secret attribute
function copyToClipboardFromButton(button) {
var text = $(button).data('secret');
@@ -277,34 +304,38 @@
if (!confirmed) {
return;
}
doDeleteApiKey(clientId);
});
}
$('.loading-overlay').show();
// 인증키 삭제 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doDeleteApiKey(clientId) {
$('.loading-overlay').show();
const requestData = {
clientId: clientId
};
$.ajax({
url: '/myapikey/api_key_delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function(response) {
if (response && response.success === false) {
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
return;
}
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function() {
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
});
}).fail(function(jqXHR, textStatus, errorThrown) {
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
}).always(function() {
$('.loading-overlay').hide();
$.ajax({
url: '/myapikey/api_key_delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: clientId }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function(response) {
if (response && response.success === false) {
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
return;
}
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function() {
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
});
}).fail(function(jqXHR, textStatus, errorThrown) {
if (isStepUpRequired(jqXHR)) {
requireStepUp('/myapikey/api_key_delete', function() { doDeleteApiKey(clientId); });
return;
}
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
}).always(function() {
$('.loading-overlay').hide();
});
}
@@ -35,6 +35,29 @@
<div id="email-validation" class="org-validation-message"></div>
</div>
</div>
<!-- 이메일 인증 (중복체크 통과 후 노출) -->
<div class="org-form-group" id="emailVerifyRow" style="display: none;">
<label class="org-form-label">
이메일 인증 <span class="required-badge">필수</span>
</label>
<div class="org-form-input-wrapper">
<div class="org-input-row">
<button type="button" class="btn org-btn-check" id="btnSendEmailCode">인증번호 발송</button>
</div>
<div class="org-input-row" id="emailCodeRow" style="display: none; margin-top: 8px;">
<div class="org-compound-input" style="position: relative; flex: 1;">
<input type="text" id="emailAuthCode" class="org-form-input" maxlength="6"
inputmode="numeric" autocomplete="one-time-code" placeholder="인증번호 6자리">
<span class="org-timer" id="emailCertifyTime"
style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #E11D48;">03:00</span>
</div>
<button type="button" class="btn org-btn-check" id="btnVerifyEmailCode">인증확인</button>
</div>
<input type="hidden" id="emailVerified" name="emailVerified" value="false"/>
<div id="email-auth-validation" class="org-validation-message"></div>
</div>
</div>
</th:block>
</body>
<th:block>
@@ -79,6 +102,9 @@
newUserForm.show();
individualConversionForm.hide();
emailChangeForm.hide();
// 중복체크 통과 → 이메일 인증 UI 노출
$('#emailVerifyRow').show();
break;
case "conversionOrChange":
@@ -162,6 +188,98 @@
}
});
});
// ===== 이메일 인증 (가입 폼 인라인) =====
var emailCodeTimer = null;
function setEmailAuthMsg(msg, isError) {
var el = $('#email-auth-validation');
el.text(msg || '');
el.css('color', isError ? '#E11D48' : '#0049B4');
}
function startEmailCodeTimer() {
clearInterval(emailCodeTimer);
var remaining = 180;
var el = document.getElementById('emailCertifyTime');
$(el).show();
function tick() {
if (remaining <= 0) {
clearInterval(emailCodeTimer);
el.textContent = '00:00';
setEmailAuthMsg('입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.', true);
$('#btnSendEmailCode').text('인증번호 재발송').prop('disabled', false);
return;
}
var m = Math.floor(remaining / 60);
var s = remaining % 60;
el.textContent = (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
remaining--;
}
tick();
emailCodeTimer = setInterval(tick, 1000);
}
$('#btnSendEmailCode').on('click', function () {
var email = $('#loginId').val();
if (!email || email.indexOf('@') === -1) {
setEmailAuthMsg('이메일을 먼저 확인해주세요.', true);
return;
}
var btn = $(this);
btn.prop('disabled', true);
$.ajax({
url: /*[[@{/signup/email-code/send}]]*/ '/signup/email-code/send',
type: 'POST',
data: { email: email, _csrf: $('input[name="_csrf"]').val() },
success: function (res) {
btn.prop('disabled', false);
if (res && res.valid) {
$('#emailCodeRow').show();
$('#emailAuthCode').val('').focus();
setEmailAuthMsg('인증번호를 발송했습니다.', false);
btn.text('인증번호 재발송');
startEmailCodeTimer();
} else {
setEmailAuthMsg((res && res.message) || '발송에 실패했습니다.', true);
}
},
error: function () {
btn.prop('disabled', false);
setEmailAuthMsg('발송 중 오류가 발생했습니다.', true);
}
});
});
$('#btnVerifyEmailCode').on('click', function () {
var email = $('#loginId').val();
var code = ($('#emailAuthCode').val() || '').trim();
if (!/^[0-9]{6}$/.test(code)) {
setEmailAuthMsg('인증번호 6자리를 입력해주세요.', true);
return;
}
$.ajax({
url: /*[[@{/signup/email-code/verify}]]*/ '/signup/email-code/verify',
type: 'POST',
data: { email: email, code: code, _csrf: $('input[name="_csrf"]').val() },
success: function (res) {
if (res && res.valid) {
clearInterval(emailCodeTimer);
$('#emailVerified').val('true');
$('#emailAuthCode').prop('readonly', true);
$('#btnVerifyEmailCode').prop('disabled', true);
$('#btnSendEmailCode').prop('disabled', true);
$('#emailCertifyTime').hide();
setEmailAuthMsg('이메일 인증이 완료되었습니다.', false);
} else {
setEmailAuthMsg((res && res.message) || '인증번호가 일치하지 않습니다.', true);
}
},
error: function () {
setEmailAuthMsg('인증 처리 중 오류가 발생했습니다.', true);
}
});
});
});
</script>
@@ -27,6 +27,29 @@
</div>
</div>
<!-- 이메일 인증 (중복체크 통과 후 노출) -->
<div class="org-form-group" id="emailVerifyRow" style="display: none;">
<label class="org-form-label">
이메일 인증 <span class="required-badge">필수</span>
</label>
<div class="org-form-input-wrapper">
<div class="org-input-row">
<button type="button" class="btn org-btn-check" id="btnSendEmailCode">인증번호 발송</button>
</div>
<div class="org-input-row" id="emailCodeRow" style="display: none; margin-top: 8px;">
<div class="org-compound-input" style="position: relative; flex: 1;">
<input type="text" id="emailAuthCode" class="org-form-input" maxlength="6"
inputmode="numeric" autocomplete="one-time-code" placeholder="인증번호 6자리">
<span class="org-timer" id="emailCertifyTime"
style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #E11D48;">03:00</span>
</div>
<button type="button" class="btn org-btn-check" id="btnVerifyEmailCode">인증확인</button>
</div>
<input type="hidden" id="emailVerified" name="emailVerified" value="false"/>
<div id="email-auth-validation" class="org-validation-message"></div>
</div>
</div>
<!-- Dynamic Form Container -->
<div id="dynamicFormContainer">
<div id="individualConversionForm" style="display: none;">
@@ -97,6 +120,10 @@
if (newUserForm) newUserForm.style.display = 'block';
if (individualConversionForm) individualConversionForm.style.display = 'none';
if (emailChangeForm) emailChangeForm.style.display = 'none';
// 중복체크 통과 → 이메일 인증 UI 노출
var evRow = document.getElementById('emailVerifyRow');
if (evRow) evRow.style.display = 'block';
break;
case "conversionOrChange":
@@ -212,6 +239,108 @@
});
});
}
// ===== 이메일 인증 (법인 가입 폼 인라인) =====
var emailCodeTimer = null;
function setEmailAuthMsg(msg, isError) {
var el = document.getElementById('email-auth-validation');
if (!el) return;
el.textContent = msg || '';
el.style.color = isError ? '#E11D48' : '#0049B4';
}
function startEmailCodeTimer() {
clearInterval(emailCodeTimer);
var remaining = 180;
var el = document.getElementById('emailCertifyTime');
if (el) el.style.display = '';
function tick() {
if (remaining <= 0) {
clearInterval(emailCodeTimer);
if (el) el.textContent = '00:00';
setEmailAuthMsg('입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.', true);
var sb = document.getElementById('btnSendEmailCode');
if (sb) { sb.textContent = '인증번호 재발송'; sb.disabled = false; }
return;
}
var m = Math.floor(remaining / 60);
var s = remaining % 60;
if (el) el.textContent = (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
remaining--;
}
tick();
emailCodeTimer = setInterval(tick, 1000);
}
var sendBtn = document.getElementById('btnSendEmailCode');
if (sendBtn) {
sendBtn.addEventListener('click', function () {
var email = document.getElementById('loginId').value;
if (!email || email.indexOf('@') === -1) {
setEmailAuthMsg('이메일을 먼저 확인해주세요.', true);
return;
}
var btn = this;
btn.disabled = true;
$.ajax({
url: '/signup/email-code/send',
type: 'POST',
data: { email: email, _csrf: document.querySelector('input[name="_csrf"]')?.value },
success: function (res) {
btn.disabled = false;
if (res && res.valid) {
document.getElementById('emailCodeRow').style.display = '';
var ci = document.getElementById('emailAuthCode');
ci.value = ''; ci.focus();
setEmailAuthMsg('인증번호를 발송했습니다.', false);
btn.textContent = '인증번호 재발송';
startEmailCodeTimer();
} else {
setEmailAuthMsg((res && res.message) || '발송에 실패했습니다.', true);
}
},
error: function () {
btn.disabled = false;
setEmailAuthMsg('발송 중 오류가 발생했습니다.', true);
}
});
});
}
var verifyBtn = document.getElementById('btnVerifyEmailCode');
if (verifyBtn) {
verifyBtn.addEventListener('click', function () {
var email = document.getElementById('loginId').value;
var code = (document.getElementById('emailAuthCode').value || '').trim();
if (!/^[0-9]{6}$/.test(code)) {
setEmailAuthMsg('인증번호 6자리를 입력해주세요.', true);
return;
}
$.ajax({
url: '/signup/email-code/verify',
type: 'POST',
data: { email: email, code: code, _csrf: document.querySelector('input[name="_csrf"]')?.value },
success: function (res) {
if (res && res.valid) {
clearInterval(emailCodeTimer);
document.getElementById('emailVerified').value = 'true';
document.getElementById('emailAuthCode').readOnly = true;
verifyBtn.disabled = true;
document.getElementById('btnSendEmailCode').disabled = true;
var t = document.getElementById('emailCertifyTime');
if (t) t.style.display = 'none';
setEmailAuthMsg('이메일 인증이 완료되었습니다.', false);
} else {
setEmailAuthMsg((res && res.message) || '인증번호가 일치하지 않습니다.', true);
}
},
error: function () {
setEmailAuthMsg('인증 처리 중 오류가 발생했습니다.', true);
}
});
});
}
});
</script>
@@ -3,16 +3,6 @@
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<body>
<th:block th:fragment="headerFragment(headerClass)">
<!-- Design Survey Bar -->
<div th:if="${designSurveyEnabled}" class="design-survey-bar" id="designSurveyBar">
<div class="survey-container">
<span class="survey-label">네비게이션 디자인을 선택해주세요:</span>
<div class="survey-buttons" id="surveyButtons">
<!-- 버튼은 JavaScript에서 동적으로 생성됩니다 -->
</div>
</div>
</div>
<!-- Global Header Container -->
<header class="global-header" th:classappend="${headerClass}">
<div class="container">
@@ -579,107 +569,6 @@
}
});
// ============================================================
// Design Survey Configuration
// 새 디자인 옵션 추가: DESIGN_OPTIONS 배열에 항목 추가
// 예: { id: 'D', label: 'D', styles: { '.logo-text': { 'font-size': '16px' } } }
// ============================================================
const DESIGN_OPTIONS = [
{
id: 'A',
label: 'A (현재)',
isDefault: true,
styles: {} // 기본 스타일 (변경 없음)
},
{
id: 'B',
label: 'B',
styles: {
'.logo-text': { 'font-size': '18px' },
'.nav-link': { 'margin': '0 20px' }
}
},
{
id: 'C',
label: 'C',
styles: {
'.nav-link': { 'margin': '0 20px', 'font-weight': 'bold' }
}
}
// 새 디자인 추가 예시:
// {
// id: 'D',
// label: 'D',
// styles: {
// '.logo-text': { 'font-size': '16px', 'color': '#333' },
// '.nav-link': { 'padding': '10px 24px' }
// }
// }
];
// Design Survey Logic
const surveyBar = document.getElementById('designSurveyBar');
if (surveyBar) {
const body = document.body;
const buttonContainer = document.getElementById('surveyButtons');
const STORAGE_KEY = 'design-survey-selection';
let styleElement = null;
body.classList.add('design-survey-active');
// 버튼 동적 생성
DESIGN_OPTIONS.forEach(option => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'survey-btn';
btn.dataset.design = option.id;
btn.textContent = option.label;
buttonContainer.appendChild(btn);
});
const surveyButtons = buttonContainer.querySelectorAll('.survey-btn');
const defaultOption = DESIGN_OPTIONS.find(o => o.isDefault) || DESIGN_OPTIONS[0];
const savedDesign = localStorage.getItem(STORAGE_KEY) || defaultOption.id;
applyDesign(savedDesign);
surveyButtons.forEach(btn => {
btn.classList.toggle('active', btn.dataset.design === savedDesign);
});
surveyButtons.forEach(btn => {
btn.addEventListener('click', function() {
surveyButtons.forEach(b => b.classList.remove('active'));
this.classList.add('active');
applyDesign(this.dataset.design);
localStorage.setItem(STORAGE_KEY, this.dataset.design);
});
});
function applyDesign(designId) {
// 기존 동적 스타일 제거
if (styleElement) {
styleElement.remove();
styleElement = null;
}
const option = DESIGN_OPTIONS.find(o => o.id === designId);
if (!option || Object.keys(option.styles).length === 0) return;
// 동적 스타일 생성
let css = '';
for (const [selector, props] of Object.entries(option.styles)) {
const propsStr = Object.entries(props)
.map(([prop, val]) => `${prop}: ${val} !important`)
.join('; ');
css += `${selector} { ${propsStr}; }\n`;
}
styleElement = document.createElement('style');
styleElement.id = 'design-survey-styles';
styleElement.textContent = css;
document.head.appendChild(styleElement);
}
}
});
</script>
</th:block>
@@ -0,0 +1,52 @@
<!-- views/fragment/popup/twoFactorAuthPopup.html : 공통 2FA(추가 인증) 팝업 -->
<div th:fragment="twoFactorAuthPopup" id="tfaPopup" style="display: none;" xmlns:th="http://www.thymeleaf.org">
<div class="modal-backdrop" id="tfaBackdrop"></div>
<div class="modal" id="tfaModal">
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title" id="tfaTitle">
<span aria-hidden="true">🔒</span> 추가 인증
</h3>
<button type="button" class="modal-close" id="tfaCloseButton" aria-label="닫기">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<p class="tfa-subtitle">제주은행(DJBank)에 오신걸 환영합니다</p>
<!-- 채널 선택 세그먼트 (이메일/전화번호) -->
<div class="tfa-segment" id="tfaSegment" role="tablist">
<!-- JS 가 채널 버튼을 렌더링 -->
</div>
<!-- 인증번호 발송 전 -->
<div id="tfaSendStep">
<button type="button" class="btn btn-primary tfa-btn-block" id="tfaSendButton">인증번호 발송</button>
</div>
<!-- 인증번호 발송 후 -->
<div id="tfaVerifyStep" style="display: none;">
<div class="tfa-code-row">
<input type="text" id="tfaCodeInput" class="tfa-code-input" inputmode="numeric" maxlength="6"
autocomplete="one-time-code" placeholder="인증번호를 입력해주세요"/>
<span class="tfa-timer" id="tfaTimer">03:00</span>
</div>
<div class="tfa-message" id="tfaMessage"></div>
<div class="tfa-test-notice" id="tfaTestNotice" style="display: none;"></div>
<div class="tfa-actions">
<button type="button" class="tfa-resend" id="tfaResendButton">인증번호 재발송</button>
</div>
<button type="button" class="btn btn-primary tfa-btn-block" id="tfaVerifyButton">인증코드 확인</button>
</div>
</div>
</div>
</div>
</div>
@@ -22,7 +22,9 @@
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
</body>
</html>
@@ -23,7 +23,9 @@
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
</body>
</html>