226 lines
7.8 KiB
Markdown
226 lines
7.8 KiB
Markdown
# Microsoft 365 Service
|
|
|
|
FastAPI služba pro server-to-server komunikaci s Microsoft 365 přes Microsoft Graph.
|
|
|
|
## Konfigurace
|
|
|
|
V Microsoft Entra ID vytvořte app registration, přidělte požadovaná aplikační oprávnění pro Microsoft Graph a nastavte tyto environment proměnné:
|
|
|
|
```env
|
|
MS365_TENANT_ID=00000000-0000-0000-0000-000000000000
|
|
MS365_CLIENT_ID=00000000-0000-0000-0000-000000000000
|
|
MS365_CLIENT_SECRET=secret-value
|
|
MS365_CREDENTIAL_ENCODING_SECRET=shared-secret-for-header-credentials
|
|
MS365_GRAPH_BASE_URL=https://graph.microsoft.com/v1.0
|
|
MS365_GRAPH_SCOPE=https://graph.microsoft.com/.default
|
|
MS365_REQUEST_TIMEOUT_SECONDS=30
|
|
```
|
|
|
|
Služba používá OAuth2 client credentials flow, takže oprávnění pro Microsoft Graph musí být aplikační oprávnění schválená administrátorem.
|
|
|
|
## Credentials v request hlavičkách
|
|
|
|
Pokud se credentials předávají pro každý request v hlavičkách místo environment proměnných, použijte vlastní HTTP hlavičky s prefixem `X-`. Názvy hlaviček musí odpovídat vzoru `X-MS365-(NAME)` a hodnoty hlaviček musí obsahovat zakódované credentials, ne plaintext secrety.
|
|
|
|
Doporučené hlavičky:
|
|
|
|
```http
|
|
X-MS365-Tenant-Id: <encoded MS365_TENANT_ID>
|
|
X-MS365-Client-Id: <encoded MS365_CLIENT_ID>
|
|
X-MS365-Client-Secret: <encoded MS365_CLIENT_SECRET>
|
|
X-MS365-Credential-Version: v1
|
|
```
|
|
|
|
Tyto hlavičky nesou stejné Microsoft Entra aplikační credentials, které by jinak byly nastavené přes `MS365_TENANT_ID`, `MS365_CLIENT_ID` a `MS365_CLIENT_SECRET`. Rozdíl je pouze ve způsobu přenosu: každá hodnota je před vložením do HTTP hlavičky zašifrovaná.
|
|
|
|
Zakódovaná hodnota musí být službou replikovatelně zpracovatelná, ale nesmí být čitelná bez sdíleného secretu, který zná volající i služba. Nepoužívejte samotné Base64, URL encoding, ROT encoding ani jinou reverzibilní obfuskaci bez tajného klíče.
|
|
|
|
Doporučený formát zakódované hodnoty:
|
|
|
|
```text
|
|
v1.<base64url nonce>.<base64url ciphertext-and-auth-tag>
|
|
```
|
|
|
|
Pravidla zpracování:
|
|
|
|
- Dekódovat pouze hlavičky s prefixem `X-MS365-`.
|
|
- Před dekódováním credentials ověřit verzi.
|
|
- Každou zakódovanou hodnotu dešifrovat a autentizovat pomocí AES-256-GCM.
|
|
- Šifrovací klíč odvodit z `MS365_CREDENTIAL_ENCODING_SECRET` pomocí HKDF-SHA256.
|
|
- Použít associated data navázaná na název hlavičky, například `X-MS365-Client-Secret`, aby zakódovanou hodnotu nešlo přesunout mezi hlavičkami.
|
|
- Odmítnout chybějící, poškozené, expirované hodnoty nebo hodnoty, které neprojdou autentizací.
|
|
- Nikdy nelogovat dekódované credentials ani celé zakódované hodnoty hlaviček. Maximálně logovat název hlavičky, verzi credentials a krátký fingerprint.
|
|
|
|
Příklad payloadu před šifrováním:
|
|
|
|
```json
|
|
{
|
|
"value": "tenant-id-client-id-or-client-secret",
|
|
"issued_at": "2026-05-28T00:00:00Z",
|
|
"expires_at": "2026-05-28T01:00:00Z"
|
|
}
|
|
```
|
|
|
|
Tím zůstanou hodnoty v hlavičkách bezpečné i při průchodu systémy, které vidí HTTP metadata, a zároveň je může deterministicky zpracovat každá instance služby, která zná sdílený secret.
|
|
|
|
### Příprava header credentials v .NET
|
|
|
|
Volající musí použít stejný sdílený secret, jaký má služba v `MS365_CREDENTIAL_ENCODING_SECRET`. Každá credential hodnota se šifruje samostatně a váže se na cílový název hlavičky.
|
|
|
|
Příklad pro .NET 8+:
|
|
|
|
```csharp
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
static string Base64Url(byte[] value)
|
|
{
|
|
return Convert.ToBase64String(value)
|
|
.TrimEnd('=')
|
|
.Replace('+', '-')
|
|
.Replace('/', '_');
|
|
}
|
|
|
|
static byte[] HkdfSha256(byte[] inputKeyMaterial, byte[] salt, byte[] info, int length)
|
|
{
|
|
using var hmacExtract = new HMACSHA256(salt);
|
|
var pseudoRandomKey = hmacExtract.ComputeHash(inputKeyMaterial);
|
|
|
|
var output = new List<byte>();
|
|
var previous = Array.Empty<byte>();
|
|
var counter = 1;
|
|
|
|
while (output.Count < length)
|
|
{
|
|
using var hmacExpand = new HMACSHA256(pseudoRandomKey);
|
|
var blockInput = previous
|
|
.Concat(info)
|
|
.Concat(new[] { (byte)counter })
|
|
.ToArray();
|
|
|
|
previous = hmacExpand.ComputeHash(blockInput);
|
|
output.AddRange(previous);
|
|
counter++;
|
|
}
|
|
|
|
return output.Take(length).ToArray();
|
|
}
|
|
|
|
static string EncodeCredential(string value, string headerName, string sharedSecret)
|
|
{
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
value,
|
|
issued_at = DateTimeOffset.UtcNow.ToString("O"),
|
|
expires_at = DateTimeOffset.UtcNow.AddHours(1).ToString("O")
|
|
});
|
|
|
|
var salt = Encoding.UTF8.GetBytes("microsoft-365-service.credentials.v1");
|
|
var key = HkdfSha256(
|
|
Encoding.UTF8.GetBytes(sharedSecret),
|
|
salt,
|
|
Encoding.UTF8.GetBytes(headerName),
|
|
32);
|
|
|
|
var nonce = RandomNumberGenerator.GetBytes(12);
|
|
var plaintext = Encoding.UTF8.GetBytes(payload);
|
|
var ciphertext = new byte[plaintext.Length];
|
|
var tag = new byte[16];
|
|
var aad = Encoding.UTF8.GetBytes(headerName);
|
|
|
|
using var aes = new AesGcm(key, tagSizeInBytes: 16);
|
|
aes.Encrypt(nonce, plaintext, ciphertext, tag, aad);
|
|
|
|
var ciphertextAndTag = ciphertext.Concat(tag).ToArray();
|
|
return $"v1.{Base64Url(nonce)}.{Base64Url(ciphertextAndTag)}";
|
|
}
|
|
|
|
var sharedSecret = "same-value-as-MS365_CREDENTIAL_ENCODING_SECRET";
|
|
|
|
var headers = new Dictionary<string, string>
|
|
{
|
|
["X-MS365-Tenant-Id"] = EncodeCredential(
|
|
"00000000-0000-0000-0000-000000000000",
|
|
"X-MS365-Tenant-Id",
|
|
sharedSecret),
|
|
["X-MS365-Client-Id"] = EncodeCredential(
|
|
"00000000-0000-0000-0000-000000000000",
|
|
"X-MS365-Client-Id",
|
|
sharedSecret),
|
|
["X-MS365-Client-Secret"] = EncodeCredential(
|
|
"client-secret-value",
|
|
"X-MS365-Client-Secret",
|
|
sharedSecret),
|
|
["X-MS365-Credential-Version"] = "v1"
|
|
};
|
|
```
|
|
|
|
Příklad requestu:
|
|
|
|
```csharp
|
|
using var http = new HttpClient();
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://service.example.com/users?top=25");
|
|
|
|
foreach (var header in headers)
|
|
{
|
|
request.Headers.Add(header.Key, header.Value);
|
|
}
|
|
|
|
using var response = await http.SendAsync(request);
|
|
response.EnsureSuccessStatusCode();
|
|
```
|
|
|
|
## Implementované služby
|
|
|
|
- Users: výpis a načtení uživatelů.
|
|
- Mail: výpis zpráv a odesílání e-mailů včetně příloh.
|
|
- Calendar: výpis a vytváření událostí včetně Teams online meetingů.
|
|
- OneDrive: výpis souborů v rootu a upload malých souborů.
|
|
- Groups a Teams: výpis skupin a výpis kanálů týmu.
|
|
|
|
## API
|
|
|
|
```http
|
|
GET /health
|
|
GET /version
|
|
GET /status
|
|
GET /users?top=25&search=jane
|
|
GET /users/{user_id}
|
|
GET /users/{user_id}/mail/messages?folder=Inbox&top=25
|
|
POST /users/{user_id}/mail/send
|
|
GET /users/{user_id}/calendar/events?top=25
|
|
POST /users/{user_id}/calendar/events
|
|
GET /users/{user_id}/drive/root/children?top=25
|
|
PUT /users/{user_id}/drive/root/{path}
|
|
GET /groups?top=25
|
|
GET /teams/{team_id}/channels
|
|
```
|
|
|
|
`user_id` může být Microsoft Graph user id nebo user principal name, například `jane@example.com`.
|
|
|
|
## Požadovaná Graph oprávnění
|
|
|
|
Přidělte pouze oprávnění, která dané nasazení skutečně používá:
|
|
|
|
- Users: `User.Read.All`
|
|
- Čtení mailů: `Mail.Read`
|
|
- Odesílání mailů: `Mail.Send`
|
|
- Čtení a zápis do kalendáře: `Calendars.ReadWrite`
|
|
- Čtení a zápis do OneDrive: `Files.ReadWrite.All`
|
|
- Výpis skupin a Teams kanálů: `Group.Read.All`, `Team.ReadBasic.All`, `Channel.ReadBasic.All`
|
|
|
|
## Lokální spuštění
|
|
|
|
```bash
|
|
pip install -r requirements.txt
|
|
uvicorn app.main:app --reload
|
|
```
|
|
|
|
Vygenerované OpenAPI UI otevřete na `http://localhost:8000/docs`.
|
|
# job queue test Thu May 28 02:12:07 PM CEST 2026
|
|
# job queue test Thu May 28 02:12:28 PM CEST 2026
|
|
# websocket logs test Fri May 29 11:03:58 AM CEST 2026
|