295 lines
7.2 KiB
TypeScript
295 lines
7.2 KiB
TypeScript
/**
|
|
* 세션 관리 유틸리티
|
|
* 세션 만료 감지, 자동 로그아웃, 세션 갱신 등을 담당
|
|
*
|
|
* 모바일/데스크톱 환경별 타임아웃 설정:
|
|
* - 데스크톱: 30분 비활성 시 만료, 5분 전 경고
|
|
* - 모바일: 24시간 비활성 시 만료, 1시간 전 경고 (WebView localStorage 초기화 이슈 대응)
|
|
*/
|
|
|
|
interface SessionConfig {
|
|
checkInterval: number; // 세션 체크 간격 (ms)
|
|
warningTime: number; // 만료 경고 시간 (ms)
|
|
maxInactiveTime: number; // 최대 비활성 시간 (ms)
|
|
}
|
|
|
|
interface SessionWarningCallbacks {
|
|
onWarning?: (remainingTime: number) => void;
|
|
onExpiry?: () => void;
|
|
onActivity?: () => void;
|
|
}
|
|
|
|
export class SessionManager {
|
|
private config: SessionConfig;
|
|
private callbacks: SessionWarningCallbacks;
|
|
private checkTimer: NodeJS.Timeout | null = null;
|
|
private warningTimer: NodeJS.Timeout | null = null;
|
|
private lastActivity: number = Date.now();
|
|
private isWarningShown: boolean = false;
|
|
|
|
constructor(config: Partial<SessionConfig> = {}, callbacks: SessionWarningCallbacks = {}) {
|
|
this.config = {
|
|
checkInterval: 60000, // 1분마다 체크
|
|
warningTime: 300000, // 5분 전 경고
|
|
maxInactiveTime: 1800000, // 30분 비활성 시 만료
|
|
...config,
|
|
};
|
|
|
|
this.callbacks = callbacks;
|
|
this.setupActivityListeners();
|
|
}
|
|
|
|
/**
|
|
* 세션 모니터링 시작
|
|
*/
|
|
start() {
|
|
this.stop(); // 기존 타이머 정리
|
|
|
|
this.checkTimer = setInterval(() => {
|
|
this.checkSession();
|
|
}, this.config.checkInterval);
|
|
}
|
|
|
|
/**
|
|
* 세션 모니터링 중지
|
|
*/
|
|
stop() {
|
|
if (this.checkTimer) {
|
|
clearInterval(this.checkTimer);
|
|
this.checkTimer = null;
|
|
}
|
|
|
|
if (this.warningTimer) {
|
|
clearTimeout(this.warningTimer);
|
|
this.warningTimer = null;
|
|
}
|
|
|
|
this.removeActivityListeners();
|
|
}
|
|
|
|
/**
|
|
* 사용자 활동 기록
|
|
*/
|
|
recordActivity() {
|
|
this.lastActivity = Date.now();
|
|
this.isWarningShown = false;
|
|
|
|
// 경고 타이머가 있다면 취소
|
|
if (this.warningTimer) {
|
|
clearTimeout(this.warningTimer);
|
|
this.warningTimer = null;
|
|
}
|
|
|
|
this.callbacks.onActivity?.();
|
|
}
|
|
|
|
/**
|
|
* 세션 상태 확인
|
|
*/
|
|
private checkSession() {
|
|
const now = Date.now();
|
|
const timeSinceLastActivity = now - this.lastActivity;
|
|
const timeUntilExpiry = this.config.maxInactiveTime - timeSinceLastActivity;
|
|
|
|
// 세션 만료 체크
|
|
if (timeSinceLastActivity >= this.config.maxInactiveTime) {
|
|
this.handleSessionExpiry();
|
|
return;
|
|
}
|
|
|
|
// 경고 시간 체크
|
|
if (timeUntilExpiry <= this.config.warningTime && !this.isWarningShown) {
|
|
this.showSessionWarning(timeUntilExpiry);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 세션 만료 경고 표시
|
|
*/
|
|
private showSessionWarning(remainingTime: number) {
|
|
this.isWarningShown = true;
|
|
this.callbacks.onWarning?.(remainingTime);
|
|
|
|
// 남은 시간 후 자동 만료
|
|
this.warningTimer = setTimeout(() => {
|
|
this.handleSessionExpiry();
|
|
}, remainingTime);
|
|
}
|
|
|
|
/**
|
|
* 세션 만료 처리
|
|
*/
|
|
private handleSessionExpiry() {
|
|
this.stop();
|
|
this.callbacks.onExpiry?.();
|
|
}
|
|
|
|
/**
|
|
* 활동 리스너 설정
|
|
*/
|
|
private setupActivityListeners() {
|
|
// 사용자 활동 이벤트들
|
|
const activityEvents = ["mousedown", "mousemove", "keypress", "scroll", "touchstart", "click"];
|
|
|
|
// 각 이벤트에 대해 리스너 등록
|
|
activityEvents.forEach((event) => {
|
|
document.addEventListener(event, this.handleActivity, true);
|
|
});
|
|
|
|
// 페이지 가시성 변경 이벤트
|
|
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
}
|
|
|
|
/**
|
|
* 활동 리스너 제거
|
|
*/
|
|
private removeActivityListeners() {
|
|
const activityEvents = ["mousedown", "mousemove", "keypress", "scroll", "touchstart", "click"];
|
|
|
|
activityEvents.forEach((event) => {
|
|
document.removeEventListener(event, this.handleActivity, true);
|
|
});
|
|
|
|
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
|
}
|
|
|
|
/**
|
|
* 활동 이벤트 핸들러
|
|
*/
|
|
private handleActivity = () => {
|
|
this.recordActivity();
|
|
};
|
|
|
|
/**
|
|
* 페이지 가시성 변경 핸들러
|
|
*/
|
|
private handleVisibilityChange = () => {
|
|
if (!document.hidden) {
|
|
this.recordActivity();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 현재 세션 상태 정보 반환
|
|
*/
|
|
getSessionInfo() {
|
|
const now = Date.now();
|
|
const timeSinceLastActivity = now - this.lastActivity;
|
|
const timeUntilExpiry = this.config.maxInactiveTime - timeSinceLastActivity;
|
|
|
|
return {
|
|
lastActivity: this.lastActivity,
|
|
timeSinceLastActivity,
|
|
timeUntilExpiry: Math.max(0, timeUntilExpiry),
|
|
isActive: timeSinceLastActivity < this.config.maxInactiveTime,
|
|
isWarningShown: this.isWarningShown,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 시간을 분:초 형식으로 포맷
|
|
*/
|
|
export function formatTime(milliseconds: number): string {
|
|
const seconds = Math.floor(milliseconds / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
|
|
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
/**
|
|
* 전역 세션 매니저 인스턴스
|
|
*/
|
|
let globalSessionManager: SessionManager | null = null;
|
|
|
|
/**
|
|
* 전역 세션 매니저 초기화
|
|
*/
|
|
export function initSessionManager(
|
|
config: Partial<SessionConfig> = {},
|
|
callbacks: SessionWarningCallbacks = {},
|
|
): SessionManager {
|
|
if (globalSessionManager) {
|
|
globalSessionManager.stop();
|
|
}
|
|
|
|
globalSessionManager = new SessionManager(config, callbacks);
|
|
return globalSessionManager;
|
|
}
|
|
|
|
/**
|
|
* 전역 세션 매니저 가져오기
|
|
*/
|
|
export function getSessionManager(): SessionManager | null {
|
|
return globalSessionManager;
|
|
}
|
|
|
|
/**
|
|
* 세션 매니저 정리
|
|
*/
|
|
export function cleanupSessionManager() {
|
|
if (globalSessionManager) {
|
|
globalSessionManager.stop();
|
|
globalSessionManager = null;
|
|
}
|
|
}
|
|
|
|
export default SessionManager;
|
|
|
|
/**
|
|
* 토큰 동기화 유틸리티
|
|
*/
|
|
export const tokenSync = {
|
|
// 토큰 상태 확인
|
|
checkToken: () => {
|
|
const token = localStorage.getItem("authToken");
|
|
return !!token;
|
|
},
|
|
|
|
// 토큰 강제 동기화 (다른 탭에서 설정된 토큰을 현재 탭에 복사)
|
|
forceSync: () => {
|
|
const token = localStorage.getItem("authToken");
|
|
if (token) {
|
|
// sessionStorage에도 복사
|
|
sessionStorage.setItem("authToken", token);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
// 토큰 복원 시도 (sessionStorage에서 복원)
|
|
restoreFromSession: () => {
|
|
const sessionToken = sessionStorage.getItem("authToken");
|
|
if (sessionToken) {
|
|
localStorage.setItem("authToken", sessionToken);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
// 토큰 유효성 검증
|
|
validateToken: (token: string) => {
|
|
if (!token) return false;
|
|
|
|
try {
|
|
// JWT 토큰 구조 확인 (header.payload.signature)
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) return false;
|
|
|
|
// payload 디코딩 시도
|
|
const payload = JSON.parse(atob(parts[1]));
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// 만료 시간 확인
|
|
if (payload.exp && payload.exp < now) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
},
|
|
};
|