107 lines
4.5 KiB
C#
107 lines
4.5 KiB
C#
using Api.Services.Contracts.Models;
|
|
using Api.Services.Contracts;
|
|
using Microsoft.Extensions.Options;
|
|
using Api.Models.Settings;
|
|
|
|
namespace Api.Services
|
|
{
|
|
public sealed class RecaptchaVerifier : ICaptchaVerifier
|
|
{
|
|
private readonly HttpClient _http;
|
|
private readonly CaptchaSettings _opt;
|
|
private readonly ILogger<RecaptchaVerifier> _log;
|
|
|
|
public RecaptchaVerifier(HttpClient http, IOptions<CaptchaSettings> options, ILogger<RecaptchaVerifier> log)
|
|
{
|
|
_http = http;
|
|
_opt = options.Value;
|
|
_log = log;
|
|
}
|
|
|
|
public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, 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 CaptchaVerdictModel(false, "Captcha not configured", null);
|
|
}
|
|
|
|
var form = new Dictionary<string, string>
|
|
{
|
|
["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 CaptchaVerdictModel(false, $"Captcha HTTP {(int)resp.StatusCode}", null);
|
|
}
|
|
|
|
var data = await resp.Content.ReadFromJsonAsync<RecaptchaResponse>(cancellationToken: ct);
|
|
if (data is null)
|
|
{
|
|
_log.LogError("Failed to parse captcha response for IP {Ip}", userIp ?? "unknown");
|
|
return new CaptchaVerdictModel(false, "Captcha parse error", null);
|
|
}
|
|
|
|
if (!data.success)
|
|
{
|
|
_log.LogWarning("Captcha verification failed for IP {Ip}. Score={Score}",
|
|
userIp ?? "unknown", data.score);
|
|
return new CaptchaVerdictModel(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 CaptchaVerdictModel(false, "Captcha score too low", score);
|
|
}
|
|
|
|
// Optional strictness (usually v3): action/hostname checks
|
|
var actionToCheck = !string.IsNullOrWhiteSpace(expectedAction) ? expectedAction : _opt.ExpectedAction;
|
|
if (!string.IsNullOrWhiteSpace(actionToCheck) &&
|
|
!string.Equals(actionToCheck, data.action, StringComparison.Ordinal))
|
|
{
|
|
_log.LogWarning("Captcha action mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}",
|
|
actionToCheck, data.action, userIp ?? "unknown");
|
|
return new CaptchaVerdictModel(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 CaptchaVerdictModel(false, "Captcha hostname mismatch", data.score);
|
|
}
|
|
|
|
_log.LogInformation("Captcha verified successfully for IP {Ip}. Score={Score}",
|
|
userIp ?? "unknown", data.score);
|
|
return new CaptchaVerdictModel(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; }
|
|
}
|
|
}
|
|
}
|