feat: xkeypad 보안 키패드 통합 및 비밀번호 변경 기능 구현
- xkeypad 보안 키패드 라이브러리 추가 - 비밀번호 변경 페이지에 보안 키패드 적용 - RSA 암호화 기능 통합 - route 설정 및 Sentry 설정 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
656
src/utils/xkeypad.ts
Normal file
656
src/utils/xkeypad.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
// XKModule 및 관련 타입 정의
|
||||
declare global {
|
||||
interface Window {
|
||||
XKModule: any;
|
||||
XKConfigMobile: {
|
||||
maxInputSize: number;
|
||||
rsaPublicKey?: {
|
||||
n: string;
|
||||
e: string;
|
||||
};
|
||||
};
|
||||
RSASetPublic: (N: string, E: string) => void;
|
||||
RSAEncrypt: (text: string) => string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface XKSessionInfo {
|
||||
sessionId: string;
|
||||
input: string;
|
||||
secToken: string;
|
||||
}
|
||||
|
||||
export interface XKModuleInstance {
|
||||
initialize: (options: any) => number;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
isOpen: () => boolean;
|
||||
clear: () => void;
|
||||
destroy: () => void;
|
||||
get_input: () => string;
|
||||
get_sessionInfo: () => XKSessionInfo;
|
||||
setRSAPublicKey: (n: string, e: string) => void;
|
||||
}
|
||||
|
||||
export type KeyType = 'qwertysmart' | 'number';
|
||||
export type ViewType = 'half' | 'normal';
|
||||
export type NumberKeyRowCount = 2 | 3 | 4;
|
||||
|
||||
export interface XKeypadOptions {
|
||||
keyType?: KeyType;
|
||||
viewType?: ViewType;
|
||||
numberKeyRowCount?: NumberKeyRowCount;
|
||||
maxInputSize?: number;
|
||||
width?: number;
|
||||
position?: { top?: number | null; left?: number | null };
|
||||
closeDelay?: number;
|
||||
autoKeyResize?: boolean;
|
||||
isE2E?: boolean;
|
||||
onlyMobile?: boolean;
|
||||
hasPressEffect?: boolean;
|
||||
useModal?: boolean;
|
||||
useOverlay?: boolean; // 오버레이 사용 여부
|
||||
onInputChange?: (newLength: number) => void;
|
||||
onKeypadClose?: () => void;
|
||||
}
|
||||
|
||||
export interface XKeypadResult {
|
||||
type: 'E2E' | 'Plain';
|
||||
sessionId?: string;
|
||||
encryptedInput?: string;
|
||||
secToken?: string;
|
||||
plainText?: string;
|
||||
rsaEncrypted?: string;
|
||||
}
|
||||
|
||||
export interface RSAConfig {
|
||||
modulus: string;
|
||||
exponent: string;
|
||||
}
|
||||
|
||||
// 기본 RSA 키 설정
|
||||
const DEFAULT_RSA_CONFIG: RSAConfig = {
|
||||
modulus: "C4F7B39E2E93DB19C016C7A0C1C05B028A1D57CB9B91E13F5B7353F8FB5AC6CE6BE31ABEB8E8F7AD18B90C08F4EBC011A6A8FCE614EA879ED5B96296B969CE92923BC9BAD6FD87F00E08F529F93010EA77E40937BDAC1C866E79ACE2F2822A3ECD982F90532D5301CF90D9BF89E953A0593AB6C5F31E99B690DD582FB85F85A9",
|
||||
exponent: "10001"
|
||||
};
|
||||
|
||||
export class XKeypadManager {
|
||||
private static instance: XKeypadManager;
|
||||
private scriptsLoaded: boolean = false;
|
||||
private loadingPromise: Promise<void> | null = null;
|
||||
private xkModules: Map<string, XKModuleInstance> = new Map();
|
||||
private rsaConfig: RSAConfig;
|
||||
|
||||
private constructor(rsaConfig?: RSAConfig) {
|
||||
this.rsaConfig = rsaConfig || DEFAULT_RSA_CONFIG;
|
||||
}
|
||||
|
||||
public static getInstance(rsaConfig?: RSAConfig): XKeypadManager {
|
||||
if (!XKeypadManager.instance) {
|
||||
XKeypadManager.instance = new XKeypadManager(rsaConfig);
|
||||
}
|
||||
return XKeypadManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* XKeypad 스크립트를 로드합니다.
|
||||
*/
|
||||
public async loadScripts(): Promise<void> {
|
||||
// 이미 로드 중이거나 로드됨
|
||||
if (this.loadingPromise) {
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
if (this.scriptsLoaded && window.XKModule) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.loadingPromise = this.loadScriptsInternal();
|
||||
await this.loadingPromise;
|
||||
this.scriptsLoaded = true;
|
||||
}
|
||||
|
||||
private async loadScriptsInternal(): Promise<void> {
|
||||
try {
|
||||
// Check if scripts are already loaded
|
||||
if (window.XKModule) {
|
||||
this.scriptsLoaded = true;
|
||||
// Set RSA keys after scripts are loaded
|
||||
if (this.rsaConfig && window.RSASetPublic) {
|
||||
window.RSASetPublic(this.rsaConfig.modulus, this.rsaConfig.exponent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Load scripts in order
|
||||
const scripts = [
|
||||
'/src/shared/ui/assets/js/xkeypad_config.js',
|
||||
'/src/shared/ui/assets/js/rsa_crypto.js',
|
||||
'/src/shared/ui/assets/js/xkeypad.js'
|
||||
];
|
||||
|
||||
for (const src of scripts) {
|
||||
await this.loadScript(src);
|
||||
}
|
||||
|
||||
// Add CSS files
|
||||
this.loadCSS('/src/shared/ui/assets/css/xkeypad-modal.css', 'xkeypad-modal-css');
|
||||
this.loadCSS('/src/shared/ui/assets/css/xkeypad.css', 'xkeypad-css');
|
||||
|
||||
// Set RSA keys after scripts are loaded
|
||||
if (this.rsaConfig && window.RSASetPublic) {
|
||||
window.RSASetPublic(this.rsaConfig.modulus, this.rsaConfig.exponent);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load XKeypad scripts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.charset = 'utf-8';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
private loadCSS(href: string, id: string): void {
|
||||
if (!document.getElementById(id)) {
|
||||
const link = document.createElement('link');
|
||||
link.id = id;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로운 XKModule 인스턴스를 생성합니다.
|
||||
*/
|
||||
public createModule(id: string): XKModuleInstance | null {
|
||||
if (!this.scriptsLoaded || !window.XKModule) {
|
||||
console.error('XKeypad scripts not loaded');
|
||||
return null;
|
||||
}
|
||||
|
||||
const module = new window.XKModule();
|
||||
this.xkModules.set(id, module);
|
||||
return module;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 XKModule 인스턴스를 가져옵니다.
|
||||
*/
|
||||
public getModule(id: string): XKModuleInstance | null {
|
||||
return this.xkModules.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* XKModule 인스턴스를 제거합니다.
|
||||
*/
|
||||
public removeModule(id: string): void {
|
||||
const module = this.xkModules.get(id);
|
||||
if (module) {
|
||||
if (module.isOpen()) {
|
||||
module.close();
|
||||
}
|
||||
this.xkModules.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 키패드를 닫습니다.
|
||||
*/
|
||||
public closeAllKeypads(): void {
|
||||
this.xkModules.forEach(module => {
|
||||
if (module && module.isOpen()) {
|
||||
module.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 공개키를 설정합니다.
|
||||
*/
|
||||
public setRSAPublicKey(modulus: string, exponent: string): void {
|
||||
this.rsaConfig = { modulus, exponent };
|
||||
|
||||
// RSASetPublic이 있는지 확인하고 설정
|
||||
if (window.RSASetPublic && typeof window.RSASetPublic === 'function') {
|
||||
try {
|
||||
window.RSASetPublic(modulus, exponent);
|
||||
} catch (error) {
|
||||
console.error('Failed to set RSA public key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update all existing modules
|
||||
this.xkModules.forEach(module => {
|
||||
if (module && typeof module.setRSAPublicKey === 'function') {
|
||||
module.setRSAPublicKey(modulus, exponent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA로 텍스트를 암호화합니다.
|
||||
*/
|
||||
public encryptRSA(text: string): string | null {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// RSAEncrypt 함수가 있는지 확인
|
||||
if (!window.RSAEncrypt || typeof window.RSAEncrypt !== 'function') {
|
||||
console.error('RSA encryption not available');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// RSA 키가 설정되어 있는지 확인
|
||||
if (this.rsaConfig && window.RSASetPublic) {
|
||||
window.RSASetPublic(this.rsaConfig.modulus, this.rsaConfig.exponent);
|
||||
}
|
||||
|
||||
return window.RSAEncrypt(text);
|
||||
} catch (error) {
|
||||
console.error('RSA encryption failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* XKeypad 인스턴스를 관리하는 클래스
|
||||
*/
|
||||
export class XKeypad {
|
||||
private manager: XKeypadManager;
|
||||
private module: XKModuleInstance | null = null;
|
||||
private moduleId: string;
|
||||
private options: XKeypadOptions;
|
||||
private containerName: string;
|
||||
private inputElement: HTMLInputElement | null = null;
|
||||
private overlayElement: HTMLDivElement | null = null;
|
||||
|
||||
constructor(
|
||||
moduleId: string,
|
||||
options: XKeypadOptions = {},
|
||||
rsaConfig?: RSAConfig
|
||||
) {
|
||||
this.manager = XKeypadManager.getInstance(rsaConfig);
|
||||
this.moduleId = moduleId;
|
||||
this.options = {
|
||||
keyType: 'qwertysmart',
|
||||
viewType: 'half',
|
||||
numberKeyRowCount: 3,
|
||||
maxInputSize: 50,
|
||||
width: 100,
|
||||
closeDelay: 300,
|
||||
autoKeyResize: false,
|
||||
isE2E: false,
|
||||
onlyMobile: false,
|
||||
hasPressEffect: true,
|
||||
useModal: false,
|
||||
useOverlay: true, // 기본값: 오버레이 사용
|
||||
...options
|
||||
};
|
||||
this.containerName = this.options.useModal
|
||||
? `xk-modal-${moduleId}`
|
||||
: `xk-pad-${moduleId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 키패드를 초기화합니다.
|
||||
*/
|
||||
public async initialize(inputElement: HTMLInputElement): Promise<number> {
|
||||
// 스크립트 로드
|
||||
await this.manager.loadScripts();
|
||||
|
||||
// 입력 요소 저장
|
||||
this.inputElement = inputElement;
|
||||
|
||||
// 모듈 생성
|
||||
this.module = this.manager.createModule(this.moduleId);
|
||||
if (!this.module) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// RSA 키 설정 (E2E가 아닌 경우)
|
||||
if (!this.options.isE2E) {
|
||||
const rsaConfig = (this.manager as any).rsaConfig;
|
||||
if (rsaConfig && typeof this.module.setRSAPublicKey === 'function') {
|
||||
this.module.setRSAPublicKey(rsaConfig.modulus, rsaConfig.exponent);
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 컨테이너 생성 (필요시)
|
||||
if (this.options.useModal) {
|
||||
this.createModalContainer();
|
||||
}
|
||||
|
||||
// 키패드 초기화
|
||||
const result = this.module.initialize({
|
||||
name: this.containerName,
|
||||
editBox: inputElement,
|
||||
keyType: this.options.keyType,
|
||||
maxInputSize: this.options.maxInputSize,
|
||||
width: this.options.width,
|
||||
position: {
|
||||
top: this.options.useModal ? 0 : (this.options.position?.top ?? null),
|
||||
left: this.options.position?.left ?? null
|
||||
},
|
||||
viewType: this.options.useModal ? 'normal' : this.options.viewType,
|
||||
numberKeyRowCount: this.options.numberKeyRowCount,
|
||||
closeDelay: this.options.closeDelay,
|
||||
autoKeyResize: this.options.autoKeyResize,
|
||||
isE2E: this.options.isE2E,
|
||||
onlyMobile: this.options.onlyMobile,
|
||||
hasPressEffect: this.options.hasPressEffect,
|
||||
onInputChange: this.options.onInputChange,
|
||||
onKeypadClose: () => {
|
||||
if (this.options.useModal) {
|
||||
this.hideModal();
|
||||
}
|
||||
if (this.options.useOverlay && !this.options.useModal) {
|
||||
this.hideOverlay();
|
||||
}
|
||||
if (this.options.onKeypadClose) {
|
||||
this.options.onKeypadClose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (result === 0) {
|
||||
if (this.options.useModal) {
|
||||
this.showModal();
|
||||
} else if (this.options.useOverlay) {
|
||||
this.showOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 키패드를 엽니다.
|
||||
*/
|
||||
public open(): void {
|
||||
if (this.module && !this.module.isOpen()) {
|
||||
this.module.open();
|
||||
if (this.options.useModal) {
|
||||
this.showModal();
|
||||
} else if (this.options.useOverlay) {
|
||||
this.showOverlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 키패드를 닫습니다.
|
||||
*/
|
||||
public close(): void {
|
||||
if (this.module && this.module.isOpen()) {
|
||||
this.module.close();
|
||||
if (this.options.useModal) {
|
||||
this.hideModal();
|
||||
} else if (this.options.useOverlay) {
|
||||
this.hideOverlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 키패드가 열려있는지 확인합니다.
|
||||
*/
|
||||
public isOpen(): boolean {
|
||||
return this.module ? this.module.isOpen() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력을 초기화합니다.
|
||||
*/
|
||||
public clear(): void {
|
||||
if (this.module) {
|
||||
this.module.clear();
|
||||
}
|
||||
if (this.inputElement) {
|
||||
this.inputElement.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 키패드를 파괴합니다.
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.module) {
|
||||
if (this.module.isOpen()) {
|
||||
this.module.close();
|
||||
}
|
||||
this.module.destroy();
|
||||
}
|
||||
this.manager.removeModule(this.moduleId);
|
||||
|
||||
// Remove modal container if exists
|
||||
if (this.options.useModal) {
|
||||
const wrapper = document.getElementById(`${this.containerName}-wrapper`);
|
||||
if (wrapper) {
|
||||
wrapper.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove overlay if exists
|
||||
if (this.options.useOverlay && !this.options.useModal) {
|
||||
this.hideOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 값을 가져옵니다.
|
||||
*/
|
||||
public getValue(): XKeypadResult | null {
|
||||
if (!this.module) return null;
|
||||
|
||||
const result: XKeypadResult = {} as XKeypadResult;
|
||||
|
||||
if (this.options.isE2E) {
|
||||
const sessionInfo = this.module.get_sessionInfo();
|
||||
result.type = 'E2E';
|
||||
result.sessionId = sessionInfo.sessionId;
|
||||
result.encryptedInput = sessionInfo.input;
|
||||
result.secToken = sessionInfo.secToken;
|
||||
} else {
|
||||
const plainText = this.module.get_input();
|
||||
result.type = 'Plain';
|
||||
result.plainText = plainText;
|
||||
|
||||
// RSA encryption if available
|
||||
if (plainText) {
|
||||
const encrypted = this.manager.encryptRSA(plainText);
|
||||
if (encrypted) {
|
||||
result.rsaEncrypted = encrypted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 평문 입력 값을 가져옵니다.
|
||||
*/
|
||||
public getPlainText(): string {
|
||||
if (!this.module) return '';
|
||||
return this.module.get_input();
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 정보를 가져옵니다. (E2E 모드에서만 유효)
|
||||
*/
|
||||
public getSessionInfo(): XKSessionInfo | null {
|
||||
if (!this.module || !this.options.isE2E) return null;
|
||||
return this.module.get_sessionInfo();
|
||||
}
|
||||
|
||||
// Modal helper methods
|
||||
private createModalContainer(): void {
|
||||
const name = this.containerName;
|
||||
|
||||
// Remove existing container if any
|
||||
const existing = document.getElementById(`${name}-wrapper`);
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
|
||||
// Create modal structure
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = `${name}-wrapper`;
|
||||
wrapper.className = 'xkeypad-modal-wrapper';
|
||||
wrapper.style.display = 'none';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'xkeypad-modal-overlay';
|
||||
overlay.onclick = () => this.close();
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'xkeypad-modal-container';
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.id = name;
|
||||
content.className = 'xkeypad-modal-content';
|
||||
|
||||
container.appendChild(content);
|
||||
wrapper.appendChild(overlay);
|
||||
wrapper.appendChild(container);
|
||||
document.body.appendChild(wrapper);
|
||||
}
|
||||
|
||||
private showModal(): void {
|
||||
const wrapper = document.getElementById(`${this.containerName}-wrapper`);
|
||||
if (wrapper) {
|
||||
wrapper.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
wrapper.classList.add('show');
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
private hideModal(): void {
|
||||
const wrapper = document.getElementById(`${this.containerName}-wrapper`);
|
||||
if (wrapper) {
|
||||
wrapper.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
wrapper.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay methods for non-modal mode
|
||||
private showOverlay(): void {
|
||||
// Remove existing overlay if any
|
||||
this.hideOverlay();
|
||||
|
||||
// Create overlay
|
||||
this.overlayElement = document.createElement('div');
|
||||
this.overlayElement.id = `xkeypad-overlay-${this.moduleId}`;
|
||||
this.overlayElement.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9998;
|
||||
transition: opacity 0.3s ease;
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
// Add click handler to close keypad
|
||||
this.overlayElement.onclick = () => this.close();
|
||||
|
||||
// Append to body
|
||||
document.body.appendChild(this.overlayElement);
|
||||
|
||||
// Trigger transition
|
||||
setTimeout(() => {
|
||||
if (this.overlayElement) {
|
||||
this.overlayElement.style.opacity = '1';
|
||||
}
|
||||
}, 10);
|
||||
|
||||
// Adjust keypad z-index to be above overlay
|
||||
const keypadContainer = document.getElementById(this.containerName);
|
||||
if (keypadContainer) {
|
||||
keypadContainer.style.zIndex = '9999';
|
||||
}
|
||||
}
|
||||
|
||||
private hideOverlay(): void {
|
||||
if (this.overlayElement) {
|
||||
this.overlayElement.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (this.overlayElement && this.overlayElement.parentNode) {
|
||||
this.overlayElement.parentNode.removeChild(this.overlayElement);
|
||||
this.overlayElement = null;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 간편 사용을 위한 헬퍼 함수들
|
||||
*/
|
||||
|
||||
/**
|
||||
* 비밀번호 입력용 키패드를 생성합니다.
|
||||
*/
|
||||
export function createPasswordKeypad(
|
||||
inputElement: HTMLInputElement,
|
||||
options?: Partial<XKeypadOptions>,
|
||||
rsaConfig?: RSAConfig
|
||||
): XKeypad {
|
||||
return new XKeypad('password', {
|
||||
keyType: 'qwertysmart',
|
||||
maxInputSize: 16,
|
||||
...options
|
||||
}, rsaConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* PIN 입력용 키패드를 생성합니다.
|
||||
*/
|
||||
export function createPinKeypad(
|
||||
inputElement: HTMLInputElement,
|
||||
options?: Partial<XKeypadOptions>,
|
||||
rsaConfig?: RSAConfig
|
||||
): XKeypad {
|
||||
return new XKeypad('pin', {
|
||||
keyType: 'number',
|
||||
maxInputSize: 6,
|
||||
...options
|
||||
}, rsaConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드번호 입력용 키패드를 생성합니다.
|
||||
*/
|
||||
export function createCardKeypad(
|
||||
inputElement: HTMLInputElement,
|
||||
options?: Partial<XKeypadOptions>,
|
||||
rsaConfig?: RSAConfig
|
||||
): XKeypad {
|
||||
return new XKeypad('card', {
|
||||
keyType: 'number',
|
||||
maxInputSize: 16,
|
||||
numberKeyRowCount: 4,
|
||||
...options
|
||||
}, rsaConfig);
|
||||
}
|
||||
|
||||
// Default export
|
||||
export default XKeypad;
|
||||
Reference in New Issue
Block a user