Skip to content

Commit

Permalink
RavenDB-19951 Introduce TOTP support for browser generated requests
Browse files Browse the repository at this point in the history
  • Loading branch information
ml054 committed Dec 5, 2023
1 parent 7c946b1 commit 5ec682d
Show file tree
Hide file tree
Showing 14 changed files with 311 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ public class PutClientCertificateOperation : IServerOperation
private readonly SecurityClearance _clearance;

public string TwoFactorAuthenticationKey { get; set; }
public TimeSpan TwoFactorAuthenticationValidityPeriod { get; set; } = TimeSpan.FromHours(2);

public PutClientCertificateOperation(string name, X509Certificate2 certificate, Dictionary<string, DatabaseAccess> permissions, SecurityClearance clearance)
{
Expand All @@ -31,7 +30,7 @@ public PutClientCertificateOperation(string name, X509Certificate2 certificate,

public RavenCommand GetCommand(DocumentConventions conventions, JsonOperationContext context)
{
return new PutClientCertificateCommand(_name, _certificate, _permissions, _clearance, TwoFactorAuthenticationKey, TwoFactorAuthenticationValidityPeriod);
return new PutClientCertificateCommand(_name, _certificate, _permissions, _clearance, TwoFactorAuthenticationKey);
}

private class PutClientCertificateCommand : RavenCommand, IRaftCommand
Expand All @@ -41,17 +40,15 @@ private class PutClientCertificateCommand : RavenCommand, IRaftCommand
private readonly string _name;
private readonly SecurityClearance _clearance;
private readonly string _twoFactorAuthenticationKey;
private readonly TimeSpan _twoFactorAuthenticationValidityPeriod;

public PutClientCertificateCommand(string name, X509Certificate2 certificate, Dictionary<string, DatabaseAccess> permissions, SecurityClearance clearance,
string twoFactorAuthenticationKey, TimeSpan twoFactorAuthenticationValidityPeriod)
string twoFactorAuthenticationKey)
{
_certificate = certificate ?? throw new ArgumentNullException(nameof(certificate));
_permissions = permissions ?? throw new ArgumentNullException(nameof(permissions));
_name = name;
_clearance = clearance;
_twoFactorAuthenticationKey = twoFactorAuthenticationKey;
_twoFactorAuthenticationValidityPeriod = twoFactorAuthenticationValidityPeriod;
}

public override bool IsReadRequest => false;
Expand Down Expand Up @@ -81,9 +78,6 @@ public override HttpRequestMessage CreateRequest(JsonOperationContext ctx, Serve
writer.WritePropertyName(nameof(TwoFactorAuthenticationKey));
writer.WriteString(_twoFactorAuthenticationKey);
writer.WriteComma();
writer.WritePropertyName(nameof(TwoFactorAuthenticationValidityPeriod));
writer.WriteString(_twoFactorAuthenticationValidityPeriod.ToString("c", CultureInfo.InvariantCulture));
writer.WriteComma();
}
writer.WritePropertyName(nameof(CertificateDefinition.Permissions));
writer.WriteStartObject();
Expand Down
5 changes: 0 additions & 5 deletions src/Raven.Server/Routing/RequestRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -501,11 +501,6 @@ public static async ValueTask UnlikelyFailAuthorizationAsync(HttpContext context
statusCode = (int)HttpStatusCode.PreconditionRequired;
message = $"The supplied client certificate '{name}' requires two factor authorization to be valid. Please POST the relevant TOTP value to /authentication/2fa";
}
else if (feature.Status == RavenServer.AuthenticationStatus.TwoFactorAuthFromInvalidLimit)
{
statusCode = (int)HttpStatusCode.PreconditionRequired;
message = $"The supplied client certificate '{name}' requires two factor authorization and is limited to a specified IP address, but this request came from a different IP address. Please POST the relevant TOTP value to /authentication/2fa to register this IP address";
}
else
{
message = "Access to the server was denied.";
Expand Down
3 changes: 0 additions & 3 deletions src/Raven.Server/ServerWide/Commands/PutCertificateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ public class PutCertificateCommand : PutValueCommand<CertificateDefinition>
{
public string PublicKeyPinningHash;
public string TwoFactorAuthenticationKey;
public TimeSpan TwoFactorAuthenticationValidityPeriod = TimeSpan.FromHours(2);

public PutCertificateCommand()
{
Expand Down Expand Up @@ -47,7 +46,6 @@ public override DynamicJsonValue ToJson(JsonOperationContext context)
djv[nameof(Name)] = Name;
djv[nameof(Value)] = Value?.ToJson();
djv[nameof(TwoFactorAuthenticationKey)] = TwoFactorAuthenticationKey;
djv[nameof(TwoFactorAuthenticationValidityPeriod)] = TwoFactorAuthenticationValidityPeriod;
djv[nameof(PublicKeyPinningHash)] = PublicKeyPinningHash;
return djv;
}
Expand All @@ -60,7 +58,6 @@ public override DynamicJsonValue ValueToJson()
if (string.IsNullOrEmpty(TwoFactorAuthenticationKey) == false)
{
djv[nameof(TwoFactorAuthenticationKey)] = TwoFactorAuthenticationKey;
djv[nameof(TwoFactorAuthenticationValidityPeriod)] = TwoFactorAuthenticationValidityPeriod;
}
return djv;
}
Expand Down
57 changes: 48 additions & 9 deletions src/Raven.Server/Web/Authentication/AdminCertificatesHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Raven.Client.Exceptions.Security;
using Raven.Client.Http;
using Raven.Client.ServerWide;
using Raven.Client.ServerWide.Operations;
using Raven.Client.ServerWide.Operations.Certificates;
using Raven.Client.Util;
using Raven.Server.Commercial;
Expand All @@ -30,6 +31,8 @@
using Raven.Server.ServerWide.Context;
using Raven.Server.Utils;
using Sparrow.Json;
using Sparrow.Json.Parsing;
using Sparrow.Json.Sync;
using Sparrow.Logging;
using Sparrow.Server.Platform.Posix;
using Sparrow.Utils;
Expand All @@ -38,6 +41,10 @@ namespace Raven.Server.Web.Authentication
{
public class AdminCertificatesHandler : ServerRequestHandler
{

public const string HasTwoFactorFieldName = "HasTwoFactor";
public const string TwoFactorExpirationDate = "TwoFactorExpirationDate";

[RavenAction("/admin/certificates/2fa/generate", "GET", AuthorizationStatus.Operator)]
public async Task GenerateSecret()
{
Expand Down Expand Up @@ -589,13 +596,19 @@ private void GetAllRegisteredCertificates(
{
var hasTwoFactor = certificate.TryGet(nameof(PutCertificateCommand.TwoFactorAuthenticationKey), out string _);

defJson["HasTwoFactor"] = hasTwoFactor;
defJson[HasTwoFactorFieldName] = hasTwoFactor;
}

certificateRef = context.ReadObject(defJson, "Client/Certificate/Definition");

certificate.Dispose();
}
else
{
// make sure we don't leak fields like TwoFactorAuthenticationKey
certificateRef = context.ReadObject(def.ToJson(false), "Client/Certificate/Definition");
}

certificates.TryAdd(thumbprint, certificateRef);
}
}
Expand Down Expand Up @@ -666,13 +679,21 @@ public async Task WhoAmI()
PublicKeyPinningHash = clientCert.GetPublicKeyPinningHash()
};
certificate = ctx.ReadObject(wellKnownCertDef.ToJson(), "WellKnown/Certificate/Definition");

}
}

await using (var writer = new AsyncBlittableJsonTextWriter(ctx, ResponseBodyStream()))
{
writer.WriteObject(certificate);
var certificateDefinition = JsonDeserializationServer.CertificateDefinition(certificate);
var certificateDJV = certificateDefinition.ToJson(false);

var hasTwoFactor = certificate.TryGet(nameof(PutCertificateCommand.TwoFactorAuthenticationKey), out string _);
certificateDJV[HasTwoFactorFieldName] = hasTwoFactor;

var feature = HttpContext.Features.Get<IHttpAuthenticationFeature>() as RavenServer.AuthenticateConnection;
certificateDJV[TwoFactorExpirationDate] = feature?.TwoFactorAuthRegistration?.Expiry;

ctx.Write(writer, certificateDJV);
}
}
}
Expand All @@ -682,24 +703,40 @@ public async Task Edit()
{
await ServerStore.EnsureNotPassiveAsync();

var deleteTwoFactorConfiguration = GetBoolValueQueryString("deleteTwoFactorConfiguration", required: false) ?? false;

var feature = HttpContext.Features.Get<IHttpAuthenticationFeature>() as RavenServer.AuthenticateConnection;
var clientCert = feature?.Certificate;

using (ServerStore.ContextPool.AllocateOperationContext(out TransactionOperationContext ctx))
using (var certificateJson = await ctx.ReadForDiskAsync(RequestBodyStream(), "edit-certificate"))
{
var newCertificate = JsonDeserializationServer.CertificateDefinition(certificateJson);

certificateJson.TryGet(nameof(PutCertificateCommand.TwoFactorAuthenticationKey), out string newTwoFactorAuthenticationKey);

ValidateCertificateDefinition(newCertificate, ServerStore);

CertificateDefinition existingCertificate;
string twoFactorAuthenticationKey;
using (ctx.OpenWriteTransaction())
{
var certificate = ServerStore.Cluster.GetCertificateByThumbprint(ctx, newCertificate.Thumbprint);
if (certificate == null)
var existingCertificateJson = ServerStore.Cluster.GetCertificateByThumbprint(ctx, newCertificate.Thumbprint);
if (existingCertificateJson == null)
throw new InvalidOperationException($"Cannot edit permissions for certificate with thumbprint '{newCertificate.Thumbprint}'. It doesn't exist in the cluster.");

existingCertificate = JsonDeserializationServer.CertificateDefinition(certificate);

existingCertificateJson.TryGet(nameof(PutCertificateCommand.TwoFactorAuthenticationKey), out twoFactorAuthenticationKey);

if (deleteTwoFactorConfiguration)
{
twoFactorAuthenticationKey = null;
}
else if (string.IsNullOrEmpty(newTwoFactorAuthenticationKey) == false)
{
twoFactorAuthenticationKey = newTwoFactorAuthenticationKey;
}

existingCertificate = JsonDeserializationServer.CertificateDefinition(existingCertificateJson);

if ((existingCertificate.SecurityClearance == SecurityClearance.ClusterAdmin || existingCertificate.SecurityClearance == SecurityClearance.ClusterNode) && IsClusterAdmin() == false)
{
Expand All @@ -716,7 +753,7 @@ public async Task Edit()
ServerStore.Cluster.DeleteLocalState(ctx, newCertificate.Thumbprint);
}

var putResult = await ServerStore.PutValueInClusterAsync(new PutCertificateCommand(newCertificate.Thumbprint,
var cmd = new PutCertificateCommand(newCertificate.Thumbprint,
new CertificateDefinition
{
Name = newCertificate.Name,
Expand All @@ -727,7 +764,9 @@ public async Task Edit()
PublicKeyPinningHash = existingCertificate.PublicKeyPinningHash,
NotAfter = existingCertificate.NotAfter,
NotBefore = existingCertificate.NotBefore
}, GetRaftRequestIdFromQuery()));
}, GetRaftRequestIdFromQuery()) {TwoFactorAuthenticationKey = twoFactorAuthenticationKey};

var putResult = await ServerStore.PutValueInClusterAsync(cmd);
await ServerStore.Cluster.WaitForIndexNotification(putResult.Index);

NoContentStatus(HttpStatusCode.Created);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ public Task LogoutTotp()

[RavenAction("/authentication/2fa", "POST", AuthorizationStatus.UnauthenticatedClients)]
public async Task ValidateTotp()
{
{
using var _ = ServerStore.ContextPool.AllocateOperationContext(out TransactionOperationContext ctx);
ctx.OpenReadTransaction();

bool hasLimits = GetBoolValueQueryString("hasLimits", false) ?? true; //tODO: default to false?
bool hasLimits = GetBoolValueQueryString("hasLimits", false) ?? true;

var clientCert = GetCurrentCertificate();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import endpoints = require("endpoints");

class getClientCertificateCommand extends commandBase {

execute(): JQueryPromise<Raven.Client.ServerWide.Operations.Certificates.CertificateDefinition> {
execute(): JQueryPromise<Raven.Client.ServerWide.Operations.Certificates.CertificateDefinition & { HasTwoFactor: boolean; TwoFactorExpirationDate: string; }> {
const url = endpoints.global.adminCertificates.certificatesWhoami;

return this.query<Raven.Client.ServerWide.Operations.Certificates.CertificateDefinition>(url, null)
return this.query<Raven.Client.ServerWide.Operations.Certificates.CertificateDefinition & { HasTwoFactor: boolean; TwoFactorExpirationDate: string; }>(url, null)
.fail((response: JQueryXHR) => this.reportError("Failed to get client certificate from server", response.responseText, response.statusText));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ class updateCertificatePermissionsCommand extends commandBase {
}

execute(): JQueryPromise<void> {
const url = endpoints.global.adminCertificates.adminCertificatesEdit;
const deleteExistingConfiguration = this.model.mode() === "editExisting" && this.model.twoFactorActionOnEdit() === "delete";

const url = endpoints.global.adminCertificates.adminCertificatesEdit + this.urlEncodeArgs({
deleteTwoFactorConfiguration: deleteExistingConfiguration ? true : undefined
});

const payload = this.model.toUpdatePermissionsDto();
return this.post<void>(url, JSON.stringify(payload), null, { dataType: undefined })
Expand Down
30 changes: 25 additions & 5 deletions src/Raven.Studio/typescript/common/shell/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import appUrl = require("common/appUrl");
import license = require("models/auth/licenseModel");
import forgotTwoFactorSecretCommand from "commands/auth/forgotTwoFactorSecretCommand";
import endpoints = require("endpoints");
import moment = require("moment");
import clientCertificateModel from "models/auth/clientCertificateModel";
import viewHelpers from "common/helpers/view/viewHelpers";

class footerStats {
countOfDocuments = ko.observable<number>();
Expand All @@ -32,10 +35,23 @@ class footer {
urlForStaleIndexes = ko.pureComputed(() => appUrl.forIndexes(this.db(), null, true));
urlForIndexingErrors = ko.pureComputed(() => appUrl.forIndexErrors(this.db()));
urlForAbout = appUrl.forAbout();

twoFactorSessionExpiration: KnockoutComputed<moment.Moment>;

licenseClass = license.licenseCssClass;
supportClass = license.supportCssClass;

constructor() {
this.twoFactorSessionExpiration = ko.pureComputed(() => {
const certInfo = clientCertificateModel.certificateInfo();
if (certInfo.HasTwoFactor) {
return moment.utc(certInfo.TwoFactorExpirationDate);
} else {
return null;
}
});
}

forDatabase(db: database) {
this.db(db);
this.stats(null);
Expand Down Expand Up @@ -63,14 +79,18 @@ class footer {
this.stats(newStats);
})
.always(() => this.spinners.loading(false));

}

logout() {
new forgotTwoFactorSecretCommand()
.execute()
.done(() => {
window.location.href = location.origin + endpoints.global.studio._2faIndex_html;
viewHelpers.confirmationMessage("Log out", "Are you sure you want to log out?")
.done(result => {
if (result.can) {
new forgotTwoFactorSecretCommand()
.execute()
.done(() => {
window.location.href = location.origin + endpoints.global.studio._2faIndex_html;
});
}
});
}

Expand Down
Loading

0 comments on commit 5ec682d

Please sign in to comment.