Files
eapim-portal/src/main/resources/static/js/popup/two-factor-auth.js
T
Rinjae 60f9f89d90
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2FA 채널 정렬 로직 추가: SMS를 우선 표시 및 기본 선택 처리
2026-09-09 09:51:44 +09:00

352 lines
13 KiB
JavaScript

/*
* 공통 2FA(추가 인증) 팝업 모듈.
*
* TwoFactorAuth.open({
* mode: 'login' | 'stepup', // 참고용(서버가 세션으로 판별). 로깅/분기용
* purpose: '/clients/...', // 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 = '';
self._maskedByType = {};
// SMS를 먼저 표시하고 첫 번째 수단을 기본 선택한다.
var channels = (info.channels || []).slice().sort(function (a, b) {
return (a.type === 'SMS' ? 0 : 1) - (b.type === 'SMS' ? 0 : 1);
});
channels.forEach(function (ch, idx) {
self._maskedByType[ch.type] = ch.masked;
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 = '<span class="tfa-seg-label">' + (ch.type === 'EMAIL' ? '이메일' : '휴대폰 문자') + '</span>'
+ '<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';
self._setMessage('', false);
document.getElementById('tfaCodeInput').value = '';
document.getElementById('tfaVerifyButton').disabled = true;
self._setStep2Active(false);
self._resetTimerUi();
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').oninput = function () {
var v = (this.value || '').replace(/\D/g, '').slice(0, 6);
this.value = v;
document.getElementById('tfaVerifyButton').disabled = v.length < 6;
};
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._setStep2Active(true);
var masked = self._maskedByType ? (self._maskedByType[self._channel] || '') : '';
self._setMessage((masked ? masked + ' 으로 ' : '') + '인증번호를 보냈습니다.', false);
document.getElementById('tfaCodeInput').value = '';
document.getElementById('tfaVerifyButton').disabled = true;
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);
}
});
},
_fmtClock: function (n) {
var m = Math.floor(n / 60);
var s = n % 60;
return m + ':' + (s < 10 ? '0' + s : s);
},
_setStep2Active: function (active) {
var num = document.getElementById('tfaStepNum');
var text = document.getElementById('tfaStepText');
if (num) { num.classList.toggle('is-active', active); }
if (text) { text.classList.toggle('is-active', active); }
},
_resetTimerUi: function () {
var base = this._ttl || 180;
var clock = document.getElementById('tfaTimer');
var bar = document.getElementById('tfaProgressBar');
var label = document.getElementById('tfaTimerLabel');
var total = document.getElementById('tfaTimerTotal');
if (clock) { clock.className = 'tfa-timer'; clock.textContent = this._fmtClock(base); }
if (bar) { bar.className = 'tfa-progress-bar'; bar.style.width = '100%'; }
if (label) { label.textContent = '남은 인증 시간'; }
if (total) { total.textContent = '/ ' + this._fmtClock(base); }
},
_startTimer: function (seconds) {
var self = this;
self._stopTimer();
var total = seconds || self._ttl || 180;
var remaining = seconds;
var clock = document.getElementById('tfaTimer');
var bar = document.getElementById('tfaProgressBar');
var label = document.getElementById('tfaTimerLabel');
var totalEl = document.getElementById('tfaTimerTotal');
if (label) { label.textContent = '남은 인증 시간'; }
if (totalEl) { totalEl.textContent = '/ ' + self._fmtClock(total); }
function tick() {
if (remaining <= 0) {
self._stopTimer();
if (clock) { clock.textContent = '0:00'; clock.className = 'tfa-timer is-expired'; }
if (bar) { bar.style.width = '0%'; }
if (label) { label.textContent = '인증 시간이 만료되었습니다. 재전송해 주세요.'; }
self._cancel('TIMEOUT');
return;
}
var warn = remaining <= 30;
if (clock) {
clock.textContent = self._fmtClock(remaining);
clock.className = 'tfa-timer' + (warn ? ' is-warning' : '');
}
if (bar) {
bar.style.width = Math.round((remaining / total) * 100) + '%';
bar.className = 'tfa-progress-bar' + (warn ? ' is-warning' : '');
}
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);