WebUntis: Browser-Header gegen HTTP 403, Klassenlehrer-Reports on-demand
CI / build-and-test (push) Canceled after 0s

Fehlzeiten-/Klassenbuch-Reports (reports.do, api/polling/REPORT) sowie
der JSON-RPC-Login schickten bisher keinen User-Agent, Referer oder die
übrigen Header, die WebUntis' eigene WebApp immer mitsendet - führte zu
HTTP 403. Header 1:1 aus einem echten Browser-Request übernommen und
zusätzliche Set-Cookie-Werte (z.B. Tenant-Id) aus der Login-Antwort
eingesammelt statt nur JSESSIONID/schoolname. Zusätzlich eine feste
kurze Pause zwischen Login und erstem Report-Request.

Die Klassenlehrer-Übersicht holt WebUntis-Daten jetzt nicht mehr eager
beim Navigieren oder bei jedem Sync-Ereignis, sondern nur noch über den
bestehenden "Aktualisieren"-Button - vermeidet spürbare Startlatenz und
unnötige WebUntis-Last. Eingehende Sync-Ereignisse (z.B. ein auf einem
anderen Gerät geschlossener Vorgang) bauen die Ansicht per
RefreshFromLocalDataOnly() weiterhin sofort neu auf, aber ausschließlich
aus den zuletzt geholten WebUntis-Daten plus aktuellen lokalen Daten -
ohne selbst je einen WebUntis-Request auszulösen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 14:41:41 +02:00
co-authored by Claude Sonnet 5
parent 216708e879
commit 0a419c4c11
3 changed files with 188 additions and 55 deletions
+59 -4
View File
@@ -10,12 +10,26 @@ public sealed class WebUntisClient : IAsyncDisposable
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(2);
// Feste (nicht zufällige) kurze Pause zwischen Login und erstem Report-Request, damit beides
// nicht im Millisekundenabstand hintereinander passiert.
private static readonly TimeSpan PostLoginDelay = TimeSpan.FromSeconds(1);
// WebUntis blockt Anfragen ohne die Header, die die eigene WebApp immer mitschickt, inzwischen
// serverseitig mit HTTP 403. Header-Satz per "Copy as cURL" aus einem echten Browser-Request
// 1:1 übernommen (kein X-Requested-With, WebUntis' Angular-Frontend sendet das nicht).
private const string BrowserUserAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6.2 Safari/605.1.15";
private readonly HttpClient _http;
private readonly WebUntisOptions _options;
private readonly SemaphoreSlim _sessionGate = new(1, 1);
private readonly Timer _sessionExpiryTimer;
private string? _sessionId;
private int? _myTeacherUntisId;
// Cookies, die WebUntis beim Login per Set-Cookie mitschickt (z.B. Tenant-Id, traceId) und die
// die WebApp bei jedem weiteren Request 1:1 zurücksendet. JSESSIONID wird separat verwaltet
// (siehe _sessionId), deshalb hier ausgeklammert.
private string? _serverCookies;
private string? _tenantId;
private DateTimeOffset _sessionExpiresAt;
private int _activeRequests;
private bool _disposed;
@@ -330,13 +344,14 @@ public sealed class WebUntisClient : IAsyncDisposable
user = configuration.Username,
password = configuration.Password,
client = configuration.Client,
}, null, cancellationToken);
}, null, cancellationToken, CaptureServerCookies);
_sessionId = OptionalString(result, "sessionId")
?? throw new WebUntisException(
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
_myTeacherUntisId = OptionalInt(result, "personId");
await Task.Delay(PostLoginDelay, cancellationToken);
}
_activeRequests++;
@@ -424,7 +439,7 @@ public sealed class WebUntisClient : IAsyncDisposable
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
CancellationToken cancellationToken)
CancellationToken cancellationToken, Action<HttpResponseMessage>? onResponse = null)
{
var configuration = GetConfiguration();
var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}";
@@ -433,9 +448,11 @@ public sealed class WebUntisClient : IAsyncDisposable
Content = JsonContent.Create(new { id = "lehrerapp-webuntis", method, @params = parameters, jsonrpc = "2.0" },
options: JsonOptions),
};
if (sessionId is not null) request.Headers.Add("Cookie", $"JSESSIONID={sessionId}");
ApplyBrowserHeaders(request, configuration.Host);
if (sessionId is not null) request.Headers.Add("Cookie", SessionCookie(sessionId));
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
onResponse?.Invoke(response);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}");
@@ -521,10 +538,48 @@ public sealed class WebUntisClient : IAsyncDisposable
{
var request = new HttpRequestMessage(HttpMethod.Get, uri);
if (acceptJson) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("Cookie", $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"");
ApplyBrowserHeaders(request, GetConfiguration().Host);
request.Headers.Add("Cookie", SessionCookie(sessionId));
if (_tenantId is not null) request.Headers.Add("Tenant-Id", _tenantId);
return request;
}
private string SessionCookie(string sessionId)
{
var cookie = $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"";
return _serverCookies is null ? cookie : $"{cookie}; {_serverCookies}";
}
// Sammelt Set-Cookie-Werte aus der Login-Antwort ein (z.B. Tenant-Id, traceId), die WebUntis'
// WebApp danach bei jedem Request unverändert zurückschickt. JSESSIONID wird ausgeklammert, da
// sie bereits separat über _sessionId verwaltet wird.
private void CaptureServerCookies(HttpResponseMessage response)
{
if (!response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) return;
var pairs = new List<string>();
foreach (var header in setCookieHeaders)
{
var nameValue = header.Split(';', 2)[0].Trim();
var name = nameValue.Split('=', 2)[0];
if (name.Equals("JSESSIONID", StringComparison.OrdinalIgnoreCase)) continue;
pairs.Add(nameValue);
if (name.Equals("Tenant-Id", StringComparison.OrdinalIgnoreCase) &&
nameValue.Split('=', 2) is [_, var value])
_tenantId = value.Trim('"');
}
_serverCookies = pairs.Count > 0 ? string.Join("; ", pairs) : null;
}
private static void ApplyBrowserHeaders(HttpRequestMessage request, string host)
{
request.Headers.UserAgent.ParseAdd(BrowserUserAgent);
request.Headers.Referrer = new Uri($"https://{host}/WebUntis/embedded.do?showSidebar=true");
request.Headers.Add("Accept-Language", "de-DE,de;q=0.9");
request.Headers.Add("Sec-Fetch-Site", "same-origin");
request.Headers.Add("Sec-Fetch-Mode", "cors");
request.Headers.Add("Sec-Fetch-Dest", "empty");
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, TimeSpan timeout,
CancellationToken cancellationToken)
{