using Api.Services.Contracts; using Api.Settings; using Microsoft.Extensions.Options; namespace Api.Services { public sealed class RecaptchaVerifier : ICaptchaVerifier { private readonly HttpClient _http; private readonly CaptchaSettings _opt; private readonly ILogger _log; public RecaptchaVerifier(HttpClient http, IOptions options, ILogger log) { _http = http; _opt = options.Value; _log = log; } public async Task VerifyAsync(string token, string? userIp, CancellationToken ct) { _log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown"); if (string.IsNullOrWhiteSpace(_opt.SecretKey)) { _log.LogWarning("Captcha verification attempted but SecretKey is not configured"); return new CaptchaVerdict(false, "Captcha not configured", null); } var form = new Dictionary { ["secret"] = _opt.SecretKey, ["response"] = token }; if (!string.IsNullOrWhiteSpace(userIp)) form["remoteip"] = userIp; using var resp = await _http.PostAsync( "https://www.google.com/recaptcha/api/siteverify", new FormUrlEncodedContent(form), ct ); if (!resp.IsSuccessStatusCode) { _log.LogWarning("Captcha HTTP request failed with status {StatusCode} for IP {Ip}", (int)resp.StatusCode, userIp ?? "unknown"); return new CaptchaVerdict(false, $"Captcha HTTP {(int)resp.StatusCode}", null); } var data = await resp.Content.ReadFromJsonAsync(cancellationToken: ct); if (data is null) { _log.LogError("Failed to parse captcha response for IP {Ip}", userIp ?? "unknown"); return new CaptchaVerdict(false, "Captcha parse error", null); } if (!data.success) { _log.LogWarning("Captcha verification failed for IP {Ip}. Score={Score}", userIp ?? "unknown", data.score); return new CaptchaVerdict(false, "Captcha failed", data.score); } // v3 score check (score is typically null for v2) if (data.score is double score && score < _opt.MinimumScore) { _log.LogWarning("Captcha score {Score} below minimum {MinScore} for IP {Ip}", score, _opt.MinimumScore, userIp ?? "unknown"); return new CaptchaVerdict(false, "Captcha score too low", score); } // Optional strictness (usually v3): action/hostname checks if (!string.IsNullOrWhiteSpace(_opt.ExpectedAction) && !string.Equals(_opt.ExpectedAction, data.action, StringComparison.Ordinal)) { _log.LogWarning("Captcha action mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}", _opt.ExpectedAction, data.action, userIp ?? "unknown"); return new CaptchaVerdict(false, "Captcha action mismatch", data.score); } if (!string.IsNullOrWhiteSpace(_opt.ExpectedHostname) && !string.Equals(_opt.ExpectedHostname, data.hostname, StringComparison.OrdinalIgnoreCase)) { _log.LogWarning("Captcha hostname mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}", _opt.ExpectedHostname, data.hostname, userIp ?? "unknown"); return new CaptchaVerdict(false, "Captcha hostname mismatch", data.score); } _log.LogInformation("Captcha verified successfully for IP {Ip}. Score={Score}", userIp ?? "unknown", data.score); return new CaptchaVerdict(true, null, data.score); } private sealed class RecaptchaResponse { public bool success { get; set; } public double? score { get; set; } // v3 public string? action { get; set; } // v3 public string? hostname { get; set; } public DateTimeOffset? challenge_ts { get; set; } } } }