< Summary

Information
Class: KicktippIntegration.Authentication.KicktippAuthenticationHandler
Assembly: KicktippIntegration
File(s): /home/runner/work/KicktippAi/KicktippAi/src/KicktippIntegration/Authentication/KicktippAuthenticationHandler.cs
Line coverage
98%
Covered lines: 90
Uncovered lines: 1
Coverable lines: 91
Total lines: 224
Line coverage: 98.9%
Branch coverage
81%
Covered branches: 44
Total branches: 54
Branch coverage: 81.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
get_LoginUrl()100%11100%
SendAsync()80%1010100%
EnsureLoggedInAsync()75%44100%
PerformLoginAsync()81.25%3232100%
CloneRequestAsync()83.33%6690%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/KicktippIntegration/Authentication/KicktippAuthenticationHandler.cs

#LineLine coverage
 1using Microsoft.Extensions.Options;
 2using Microsoft.Extensions.Logging;
 3using AngleSharp;
 4using AngleSharp.Html.Dom;
 5
 6namespace KicktippIntegration.Authentication;
 7
 8/// <summary>
 9/// HTTP message handler that manages Kicktipp authentication
 10/// Automatically handles login and maintains session cookies
 11/// </summary>
 12public 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;
 124    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>
 134    public KicktippAuthenticationHandler(
 135        IOptions<KicktippOptions> options,
 136        ILogger<KicktippAuthenticationHandler> logger,
 137        string? baseUrl = null)
 38    {
 139        _options = options;
 140        _logger = logger;
 141        _baseUrl = baseUrl ?? DefaultBaseUrl;
 142        var config = Configuration.Default.WithDefaultLoader();
 143        _browsingContext = BrowsingContext.New(config);
 144    }
 45
 146    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
 153        await EnsureLoggedInAsync(cancellationToken);
 54
 55        // Send the original request
 156        var response = await base.SendAsync(request, cancellationToken);
 57
 58        // If we get a 401/403 or are redirected to login, try to re-authenticate
 159        if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
 160            response.StatusCode == System.Net.HttpStatusCode.Forbidden ||
 161            response.RequestMessage?.RequestUri?.ToString().Contains("login") == true)
 62        {
 163            _logger.LogWarning("Authentication may have expired, attempting re-login...");
 164            _isLoggedIn = false;
 165            await EnsureLoggedInAsync(cancellationToken);
 66
 67            // Retry the original request
 168            var retryRequest = await CloneRequestAsync(request);
 169            response = await base.SendAsync(retryRequest, cancellationToken);
 70        }
 71
 172        return response;
 173    }
 74
 75    private async Task EnsureLoggedInAsync(CancellationToken cancellationToken)
 76    {
 177        if (_isLoggedIn) return;
 78
 79        // Use a semaphore for async-safe synchronization
 180        await _loginSemaphore.WaitAsync(cancellationToken);
 81        try
 82        {
 183            if (_isLoggedIn) return;
 84
 85            // Perform login
 186            await PerformLoginAsync(cancellationToken);
 187        }
 88        finally
 89        {
 190            _loginSemaphore.Release();
 91        }
 192    }
 93
 94    private async Task PerformLoginAsync(CancellationToken cancellationToken)
 95    {
 96        try
 97        {
 198            var credentials = _options.Value.ToCredentials();
 199            if (!credentials.IsValid)
 100            {
 1101                throw new InvalidOperationException("Invalid Kicktipp credentials configured");
 102            }
 103
 1104            _logger.LogInformation("Performing Kicktipp authentication...");
 105
 106            // Get the login page first
 1107            var loginPageRequest = new HttpRequestMessage(HttpMethod.Get, LoginUrl);
 1108            var loginPageResponse = await base.SendAsync(loginPageRequest, cancellationToken);
 109
 1110            if (!loginPageResponse.IsSuccessStatusCode)
 111            {
 1112                throw new HttpRequestException($"Failed to access login page: {loginPageResponse.StatusCode}");
 113            }
 114
 1115            var loginPageContent = await loginPageResponse.Content.ReadAsStringAsync(cancellationToken);
 1116            var loginDocument = await _browsingContext.OpenAsync(req => req.Content(loginPageContent));
 117
 118            // Find the login form
 1119            var loginForm = loginDocument.QuerySelector("form") as IHtmlFormElement;
 1120            if (loginForm == null)
 121            {
 1122                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
 1126            var formAction = loginForm.Action;
 1127            var formActionUrl = string.IsNullOrEmpty(formAction) ? LoginUrl :
 1128                (formAction.StartsWith("http") ? formAction : $"{_baseUrl}{formAction}");
 129
 130            // Prepare form data with the exact field names from the HTML
 1131            var formData = new List<KeyValuePair<string, string>>
 1132            {
 1133                new("kennung", credentials.Username),
 1134                new("passwort", credentials.Password)
 1135            };
 136
 137            // Add hidden fields (like _charset_)
 1138            var hiddenInputs = loginForm.QuerySelectorAll("input[type=hidden]").OfType<IHtmlInputElement>();
 1139            foreach (var input in hiddenInputs)
 140            {
 1141                if (!string.IsNullOrEmpty(input.Name))
 142                {
 1143                    formData.Add(new KeyValuePair<string, string>(input.Name, input.Value ?? ""));
 144                }
 145            }
 146
 147            // Submit the login form
 1148            var loginRequest = new HttpRequestMessage(HttpMethod.Post, formActionUrl)
 1149            {
 1150                Content = new FormUrlEncodedContent(formData)
 1151            };
 152
 1153            var loginResponse = await base.SendAsync(loginRequest, cancellationToken);
 154
 1155            if (!loginResponse.IsSuccessStatusCode)
 156            {
 1157                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
 1162            var responseContent = await loginResponse.Content.ReadAsStringAsync(cancellationToken);
 1163            var currentUrl = loginResponse.RequestMessage?.RequestUri?.ToString() ?? "";
 164
 165            // Simple and reliable: if we're not on a login-related URL, login was successful
 1166            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
 1170            if (loginSuccessful)
 171            {
 1172                var responseDocument = await _browsingContext.OpenAsync(req => req.Content(responseContent));
 1173                var stillHasLoginForm = responseDocument.QuerySelector("form#loginFormular") != null;
 174
 1175                if (stillHasLoginForm)
 176                {
 1177                    loginSuccessful = false;
 178                }
 179            }
 180
 1181            if (loginSuccessful)
 182            {
 1183                _logger.LogInformation("✓ Kicktipp authentication successful");
 1184                _isLoggedIn = true;
 185            }
 186            else
 187            {
 1188                _logger.LogError("Login failed - still on login page or login form present");
 1189                throw new UnauthorizedAccessException("Kicktipp login failed - check credentials");
 190            }
 1191        }
 1192        catch (Exception ex)
 193        {
 1194            _logger.LogError(ex, "✗ Kicktipp authentication failed");
 1195            throw;
 196        }
 1197    }
 198
 199    private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request)
 200    {
 1201        var clone = new HttpRequestMessage(request.Method, request.RequestUri);
 202
 203        // Copy headers
 1204        foreach (var header in request.Headers)
 205        {
 0206            clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
 207        }
 208
 209        // Copy content if present
 1210        if (request.Content != null)
 211        {
 1212            var contentBytes = await request.Content.ReadAsByteArrayAsync();
 1213            clone.Content = new ByteArrayContent(contentBytes);
 214
 215            // Copy content headers
 1216            foreach (var header in request.Content.Headers)
 217            {
 1218                clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
 219            }
 220        }
 221
 1222        return clone;
 1223    }
 224}