Removed the captcha. (#5816)

This commit removes the Captcha support, and replaces with a copy-paste-able safeguard in the UI.
This commit is contained in:
Kenneth Skovhede
2024-12-28 20:03:52 +01:00
committed by GitHub
parent d940af1f64
commit 7f40ec46db
15 changed files with 81 additions and 406 deletions
+1 -1
View File
@@ -79,7 +79,6 @@
<script type="text/javascript" src="scripts/services/DialogService.js?v=2.0.0.7"></script>
<script type="text/javascript" src="scripts/services/LogService.js?v=2.0.0.7"></script>
<script type="text/javascript" src="scripts/services/EditBackupService.js?v=2.0.0.7"></script>
<script type="text/javascript" src="scripts/services/CaptchaService.js?v=2.0.0.7"></script>
<script type="text/javascript" src="scripts/controllers/AppController.js?v=2.0.0.7"></script>
<script type="text/javascript" src="scripts/controllers/AboutController.js?v=2.0.0.7"></script>
@@ -104,6 +103,7 @@
<script type="text/javascript" src="scripts/controllers/CommandlineController.js?v=2.0.0.7"></script>
<script type="text/javascript" src="scripts/controllers/ThrottleController.js?v=2.0.0.7"></script>
<script type="text/javascript" src="scripts/controllers/ChangePasswordController.js?v=2.0.0.7"></script>
<script type="text/javascript" src="scripts/controllers/ConfirmDeleteController.js?v=2.0.0.7"></script>
<script type="text/javascript" src="scripts/filters/timeremaining.js?v=2.0.0.7"></script>
<script type="text/javascript" src="scripts/filters/highlight.js?v=2.0.0.7"></script>
@@ -2222,6 +2222,29 @@ div.modal-dialog {
}
}
.confirmdelete {
div.overview {
width: 420px;
}
div.word {
font-weight: bold;
padding: 12px;
border: 1px solid black;
margin-top: 12px;
margin-bottom: 12px;
background-color: #e0e0e0;
width: 420px;
}
span.backupname {
font-weight: bold;
}
input.typedword {
float: none;
}
}
/* Progress bar styles from Bootstrap */
.progress-bar-striped {
background-image: linear-gradient(45deg, rgb(255 255 255 / 15%) 25%, transparent 25%, transparent 50%, rgb(255 255 255 / 15%) 50%, rgb(255 255 255 / 15%) 75%, transparent 75%, transparent);
@@ -1,23 +0,0 @@
backupApp.controller('CaptchaController', function($scope, CaptchaService, DialogService, AppService, AppUtils) {
var entry = $scope.entry = CaptchaService.active;
function refreshChallenge() {
entry.imageurl = null;
AppService.postJson('/captcha', { 'target': entry.target}).then(function(resp) {
entry.token = resp.data.Token;
entry.expectedAnswer = resp.data.Answer;
entry.noVisualChallenge = resp.data.NoVisualChallenge;
entry.imageurl = AppService.apiurl + '/captcha/' + entry.token;
}, function(err) {
DialogService.dismissCurrent();
AppUtils.connectionError(err);
});
};
if (entry.token == null)
refreshChallenge();
$scope.reload = refreshChallenge;
});
@@ -0,0 +1,7 @@
backupApp.controller('ConfirmDeleteController', function($scope, $location, gettextCatalog) {
$scope.selection = $scope.$parent.state.CurrentItem;
if ($scope.selection.requiredword == '')
$scope.selection.requiredword = 'delete all files';
$scope.selection.typedword = '';
});
@@ -1,4 +1,4 @@
backupApp.controller('DeleteController', function($scope, $routeParams, $location, gettextCatalog, CaptchaService, DialogService, ServerStatus, SystemInfo, BackupList, AppService, AppUtils) {
backupApp.controller('DeleteController', function($scope, $routeParams, $location, gettextCatalog, DialogService, ServerStatus, SystemInfo, BackupList, AppService, AppUtils) {
$scope.BackupID = $routeParams.backupid;
$scope.DeleteLocalDatabase = true;
$scope.DeleteRemoteFiles = false;
@@ -61,16 +61,38 @@ backupApp.controller('DeleteController', function($scope, $routeParams, $locatio
$scope.doDelete = function() {
if ($scope.DeleteRemoteFiles)
{
CaptchaService.Authorize(
const dlg = DialogService.htmlDialog(
gettextCatalog.getString('Confirm delete'),
gettextCatalog.getString('To confirm you want to delete all remote files for "{{name}}", please enter the word you see below', {name: $scope.Backup.Backup.Name}),
'DELETE /backup/' + $scope.BackupID,
function(token, answer) {
AppService.delete('/backup/' + $scope.BackupID + '?delete-local-db=' + $scope.DeleteLocalDatabase + '&delete-remote-files=' + $scope.DeleteRemoteFiles + '&captcha-token=' + token + '&captcha-answer=' + answer).then(function() {
$location.path('/');
}, AppUtils.connectionError);
'templates/confirmdelete.html',
[gettextCatalog.getString('Cancel'), gettextCatalog.getString('Delete')],
function(ix) {
if (ix == 1) {
AppService.delete('/backup/' + $scope.BackupID + '?delete-local-db=' + $scope.DeleteLocalDatabase + '&delete-remote-files=' + $scope.DeleteRemoteFiles).then(function() {
$location.path('/');
}, AppUtils.connectionError);
}
},
null,
function(index, text, cur) {
// Allow the user to cancel
if (index != 1)
return true;
// Compare case insensitive
if (cur.requiredword?.toLowerCase() != cur.typedword?.toLowerCase() || cur.typedword == '')
{
alert("Please enter the required phrase to confirm the deletion");
return false;
}
return true;
}
);
// Set the dialog data
dlg.backupId = $scope.BackupID;
dlg.backupname = $scope.Backup.Backup.Name;
dlg.requiredword = ('delete ' + $scope.Backup.Backup.Name).toLowerCase();
}
else
{
@@ -1,55 +0,0 @@
backupApp.service('CaptchaService', function(DialogService, AppService, AppUtils, gettextCatalog) {
this.active = null;
var self = this;
this.Authorize = function(title, message, target, callback) {
var cb = self.active = {
'message': message,
'target': target,
'callback': callback,
'attempts': 0,
'hasfailed': false,
'verifying': false
};
self.attemptSolve = function() {
if (cb.attempts >= 3) {
cb.attempts = 0;
cb.token = null;
}
DialogService.htmlDialog(title, 'templates/captcha.html', [gettextCatalog.getString('Cancel'), gettextCatalog.getString('OK')], function(btn) {
if (btn != 1) {
self.active = null;
return;
}
cb.attempts += 1;
cb.verifying = true;
DialogService.dialog(gettextCatalog.getString('Verifying answer'), gettextCatalog.getString('Verifying …'), [], function() {}, function() {
AppService.postJson('/captcha/' + encodeURIComponent(cb.token), {'answer': cb.answer, 'target': cb.target}).then(function(resp) {
DialogService.dismissCurrent();
self.active = null;
cb.callback(cb.token, cb.answer);
}, function(err) {
DialogService.dismissCurrent();
cb.verifying = false;
cb.hasfailed = true;
if (err.status == 400)
self.attemptSolve();
else
AppUtils.connectionError(err);
});
});
});
};
self.attemptSolve();
};
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
<div class="captcha" ng-controller="CaptchaController">
<div ng-show="entry.imageurl">
<div>{{entry.message}}</div>
<div class="details">
<img ng-hide="entry.noVisualChallenge" src="{{entry.imageurl}}" />
<code class="code" ng-show="entry.noVisualChallenge">{{entry.expectedAnswer}}</code>
<input class="answer" type="text" ng-model="entry.answer" />
<input type="button" value="{{'Reload' | translate}}" ng-click="reload()" />
</div>
<div class="centered-text" ng-show="entry.verifying" translate>Checking …</div>
<div class="centered-text warning" ng-show="!entry.verifying && entry.hasfailed" translate>Incorrect answer, try again</div>
</div>
<div ng-hide="entry.imageurl" translate>Loading …</div>
</div>
@@ -0,0 +1,15 @@
<div ng-controller="ConfirmDeleteController" class="confirmdelete">
<form class="styled">
<div class="overview" translate>
To confirm you want to delete all remote files for
<span class="backupname">"{{selection.backupname}}"</span>, please enter
this phrase:
</div>
<div class="word">{{selection.requiredword}}</div>
<div>
<input type="text" class="typedword" ng-model="selection.typedword" />
</div>
</form>
</div>
@@ -1,30 +0,0 @@
namespace Duplicati.WebserverCore.Abstractions;
/// <summary>
/// A captcha provider
/// </summary>
public interface ICaptchaProvider
{
/// <summary>
/// Check if the captcha is solved
/// </summary>
/// <param name="token">The captcha token</param>
/// <param name="target">The captcha target</param>
/// <param name="answer">The captcha answer</param>
bool SolvedCaptcha(string token, string target, string answer);
/// <summary>
/// Create a captcha
/// </summary>
/// <param name="target">The captcha target</param>
/// <returns>The captcha token and the answer</returns>
(string Token, string? Answer) CreateCaptcha(string target);
/// <summary>
/// Get the captcha image
/// </summary>
/// <param name="token">The captcha token</param>
byte[] GetCaptchaImage(string token);
/// <summary>
/// Gets a value indicating whether the visual captcha is disabled
/// </summary>
bool VisualCaptchaDisabled { get; }
}
@@ -18,9 +18,9 @@ public class BackupPutDelete : IEndpointV1
=> ExecutePut(GetBackup(connection, id), connection, input))
.RequireAuthorization();
group.MapDelete("/backup/{id}", ([FromServices] Connection connection, [FromServices] IWorkerThreadsManager workerThreadsManager, [FromServices] ICaptchaProvider captchaProvider, [FromServices] LiveControls liveControls, [FromServices] IHttpContextAccessor httpContextAccessor, [FromRoute] string id, [FromQuery(Name = "delete-remote-files")] bool? delete_remote_files, [FromQuery(Name = "delete-local-db")] bool? delete_local_db, [FromQuery(Name = "captcha-token")] string? captcha_token, [FromQuery(Name = "captcha-answer")] string? captcha_answer, [FromQuery] bool? force) =>
group.MapDelete("/backup/{id}", ([FromServices] Connection connection, [FromServices] IWorkerThreadsManager workerThreadsManager, [FromServices] LiveControls liveControls, [FromServices] IHttpContextAccessor httpContextAccessor, [FromRoute] string id, [FromQuery(Name = "delete-remote-files")] bool? delete_remote_files, [FromQuery(Name = "delete-local-db")] bool? delete_local_db, [FromQuery] bool? force) =>
{
var res = ExecuteDelete(GetBackup(connection, id), workerThreadsManager, captchaProvider, liveControls, delete_remote_files ?? false, delete_local_db, captcha_token, captcha_answer, force ?? false);
var res = ExecuteDelete(GetBackup(connection, id), workerThreadsManager, liveControls, delete_remote_files ?? false, delete_local_db, force ?? false);
if (res.Status != "OK" && httpContextAccessor.HttpContext != null)
httpContextAccessor.HttpContext.Response.StatusCode = 500;
return res;
@@ -112,17 +112,8 @@ public class BackupPutDelete : IEndpointV1
}
}
private static Dto.DeleteBackupOutputDto ExecuteDelete(IBackup backup, IWorkerThreadsManager workerThreadsManager, ICaptchaProvider captchaProvider, LiveControls liveControls, bool delete_remote_files, bool? delete_local_db, string? captcha_token, string? captcha_answer, bool force)
private static Dto.DeleteBackupOutputDto ExecuteDelete(IBackup backup, IWorkerThreadsManager workerThreadsManager, LiveControls liveControls, bool delete_remote_files, bool? delete_local_db, bool force)
{
if (delete_remote_files)
{
if (string.IsNullOrWhiteSpace(captcha_token) || string.IsNullOrWhiteSpace(captcha_answer))
throw new UnauthorizedException("Missing captcha");
if (!captchaProvider.SolvedCaptcha(captcha_token, "DELETE /backup/" + backup.ID, captcha_answer))
throw new ForbiddenException("Invalid captcha");
}
if (workerThreadsManager.WorkerThread!.Active)
{
try
@@ -1,40 +0,0 @@
using Duplicati.WebserverCore.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace Duplicati.WebserverCore.Endpoints.V1;
public class Captcha : IEndpointV1
{
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/captcha/{token}", ([FromServices] ICaptchaProvider captchaProvider, [FromServices] IHttpContextAccessor httpContextAccessor, [FromRoute] string token, CancellationToken ct) =>
{
var jpeg = captchaProvider.GetCaptchaImage(token);
var response = httpContextAccessor.HttpContext!.Response;
response.ContentLength = jpeg.Length;
response.ContentType = "image/jpeg";
response.Body.WriteAsync(jpeg, ct);
});
group.MapPost("/captcha", ([FromServices] ICaptchaProvider captchaProvider, [FromBody] Dto.SolveCaptchaInputDto input) =>
{
var (token, answer) = captchaProvider.CreateCaptcha(input.target);
return new Dto.GenerateCaptchaOutput(
token,
answer,
captchaProvider.VisualCaptchaDisabled
);
})
.RequireAuthorization();
group.MapPost("/captcha/{token}", ([FromServices] ICaptchaProvider captchaProvider, [FromRoute] string token, [FromBody] Dto.SolveCaptchaInputDto input) =>
{
if (captchaProvider.SolvedCaptcha(token, input.target, input.answer ?? ""))
return new { success = true };
return new { success = false };
})
.RequireAuthorization();
}
}
@@ -41,7 +41,6 @@ public static class ServiceCollectionsExtensions
.AddSingleton<IScheduler, SchedulerService>()
.AddSingleton<IWebsocketAccessor, WebsocketAccessor>()
.AddTransient<ILanguageService, LanguageService>()
.AddSingleton<ICaptchaProvider, CaptchaService>()
.AddSingleton<ICommandlineRunService, CommandlineRunService>()
.AddTransient<IJWTTokenProvider, JWTTokenProvider>()
.AddTransient<ITokenFamilyStore, TokenFamilyStore>()
@@ -1,218 +0,0 @@
using Duplicati.WebserverCore.Abstractions;
using Duplicati.WebserverCore.Exceptions;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Drawing.Processing;
namespace Duplicati.WebserverCore.Services;
public class CaptchaService : ICaptchaProvider
{
private readonly object m_lock = new();
private readonly Dictionary<string, CaptchaEntry> m_captchas = [];
private readonly bool m_disableVisualCaptcha;
public CaptchaService(ISettingsService settings)
{
m_disableVisualCaptcha = settings.GetSettings().DisableVisualCaptcha;
}
/// <summary>
/// List of possible system fonts, ordered by preference
/// </summary>
private static readonly Dictionary<string, int> FontNamePreference = new string[] {
"Arial", "Verdana", "FreeSans", "Tahoma", "Helvetica", "Times New Roman", "Courier New", "Andale Mono"
}
.Select((x, i) => new { Key = x, Value = i })
.ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase);
private class CaptchaEntry
{
public readonly string Answer;
public readonly string Target;
public int Attempts;
public readonly DateTime Expires;
public CaptchaEntry(string answer, string target)
{
Answer = answer;
Target = target;
Attempts = 4;
Expires = DateTime.Now.AddMinutes(2);
}
}
public (string Token, string? Answer) CreateCaptcha(string target)
{
var answer = CaptchaUtil.CreateRandomAnswer(minlength: 6, maxlength: 6);
var nonce = Guid.NewGuid().ToString();
string token;
using (var ms = new MemoryStream())
{
var bytes = System.Text.Encoding.UTF8.GetBytes(answer + nonce);
ms.Write(bytes, 0, bytes.Length);
ms.Position = 0;
using (var hasher = Library.Utility.HashFactory.CreateHasher(Library.Utility.HashFactory.SHA256))
token = Library.Utility.Utility.Base64PlainToBase64Url(Convert.ToBase64String(hasher.ComputeHash(ms)));
}
lock (m_lock)
{
var expired = m_captchas.Where(x => x.Value.Expires < DateTime.Now).Select(x => x.Key).ToArray();
foreach (var x in expired)
m_captchas.Remove(x);
if (m_captchas.Count > 3)
throw new ServiceUnavailableException("Too many captchas, wait 2 minutes and try again");
m_captchas[token] = new CaptchaEntry(answer, target);
}
return (token, m_disableVisualCaptcha ? answer : null);
}
public byte[] GetCaptchaImage(string token)
{
if (m_disableVisualCaptcha)
throw new NotFoundException("No such entry");
string? answer = null;
lock (m_lock)
{
m_captchas.TryGetValue(token, out var tp);
if (tp != null && tp.Expires > DateTime.Now)
answer = tp.Answer;
}
if (string.IsNullOrWhiteSpace(answer))
throw new NotFoundException("No such entry");
using var image = CaptchaUtil.CreateCaptcha(answer);
using var ms = new MemoryStream();
image.SaveAsJpeg(ms);
return ms.ToArray();
}
public bool SolvedCaptcha(string token, string target, string answer)
{
lock (m_lock)
{
m_captchas.TryGetValue(token ?? string.Empty, out var tp);
if (tp == null)
return false;
if (tp.Attempts > 0)
tp.Attempts--;
return tp.Attempts >= 0 && string.Equals(tp.Answer, answer, StringComparison.OrdinalIgnoreCase) && tp.Target == target && tp.Expires >= DateTime.Now;
}
}
public bool VisualCaptchaDisabled => m_disableVisualCaptcha;
public static class CaptchaUtil
{
/// <summary>
/// A lookup string with characters to use
/// </summary>
private static readonly string DEFAULT_CHARS = "ACDEFGHJKLMNPQRTUVWXY34679";
/// <summary>
/// Approximate the size in pixels of text drawn at the given fontsize
/// </summary>
/// <param name="text">The text to measure</param>
/// <param name="font">The font to use</param>
private static int ApproxTextWidth(string text, Font font)
=> (int)TextMeasurer.MeasureSize(text, new TextOptions(font) { KerningMode = KerningMode.Standard }).Width;
/// <summary>
/// Creates a random answer.
/// </summary>
/// <returns>The random answer.</returns>
/// <param name="allowedchars">The list of allowed chars, supply a character multiple times to change frequency.</param>
/// <param name="minlength">The minimum answer length.</param>
/// <param name="maxlength">The maximum answer length.</param>
public static string CreateRandomAnswer(string? allowedchars = null, int minlength = 10, int maxlength = 12)
{
allowedchars = allowedchars ?? DEFAULT_CHARS;
var rnd = new Random();
var len = rnd.Next(Math.Min(minlength, maxlength), Math.Max(minlength, maxlength) + 1);
if (len <= 0)
throw new ArgumentException($"The values {minlength} and {maxlength} gave a final length of {len} and it must be greater than 0");
return new string(Enumerable.Range(0, len).Select(x => allowedchars[rnd.Next(0, allowedchars.Length)]).ToArray());
}
/// <summary>
/// Creates a captcha image.
/// </summary>
/// <returns>The captcha image.</returns>
/// <param name="answer">The captcha solution string.</param>
/// <param name="size">The size of the image, omit to get a size based on the string.</param>
/// <param name="fontsize">The size of the font used to create the captcha, in pixels.</param>
public static Image<Rgba32> CreateCaptcha(string answer, Size size = default(Size), float fontsize = 40)
{
var fontFamily = SystemFonts.Collection.Families.OrderBy(x =>
{
if (FontNamePreference.TryGetValue(x.Name, out var val))
return val;
return int.MaxValue;
}).FirstOrDefault();
if (string.IsNullOrWhiteSpace(fontFamily.Name))
throw new Exception("No usable font found");
var font = fontFamily.CreateFont(fontsize);
var text_width = ApproxTextWidth(answer, font);
if (size.Width == 0 || size.Height == 0)
size = new Size((int)(text_width * 1.2), (int)(fontsize * 1.2));
var image = new Image<Rgba32>(size.Width, size.Height);
var rnd = new Random();
var stray_x = (int)fontsize / 2;
var stray_y = size.Height / 4;
var ans_stray_x = (int)fontsize / 3;
var ans_stray_y = size.Height / 6;
image.Mutate(ctx =>
{
ctx.Fill(Color.White);
// Apply a background string to make it hard to do OCR
foreach (var color in new[] { Color.Yellow, Color.LightGreen, Color.GreenYellow })
{
var backgroundFont = font;
ctx.DrawText(CreateRandomAnswer(minlength: answer.Length, maxlength: answer.Length), backgroundFont, color, new PointF(rnd.Next(-stray_x, stray_x), rnd.Next(-stray_y, stray_y)));
}
var spacing = (size.Width / (int)fontsize) + rnd.Next(0, stray_x);
// Create vertical background lines
for (var i = rnd.Next(0, stray_x); i < size.Width; i += spacing)
{
var color = Color.FromRgb((byte)rnd.Next(256), (byte)rnd.Next(256), (byte)rnd.Next(256));
ctx.DrawLine(color, 1, new PointF(i + rnd.Next(-stray_x, stray_x), rnd.Next(0, stray_y)), new PointF(i + rnd.Next(-stray_x, stray_x), size.Height - rnd.Next(0, stray_y)));
}
spacing = (size.Height / (int)fontsize) + rnd.Next(0, stray_y);
// Create horizontal background lines
for (var i = rnd.Next(0, stray_y); i < size.Height; i += spacing)
{
var color = Color.FromRgb((byte)rnd.Next(256), (byte)rnd.Next(256), (byte)rnd.Next(256));
ctx.DrawLine(color, 1, new PointF(rnd.Next(0, stray_x), i + rnd.Next(-stray_y, stray_y)), new PointF(size.Width - rnd.Next(0, stray_x), i + rnd.Next(-stray_y, stray_y)));
}
// Draw the actual answer
var answerColor = Color.FromRgb((byte)rnd.Next(256), (byte)rnd.Next(256), (byte)rnd.Next(256));
ctx.DrawText(answer, font, answerColor, new PointF(((size.Width - text_width) / 2) + rnd.Next(-ans_stray_x, ans_stray_x), ((size.Height - fontsize) / 2) + rnd.Next(-ans_stray_y, ans_stray_y)));
});
return image;
}
}
}