Skip to content

Commit a42beb8

Browse files
committed
Merge branch 'v15/dev' into release/15.2
2 parents 4d3be0a + f96ac32 commit a42beb8

File tree

40 files changed

+11505
-33
lines changed

40 files changed

+11505
-33
lines changed

src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ private static void ConfigureOpenIddict(IUmbracoBuilder builder)
4646
Paths.BackOfficeApi.LogoutEndpoint.TrimStart(Constants.CharArrays.ForwardSlash))
4747
.SetRevocationEndpointUris(
4848
Paths.MemberApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash),
49-
Paths.BackOfficeApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash));
49+
Paths.BackOfficeApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash))
50+
.SetUserInfoEndpointUris(
51+
Paths.MemberApi.UserinfoEndpoint.TrimStart(Constants.CharArrays.ForwardSlash));
5052

5153
// Enable authorization code flow with PKCE
5254
options
@@ -62,7 +64,8 @@ private static void ConfigureOpenIddict(IUmbracoBuilder builder)
6264
.UseAspNetCore()
6365
.EnableAuthorizationEndpointPassthrough()
6466
.EnableTokenEndpointPassthrough()
65-
.EnableEndSessionEndpointPassthrough();
67+
.EnableEndSessionEndpointPassthrough()
68+
.EnableUserInfoEndpointPassthrough();
6669

6770
// Enable reference tokens
6871
// - see https://documentation.openiddict.com/configuration/token-storage.html

src/Umbraco.Cms.Api.Common/Security/Paths.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ public static class MemberApi
3131

3232
public static readonly string RevokeEndpoint = EndpointPath($"{EndpointTemplate}/revoke");
3333

34+
public static readonly string UserinfoEndpoint = EndpointPath($"{EndpointTemplate}/userinfo");
35+
3436
// NOTE: we're NOT using /api/v1.0/ here because it will clash with the Delivery API docs
3537
private static string EndpointPath(string relativePath) => $"/umbraco/delivery/api/v1/{relativePath}";
3638
}

src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiControllerBase.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ namespace Umbraco.Cms.Api.Delivery.Controllers.Content;
1515
[ApiExplorerSettings(GroupName = "Content")]
1616
[LocalizeFromAcceptLanguageHeader]
1717
[ValidateStartItem]
18+
[AddVaryHeader]
1819
[OutputCache(PolicyName = Constants.DeliveryApi.OutputCache.ContentCachePolicy)]
1920
public abstract class ContentApiControllerBase : DeliveryApiControllerBase
2021
{
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Asp.Versioning;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.AspNetCore.Mvc;
4+
using OpenIddict.Server.AspNetCore;
5+
using Umbraco.Cms.Api.Delivery.Routing;
6+
using Umbraco.Cms.Core.DeliveryApi;
7+
8+
namespace Umbraco.Cms.Api.Delivery.Controllers.Security;
9+
10+
[ApiVersion("1.0")]
11+
[ApiController]
12+
[VersionedDeliveryApiRoute(Common.Security.Paths.MemberApi.EndpointTemplate)]
13+
[ApiExplorerSettings(IgnoreApi = true)]
14+
[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
15+
public class CurrentMemberController : DeliveryApiControllerBase
16+
{
17+
private readonly ICurrentMemberClaimsProvider _currentMemberClaimsProvider;
18+
19+
public CurrentMemberController(ICurrentMemberClaimsProvider currentMemberClaimsProvider)
20+
=> _currentMemberClaimsProvider = currentMemberClaimsProvider;
21+
22+
[HttpGet("userinfo")]
23+
public async Task<IActionResult> Userinfo()
24+
{
25+
Dictionary<string, object> claims = await _currentMemberClaimsProvider.GetClaimsAsync();
26+
return Ok(claims);
27+
}
28+
}

src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public static IUmbracoBuilder AddDeliveryApi(this IUmbracoBuilder builder)
6161
builder.Services.AddSingleton<IApiMediaQueryService, ApiMediaQueryService>();
6262
builder.Services.AddTransient<IMemberApplicationManager, MemberApplicationManager>();
6363
builder.Services.AddTransient<IRequestMemberAccessService, RequestMemberAccessService>();
64+
builder.Services.AddTransient<ICurrentMemberClaimsProvider, CurrentMemberClaimsProvider>();
6465
builder.Services.AddScoped<IMemberClientCredentialsManager, MemberClientCredentialsManager>();
6566

6667
builder.Services.ConfigureOptions<ConfigureUmbracoDeliveryApiSwaggerGenOptions>();
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using Microsoft.AspNetCore.Mvc.Filters;
2+
3+
namespace Umbraco.Cms.Api.Delivery.Filters;
4+
5+
public sealed class AddVaryHeaderAttribute : ActionFilterAttribute
6+
{
7+
private const string Vary = "Accept-Language, Preview, Start-Item";
8+
9+
public override void OnResultExecuting(ResultExecutingContext context)
10+
=> context.HttpContext.Response.Headers.Vary = context.HttpContext.Response.Headers.Vary.Count > 0
11+
? $"{context.HttpContext.Response.Headers.Vary}, {Vary}"
12+
: Vary;
13+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using System.Reflection;
2+
using System.Text.Json;
3+
using System.Text.Json.Serialization;
4+
using Asp.Versioning;
5+
using Microsoft.AspNetCore.Http;
6+
using Umbraco.Cms.Core.DeliveryApi;
7+
8+
namespace Umbraco.Cms.Api.Delivery.Json;
9+
10+
public abstract class DeliveryApiVersionAwareJsonConverterBase<T> : JsonConverter<T>
11+
{
12+
private readonly IHttpContextAccessor _httpContextAccessor;
13+
private readonly JsonConverter<T> _defaultConverter = (JsonConverter<T>)JsonSerializerOptions.Default.GetConverter(typeof(T));
14+
15+
public DeliveryApiVersionAwareJsonConverterBase(IHttpContextAccessor httpContextAccessor)
16+
=> _httpContextAccessor = httpContextAccessor;
17+
18+
/// <inheritdoc />
19+
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
20+
=> _defaultConverter.Read(ref reader, typeToConvert, options);
21+
22+
/// <inheritdoc />
23+
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
24+
{
25+
Type type = typeof(T);
26+
var apiVersion = GetApiVersion();
27+
28+
// Get the properties in the specified order
29+
PropertyInfo[] properties = type.GetProperties().OrderBy(GetPropertyOrder).ToArray();
30+
31+
writer.WriteStartObject();
32+
33+
foreach (PropertyInfo property in properties)
34+
{
35+
// Filter out properties based on the API version
36+
var include = apiVersion is null || ShouldIncludeProperty(property, apiVersion.Value);
37+
38+
if (include is false)
39+
{
40+
continue;
41+
}
42+
43+
var propertyName = property.Name;
44+
writer.WritePropertyName(options.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName);
45+
JsonSerializer.Serialize(writer, property.GetValue(value), options);
46+
}
47+
48+
writer.WriteEndObject();
49+
}
50+
51+
private int? GetApiVersion()
52+
{
53+
HttpContext? httpContext = _httpContextAccessor.HttpContext;
54+
ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion();
55+
56+
return apiVersion?.MajorVersion;
57+
}
58+
59+
private int GetPropertyOrder(PropertyInfo prop)
60+
{
61+
var attribute = prop.GetCustomAttribute<JsonPropertyOrderAttribute>();
62+
return attribute?.Order ?? 0;
63+
}
64+
65+
/// <summary>
66+
/// Determines whether a property should be included based on version bounds.
67+
/// </summary>
68+
/// <param name="propertyInfo">The property info.</param>
69+
/// <param name="version">An integer representing an API version.</param>
70+
/// <returns><c>true</c> if the property should be included; otherwise, <c>false</c>.</returns>
71+
private bool ShouldIncludeProperty(PropertyInfo propertyInfo, int version)
72+
{
73+
var attribute = propertyInfo
74+
.GetCustomAttributes(typeof(IncludeInApiVersionAttribute), false)
75+
.FirstOrDefault();
76+
77+
if (attribute is not IncludeInApiVersionAttribute apiVersionAttribute)
78+
{
79+
return true; // No attribute means include the property
80+
}
81+
82+
// Check if the version is within the specified bounds
83+
var isWithinMinVersion = apiVersionAttribute.MinVersion.HasValue is false || version >= apiVersionAttribute.MinVersion.Value;
84+
var isWithinMaxVersion = apiVersionAttribute.MaxVersion.HasValue is false || version <= apiVersionAttribute.MaxVersion.Value;
85+
86+
return isWithinMinVersion && isWithinMaxVersion;
87+
}
88+
}

src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContentTypeFilter.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using Umbraco.Cms.Api.Delivery.Indexing.Filters;
22
using Umbraco.Cms.Core.DeliveryApi;
3-
using Umbraco.Extensions;
43

54
namespace Umbraco.Cms.Api.Delivery.Querying.Filters;
65

@@ -15,15 +14,15 @@ public bool CanHandle(string query)
1514
/// <inheritdoc/>
1615
public FilterOption BuildFilterOption(string filter)
1716
{
18-
var alias = filter.Substring(ContentTypeSpecifier.Length);
17+
var filterValue = filter.Substring(ContentTypeSpecifier.Length);
18+
var negate = filterValue.StartsWith('!');
19+
var aliases = filterValue.TrimStart('!').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
1920

2021
return new FilterOption
2122
{
2223
FieldName = ContentTypeFilterIndexer.FieldName,
23-
Values = alias.IsNullOrWhiteSpace() == false
24-
? new[] { alias.TrimStart('!') }
25-
: Array.Empty<string>(),
26-
Operator = alias.StartsWith('!')
24+
Values = aliases,
25+
Operator = negate
2726
? FilterOperation.IsNot
2827
: FilterOperation.Is
2928
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using OpenIddict.Abstractions;
2+
using Umbraco.Cms.Core.DeliveryApi;
3+
using Umbraco.Cms.Core.Security;
4+
5+
namespace Umbraco.Cms.Api.Delivery.Services;
6+
7+
// NOTE: this is public and unsealed to allow overriding the default claims with minimal effort.
8+
public class CurrentMemberClaimsProvider : ICurrentMemberClaimsProvider
9+
{
10+
private readonly IMemberManager _memberManager;
11+
12+
public CurrentMemberClaimsProvider(IMemberManager memberManager)
13+
=> _memberManager = memberManager;
14+
15+
public virtual async Task<Dictionary<string, object>> GetClaimsAsync()
16+
{
17+
MemberIdentityUser? memberIdentityUser = await _memberManager.GetCurrentMemberAsync();
18+
return memberIdentityUser is not null
19+
? await GetClaimsForMemberIdentityAsync(memberIdentityUser)
20+
: throw new InvalidOperationException("Could not retrieve the current member. This method should only ever be invoked when a member has been authorized.");
21+
}
22+
23+
protected virtual async Task<Dictionary<string, object>> GetClaimsForMemberIdentityAsync(MemberIdentityUser memberIdentityUser)
24+
{
25+
var claims = new Dictionary<string, object>
26+
{
27+
[OpenIddictConstants.Claims.Subject] = memberIdentityUser.Key
28+
};
29+
30+
if (memberIdentityUser.Name is not null)
31+
{
32+
claims[OpenIddictConstants.Claims.Name] = memberIdentityUser.Name;
33+
}
34+
35+
if (memberIdentityUser.Email is not null)
36+
{
37+
claims[OpenIddictConstants.Claims.Email] = memberIdentityUser.Email;
38+
}
39+
40+
claims[OpenIddictConstants.Claims.Role] = await _memberManager.GetRolesAsync(memberIdentityUser);
41+
42+
return claims;
43+
}
44+
}

src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,12 @@ public async Task<IActionResult> Token()
275275
[MapToApiVersion("1.0")]
276276
public async Task<IActionResult> Signout(CancellationToken cancellationToken)
277277
{
278-
var userName = await GetUserNameFromAuthCookie();
278+
AuthenticateResult cookieAuthResult = await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
279+
var userName = cookieAuthResult.Principal?.Identity?.Name;
280+
var userId = cookieAuthResult.Principal?.Identity?.GetUserId();
279281

280282
await _backOfficeSignInManager.SignOutAsync();
283+
_backOfficeUserManager.NotifyLogoutSuccess(cookieAuthResult.Principal ?? User, userId);
281284

282285
_logger.LogInformation(
283286
"User {UserName} from IP address {RemoteIpAddress} has logged out",

0 commit comments

Comments
 (0)