* feat: adds the `credentials: include` header to all manual requests * feat: adds `credentials: include` as a configurable option to xhr requests (and sets it by default to true) * feat: configures the auto-generated fetch client from hey-api to include credentials by default * Add OpenIddict handler to hide tokens from the back-office client * Make back-office token redaction optional (default false) * Clear back-office token cookies on logout * Add configuration for backoffice cookie settings * Make cookies forcefully secure + move cookie handler enabling to the BackOfficeTokenCookieSettings * Use the "__Host-" prefix for cookie names * docs: adds documentation on cookie settings * build: sets up launch profile for vscode with new cookie recommended settings * docs: adds extra note around SameSite settings * docs: adds extra note around SameSite settings * Respect sites that do not use HTTPS * Explicitly invalidate potentially valid, old refresh tokens that should no longer be used * Removed obsolete const --------- Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
188 lines
7.7 KiB
C#
188 lines
7.7 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Options;
|
|
using OpenIddict.Server;
|
|
using OpenIddict.Validation;
|
|
using Umbraco.Cms.Core;
|
|
using Umbraco.Cms.Core.Configuration.Models;
|
|
using Umbraco.Cms.Core.Events;
|
|
using Umbraco.Cms.Core.Notifications;
|
|
using Umbraco.Cms.Web.Common.Security;
|
|
using Umbraco.Extensions;
|
|
|
|
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
|
|
|
internal sealed class HideBackOfficeTokensHandler
|
|
: IOpenIddictServerHandler<OpenIddictServerEvents.ApplyTokenResponseContext>,
|
|
IOpenIddictServerHandler<OpenIddictServerEvents.ExtractTokenRequestContext>,
|
|
IOpenIddictValidationHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>,
|
|
INotificationHandler<UserLogoutSuccessNotification>
|
|
{
|
|
private const string RedactedTokenValue = "[redacted]";
|
|
private const string AccessTokenCookieKey = "__Host-umbAccessToken";
|
|
private const string RefreshTokenCookieKey = "__Host-umbRefreshToken";
|
|
|
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
private readonly IDataProtectionProvider _dataProtectionProvider;
|
|
private readonly BackOfficeTokenCookieSettings _backOfficeTokenCookieSettings;
|
|
private readonly GlobalSettings _globalSettings;
|
|
|
|
public HideBackOfficeTokensHandler(
|
|
IHttpContextAccessor httpContextAccessor,
|
|
IDataProtectionProvider dataProtectionProvider,
|
|
IOptions<BackOfficeTokenCookieSettings> backOfficeTokenCookieSettings,
|
|
IOptions<GlobalSettings> globalSettings)
|
|
{
|
|
_httpContextAccessor = httpContextAccessor;
|
|
_dataProtectionProvider = dataProtectionProvider;
|
|
_backOfficeTokenCookieSettings = backOfficeTokenCookieSettings.Value;
|
|
_globalSettings = globalSettings.Value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// This is invoked when tokens (access and refresh tokens) are issued to a client. For the back-office client,
|
|
/// we will intercept the response, write the tokens from the response into HTTP-only cookies, and redact the
|
|
/// tokens from the response, so they are not exposed to the client.
|
|
/// </summary>
|
|
public ValueTask HandleAsync(OpenIddictServerEvents.ApplyTokenResponseContext context)
|
|
{
|
|
if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice)
|
|
{
|
|
// Only ever handle the back-office client.
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
HttpContext httpContext = GetHttpContext();
|
|
|
|
if (context.Response.AccessToken is not null)
|
|
{
|
|
SetCookie(httpContext, AccessTokenCookieKey, context.Response.AccessToken);
|
|
context.Response.AccessToken = RedactedTokenValue;
|
|
}
|
|
|
|
if (context.Response.RefreshToken is not null)
|
|
{
|
|
SetCookie(httpContext, RefreshTokenCookieKey, context.Response.RefreshToken);
|
|
context.Response.RefreshToken = RedactedTokenValue;
|
|
}
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// This is invoked when requesting new tokens.
|
|
/// </summary>
|
|
public ValueTask HandleAsync(OpenIddictServerEvents.ExtractTokenRequestContext context)
|
|
{
|
|
if (context.Request?.ClientId != Constants.OAuthClientIds.BackOffice)
|
|
{
|
|
// Only ever handle the back-office client.
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
// For the back-office client, this only happens when a refresh token is being exchanged for a new access token.
|
|
if (context.Request.RefreshToken == RedactedTokenValue
|
|
&& TryGetCookie(RefreshTokenCookieKey, out var refreshToken))
|
|
{
|
|
context.Request.RefreshToken = refreshToken;
|
|
}
|
|
else
|
|
{
|
|
// If we got here, either the refresh token was not redacted, or nothing was found in the refresh token cookie.
|
|
// If OpenIddict found a refresh token, it could be an old token that is potentially still valid. For security
|
|
// reasons, we cannot accept that; at this point, we expect the refresh tokens to be explicitly redacted.
|
|
context.Request.RefreshToken = null;
|
|
}
|
|
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// This is invoked when extracting the auth context for a client request.
|
|
/// </summary>
|
|
public ValueTask HandleAsync(OpenIddictValidationEvents.ProcessAuthenticationContext context)
|
|
{
|
|
// For the back-office client, this only happens when an access token is sent to the API.
|
|
if (context.AccessToken != RedactedTokenValue)
|
|
{
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
if (TryGetCookie(AccessTokenCookieKey, out var accessToken))
|
|
{
|
|
context.AccessToken = accessToken;
|
|
}
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public void Handle(UserLogoutSuccessNotification notification)
|
|
{
|
|
HttpContext? context = _httpContextAccessor.HttpContext;
|
|
if (context is null)
|
|
{
|
|
// For some reason there is no ambient HTTP context, so we can't clean up the cookies.
|
|
// This is OK, because the tokens in the cookies have already been revoked at user sign-out,
|
|
// so the cookie clean-up is mostly cosmetic.
|
|
return;
|
|
}
|
|
|
|
context.Response.Cookies.Delete(AccessTokenCookieKey);
|
|
context.Response.Cookies.Delete(RefreshTokenCookieKey);
|
|
}
|
|
|
|
private HttpContext GetHttpContext()
|
|
=> _httpContextAccessor.GetRequiredHttpContext();
|
|
|
|
private void SetCookie(HttpContext httpContext, string key, string value)
|
|
{
|
|
var cookieValue = EncryptionHelper.Encrypt(value, _dataProtectionProvider);
|
|
|
|
var cookieOptions = new CookieOptions
|
|
{
|
|
// Prevent the client-side scripts from accessing the cookie.
|
|
HttpOnly = true,
|
|
|
|
// Mark the cookie as essential to the application, to enforce it despite any
|
|
// data collection consent options. This aligns with how ASP.NET Core Identity
|
|
// does when writing cookies for cookie authentication.
|
|
IsEssential = true,
|
|
|
|
// Cookie path must be root for optimal security.
|
|
Path = "/",
|
|
|
|
// For optimal security, the cooke must be secure. However, Umbraco allows for running development
|
|
// environments over HTTP, so we need to take that into account here.
|
|
// Thus, we will make the cookie secure if:
|
|
// - HTTPS is explicitly enabled by config (default for production environments), or
|
|
// - The current request is over HTTPS (meaning the environment supports it regardless of config).
|
|
Secure = _globalSettings.UseHttps || httpContext.Request.IsHttps,
|
|
|
|
// SameSite is configurable (see BackOfficeTokenCookieSettings for defaults):
|
|
SameSite = ParseSameSiteMode(_backOfficeTokenCookieSettings.SameSite),
|
|
};
|
|
|
|
httpContext.Response.Cookies.Delete(key, cookieOptions);
|
|
httpContext.Response.Cookies.Append(key, cookieValue, cookieOptions);
|
|
}
|
|
|
|
private bool TryGetCookie(string key, [NotNullWhen(true)] out string? value)
|
|
{
|
|
if (GetHttpContext().Request.Cookies.TryGetValue(key, out var cookieValue))
|
|
{
|
|
value = EncryptionHelper.Decrypt(cookieValue, _dataProtectionProvider);
|
|
return true;
|
|
}
|
|
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
private static SameSiteMode ParseSameSiteMode(string sameSiteMode) =>
|
|
Enum.TryParse(sameSiteMode, ignoreCase: true, out SameSiteMode result)
|
|
? result
|
|
: throw new ArgumentException($"The provided {nameof(sameSiteMode)} value could not be parsed into as SameSiteMode value.", nameof(sameSiteMode));
|
|
}
|