2FA (추가 인증) 기능 추가:
- Step-up 인증 Interceptor 및 보호 경로 관리 로직 도입 - 2FA 팝업 모듈 및 스타일(SASS, JS) 추가 - 로그인 실패 사유 Enum 및 2FA 관련 처리 로직 공통화
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user