통합 회원 관리(SSO/OIDC)와 로컬스토리지 기반 인증 구조
요약:
REINDEERS의 글로벌 플랫폼 운영을 위해 회원, 조직, 권한, 인증 시스템이 통합되었다. 6월, 기존의 국가별 로그인 구조를 모두 폐기하고, OIDC(OpenID Connect) 기반의 단일 인증 서버(MCP Auth)를 구축했다. 인증 토큰은 PASETO와 JWT를 병행해 발급되며, 클라이언트 단에서는 LocalStorage를 이용해 토큰을 안전하게 저장·관리한다. 모든 인증 알림은 Telegram을 통해 운영자에게 전송된다.
1. 배경 — “로그인은 하나, 서비스는 전 세계”
이전까지는 각 국가별로 별도의 회원 시스템을 운영했다. 태국, 한국, 중국, 말레이시아의 서비스가 모두 다른 사용자 DB를 가졌고, 글로벌 공급사는 국가별로 중복 계정을 등록해야 했다. 또한 로그인 토큰이 서버별로 상이해 SSO가 불가능했다. 6월에 이 구조를 완전히 개편하며, 단일 로그인으로 모든 국가의 서비스에 접근할 수 있도록 통합 작업이 진행되었다.
2. MCP Auth 아키텍처
인증 서버는 MCP Auth라는 이름으로 독립 구축되었다. 모든 프런트엔드(Nuxt/Vue3), API Gateway, Cloud Function은 이 Auth 서버를 통해 인증을 수행한다.
- Auth Server: FastAPI + PostgreSQL + Redis
- Token: PASETO(v2.local) + JWT(v5 hybrid)
- Cache: Redis 복제 (홍콩 ↔ 서울)
- SSO Provider: Google, LINE, WeChat, Kakao
- Notification: Telegram Bot (로그인 실패, 세션 만료)
API 서버는 MCP Auth의 공개키를 이용해 모든 토큰을 검증한다.
3. 회원 및 권한 테이블 구조
CREATE TABLE organization (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
country_code CHAR(2) NOT NULL,
type ENUM('CUSTOMER','SUPPLIER','OPERATOR') NOT NULL
);
CREATE TABLE user_account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
org_id BIGINT NOT NULL,
email VARCHAR(255) UNIQUE,
password_hash VARCHAR(255),
display_name VARCHAR(255),
status ENUM('ACTIVE','SUSPENDED') DEFAULT 'ACTIVE',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (org_id) REFERENCES organization(id)
);
CREATE TABLE user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
role_name VARCHAR(64) NOT NULL,
FOREIGN KEY (user_id) REFERENCES user_account(id)
);
권한은 RBAC(Role-Based)으로 관리되며, 특정 리소스 접근은 ABAC(Attribute-Based) 정책을 이용한다. 예를 들어, country_code='TH' 속성을 가진 계정만 태국 세관 API에 접근할 수 있다.
4. 로그인 및 토큰 발급
로그인은 OIDC 기반이며, Access Token은 PASETO로, Refresh Token은 JWT로 발급된다. 토큰은 서명 검증만으로 신속하게 검증할 수 있다.
from paseto import PasetoV2
from datetime import datetime, timedelta
import jwt
SECRET = "reindeers-local-secret"
JWT_SECRET = "jwt-fallback"
def issue_tokens(user_id):
access = PasetoV2.encrypt({"uid": user_id, "exp": (datetime.utcnow() + timedelta(minutes=30)).timestamp()}, SECRET)
refresh = jwt.encode({"uid": user_id, "exp": datetime.utcnow() + timedelta(days=7)}, JWT_SECRET, algorithm="HS256")
return {"access_token": access, "refresh_token": refresh, "exp": int((datetime.utcnow() + timedelta(minutes=30)).timestamp())}
5. 클라이언트 영속 저장 — LocalStorage 표준
클라이언트 단의 토큰 저장은 LocalStorage를 표준으로 한다. Access/Refresh 토큰 및 메타 정보는 다음 키로 저장된다:
reindeers.access— Access Tokenreindeers.refresh— Refresh Tokenreindeers.session.meta— 만료시간, 사용자, 스코프 정보
사용자는 로그인 시 토큰을 발급받고, 이후 메모리 상태로 관리한다.
변경 시 LocalStorage에 동기화하며,
storage 이벤트를 활용해 여러 탭에서도 즉시 반영된다.
CSP를 적용하고, XSS를 차단하기 위해 토큰 수명은 30분 이하로 제한된다.
6. Nuxt 3 구현 예시
export default defineNuxtPlugin((nuxtApp) => {
const ACCESS_KEY = 'reindeers.access'
const REFRESH_KEY = 'reindeers.refresh'
const META_KEY = 'reindeers.session.meta'
const accessToken = useState('access', () => null)
const refreshToken = useState('refresh', () => null)
const sessionMeta = useState('sessionMeta', () => null)
if (process.client) {
accessToken.value = localStorage.getItem(ACCESS_KEY)
refreshToken.value = localStorage.getItem(REFRESH_KEY)
const raw = localStorage.getItem(META_KEY)
sessionMeta.value = raw ? JSON.parse(raw) : null
}
watch(accessToken, (v) => v ? localStorage.setItem(ACCESS_KEY, v) : localStorage.removeItem(ACCESS_KEY))
watch(refreshToken, (v) => v ? localStorage.setItem(REFRESH_KEY, v) : localStorage.removeItem(REFRESH_KEY))
watch(sessionMeta, (v) => v ? localStorage.setItem(META_KEY, JSON.stringify(v)) : localStorage.removeItem(META_KEY))
window.addEventListener('storage', (e) => {
if (e.key === ACCESS_KEY) accessToken.value = e.newValue
if (e.key === REFRESH_KEY) refreshToken.value = e.newValue
if (e.key === META_KEY) sessionMeta.value = e.newValue ? JSON.parse(e.newValue) : null
})
})
로그인, 로그아웃, 토큰 갱신 등의 로직은 모두 이 플러그인을 통해 관리된다. 브라우저의 LocalStorage를 이용하되, 렌더링 시에는 메모리 변수를 우선 사용한다.
7. 세션 동기화 및 Telegram 알림
세션은 Redis를 통해 지역 간 자동 동기화되며,
로그인 실패나 반복된 토큰 오류가 감지되면 Telegram Bot으로 통보된다.
운영자는 Telegram 명령어 /authstatus로
현재 로그인 상태를 원격에서 모니터링할 수 있다.
import os, requests
def notify_auth_failure(user_email, ip):
msg = f"🚨 로그인 실패: {user_email}\\nIP: {ip}\\n조치 필요"
requests.post(f"https://api.telegram.org/bot{os.getenv('TELEGRAM_TOKEN')}/sendMessage",
json={"chat_id": os.getenv("TELEGRAM_CHAT_ID"), "text": msg})
8. 결론 — 글로벌 단일 인증의 완성
이제 REINDEERS의 모든 서비스는 하나의 계정으로 로그인된다. 국가와 리전에 상관없이 동일한 인증 체계를 공유하며, 세션은 Redis를 통해 실시간 복제된다. Telegram을 통해 모든 로그인 이벤트를 추적하고, 클라이언트는 LocalStorage를 기반으로 세션을 유지한다.
“로그인은 단일화되고, 관리자는 세계 어디서나 통제할 수 있다.” 6월의 이 개발은 REINDEERS 글로벌 플랫폼의 본격적인 시작이었다.
Comments
Post a Comment