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
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"]
|