First Commit with complete Setup coded by Claude 4.7 Opus
This commit is contained in:
commit
bf1d37de77
19 changed files with 4544 additions and 0 deletions
275
backend/ms_graph.py
Normal file
275
backend/ms_graph.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue