| | | 1 | | using Microsoft.Extensions.Options; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using AngleSharp; |
| | | 4 | | using AngleSharp.Html.Dom; |
| | | 5 | | |
| | | 6 | | namespace KicktippIntegration.Authentication; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// HTTP message handler that manages Kicktipp authentication |
| | | 10 | | /// Automatically handles login and maintains session cookies |
| | | 11 | | /// </summary> |
| | | 12 | | public class KicktippAuthenticationHandler : DelegatingHandler |
| | | 13 | | { |
| | | 14 | | /// <summary> |
| | | 15 | | /// The default base URL for Kicktipp. |
| | | 16 | | /// </summary> |
| | | 17 | | public const string DefaultBaseUrl = "https://www.kicktipp.de"; |
| | | 18 | | |
| | | 19 | | private const string LoginPath = "/info/profil/login"; |
| | | 20 | | |
| | | 21 | | private readonly IOptions<KicktippOptions> _options; |
| | | 22 | | private readonly ILogger<KicktippAuthenticationHandler> _logger; |
| | | 23 | | private readonly IBrowsingContext _browsingContext; |
| | 1 | 24 | | private readonly SemaphoreSlim _loginSemaphore = new(1, 1); |
| | | 25 | | private readonly string _baseUrl; |
| | | 26 | | private bool _isLoggedIn = false; |
| | | 27 | | |
| | | 28 | | /// <summary> |
| | | 29 | | /// Creates a new instance of the authentication handler. |
| | | 30 | | /// </summary> |
| | | 31 | | /// <param name="options">Kicktipp configuration options.</param> |
| | | 32 | | /// <param name="logger">Logger instance.</param> |
| | | 33 | | /// <param name="baseUrl">Base URL for Kicktipp. Defaults to production URL. Can be overridden for testing.</param> |
| | 1 | 34 | | public KicktippAuthenticationHandler( |
| | 1 | 35 | | IOptions<KicktippOptions> options, |
| | 1 | 36 | | ILogger<KicktippAuthenticationHandler> logger, |
| | 1 | 37 | | string? baseUrl = null) |
| | | 38 | | { |
| | 1 | 39 | | _options = options; |
| | 1 | 40 | | _logger = logger; |
| | 1 | 41 | | _baseUrl = baseUrl ?? DefaultBaseUrl; |
| | 1 | 42 | | var config = Configuration.Default.WithDefaultLoader(); |
| | 1 | 43 | | _browsingContext = BrowsingContext.New(config); |
| | 1 | 44 | | } |
| | | 45 | | |
| | 1 | 46 | | private string LoginUrl => $"{_baseUrl}{LoginPath}"; |
| | | 47 | | |
| | | 48 | | protected override async Task<HttpResponseMessage> SendAsync( |
| | | 49 | | HttpRequestMessage request, |
| | | 50 | | CancellationToken cancellationToken) |
| | | 51 | | { |
| | | 52 | | // Ensure we're logged in before making any requests |
| | 1 | 53 | | await EnsureLoggedInAsync(cancellationToken); |
| | | 54 | | |
| | | 55 | | // Send the original request |
| | 1 | 56 | | var response = await base.SendAsync(request, cancellationToken); |
| | | 57 | | |
| | | 58 | | // If we get a 401/403 or are redirected to login, try to re-authenticate |
| | 1 | 59 | | if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || |
| | 1 | 60 | | response.StatusCode == System.Net.HttpStatusCode.Forbidden || |
| | 1 | 61 | | response.RequestMessage?.RequestUri?.ToString().Contains("login") == true) |
| | | 62 | | { |
| | 1 | 63 | | _logger.LogWarning("Authentication may have expired, attempting re-login..."); |
| | 1 | 64 | | _isLoggedIn = false; |
| | 1 | 65 | | await EnsureLoggedInAsync(cancellationToken); |
| | | 66 | | |
| | | 67 | | // Retry the original request |
| | 1 | 68 | | var retryRequest = await CloneRequestAsync(request); |
| | 1 | 69 | | response = await base.SendAsync(retryRequest, cancellationToken); |
| | | 70 | | } |
| | | 71 | | |
| | 1 | 72 | | return response; |
| | 1 | 73 | | } |
| | | 74 | | |
| | | 75 | | private async Task EnsureLoggedInAsync(CancellationToken cancellationToken) |
| | | 76 | | { |
| | 1 | 77 | | if (_isLoggedIn) return; |
| | | 78 | | |
| | | 79 | | // Use a semaphore for async-safe synchronization |
| | 1 | 80 | | await _loginSemaphore.WaitAsync(cancellationToken); |
| | | 81 | | try |
| | | 82 | | { |
| | 1 | 83 | | if (_isLoggedIn) return; |
| | | 84 | | |
| | | 85 | | // Perform login |
| | 1 | 86 | | await PerformLoginAsync(cancellationToken); |
| | 1 | 87 | | } |
| | | 88 | | finally |
| | | 89 | | { |
| | 1 | 90 | | _loginSemaphore.Release(); |
| | | 91 | | } |
| | 1 | 92 | | } |
| | | 93 | | |
| | | 94 | | private async Task PerformLoginAsync(CancellationToken cancellationToken) |
| | | 95 | | { |
| | | 96 | | try |
| | | 97 | | { |
| | 1 | 98 | | var credentials = _options.Value.ToCredentials(); |
| | 1 | 99 | | if (!credentials.IsValid) |
| | | 100 | | { |
| | 1 | 101 | | throw new InvalidOperationException("Invalid Kicktipp credentials configured"); |
| | | 102 | | } |
| | | 103 | | |
| | 1 | 104 | | _logger.LogInformation("Performing Kicktipp authentication..."); |
| | | 105 | | |
| | | 106 | | // Get the login page first |
| | 1 | 107 | | var loginPageRequest = new HttpRequestMessage(HttpMethod.Get, LoginUrl); |
| | 1 | 108 | | var loginPageResponse = await base.SendAsync(loginPageRequest, cancellationToken); |
| | | 109 | | |
| | 1 | 110 | | if (!loginPageResponse.IsSuccessStatusCode) |
| | | 111 | | { |
| | 1 | 112 | | throw new HttpRequestException($"Failed to access login page: {loginPageResponse.StatusCode}"); |
| | | 113 | | } |
| | | 114 | | |
| | 1 | 115 | | var loginPageContent = await loginPageResponse.Content.ReadAsStringAsync(cancellationToken); |
| | 1 | 116 | | var loginDocument = await _browsingContext.OpenAsync(req => req.Content(loginPageContent)); |
| | | 117 | | |
| | | 118 | | // Find the login form |
| | 1 | 119 | | var loginForm = loginDocument.QuerySelector("form") as IHtmlFormElement; |
| | 1 | 120 | | if (loginForm == null) |
| | | 121 | | { |
| | 1 | 122 | | throw new InvalidOperationException("Could not find login form on the page"); |
| | | 123 | | } |
| | | 124 | | |
| | | 125 | | // Parse the form action URL - use the action from the form |
| | 1 | 126 | | var formAction = loginForm.Action; |
| | 1 | 127 | | var formActionUrl = string.IsNullOrEmpty(formAction) ? LoginUrl : |
| | 1 | 128 | | (formAction.StartsWith("http") ? formAction : $"{_baseUrl}{formAction}"); |
| | | 129 | | |
| | | 130 | | // Prepare form data with the exact field names from the HTML |
| | 1 | 131 | | var formData = new List<KeyValuePair<string, string>> |
| | 1 | 132 | | { |
| | 1 | 133 | | new("kennung", credentials.Username), |
| | 1 | 134 | | new("passwort", credentials.Password) |
| | 1 | 135 | | }; |
| | | 136 | | |
| | | 137 | | // Add hidden fields (like _charset_) |
| | 1 | 138 | | var hiddenInputs = loginForm.QuerySelectorAll("input[type=hidden]").OfType<IHtmlInputElement>(); |
| | 1 | 139 | | foreach (var input in hiddenInputs) |
| | | 140 | | { |
| | 1 | 141 | | if (!string.IsNullOrEmpty(input.Name)) |
| | | 142 | | { |
| | 1 | 143 | | formData.Add(new KeyValuePair<string, string>(input.Name, input.Value ?? "")); |
| | | 144 | | } |
| | | 145 | | } |
| | | 146 | | |
| | | 147 | | // Submit the login form |
| | 1 | 148 | | var loginRequest = new HttpRequestMessage(HttpMethod.Post, formActionUrl) |
| | 1 | 149 | | { |
| | 1 | 150 | | Content = new FormUrlEncodedContent(formData) |
| | 1 | 151 | | }; |
| | | 152 | | |
| | 1 | 153 | | var loginResponse = await base.SendAsync(loginRequest, cancellationToken); |
| | | 154 | | |
| | 1 | 155 | | if (!loginResponse.IsSuccessStatusCode) |
| | | 156 | | { |
| | 1 | 157 | | throw new HttpRequestException($"Login request failed: {loginResponse.StatusCode}"); |
| | | 158 | | } |
| | | 159 | | |
| | | 160 | | // Check if login was successful |
| | | 161 | | // The most reliable indicator is that we're no longer on a login page |
| | 1 | 162 | | var responseContent = await loginResponse.Content.ReadAsStringAsync(cancellationToken); |
| | 1 | 163 | | var currentUrl = loginResponse.RequestMessage?.RequestUri?.ToString() ?? ""; |
| | | 164 | | |
| | | 165 | | // Simple and reliable: if we're not on a login-related URL, login was successful |
| | 1 | 166 | | var loginSuccessful = !currentUrl.Contains("/login") && !currentUrl.Contains("/profil/login"); |
| | | 167 | | |
| | | 168 | | // Additional check: look for login form on the response page |
| | | 169 | | // If we still see a login form, login probably failed |
| | 1 | 170 | | if (loginSuccessful) |
| | | 171 | | { |
| | 1 | 172 | | var responseDocument = await _browsingContext.OpenAsync(req => req.Content(responseContent)); |
| | 1 | 173 | | var stillHasLoginForm = responseDocument.QuerySelector("form#loginFormular") != null; |
| | | 174 | | |
| | 1 | 175 | | if (stillHasLoginForm) |
| | | 176 | | { |
| | 1 | 177 | | loginSuccessful = false; |
| | | 178 | | } |
| | | 179 | | } |
| | | 180 | | |
| | 1 | 181 | | if (loginSuccessful) |
| | | 182 | | { |
| | 1 | 183 | | _logger.LogInformation("✓ Kicktipp authentication successful"); |
| | 1 | 184 | | _isLoggedIn = true; |
| | | 185 | | } |
| | | 186 | | else |
| | | 187 | | { |
| | 1 | 188 | | _logger.LogError("Login failed - still on login page or login form present"); |
| | 1 | 189 | | throw new UnauthorizedAccessException("Kicktipp login failed - check credentials"); |
| | | 190 | | } |
| | 1 | 191 | | } |
| | 1 | 192 | | catch (Exception ex) |
| | | 193 | | { |
| | 1 | 194 | | _logger.LogError(ex, "✗ Kicktipp authentication failed"); |
| | 1 | 195 | | throw; |
| | | 196 | | } |
| | 1 | 197 | | } |
| | | 198 | | |
| | | 199 | | private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request) |
| | | 200 | | { |
| | 1 | 201 | | var clone = new HttpRequestMessage(request.Method, request.RequestUri); |
| | | 202 | | |
| | | 203 | | // Copy headers |
| | 1 | 204 | | foreach (var header in request.Headers) |
| | | 205 | | { |
| | 0 | 206 | | clone.Headers.TryAddWithoutValidation(header.Key, header.Value); |
| | | 207 | | } |
| | | 208 | | |
| | | 209 | | // Copy content if present |
| | 1 | 210 | | if (request.Content != null) |
| | | 211 | | { |
| | 1 | 212 | | var contentBytes = await request.Content.ReadAsByteArrayAsync(); |
| | 1 | 213 | | clone.Content = new ByteArrayContent(contentBytes); |
| | | 214 | | |
| | | 215 | | // Copy content headers |
| | 1 | 216 | | foreach (var header in request.Content.Headers) |
| | | 217 | | { |
| | 1 | 218 | | clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); |
| | | 219 | | } |
| | | 220 | | } |
| | | 221 | | |
| | 1 | 222 | | return clone; |
| | 1 | 223 | | } |
| | | 224 | | } |