Files
nice-app-web/src/utils/xkeypad.ts
Jay Sheen 1648a30844 feat: xkeypad 보안 키패드 통합 및 비밀번호 변경 기능 구현
- xkeypad 보안 키패드 라이브러리 추가
- 비밀번호 변경 페이지에 보안 키패드 적용
- RSA 암호화 기능 통합
- route 설정 및 Sentry 설정 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 15:00:02 +09:00

656 lines
17 KiB
TypeScript

// 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;