Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,29 @@ making the database authentication method explicit, so password and OAuth access
Authentication is applied to all HTTP endpoints by default, except `/-/liveness`, `/-/readiness`, static web
content, and redirects.

### Management UI sessions

Browser sign-in uses ASP.NET Core cookie authentication with a protected session identifier. Passwords are
used only when signing in; they are not retained in the browser or the session store. OAuth access tokens
remain in the node's memory and are validated on subsequent requests. Sessions expire after 15 minutes
without automatic renewal. Signing out revokes the server-side ticket, including copies of its cookie.
Existing credential cookies are discarded and require a new sign-in after upgrading.

Browser sign-in requires HTTPS to the node, including when an ingress or reverse proxy terminates public
TLS. Configure TLS on the upstream connection as well. `DisableTls` does not enable browser sessions over
HTTP. Session cookies are Secure, HttpOnly, host-only, and SameSite=Lax. Cookie authentication is limited
to `/ui`; gRPC clients continue to supply their own credentials. UI mutations require antiforgery tokens.

Sessions are held in a bounded, node-local memory store. A node restart, store eviction, or connection to
a different node requires signing in again. Use a node-specific management address or session affinity
at the ingress. Sharing Data Protection keys alone does not share the session store.

Each password session is checked against the latest local user record on every request. Password changes,
role changes, disabling, or deleting the account invalidate its previous sessions as those changes replicate
to each node. A node that cannot validate the account rejects the session. Forwarded writes are revalidated
by the leader over the authenticated TLS connection between nodes. During a rolling upgrade, use the
leader's UI for writes until all nodes support session forwarding.

### Authentication methods

Use `Auth:Methods` to choose the authentication methods enabled by the node. If `Auth:Methods` is not set, the
Expand Down
20 changes: 20 additions & 0 deletions proto.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2343,6 +2343,26 @@
"id": 4,
"name": "anonymous",
"type": "google.protobuf.Empty"
},
{
"id": 5,
"name": "local_session",
"type": "LocalSession"
}
]
},
{
"name": "LocalSession",
"fields": [
{
"id": 1,
"name": "username",
"type": "string"
},
{
"id": 2,
"name": "user_event_id",
"type": "event_store.client.UUID"
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ else
}

<form class="mt-5 grid gap-4 rounded-[1.5rem] border border-es-ink/10 bg-white/70 p-5 md:grid-cols-5" method="post" action="/ui/operations/scavenge/start" data-admin-command data-confirm="Start a database scavenge?">
<Microsoft.AspNetCore.Components.Forms.AntiforgeryToken />
<label class="text-sm font-bold text-es-muted">
Start chunk
<input class="mt-2 w-full rounded-2xl border border-es-ink/10 bg-white px-3 py-2 text-es-ink outline-none focus:border-es-green focus:ring-4 focus:ring-es-green/15" name="startFromChunk" type="number" min="0" value="0" disabled="@(!Page.Scavenge.SupportsScavenge)" />
Expand Down Expand Up @@ -162,6 +163,7 @@ else
@if (item.CanStop)
{
<form method="post" action="/ui/operations/scavenge/stop" data-admin-command data-confirm="Stop this scavenge?">
<Microsoft.AspNetCore.Components.Forms.AntiforgeryToken />
<input name="scavengeId" type="hidden" value="@item.ScavengeId" />
<button class="rounded-full border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-black text-red-800 transition hover:bg-red-100" type="submit">Stop</button>
</form>
Expand Down Expand Up @@ -190,6 +192,7 @@ else
<p class="text-sm font-black uppercase tracking-[0.22em] text-es-green">Node priority</p>
<p class="mt-3 text-sm leading-6 text-es-muted">Adjust election priority deliberately during operations work.</p>
<form class="mt-5 flex gap-2" method="post" action="/ui/operations/set-priority" data-admin-command data-confirm="Set node priority?">
<Microsoft.AspNetCore.Components.Forms.AntiforgeryToken />
<input class="min-w-0 flex-1 rounded-2xl border border-es-ink/10 bg-white px-3 py-2 text-es-ink outline-none focus:border-es-green focus:ring-4 focus:ring-es-green/15" name="priority" type="number" value="0" />
<button class="rounded-2xl bg-es-ink px-4 py-2 text-sm font-black text-white transition hover:bg-es-green" type="submit">Set</button>
</form>
Expand All @@ -204,6 +207,7 @@ else
<p class="mt-3 max-w-3xl text-sm leading-6">This requests process shutdown and should only be used when the operator intends to stop the node.</p>
</div>
<form method="post" action="/ui/operations/shutdown" data-admin-command data-admin-no-refresh="true" data-confirm="Shutdown this node?">
<Microsoft.AspNetCore.Components.Forms.AntiforgeryToken />
<button class="rounded-2xl bg-red-700 px-5 py-3 text-sm font-black text-white shadow-lg shadow-red-900/15 transition hover:bg-red-800" type="submit">Shutdown</button>
</form>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ else
@if (Page.CanStop)
{
<form method="post" action="/ui/operations/scavenge/stop" data-admin-command data-confirm="Stop this scavenge?">
<Microsoft.AspNetCore.Components.Forms.AntiforgeryToken />
<input name="scavengeId" type="hidden" value="@Page.ScavengeId" />
<button class="rounded-2xl border border-red-200 bg-red-50 px-4 py-2 text-sm font-black text-red-800 transition hover:bg-red-100" type="submit">Stop scavenge</button>
</form>
Expand Down
17 changes: 7 additions & 10 deletions src/EventStore.ClusterNode/Components/Pages/SignIn.razor
Original file line number Diff line number Diff line change
Expand Up @@ -160,22 +160,19 @@

private async Task SignInUser() {
try {
var result = await Security.Validate(Input.Username, Input.Password);
if (!result.Success) {
DisplayedOAuthError = "";
Message = result.Message;
return;
}

var context = HttpContextAccessor.HttpContext;
if (context is null) {
DisplayedOAuthError = "";
Message = "The sign-in response is no longer available.";
return;
}
var result = await Security.SignInAsync(context, Input.Username, Input.Password);
Input.Password = "";
if (!result.Success) {
DisplayedOAuthError = "";
Message = result.Message;
return;
}

UiCredentialCookie.DeleteOAuthToken(context.Response);
UiCredentialCookie.AppendBasic(context.Response, new UiCredentials(Input.Username.Trim(), Input.Password));
} catch (OperationCanceledException) {
DisplayedOAuthError = "";
Message = "Sign-in was canceled.";
Expand Down
26 changes: 18 additions & 8 deletions src/EventStore.ClusterNode/Components/Pages/SignOut.razor
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
@page "/ui/signout"
@layout AuthLayout
@inject IHttpContextAccessor HttpContextAccessor
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Components.Forms

<PageTitle>Signed out - EventStore UI</PageTitle>
<PageTitle>Sign out - EventStore UI</PageTitle>

<section class="w-full max-w-2xl rounded-[2rem] border border-white/80 bg-white/95 p-8 text-center shadow-[0_24px_90px_rgba(23,32,51,0.12)] backdrop-blur" data-ui-clear-auth>
<p class="text-xs font-black uppercase tracking-[0.28em] text-es-green">Session cleared</p>
<h1 class="mt-4 text-5xl font-black tracking-tight text-es-ink sm:text-6xl">You have been signed out.</h1>
<p class="mt-5 text-lg leading-8 text-es-muted">Stored browser credentials for the management UI have been removed.</p>
<section class="w-full max-w-2xl rounded-[2rem] border border-white/80 bg-white/95 p-8 text-center shadow-[0_24px_90px_rgba(23,32,51,0.12)] backdrop-blur">
<h1 class="text-5xl font-black tracking-tight text-es-ink sm:text-6xl">@(SignedOut ? "You have been signed out." : "Sign out of this node?")</h1>
<p class="mt-5 text-lg leading-8 text-es-muted">@(SignedOut ? "Your management session has been revoked." : "This ends your current management session.")</p>
@if (!SignedOut) {
<form method="post" @formname="signout" @onsubmit="SignOutUser">
<AntiforgeryToken />
<button type="submit" class="rounded-2xl bg-es-ink px-5 py-3 font-black text-white">Sign out</button>
</form>
}
<div class="mt-7 flex flex-wrap justify-center gap-2">
<a class="rounded-2xl bg-es-ink px-5 py-3 text-sm font-black text-white shadow-lg shadow-es-ink/15 transition hover:bg-es-green" href="/ui/signin">Sign in again</a>
<a class="rounded-2xl border border-es-ink/10 bg-white px-5 py-3 text-sm font-black text-es-ink transition hover:border-es-green/30 hover:text-es-forest" href="/ui">Return to UI</a>
</div>
</section>

@code {
protected override void OnInitialized() {
private bool SignedOut;

private async Task SignOutUser() {
var context = HttpContextAccessor.HttpContext;
if (context is null)
return;

UiCredentialCookie.Delete(context.Response);
UiCredentialCookie.DeleteOAuthToken(context.Response);
await context.SignOutAsync(UiSessionAuthentication.Scheme);
UiCredentialCookie.DeleteLegacyCookies(context.Response);
SignedOut = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
Expand All @@ -10,10 +11,12 @@
using System.Threading.Tasks;
using EventStore.Core;
using EventStore.Core.Authentication.OAuth;
using EventStore.Plugins.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

namespace EventStore.ClusterNode.Components.Services;

Expand All @@ -24,7 +27,7 @@ public static IEndpointRouteBuilder MapOAuthBrowserFlowEndpoints(
ClusterVNodeOptions.OAuthOptions options)
{
app.MapGet(options.CodeChallengePath, (HttpContext context, OAuthBrowserFlowService service) =>
Results.Json(service.CreateCodeChallenge(context), OAuthBrowserFlowService.JsonOptions));
service.HandleCodeChallenge(context));

app.MapGet(options.RedirectPath, async (
HttpContext context,
Expand All @@ -48,8 +51,16 @@ public sealed class OAuthBrowserFlowService(
private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(5);
private readonly IDataProtector _challengeProtector = dataProtectionProvider.CreateProtector("EventStore.ClusterNode.Components.Services.OAuthBrowserFlowService.Pkce");

public IResult HandleCodeChallenge(HttpContext context) =>
context.Request.IsHttps
? Results.Json(CreateCodeChallenge(context), JsonOptions)
: ErrorRedirect("https_required", "");

public OAuthCodeChallenge CreateCodeChallenge(HttpContext context)
{
if (!context.Request.IsHttps)
throw new InvalidOperationException("OAuth browser authentication requires HTTPS.");

var verifier = Base64Url(RandomNumberGenerator.GetBytes(32));
var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
var correlationId = Base64Url(RandomNumberGenerator.GetBytes(32));
Expand All @@ -58,12 +69,14 @@ public OAuthCodeChallenge CreateCodeChallenge(HttpContext context)
context.Response.Cookies.Append(
ChallengeCookieName,
_challengeProtector.Protect(JsonSerializer.Serialize(payload, JsonOptions)),
ChallengeCookieOptions(context.Request));
ChallengeCookieOptions(ChallengeLifetime));
return new OAuthCodeChallenge(correlationId, challenge, "S256");
}

public async Task<IResult> HandleCallback(HttpContext context, CancellationToken cancellationToken)
{
if (!context.Request.IsHttps)
return ErrorRedirect("https_required", "");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
var code = context.Request.Query["code"].ToString();
var state = context.Request.Query["state"].ToString();
var providerError = context.Request.Query["error"].ToString();
Expand Down Expand Up @@ -115,8 +128,21 @@ public async Task<IResult> HandleCallback(HttpContext context, CancellationToken
return ErrorRedirect("invalid_token", returnUrl);
}

UiCredentialCookie.Delete(context.Response);
UiCredentialCookie.AppendOAuthToken(context.Response, token);
var request = new HttpAuthenticationRequest(context, token);
context.RequestServices.GetRequiredService<IAuthenticationProvider>().Authenticate(request);
HttpAuthenticationRequestStatus status;
ClaimsPrincipal principal;
try
{
(status, principal) = await request.AuthenticateAsync().WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
}
catch (TimeoutException)
{
return ErrorRedirect("invalid_token", returnUrl);
}
if (status != HttpAuthenticationRequestStatus.Authenticated)
return ErrorRedirect("invalid_token", returnUrl);
await UiSessionAuthentication.SignInAsync(context, principal, token);
return Results.Redirect(adminUiEnabled ? SignInLocation(returnUrl) : DirectReturnLocation(returnUrl));
}

Expand Down Expand Up @@ -225,6 +251,8 @@ private bool TryReadState(string state, out string correlationId, out string ret
if (document.RootElement.TryGetProperty("redirect_uri", out var redirectUriElement))
{
redirectUri = NormalizeRedirectUri(redirectUriElement.GetString() ?? "");
if (string.IsNullOrWhiteSpace(redirectUri))
return false;
}

return !string.IsNullOrWhiteSpace(correlationId);
Expand All @@ -247,8 +275,7 @@ private string NormalizeRedirectUri(string redirectUri)
return "";
}

if (!uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) &&
!uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase))
if (!uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return "";
}
Expand All @@ -271,15 +298,12 @@ private static string DirectReturnLocation(string returnUrl) =>
: returnUrl;

private void DeleteChallengeCookie(HttpContext context) =>
context.Response.Cookies.Delete(ChallengeCookieName, ChallengeCookieOptions(context.Request, maxAge: null));

private CookieOptions ChallengeCookieOptions(HttpRequest request) =>
ChallengeCookieOptions(request, ChallengeLifetime);
context.Response.Cookies.Delete(ChallengeCookieName, ChallengeCookieOptions(maxAge: null));

private CookieOptions ChallengeCookieOptions(HttpRequest request, TimeSpan? maxAge) => new()
private static CookieOptions ChallengeCookieOptions(TimeSpan? maxAge) => new()
{
HttpOnly = true,
Secure = request.IsHttps,
Secure = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = maxAge
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using EventStore.Core.Authentication;
using EventStore.Plugins.Authentication;
using Microsoft.AspNetCore.Http;

Expand Down Expand Up @@ -37,8 +38,12 @@ public SecurityAuthenticationInfo AuthenticationInfo()
properties);
}

public async Task<SecurityCommandResult> Validate(string username, string password)
public async Task<SecurityCommandResult> SignInAsync(HttpContext context, string username, string password)
{
if (!supportsPassword || authenticationProvider is not ISessionAuthenticationProvider sessions)
return SecurityCommandResult.Failure("Password browser sign-in is not available.");
if (!context.Request.IsHttps)
return SecurityCommandResult.Failure("Browser sign-in requires HTTPS.");
if (string.IsNullOrWhiteSpace(username))
{
return SecurityCommandResult.Failure("Enter a username.");
Expand All @@ -49,10 +54,25 @@ public async Task<SecurityCommandResult> Validate(string username, string passwo
return SecurityCommandResult.Failure("Enter a password.");
}

var context = new DefaultHttpContext();
var request = new HttpAuthenticationRequest(context, username.Trim(), password);
authenticationProvider.Authenticate(request);
var (status, _) = await request.AuthenticateAsync();
sessions.AuthenticateSession(request);
HttpAuthenticationRequestStatus status;
ClaimsPrincipal principal;
try
{
(status, principal) = await request.AuthenticateAsync().WaitAsync(TimeSpan.FromSeconds(5), context.RequestAborted);
}
catch (TimeoutException)
{
return SecurityCommandResult.Failure("The authentication provider is not ready yet.");
}
if (status == HttpAuthenticationRequestStatus.Authenticated)
{
var current = await sessions.ValidateSessionAsync(principal, context.RequestAborted);
if (current is null)
return SecurityCommandResult.Failure("The account changed during sign-in. Try again.");
await UiSessionAuthentication.SignInAsync(context, current);
Comment thread
cursor[bot] marked this conversation as resolved.
}

return status switch
{
Expand Down
Loading
Loading