G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현 G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건) G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST) G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직 G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트 G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트 G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트 G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트 G-10: core/push_notify.py + routers/push.py + PushSubscription 모델 G-11: approvals 다중승인 (위임/서명/기한초과/마감연장) G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh 하네스: guardia-orchestrator 확장기능 Phase 반영 봇명령어: /sr /status /license /bulk 슬래시 명령어 추가 설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
145 lines
6.2 KiB
Python
145 lines
6.2 KiB
Python
"""D-2 MFA (TOTP) 테스트"""
|
|
import sys, ast, os
|
|
|
|
os.environ.setdefault("GUARDIA_SECRET_KEY", "test-mfa-secret-key-32bytes-pad!!")
|
|
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test_d2.db")
|
|
|
|
# ── 1. 구문 검사 ─────────────────────────────────────────────────────────────
|
|
print("=== 1. 구문 검사 ===")
|
|
files = ["core/mfa.py", "routers/auth.py", "models.py"]
|
|
ok = True
|
|
for f in files:
|
|
try:
|
|
with open(f, encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
ast.parse(src)
|
|
print(f" OK {f}")
|
|
except SyntaxError as e:
|
|
print(f" ERR {f}: {e}")
|
|
ok = False
|
|
if not ok:
|
|
sys.exit(1)
|
|
|
|
# ── 2. AES-GCM 암호화/복호화 라운드트립 ─────────────────────────────────────
|
|
print("\n=== 2. AES-GCM 암호화/복호화 테스트 ===")
|
|
from core.mfa import (
|
|
generate_totp_secret,
|
|
get_totp_uri,
|
|
verify_totp,
|
|
generate_qr_base64,
|
|
encrypt_totp_secret,
|
|
decrypt_totp_secret,
|
|
create_mfa_pending_token,
|
|
verify_mfa_pending_token,
|
|
_verify_totp_fallback,
|
|
)
|
|
|
|
secret = generate_totp_secret()
|
|
print(f" OK generate_totp_secret: {secret[:8]}... (len={len(secret)})")
|
|
|
|
enc = encrypt_totp_secret(secret)
|
|
dec = decrypt_totp_secret(enc)
|
|
assert dec == secret, f"복호화 실패: {dec!r} != {secret!r}"
|
|
print(f" OK AES-GCM 암호화/복호화 라운드트립")
|
|
|
|
# 다른 시크릿과 다른지 확인
|
|
enc2 = encrypt_totp_secret(secret)
|
|
assert enc != enc2, "동일 평문이 항상 다른 ciphertext 생성 (nonce 랜덤화)"
|
|
print(f" OK 랜덤 nonce로 매번 다른 ciphertext 생성")
|
|
|
|
# ── 3. TOTP URI 생성 ─────────────────────────────────────────────────────────
|
|
print("\n=== 3. TOTP URI 생성 테스트 ===")
|
|
uri = get_totp_uri("test_user", secret)
|
|
assert "otpauth://totp/" in uri, f"URI 포맷 오류: {uri[:60]}"
|
|
assert "GUARDiA" in uri or "guardia" in uri.lower(), f"issuer 없음: {uri[:80]}"
|
|
assert secret in uri, "secret 미포함"
|
|
print(f" OK TOTP URI: {uri[:60]}...")
|
|
|
|
# ── 4. QR 코드 생성 (옵션) ──────────────────────────────────────────────────
|
|
print("\n=== 4. QR 코드 생성 테스트 ===")
|
|
qr = generate_qr_base64(uri)
|
|
if qr:
|
|
assert len(qr) > 100, "QR base64 너무 짧음"
|
|
print(f" OK QR base64 생성 (len={len(qr)})")
|
|
else:
|
|
print(f" INFO qrcode not installed - QR skip (returns None)")
|
|
|
|
# ── 5. TOTP 검증 (폴백 함수 사용) ───────────────────────────────────────────
|
|
print("\n=== 5. TOTP 검증 테스트 ===")
|
|
# 현재 코드 생성 후 검증 (pyotp 있는 경우)
|
|
try:
|
|
import pyotp
|
|
totp_obj = pyotp.TOTP(secret)
|
|
current_code = totp_obj.now()
|
|
result = verify_totp(secret, current_code)
|
|
assert result == True, f"현재 코드 검증 실패: {current_code}"
|
|
print(f" OK 현재 TOTP 코드 검증 성공 (pyotp)")
|
|
|
|
# 잘못된 코드
|
|
assert verify_totp(secret, "000000") == False or verify_totp(secret, "000000") == True # 우연히 맞을 수 있음
|
|
assert verify_totp(secret, "abc123") == False, "비숫자 코드는 무효"
|
|
assert verify_totp(secret, "12345") == False, "5자리는 무효"
|
|
print(f" OK 잘못된 코드 거부 (형식 검사)")
|
|
except ImportError:
|
|
print(f" INFO pyotp not installed - using fallback")
|
|
# 폴백은 실제 코드 없이 테스트 불가 (시간 기반)
|
|
assert verify_totp(secret, "abc123") == False
|
|
print(f" OK 잘못된 코드 거부 (폴백)")
|
|
|
|
# ── 6. MFA 대기 토큰 발급/검증 ──────────────────────────────────────────────
|
|
print("\n=== 6. MFA 대기 토큰 테스트 ===")
|
|
mfa_token = create_mfa_pending_token("alice")
|
|
assert mfa_token, "토큰 생성 실패"
|
|
print(f" OK MFA 대기 토큰 생성: {mfa_token[:20]}...")
|
|
|
|
username = verify_mfa_pending_token(mfa_token)
|
|
assert username == "alice", f"사용자 추출 실패: {username}"
|
|
print(f" OK MFA 대기 토큰 검증: sub={username}")
|
|
|
|
# 일반 access_token은 mfa_pending 토큰으로 거부
|
|
from core.auth import create_access_token
|
|
normal_token = create_access_token({"sub": "alice", "role": "ENGINEER"})
|
|
assert verify_mfa_pending_token(normal_token) is None, "일반 토큰이 mfa_pending으로 통과됨!"
|
|
print(f" OK 일반 access_token은 mfa_pending 검증 거부")
|
|
|
|
# 위조된 토큰 거부
|
|
assert verify_mfa_pending_token("invalid.token.here") is None
|
|
print(f" OK 위조 토큰 거부")
|
|
|
|
# ── 7. models.py User 컬럼 확인 ─────────────────────────────────────────────
|
|
print("\n=== 7. User 모델 MFA 컬럼 확인 ===")
|
|
from models import User, UserOut
|
|
cols = {c.key for c in User.__table__.columns}
|
|
for col in ["mfa_enabled", "totp_secret_enc"]:
|
|
status = "OK" if col in cols else "ERR"
|
|
print(f" {status} User.{col}")
|
|
if status == "ERR":
|
|
ok = False
|
|
|
|
out_fields = UserOut.model_fields
|
|
status = "OK" if "mfa_enabled" in out_fields else "ERR"
|
|
print(f" {status} UserOut.mfa_enabled")
|
|
|
|
# ── 8. routers/auth.py 엔드포인트 확인 ─────────────────────────────────────
|
|
print("\n=== 8. auth.py MFA 엔드포인트 확인 ===")
|
|
with open("routers/auth.py", encoding="utf-8") as f:
|
|
auth_src = f.read()
|
|
|
|
endpoints = [
|
|
("/mfa/setup", "mfa_setup"),
|
|
("/mfa/enable", "mfa_enable"),
|
|
("/mfa/disable", "mfa_disable"),
|
|
("/mfa/status", "mfa_status"),
|
|
("/login/mfa", "login_mfa"),
|
|
("/admin/users", "admin_mfa_reset"),
|
|
]
|
|
for path, fn in endpoints:
|
|
status = "OK" if path in auth_src and fn in auth_src else "ERR"
|
|
print(f" {status} {path} ({fn})")
|
|
|
|
print("\n=== 테스트 완료: D-2 MFA (TOTP) ===")
|
|
if ok:
|
|
print("모든 검사 통과")
|
|
else:
|
|
sys.exit(1)
|