275 lines
10 KiB
Python
275 lines
10 KiB
Python
import os
|
|
import httpx
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Optional
|
|
import logging
|
|
import asyncio
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MS_CLIENT_ID = os.getenv("MS_CLIENT_ID")
|
|
MS_CLIENT_SECRET = os.getenv("MS_CLIENT_SECRET")
|
|
MS_TENANT_ID = os.getenv("MS_TENANT_ID", "common")
|
|
APP_BASE_URL = os.getenv("APP_BASE_URL", "http://localhost")
|
|
|
|
REDIRECT_URI = f"{APP_BASE_URL}/auth/callback"
|
|
SCOPES = "openid profile email offline_access Contacts.ReadWrite"
|
|
|
|
AUTH_URL = f"https://login.microsoftonline.com/{MS_TENANT_ID}/oauth2/v2.0/authorize"
|
|
TOKEN_URL = f"https://login.microsoftonline.com/{MS_TENANT_ID}/oauth2/v2.0/token"
|
|
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
|
|
|
# ── App-Token-Cache (in-memory) ────────────────────────────────────────────
|
|
_app_token_cache: dict = {"token": None, "expires_at": None}
|
|
|
|
|
|
def get_auth_url(state: str = "") -> str:
|
|
params = {
|
|
"client_id": MS_CLIENT_ID,
|
|
"response_type": "code",
|
|
"redirect_uri": REDIRECT_URI,
|
|
"scope": SCOPES,
|
|
"response_mode": "query",
|
|
"state": state,
|
|
}
|
|
query = "&".join(f"{k}={v}" for k, v in params.items())
|
|
return f"{AUTH_URL}?{query}"
|
|
|
|
|
|
async def exchange_code_for_tokens(code: str) -> dict:
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(TOKEN_URL, data={
|
|
"client_id": MS_CLIENT_ID,
|
|
"client_secret": MS_CLIENT_SECRET,
|
|
"code": code,
|
|
"redirect_uri": REDIRECT_URI,
|
|
"grant_type": "authorization_code",
|
|
})
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def refresh_access_token(refresh_token: str) -> Optional[dict]:
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(TOKEN_URL, data={
|
|
"client_id": MS_CLIENT_ID,
|
|
"client_secret": MS_CLIENT_SECRET,
|
|
"refresh_token": refresh_token,
|
|
"grant_type": "refresh_token",
|
|
"scope": SCOPES,
|
|
})
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.error(f"Token refresh failed: {e}")
|
|
return None
|
|
|
|
|
|
async def get_ms_user_info(access_token: str) -> dict:
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(
|
|
f"{GRAPH_BASE}/me",
|
|
headers={"Authorization": f"Bearer {access_token}"}
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def get_valid_token(user, db) -> Optional[str]:
|
|
"""Gibt ein gültiges Access Token zurück.
|
|
|
|
1. Wenn der User per Gruppen-Import angelegt wurde: App-Token (Application Permissions)
|
|
2. Wenn der User OAuth-Login gemacht hat: User-Token (mit Refresh)
|
|
"""
|
|
# Gruppen-Import User → App-Token nutzen
|
|
if getattr(user, "source", "oauth") == "group_import":
|
|
return await get_app_token()
|
|
|
|
now = datetime.now(timezone.utc)
|
|
expires = user.token_expires_at
|
|
if expires and expires.tzinfo is None:
|
|
expires = expires.replace(tzinfo=timezone.utc)
|
|
|
|
if expires and now < expires - timedelta(minutes=5):
|
|
return user.ms_access_token
|
|
|
|
if not user.ms_refresh_token:
|
|
# Kein Refresh-Token? Versuche App-Token als Fallback
|
|
return await get_app_token()
|
|
|
|
tokens = await refresh_access_token(user.ms_refresh_token)
|
|
if not tokens:
|
|
return await get_app_token()
|
|
|
|
user.ms_access_token = tokens["access_token"]
|
|
if "refresh_token" in tokens:
|
|
user.ms_refresh_token = tokens["refresh_token"]
|
|
user.token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=tokens.get("expires_in", 3600))
|
|
db.commit()
|
|
return user.ms_access_token
|
|
|
|
|
|
# ── Application Permissions (Client Credentials Flow) ──────────────────────
|
|
|
|
async def get_app_token() -> Optional[str]:
|
|
"""Holt ein App-Token via Client Credentials Flow.
|
|
|
|
Wird gecacht und automatisch erneuert.
|
|
Voraussetzung: In Azure App-Registrierung Application Permissions vergeben
|
|
+ Admin-Consent erteilt:
|
|
- Contacts.ReadWrite (Application)
|
|
- User.Read.All (Application)
|
|
- GroupMember.Read.All (Application)
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
cached = _app_token_cache.get("token")
|
|
expires = _app_token_cache.get("expires_at")
|
|
|
|
if cached and expires and now < expires - timedelta(minutes=5):
|
|
return cached
|
|
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(TOKEN_URL, data={
|
|
"client_id": MS_CLIENT_ID,
|
|
"client_secret": MS_CLIENT_SECRET,
|
|
"scope": "https://graph.microsoft.com/.default",
|
|
"grant_type": "client_credentials",
|
|
})
|
|
if resp.status_code != 200:
|
|
logger.error(f"App-Token Anfrage fehlgeschlagen: {resp.status_code} {resp.text}")
|
|
return None
|
|
data = resp.json()
|
|
token = data["access_token"]
|
|
_app_token_cache["token"] = token
|
|
_app_token_cache["expires_at"] = now + timedelta(seconds=data.get("expires_in", 3600))
|
|
return token
|
|
except Exception as e:
|
|
logger.error(f"App-Token Fehler: {e}")
|
|
return None
|
|
|
|
|
|
# ── Group / User Lookup (App-Token) ─────────────────────────────────────────
|
|
|
|
async def get_group_info(group_id: str) -> Optional[dict]:
|
|
"""Liest Gruppen-Metadaten (Name etc.)"""
|
|
token = await get_app_token()
|
|
if not token:
|
|
return None
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(
|
|
f"{GRAPH_BASE}/groups/{group_id}?$select=id,displayName,description",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
if resp.status_code != 200:
|
|
logger.error(f"get_group_info failed: {resp.status_code} {resp.text}")
|
|
return None
|
|
return resp.json()
|
|
|
|
|
|
async def get_group_members(group_id: str) -> list[dict]:
|
|
"""Liest alle Mitglieder einer Azure AD Gruppe.
|
|
|
|
Folgt automatisch der Paginierung (@odata.nextLink).
|
|
Filtert auf User-Objekte (keine Gruppen-in-Gruppen, keine Service Principals).
|
|
"""
|
|
token = await get_app_token()
|
|
if not token:
|
|
return []
|
|
|
|
members = []
|
|
url = f"{GRAPH_BASE}/groups/{group_id}/members?$select=id,displayName,mail,userPrincipalName&$top=100"
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
while url:
|
|
resp = await client.get(url, headers={"Authorization": f"Bearer {token}"})
|
|
if resp.status_code != 200:
|
|
logger.error(f"get_group_members failed: {resp.status_code} {resp.text}")
|
|
break
|
|
data = resp.json()
|
|
for m in data.get("value", []):
|
|
# Nur echte User
|
|
if m.get("@odata.type", "").lower().endswith("user"):
|
|
members.append(m)
|
|
elif "userPrincipalName" in m:
|
|
members.append(m)
|
|
url = data.get("@odata.nextLink")
|
|
|
|
return members
|
|
|
|
|
|
# ── Microsoft Graph: Contacts ──────────────────────────────────────────────
|
|
|
|
def _contacts_endpoint(user_principal: Optional[str]) -> str:
|
|
"""Wählt den richtigen Contacts-Endpoint.
|
|
|
|
user_principal=None → /me/contacts (Delegated)
|
|
user_principal=email/id → /users/{principal}/contacts (Application)
|
|
"""
|
|
if user_principal:
|
|
return f"{GRAPH_BASE}/users/{user_principal}/contacts"
|
|
return f"{GRAPH_BASE}/me/contacts"
|
|
|
|
|
|
CONTACT_SELECT = "id,displayName,emailAddresses,businessPhones,mobilePhone,homePhones,jobTitle,companyName,department,businessAddress,homeAddress,birthday,personalNotes,givenName,surname,middleName,nickName,title,generation,imAddresses,fileAs,initials,businessHomePage,spouseName,categories"
|
|
|
|
|
|
async def graph_get_contacts(access_token: str, user_principal: Optional[str] = None) -> list[dict]:
|
|
"""Alle Kontakte abrufen. user_principal nur bei App-Token nötig."""
|
|
contacts = []
|
|
url = f"{_contacts_endpoint(user_principal)}?$top=100&$select={CONTACT_SELECT}"
|
|
headers = {"Authorization": f"Bearer {access_token}"}
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
while url:
|
|
resp = await client.get(url, headers=headers)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
contacts.extend(data.get("value", []))
|
|
url = data.get("@odata.nextLink")
|
|
|
|
return contacts
|
|
|
|
|
|
async def graph_create_contact(access_token: str, contact_data: dict, user_principal: Optional[str] = None) -> dict:
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(
|
|
_contacts_endpoint(user_principal),
|
|
headers={
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json=contact_data,
|
|
)
|
|
if resp.status_code >= 400:
|
|
logger.error(f"Graph create_contact failed ({resp.status_code}): {resp.text}")
|
|
logger.error(f"Payload was: {contact_data}")
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def graph_update_contact(access_token: str, contact_id: str, contact_data: dict, user_principal: Optional[str] = None) -> dict:
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.patch(
|
|
f"{_contacts_endpoint(user_principal)}/{contact_id}",
|
|
headers={
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json=contact_data,
|
|
)
|
|
if resp.status_code >= 400:
|
|
logger.error(f"Graph update_contact failed ({resp.status_code}): {resp.text}")
|
|
logger.error(f"Payload was: {contact_data}")
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def graph_delete_contact(access_token: str, contact_id: str, user_principal: Optional[str] = None):
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.delete(
|
|
f"{_contacts_endpoint(user_principal)}/{contact_id}",
|
|
headers={"Authorization": f"Bearer {access_token}"}
|
|
)
|
|
resp.raise_for_status()
|