WebUntis: Browser-Header gegen HTTP 403, Klassenlehrer-Reports on-demand
CI / build-and-test (push) Canceled after 0s
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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user