[PMS 완성] - core/si_report.py: 일/주/월 보고서 (Excel/HTML/PDF/DOCX/PPTX) - routers/si_report.py: daily|weekly|monthly + 메신저 발송 - routers/deliverables.py: 산출물 CRUD + 제출/검토 - si_issues.py: 이슈→SR 자동 연결 - scheduler.py: 일일 18:00 + 주간 금 17:00 자동 보고서 - models.py: Deliverable 모델 [준수성 자동 점검] - core/compliance_check.py: SC-8개/WA-7개/PI-6개 규칙 - routers/compliance.py: 스캔 + HTML/Excel 보고서 [JMeter 성능 테스트] - routers/jmeter.py: JTL 업로드 + 내장 부하 테스트 + 보고서 [공공기관 필수 기능] - routers/public_checklist.py: 행안부 기준 19개 항목 [UI/브랜드] - 로고(ziologo.png) + Copyright 2026 All Rights Reserved - Nifty 계층형 사이드바 (PMS 서브메뉴) - X-Powered-By + X-Copyright 응답 헤더 - manual/15_UI_Nifty_가이드.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
7.8 KiB
Python
200 lines
7.8 KiB
Python
"""
|
|
OAuth2 / OpenID Connect 소셜 로그인 — GUARDiA ITSM
|
|
|
|
지원 제공자:
|
|
google : Google OAuth2 (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET)
|
|
github : GitHub OAuth2 (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET)
|
|
kakao : 카카오 로그인 (KAKAO_CLIENT_ID)
|
|
naver : 네이버 로그인 (NAVER_CLIENT_ID / NAVER_CLIENT_SECRET)
|
|
keycloak: 온프레미스 Keycloak OIDC (KEYCLOAK_BASE_URL / KEYCLOAK_REALM / ...)
|
|
|
|
환경변수 미설정 시 해당 제공자는 로그인 화면에서 숨겨진다.
|
|
외부망 차단 환경에서는 Keycloak(온프레미스)만 활성화 권장.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import secrets
|
|
import time
|
|
from typing import Optional
|
|
from urllib.parse import urlencode
|
|
|
|
import httpx
|
|
|
|
# ── 환경변수 ──────────────────────────────────────────────────────────────────
|
|
|
|
_BASE = os.getenv("OAUTH_REDIRECT_BASE", "http://localhost:8001")
|
|
|
|
PROVIDERS: dict[str, dict] = {
|
|
"google": {
|
|
"name": "Google",
|
|
"icon": "google",
|
|
"enabled": bool(os.getenv("GOOGLE_CLIENT_ID")),
|
|
"client_id": os.getenv("GOOGLE_CLIENT_ID", ""),
|
|
"client_secret": os.getenv("GOOGLE_CLIENT_SECRET", ""),
|
|
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
|
"token_url": "https://oauth2.googleapis.com/token",
|
|
"userinfo_url": "https://www.googleapis.com/oauth2/v3/userinfo",
|
|
"scope": "openid email profile",
|
|
"redirect_uri": f"{_BASE}/api/auth/oauth/google/callback",
|
|
},
|
|
"github": {
|
|
"name": "GitHub",
|
|
"icon": "github",
|
|
"enabled": bool(os.getenv("GITHUB_CLIENT_ID")),
|
|
"client_id": os.getenv("GITHUB_CLIENT_ID", ""),
|
|
"client_secret": os.getenv("GITHUB_CLIENT_SECRET", ""),
|
|
"auth_url": "https://github.com/login/oauth/authorize",
|
|
"token_url": "https://github.com/login/oauth/access_token",
|
|
"userinfo_url": "https://api.github.com/user",
|
|
"scope": "read:user user:email",
|
|
"redirect_uri": f"{_BASE}/api/auth/oauth/github/callback",
|
|
},
|
|
"kakao": {
|
|
"name": "카카오",
|
|
"icon": "kakao",
|
|
"enabled": bool(os.getenv("KAKAO_CLIENT_ID")),
|
|
"client_id": os.getenv("KAKAO_CLIENT_ID", ""),
|
|
"client_secret": os.getenv("KAKAO_CLIENT_SECRET", ""),
|
|
"auth_url": "https://kauth.kakao.com/oauth/authorize",
|
|
"token_url": "https://kauth.kakao.com/oauth/token",
|
|
"userinfo_url": "https://kapi.kakao.com/v2/user/me",
|
|
"scope": "profile_nickname account_email",
|
|
"redirect_uri": f"{_BASE}/api/auth/oauth/kakao/callback",
|
|
},
|
|
"naver": {
|
|
"name": "네이버",
|
|
"icon": "naver",
|
|
"enabled": bool(os.getenv("NAVER_CLIENT_ID")),
|
|
"client_id": os.getenv("NAVER_CLIENT_ID", ""),
|
|
"client_secret": os.getenv("NAVER_CLIENT_SECRET", ""),
|
|
"auth_url": "https://nid.naver.com/oauth2.0/authorize",
|
|
"token_url": "https://nid.naver.com/oauth2.0/token",
|
|
"userinfo_url": "https://openapi.naver.com/v1/nid/me",
|
|
"scope": "name email",
|
|
"redirect_uri": f"{_BASE}/api/auth/oauth/naver/callback",
|
|
},
|
|
"keycloak": {
|
|
"name": "SSO (Keycloak)",
|
|
"icon": "sso",
|
|
"enabled": bool(os.getenv("KEYCLOAK_BASE_URL")),
|
|
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "guardia"),
|
|
"client_secret": os.getenv("KEYCLOAK_CLIENT_SECRET", ""),
|
|
"auth_url": f"{os.getenv('KEYCLOAK_BASE_URL','')}/realms/{os.getenv('KEYCLOAK_REALM','master')}/protocol/openid-connect/auth",
|
|
"token_url": f"{os.getenv('KEYCLOAK_BASE_URL','')}/realms/{os.getenv('KEYCLOAK_REALM','master')}/protocol/openid-connect/token",
|
|
"userinfo_url": f"{os.getenv('KEYCLOAK_BASE_URL','')}/realms/{os.getenv('KEYCLOAK_REALM','master')}/protocol/openid-connect/userinfo",
|
|
"scope": "openid email profile",
|
|
"redirect_uri": f"{_BASE}/api/auth/oauth/keycloak/callback",
|
|
},
|
|
}
|
|
|
|
# 임시 state 저장소 (메모리 — 분산 환경에서는 Redis 사용)
|
|
_states: dict[str, float] = {}
|
|
|
|
|
|
def get_enabled_providers() -> list[dict]:
|
|
"""활성화된 OAuth 제공자 목록 (민감정보 제외)."""
|
|
return [
|
|
{"id": pid, "name": p["name"], "icon": p["icon"]}
|
|
for pid, p in PROVIDERS.items()
|
|
if p["enabled"]
|
|
]
|
|
|
|
|
|
def build_auth_url(provider_id: str) -> str:
|
|
"""OAuth 인증 URL 생성 + CSRF state 발급."""
|
|
p = PROVIDERS.get(provider_id)
|
|
if not p or not p["enabled"]:
|
|
raise ValueError(f"OAuth 제공자 미설정: {provider_id}")
|
|
|
|
state = secrets.token_urlsafe(32)
|
|
_states[state] = time.time()
|
|
# 5분 초과 state 정리
|
|
cutoff = time.time() - 300
|
|
expired = [k for k, v in _states.items() if v < cutoff]
|
|
for k in expired:
|
|
_states.pop(k, None)
|
|
|
|
params = {
|
|
"client_id": p["client_id"],
|
|
"redirect_uri": p["redirect_uri"],
|
|
"scope": p["scope"],
|
|
"response_type": "code",
|
|
"state": state,
|
|
}
|
|
return p["auth_url"] + "?" + urlencode(params)
|
|
|
|
|
|
def verify_state(state: str) -> bool:
|
|
"""CSRF state 검증."""
|
|
ts = _states.pop(state, None)
|
|
if ts is None:
|
|
return False
|
|
return (time.time() - ts) < 300 # 5분 이내
|
|
|
|
|
|
async def exchange_code(provider_id: str, code: str) -> Optional[dict]:
|
|
"""인가 코드 → 액세스 토큰 교환 → 사용자 정보 조회."""
|
|
p = PROVIDERS.get(provider_id)
|
|
if not p:
|
|
return None
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
# 토큰 교환
|
|
token_data = {
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": p["redirect_uri"],
|
|
"client_id": p["client_id"],
|
|
"client_secret": p["client_secret"],
|
|
}
|
|
headers = {"Accept": "application/json"}
|
|
resp = await client.post(p["token_url"], data=token_data, headers=headers)
|
|
resp.raise_for_status()
|
|
tokens = resp.json()
|
|
|
|
access_token = tokens.get("access_token")
|
|
if not access_token:
|
|
return None
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
# 사용자 정보 조회
|
|
userinfo_resp = await client.get(
|
|
p["userinfo_url"],
|
|
headers={"Authorization": f"Bearer {access_token}"},
|
|
)
|
|
userinfo_resp.raise_for_status()
|
|
return userinfo_resp.json()
|
|
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def extract_email(provider_id: str, userinfo: dict) -> Optional[str]:
|
|
"""제공자별 이메일 추출."""
|
|
if provider_id in ("google", "keycloak"):
|
|
return userinfo.get("email")
|
|
if provider_id == "github":
|
|
return userinfo.get("email") or f"{userinfo.get('login', '')}@github.com"
|
|
if provider_id == "kakao":
|
|
return userinfo.get("kakao_account", {}).get("email")
|
|
if provider_id == "naver":
|
|
return userinfo.get("response", {}).get("email")
|
|
return userinfo.get("email")
|
|
|
|
|
|
def extract_name(provider_id: str, userinfo: dict) -> str:
|
|
"""제공자별 표시 이름 추출."""
|
|
if provider_id in ("google", "keycloak"):
|
|
return userinfo.get("name", "")
|
|
if provider_id == "github":
|
|
return userinfo.get("name") or userinfo.get("login", "")
|
|
if provider_id == "kakao":
|
|
return userinfo.get("kakao_account", {}).get("profile", {}).get("nickname", "")
|
|
if provider_id == "naver":
|
|
return userinfo.get("response", {}).get("name", "")
|
|
return userinfo.get("name", "")
|