You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

303 lines
11 KiB
Python

# backend/app/connectors/oauth.py — OAuth2 Authorization Code + PKCE (phase-13, phase-16)
# start(인증 URL) → finish(code→token·프로필조회) → valid_access_token(자동 refresh).
# phase-16: Microsoft(Outlook) provider + 멀티계정(이메일별 고유 계정 id) + MailAccount upsert.
import base64
import hashlib
import os
import secrets
import time
import urllib.parse
import httpx
from sqlmodel import Session
from ..config import get_settings
from ..crypto import decrypt_token, encrypt_token
from ..models import (
ConnectorAccount,
ConnectorDomain,
ConnectorMode,
ConnState,
MailAccount,
OAuthState,
)
from .mail.normalize import mail_account_id_for
PROVIDERS = {
"google": { # Gmail + Google Calendar
"auth": "https://accounts.google.com/o/oauth2/v2/auth",
"token": "https://oauth2.googleapis.com/token",
"scopes": {
"gmail": [
# modify = 읽기 + 라벨변경(별표/보관/읽음) + 휴지통. send = 발송.
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.send",
],
"google_calendar": ["https://www.googleapis.com/auth/calendar.events"],
},
},
"microsoft": { # Outlook / Microsoft 365 (Graph). {tenant} 는 호출 시 치환.
"auth": "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
"token": "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
"scopes": {
"outlook": [
# ReadWrite = 읽기 + 플래그/읽음/이동/삭제. Send = 발송.
"https://graph.microsoft.com/Mail.ReadWrite",
"https://graph.microsoft.com/Mail.Send",
"https://graph.microsoft.com/User.Read",
"offline_access",
],
"outlook_calendar": [
# ReadWrite = 일정 읽기 + 생성/수정/삭제.
"https://graph.microsoft.com/Calendars.ReadWrite",
"https://graph.microsoft.com/User.Read",
"offline_access",
],
},
},
"notion": {
"auth": "https://api.notion.com/v1/oauth/authorize",
"token": "https://api.notion.com/v1/oauth/token",
"scopes": {"notion": []},
},
}
# provider → (domain, name, kind, tone) 새 real 계정 메타
_REAL_META = {
"gmail": ("mail", "Gmail", "Google", "blue"),
"outlook": ("mail", "Outlook", "Microsoft", "blue"),
"google_calendar": ("calendar", "Google 캘린더", "캘린더", "blue"),
"outlook_calendar": ("calendar", "Outlook 캘린더", "캘린더", "blue"),
"notion": ("knowledge", "Notion", "메모·문서", "violet"),
}
def _family(provider: str) -> str:
if provider in ("gmail", "google_calendar"):
return "google"
if provider in ("outlook", "outlook_calendar"):
return "microsoft"
return provider
def _conf(fam: str) -> dict:
"""provider family 설정. microsoft 는 {tenant} 를 런타임 치환."""
c = PROVIDERS[fam]
if fam == "microsoft":
t = get_settings().microsoft_tenant
return {**c, "auth": c["auth"].format(tenant=t), "token": c["token"].format(tenant=t)}
return c
def _creds(fam: str) -> tuple[str, str]:
st = get_settings()
if fam == "google":
return st.google_client_id, st.google_client_secret
if fam == "microsoft":
return st.microsoft_client_id, st.microsoft_client_secret
return st.notion_client_id, st.notion_client_secret
def _redirect_uri() -> str:
st = get_settings()
return st.oauth_redirect_uri or st.google_redirect_uri
def start_oauth(session: Session, domain: str, provider: str, redirect_after: str = "/life") -> str:
fam = _family(provider)
conf = _conf(fam)
client_id, _ = _creds(fam)
state = secrets.token_urlsafe(24)
verifier = base64.urlsafe_b64encode(os.urandom(40)).decode().rstrip("=")
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
)
session.add(
OAuthState(
id=state,
domain=domain,
provider=provider,
code_verifier=verifier,
redirect_after=redirect_after,
)
)
session.commit()
scopes = list(conf["scopes"].get(provider, []))
# 메일 동의에 캘린더 권한도 함께 요청 → 한 번의 동의로 메일+일정 모두 연결.
if provider == "gmail":
scopes += PROVIDERS["google"]["scopes"]["google_calendar"]
if provider == "outlook":
scopes.append("https://graph.microsoft.com/Calendars.ReadWrite")
params = {
"client_id": client_id,
"redirect_uri": _redirect_uri(),
"response_type": "code",
"scope": " ".join(scopes),
"state": state,
"access_type": "offline",
"prompt": "consent",
"code_challenge": challenge,
"code_challenge_method": "S256",
}
return f"{conf['auth']}?" + urllib.parse.urlencode(params)
def _fetch_identity(fam: str, access_token: str) -> str:
"""연결된 계정의 이메일 주소를 조회(멀티계정 식별용). 실패해도 빈 문자열로 graceful."""
st = get_settings()
headers = {"Authorization": f"Bearer {access_token}"}
try:
if fam == "google":
r = httpx.get(
"https://gmail.googleapis.com/gmail/v1/users/me/profile",
headers=headers,
timeout=st.connector_http_timeout,
)
r.raise_for_status()
return r.json().get("emailAddress", "") or ""
if fam == "microsoft":
r = httpx.get(
"https://graph.microsoft.com/v1.0/me",
headers=headers,
timeout=st.connector_http_timeout,
)
r.raise_for_status()
j = r.json()
return j.get("mail") or j.get("userPrincipalName", "") or ""
except Exception:
return ""
return ""
def _ensure_account(session: Session, domain, provider: str, email: str = "") -> ConnectorAccount:
"""이메일이 있으면 계정별 고유 id(멀티계정). 없으면 provider 단일 폴백."""
dom = domain.value if hasattr(domain, "value") else str(domain)
meta = _REAL_META.get(provider, (dom, provider, "", "ink"))
if email:
acct_id = f"ca-{dom}-{provider}-{hashlib.sha1(email.lower().encode()).hexdigest()[:8]}"
else:
acct_id = f"ca-{dom}-{provider}"
acct = session.get(ConnectorAccount, acct_id)
if not acct:
acct = ConnectorAccount(
id=acct_id,
user_id="jiwoo",
domain=domain,
provider=provider,
name=email or meta[1],
kind=meta[2],
tone=meta[3],
)
session.add(acct)
if email:
acct.external_account_id = email
acct.name = email
# 메일 계정은 메일 페이지 계정 탭/필터에 노출되도록 MailAccount 도 보장.
if dom == "mail" and email:
ma_id = mail_account_id_for(email)
if not session.get(MailAccount, ma_id):
session.add(
MailAccount(
id=ma_id,
name=email.split("@")[0],
email=email,
tone=meta[3],
kind=meta[2],
connector=provider,
sort_order=100,
)
)
return acct
def _connect(session: Session, domain, provider: str, email: str, tok: dict) -> ConnectorAccount:
"""provider 계정을 real+connected 로 만들고 토큰 저장(여러 도메인이 토큰 공유 가능)."""
acct = _ensure_account(session, domain, provider, email)
acct.mode = ConnectorMode.real
acct.state = ConnState.connected
acct.token_enc = encrypt_token(tok)
acct.scopes = tok.get("scope", "")
acct.last_label = "방금 연결됨"
session.add(acct)
# 캘린더 계정은 연결 즉시 Calendar 행 보장 → 동기화 전에도 일정 화면에 노출.
dom = domain.value if hasattr(domain, "value") else str(domain)
if dom == "calendar":
from .calendar.normalize import ensure_calendar_row # 지연 import(순환 회피)
ensure_calendar_row(session, acct)
return acct
def finish_oauth(session: Session, code: str, state: str) -> ConnectorAccount:
os_row = session.get(OAuthState, state)
if not os_row:
raise ValueError("invalid oauth state")
fam = _family(os_row.provider)
conf = _conf(fam)
client_id, client_secret = _creds(fam)
data = {
"code": code,
"grant_type": "authorization_code",
"redirect_uri": _redirect_uri(),
"code_verifier": os_row.code_verifier,
"client_id": client_id,
"client_secret": client_secret,
}
r = httpx.post(conf["token"], data=data, timeout=get_settings().connector_http_timeout)
r.raise_for_status()
tok = r.json()
tok["expires_at"] = int(time.time()) + int(tok.get("expires_in", 3600))
email = _fetch_identity(fam, tok.get("access_token", ""))
acct = _connect(session, os_row.domain, os_row.provider, email, tok)
# 메일 연결 시, 같은 동의에 포함된 캘린더 권한으로 캘린더 계정도 함께 연결.
if os_row.provider == "gmail":
_connect(session, ConnectorDomain.calendar, "google_calendar", email, tok)
elif os_row.provider == "outlook":
_connect(session, ConnectorDomain.calendar, "outlook_calendar", email, tok)
session.delete(os_row)
session.commit()
return acct
def valid_access_token(session: Session, account: ConnectorAccount) -> str:
"""만료되면 refresh_token 으로 갱신. 갱신 불가면 token_expired 로 표시 후 예외."""
tok = decrypt_token(account.token_enc)
if not tok:
account.state = ConnState.token_expired
session.add(account)
session.commit()
raise PermissionError("token missing/corrupt")
if tok.get("expires_at", 0) > int(time.time()) + 60:
return tok["access_token"]
fam = _family(account.provider)
conf = _conf(fam)
client_id, client_secret = _creds(fam)
rt = tok.get("refresh_token")
if not rt:
account.state = ConnState.token_expired
session.add(account)
session.commit()
raise PermissionError("no refresh_token")
r = httpx.post(
conf["token"],
data={
"grant_type": "refresh_token",
"refresh_token": rt,
"client_id": client_id,
"client_secret": client_secret,
},
timeout=get_settings().connector_http_timeout,
)
if r.status_code != 200:
account.state = ConnState.token_expired
session.add(account)
session.commit()
raise PermissionError("refresh failed")
new = r.json()
new.setdefault("refresh_token", rt) # MS 는 새 refresh_token 을 줄 수도, 안 줄 수도 있음
new["expires_at"] = int(time.time()) + int(new.get("expires_in", 3600))
account.token_enc = encrypt_token(new)
session.add(account)
session.commit()
return new["access_token"]