352 lines
13 KiB
JavaScript
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);
|