Browse Source

Allow anonymous access for clients. (#544)

pull/546/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
6c4ea7b7d9
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 15
      backend/src/Migrations/OldEvents/AppClientRenamed.cs
  2. 18
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs
  3. 19
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs
  4. 4
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs
  5. 6
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppPatterns.cs
  6. 10
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppClientsConverter.cs
  7. 38
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/Json/JsonAppClient.cs
  8. 18
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs
  9. 16
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs
  10. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs
  11. 5
      backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs
  12. 5
      backend/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  13. 6
      backend/src/Squidex.Domain.Apps.Events/Apps/AppClientUpdated.cs
  14. 5
      backend/src/Squidex.Infrastructure/StringExtensions.cs
  15. 6
      backend/src/Squidex.Infrastructure/Validation/Not.cs
  16. 2
      backend/src/Squidex.Web/ApiPermissionAttribute.cs
  17. 27
      backend/src/Squidex.Web/Pipeline/AppResolver.cs
  18. 7
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateClientDto.cs
  19. 8
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs
  20. 36
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs
  21. 3
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs
  22. 11
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs
  23. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs
  24. 88
      backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs
  25. 19
      frontend/app/features/settings/pages/clients/client.component.html
  26. 4
      frontend/app/features/settings/pages/clients/client.component.ts
  27. 3
      frontend/app/shared/services/clients.service.spec.ts
  28. 7
      frontend/app/shared/services/clients.service.ts

15
backend/src/Squidex.Domain.Apps.Events/Apps/AppClientRenamed.cs → backend/src/Migrations/OldEvents/AppClientRenamed.cs

@ -5,15 +5,26 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Reflection;
using AppClientUpdatedV2 = Squidex.Domain.Apps.Events.Apps.AppClientUpdated;
namespace Squidex.Domain.Apps.Events.Apps namespace Migrations.OldEvents
{ {
[EventType(nameof(AppClientRenamed))] [EventType(nameof(AppClientRenamed))]
public sealed class AppClientRenamed : AppEvent public sealed class AppClientRenamed : AppEvent, IMigrated<IEvent>
{ {
public string Id { get; set; } public string Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public IEvent Migrate()
{
var result = SimpleMapper.Map(this, new AppClientUpdatedV2());
return result;
}
} }
} }

18
backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs

@ -17,7 +17,9 @@ namespace Squidex.Domain.Apps.Core.Apps
public string Secret { get; } public string Secret { get; }
public AppClient(string name, string secret, string role) public bool AllowAnonymous { get; set; }
public AppClient(string name, string secret, string role, bool allowAnonymous)
: base(name) : base(name)
{ {
Guard.NotNullOrEmpty(secret, nameof(secret)); Guard.NotNullOrEmpty(secret, nameof(secret));
@ -26,22 +28,14 @@ namespace Squidex.Domain.Apps.Core.Apps
Role = role; Role = role;
Secret = secret; Secret = secret;
}
[Pure]
public AppClient Update(string newRole)
{
Guard.NotNullOrEmpty(newRole, nameof(newRole));
return new AppClient(Name, Secret, newRole); AllowAnonymous = allowAnonymous;
} }
[Pure] [Pure]
public AppClient Rename(string newName) public AppClient Update(string? name, string? role, bool? allowAnonymous)
{ {
Guard.NotNullOrEmpty(newName, nameof(newName)); return new AppClient(name.Or(Name), Secret, role.Or(Role), allowAnonymous ?? AllowAnonymous);
return new AppClient(newName, Secret, Role);
} }
} }
} }

19
backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs

@ -53,11 +53,11 @@ namespace Squidex.Domain.Apps.Core.Apps
throw new ArgumentException("Id already exists.", nameof(id)); throw new ArgumentException("Id already exists.", nameof(id));
} }
return With<AppClients>(id, new AppClient(id, secret, Role.Editor)); return With<AppClients>(id, new AppClient(id, secret, Role.Editor, false));
} }
[Pure] [Pure]
public AppClients Rename(string id, string newName) public AppClients Update(string id, string? name = null, string? role = null, bool? allowAnonymous = false)
{ {
Guard.NotNullOrEmpty(id, nameof(id)); Guard.NotNullOrEmpty(id, nameof(id));
@ -66,20 +66,7 @@ namespace Squidex.Domain.Apps.Core.Apps
return this; return this;
} }
return With<AppClients>(id, client.Rename(newName)); return With<AppClients>(id, client.Update(name, role, allowAnonymous));
}
[Pure]
public AppClients Update(string id, string role)
{
Guard.NotNullOrEmpty(id, nameof(id));
if (!TryGetValue(id, out var client))
{
return this;
}
return With<AppClients>(id, client.Update(role));
} }
} }
} }

4
backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs

@ -28,9 +28,9 @@ namespace Squidex.Domain.Apps.Core.Apps
} }
[Pure] [Pure]
public AppPattern Update(string newName, string newPattern, string? newMessage) public AppPattern Update(string? name, string? pattern, string? message)
{ {
return new AppPattern(newName, newPattern, newMessage); return new AppPattern(name.Or(Name), pattern.Or(Pattern), message);
} }
} }
} }

6
backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppPatterns.cs

@ -8,7 +8,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.Contracts; using System.Diagnostics.Contracts;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Collections;
namespace Squidex.Domain.Apps.Core.Apps namespace Squidex.Domain.Apps.Core.Apps
@ -41,11 +40,8 @@ namespace Squidex.Domain.Apps.Core.Apps
} }
[Pure] [Pure]
public AppPatterns Update(Guid id, string name, string pattern, string? message = null) public AppPatterns Update(Guid id, string? name = null, string? pattern = null, string? message = null)
{ {
Guard.NotNullOrEmpty(name, nameof(name));
Guard.NotNullOrEmpty(pattern, nameof(pattern));
if (!TryGetValue(id, out var appPattern)) if (!TryGetValue(id, out var appPattern))
{ {
return this; return this;

10
backend/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppClientsConverter.cs

@ -17,11 +17,11 @@ namespace Squidex.Domain.Apps.Core.Apps.Json
{ {
protected override void WriteValue(JsonWriter writer, AppClients value, JsonSerializer serializer) protected override void WriteValue(JsonWriter writer, AppClients value, JsonSerializer serializer)
{ {
var json = new Dictionary<string, JsonAppClient>(value.Count); var json = new Dictionary<string, AppClient>(value.Count);
foreach (var (key, appClient) in value) foreach (var (key, client) in value)
{ {
json.Add(key, new JsonAppClient(appClient)); json.Add(key, client);
} }
serializer.Serialize(writer, json); serializer.Serialize(writer, json);
@ -29,9 +29,9 @@ namespace Squidex.Domain.Apps.Core.Apps.Json
protected override AppClients ReadValue(JsonReader reader, Type objectType, JsonSerializer serializer) protected override AppClients ReadValue(JsonReader reader, Type objectType, JsonSerializer serializer)
{ {
var json = serializer.Deserialize<Dictionary<string, JsonAppClient>>(reader)!; var json = serializer.Deserialize<Dictionary<string, AppClient>>(reader)!;
return new AppClients(json.ToDictionary(x => x.Key, x => x.Value.ToClient())); return new AppClients(json);
} }
} }
} }

38
backend/src/Squidex.Domain.Apps.Core.Model/Apps/Json/JsonAppClient.cs

@ -1,38 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Core.Apps.Json
{
public class JsonAppClient
{
[JsonProperty]
public string Name { get; set; }
[JsonProperty]
public string Secret { get; set; }
[JsonProperty]
public string Role { get; set; }
public JsonAppClient()
{
}
public JsonAppClient(AppClient client)
{
SimpleMapper.Map(client, this);
}
public AppClient ToClient()
{
return new AppClient(Name, Secret, Role);
}
}
}

18
backend/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs

@ -341,19 +341,6 @@ namespace Squidex.Domain.Apps.Entities.Apps
} }
} }
public void UpdateClient(UpdateClient command)
{
if (!string.IsNullOrWhiteSpace(command.Name))
{
RaiseEvent(SimpleMapper.Map(command, new AppClientRenamed()));
}
if (command.Role != null)
{
RaiseEvent(SimpleMapper.Map(command, new AppClientUpdated { Role = command.Role }));
}
}
public void ChangePlan(ChangePlan command) public void ChangePlan(ChangePlan command)
{ {
if (string.Equals(appPlansProvider.GetFreePlan()?.Id, command.PlanId)) if (string.Equals(appPlansProvider.GetFreePlan()?.Id, command.PlanId))
@ -371,6 +358,11 @@ namespace Squidex.Domain.Apps.Entities.Apps
RaiseEvent(SimpleMapper.Map(command, new AppUpdated())); RaiseEvent(SimpleMapper.Map(command, new AppUpdated()));
} }
public void UpdateClient(UpdateClient command)
{
RaiseEvent(SimpleMapper.Map(command, new AppClientUpdated()));
}
public void UploadImage(UploadAppImage command) public void UploadImage(UploadAppImage command)
{ {
RaiseEvent(SimpleMapper.Map(command, new AppImageUploaded { Image = new AppImage(command.File.MimeType) })); RaiseEvent(SimpleMapper.Map(command, new AppImageUploaded { Image = new AppImage(command.File.MimeType) }));

16
backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs

@ -35,9 +35,6 @@ namespace Squidex.Domain.Apps.Entities.Apps
AddEventMessage<AppClientUpdated>( AddEventMessage<AppClientUpdated>(
"updated client {[Id]}"); "updated client {[Id]}");
AddEventMessage<AppClientRenamed>(
"renamed client {[Id]} to {[Name]}");
AddEventMessage<AppPlanChanged>( AddEventMessage<AppPlanChanged>(
"changed plan to {[Plan]}"); "changed plan to {[Plan]}");
@ -85,8 +82,8 @@ namespace Squidex.Domain.Apps.Entities.Apps
return CreateContributorsEvent(e, e.ContributorId); return CreateContributorsEvent(e, e.ContributorId);
case AppClientAttached e: case AppClientAttached e:
return CreateClientsEvent(e, e.Id); return CreateClientsEvent(e, e.Id);
case AppClientRenamed e: case AppClientUpdated e:
return CreateClientsEvent(e, e.Id, ClientName(e)); return CreateClientsEvent(e, e.Id);
case AppClientRevoked e: case AppClientRevoked e:
return CreateClientsEvent(e, e.Id); return CreateClientsEvent(e, e.Id);
case AppLanguageAdded e: case AppLanguageAdded e:
@ -138,9 +135,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
return ForEvent(e, "settings.patterns").Param("PatternId", id).Param("Name", name); return ForEvent(e, "settings.patterns").Param("PatternId", id).Param("Name", name);
} }
private HistoryEvent CreateClientsEvent(IEvent e, string id, string? name = null) private HistoryEvent CreateClientsEvent(IEvent e, string id)
{ {
return ForEvent(e, "settings.clients").Param("Id", id).Param("Name", name); return ForEvent(e, "settings.clients").Param("Id", id);
} }
private HistoryEvent CreatePlansEvent(IEvent e, string? plan = null) private HistoryEvent CreatePlansEvent(IEvent e, string? plan = null)
@ -152,10 +149,5 @@ namespace Squidex.Domain.Apps.Entities.Apps
{ {
return Task.FromResult(CreateEvent(@event.Payload)); return Task.FromResult(CreateEvent(@event.Payload));
} }
private static string ClientName(AppClientRenamed e)
{
return !string.IsNullOrWhiteSpace(e.Name) ? e.Name : e.Id;
}
} }
} }

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs

@ -11,8 +11,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public string Id { get; set; } public string Id { get; set; }
public string Name { get; set; } public string? Name { get; set; }
public string? Role { get; set; } public string? Role { get; set; }
public bool? AllowAnonymous { get; set; }
} }
} }

5
backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs

@ -59,11 +59,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
e(Not.Defined("Client id"), nameof(command.Id)); e(Not.Defined("Client id"), nameof(command.Id));
} }
if (string.IsNullOrWhiteSpace(command.Name) && command.Role == null)
{
e(Not.DefinedOr("name", "role"), nameof(command.Name), nameof(command.Role));
}
if (command.Role != null && !roles.Contains(command.Role)) if (command.Role != null && !roles.Contains(command.Role))
{ {
e(Not.Valid("role"), nameof(command.Role)); e(Not.Valid("role"), nameof(command.Role));

5
backend/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs

@ -85,10 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
return UpdateClients(e, (ev, c) => c.Add(ev.Id, ev.Secret)); return UpdateClients(e, (ev, c) => c.Add(ev.Id, ev.Secret));
case AppClientUpdated e: case AppClientUpdated e:
return UpdateClients(e, (ev, c) => c.Update(ev.Id, ev.Role)); return UpdateClients(e, (ev, c) => c.Update(ev.Id, ev.Name, ev.Role, ev.AllowAnonymous));
case AppClientRenamed e:
return UpdateClients(e, (ev, c) => c.Rename(ev.Id, ev.Name));
case AppClientRevoked e: case AppClientRevoked e:
return UpdateClients(e, (ev, c) => c.Revoke(ev.Id)); return UpdateClients(e, (ev, c) => c.Revoke(ev.Id));

6
backend/src/Squidex.Domain.Apps.Events/Apps/AppClientUpdated.cs

@ -14,6 +14,10 @@ namespace Squidex.Domain.Apps.Events.Apps
{ {
public string Id { get; set; } public string Id { get; set; }
public string Role { get; set; } public string? Name { get; set; }
public string? Role { get; set; }
public bool? AllowAnonymous { get; set; }
} }
} }

5
backend/src/Squidex.Infrastructure/StringExtensions.cs

@ -542,6 +542,11 @@ namespace Squidex.Infrastructure
return value != null && PropertyNameRegex.IsMatch(value); return value != null && PropertyNameRegex.IsMatch(value);
} }
public static string Or(this string? value, string fallback)
{
return !string.IsNullOrWhiteSpace(value) ? value.Trim() : fallback;
}
public static string WithFallback(this string? value, string fallback) public static string WithFallback(this string? value, string fallback)
{ {
return !string.IsNullOrWhiteSpace(value) ? value.Trim() : fallback; return !string.IsNullOrWhiteSpace(value) ? value.Trim() : fallback;

6
backend/src/Squidex.Infrastructure/Validation/Not.cs

@ -71,12 +71,6 @@ namespace Squidex.Infrastructure.Validation
return $"{Upper(property)} is not a valid value."; return $"{Upper(property)} is not a valid value.";
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string DefinedOr(string property1, string property2)
{
return $"Either {Lower(property1)} or {Lower(property2)} must be defined.";
}
private static string Lower(string property) private static string Lower(string property)
{ {
if (char.IsUpper(property[0])) if (char.IsUpper(property[0]))

2
backend/src/Squidex.Web/ApiPermissionAttribute.cs

@ -15,7 +15,7 @@ using Squidex.Shared;
namespace Squidex.Web namespace Squidex.Web
{ {
public sealed class ApiPermissionAttribute : AuthorizeAttribute, IAsyncActionFilter public sealed class ApiPermissionAttribute : AuthorizeAttribute, IAsyncActionFilter, IAllowAnonymous
{ {
private readonly string[] permissionIds; private readonly string[] permissionIds;

27
backend/src/Squidex.Web/Pipeline/AppResolver.cs

@ -58,6 +58,11 @@ namespace Squidex.Web.Pipeline
(role, permissions) = FindByOpenIdClient(app, user); (role, permissions) = FindByOpenIdClient(app, user);
} }
if (permissions == null)
{
(role, permissions) = FindAnonymousClient(app);
}
if (permissions != null) if (permissions != null)
{ {
var identity = user.Identities.First(); var identity = user.Identities.First();
@ -77,7 +82,15 @@ namespace Squidex.Web.Pipeline
if (!AllowAnonymous(context) && !HasPermission(appName, requestContext)) if (!AllowAnonymous(context) && !HasPermission(appName, requestContext))
{ {
context.Result = new NotFoundResult(); if (string.IsNullOrWhiteSpace(user.Identity.AuthenticationType))
{
context.Result = new UnauthorizedResult();
}
else
{
context.Result = new NotFoundResult();
}
return; return;
} }
@ -136,6 +149,18 @@ namespace Squidex.Web.Pipeline
return (null, null); return (null, null);
} }
private static (string?, PermissionSet?) FindAnonymousClient(IAppEntity app)
{
var client = app.Clients.Values.FirstOrDefault(x => x.AllowAnonymous);
if (client != null && app.Roles.TryGet(app.Name, client.Role, out var role))
{
return (client.Role, role.Permissions);
}
return (null, null);
}
private static (string?, PermissionSet?) FindByOpenIdSubject(IAppEntity app, ClaimsPrincipal user) private static (string?, PermissionSet?) FindByOpenIdSubject(IAppEntity app, ClaimsPrincipal user)
{ {
var subjectId = user.OpenIdSubject(); var subjectId = user.OpenIdSubject();

7
backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateClientDto.cs

@ -17,13 +17,18 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// The new display name of the client. /// The new display name of the client.
/// </summary> /// </summary>
[StringLength(20)] [StringLength(20)]
public string Name { get; set; } public string? Name { get; set; }
/// <summary> /// <summary>
/// The role of the client. /// The role of the client.
/// </summary> /// </summary>
public string? Role { get; set; } public string? Role { get; set; }
/// <summary>
/// True to allow anonymous access without an access token for this client.
/// </summary>
public bool? AllowAnonymous { get; set; }
public UpdateClient ToCommand(string clientId) public UpdateClient ToCommand(string clientId)
{ {
return SimpleMapper.Map(this, new UpdateClient { Id = clientId }); return SimpleMapper.Map(this, new UpdateClient { Id = clientId });

8
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs

@ -23,10 +23,12 @@ namespace Squidex.Domain.Apps.Core.Model.Apps
clients = clients.Add("3", "my-secret"); clients = clients.Add("3", "my-secret");
clients = clients.Add("4", "my-secret"); clients = clients.Add("4", "my-secret");
clients = clients.Update("3", Role.Editor); clients = clients.Update("3", role: Role.Editor);
clients = clients.Rename("3", "My Client 3"); clients = clients.Update("3", name: "My Client 3");
clients = clients.Rename("2", "My Client 2"); clients = clients.Update("2", name: "My Client 2");
clients = clients.Update("1", allowAnonymous: true);
clients = clients.Revoke("4"); clients = clients.Revoke("4");

36
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs

@ -23,15 +23,15 @@ namespace Squidex.Domain.Apps.Core.Model.Apps
{ {
var clients_1 = clients_0.Add("2", "my-secret"); var clients_1 = clients_0.Add("2", "my-secret");
clients_1["2"].Should().BeEquivalentTo(new AppClient("2", "my-secret", Role.Editor)); clients_1["2"].Should().BeEquivalentTo(new AppClient("2", "my-secret", Role.Editor, false));
} }
[Fact] [Fact]
public void Should_assign_clients_with_permission() public void Should_assign_clients_with_permission()
{ {
var clients_1 = clients_0.Add("2", new AppClient("my-name", "my-secret", Role.Reader)); var clients_1 = clients_0.Add("2", new AppClient("my-name", "my-secret", Role.Reader, false));
clients_1["2"].Should().BeEquivalentTo(new AppClient("my-name", "my-secret", Role.Reader)); clients_1["2"].Should().BeEquivalentTo(new AppClient("my-name", "my-secret", Role.Reader, false));
} }
[Fact] [Fact]
@ -47,45 +47,37 @@ namespace Squidex.Domain.Apps.Core.Model.Apps
{ {
var clients_1 = clients_0.Add("2", "my-secret"); var clients_1 = clients_0.Add("2", "my-secret");
clients_1.Add("2", new AppClient("my-name", "my-secret", "my-role")); clients_1.Add("2", new AppClient("my-name", "my-secret", "my-role", false));
} }
[Fact] [Fact]
public void Should_rename_client() public void Should_update_client_with_role()
{ {
var clients_1 = clients_0.Rename("1", "new-name"); var client_1 = clients_0.Update("1", role: Role.Reader);
clients_1["1"].Should().BeEquivalentTo(new AppClient("new-name", "my-secret", Role.Editor)); client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Reader, false));
} }
[Fact] [Fact]
public void Should_return_same_clients_if_client_is_updated_with_the_same_values() public void Should_update_client_with_name()
{ {
var clients_1 = clients_0.Rename("2", "2"); var client_1 = clients_0.Update("1", name: "New-Name");
Assert.Same(clients_0, clients_1); client_1["1"].Should().BeEquivalentTo(new AppClient("New-Name", "my-secret", Role.Editor, false));
}
[Fact]
public void Should_return_same_clients_if_client_to_rename_not_found()
{
var clients_1 = clients_0.Rename("2", "new-name");
Assert.Same(clients_0, clients_1);
} }
[Fact] [Fact]
public void Should_update_client() public void Should_update_client_with_allow_anonymous()
{ {
var client_1 = clients_0.Update("1", Role.Reader); var client_1 = clients_0.Update("1", allowAnonymous: true);
client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Reader)); client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Editor, true));
} }
[Fact] [Fact]
public void Should_return_same_clients_if_client_to_update_not_found() public void Should_return_same_clients_if_client_to_update_not_found()
{ {
var clients_1 = clients_0.Update("2", Role.Reader); var clients_1 = clients_0.Update("2", role: Role.Reader);
Assert.Same(clients_0, clients_1); Assert.Same(clients_0, clients_1);
} }

3
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs

@ -406,8 +406,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
LastEvents LastEvents
.ShouldHaveSameEvents( .ShouldHaveSameEvents(
CreateEvent(new AppClientRenamed { Id = clientId, Name = clientNewName }), CreateEvent(new AppClientUpdated { Id = clientId, Name = clientNewName, Role = Role.Developer })
CreateEvent(new AppClientUpdated { Id = clientId, Role = Role.Developer })
); );
} }

11
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs

@ -95,17 +95,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
Assert.Throws<DomainObjectNotFoundException>(() => GuardAppClients.CanUpdate(clients_0, command, Roles.Empty)); Assert.Throws<DomainObjectNotFoundException>(() => GuardAppClients.CanUpdate(clients_0, command, Roles.Empty));
} }
[Fact]
public void UpdateClient_should_throw_exception_if_client_has_no_name_and_role()
{
var command = new UpdateClient { Id = "ios" };
var clients_1 = clients_0.Add("ios", "secret");
ValidationAssert.Throws(() => GuardAppClients.CanUpdate(clients_1, command, roles),
new ValidationError("Either name or role must be defined.", "Name", "Role"));
}
[Fact] [Fact]
public void UpdateClient_should_throw_exception_if_client_has_invalid_role() public void UpdateClient_should_throw_exception_if_client_has_invalid_role()
{ {

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs

@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
var command = new DeleteRole { Name = roleName }; var command = new DeleteRole { Name = roleName };
ValidationAssert.Throws(() => GuardAppRoles.CanDelete(roles_1, command, contributors, clients.Add("1", new AppClient("client", "1", roleName))), ValidationAssert.Throws(() => GuardAppRoles.CanDelete(roles_1, command, contributors, clients.Add("1", new AppClient("client", "1", roleName, false))),
new ValidationError("Cannot remove a role when a client is assigned.")); new ValidationError("Cannot remove a role when a client is assigned."));
} }

88
backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs

@ -34,16 +34,12 @@ namespace Squidex.Web.Pipeline
private readonly ActionContext actionContext; private readonly ActionContext actionContext;
private readonly ActionExecutingContext actionExecutingContext; private readonly ActionExecutingContext actionExecutingContext;
private readonly ActionExecutionDelegate next; private readonly ActionExecutionDelegate next;
private readonly ClaimsIdentity userIdentiy = new ClaimsIdentity();
private readonly ClaimsPrincipal user;
private readonly string appName = "my-app"; private readonly string appName = "my-app";
private readonly AppResolver sut; private readonly AppResolver sut;
private bool isNextCalled; private bool isNextCalled;
public AppResolverTests() public AppResolverTests()
{ {
user = new ClaimsPrincipal(userIdentiy);
actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor
{ {
EndpointMetadata = new List<object>() EndpointMetadata = new List<object>()
@ -51,7 +47,6 @@ namespace Squidex.Web.Pipeline
actionExecutingContext = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object>(), this); actionExecutingContext = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object>(), this);
actionExecutingContext.HttpContext = httpContext; actionExecutingContext.HttpContext = httpContext;
actionExecutingContext.HttpContext.User = user;
actionExecutingContext.RouteData.Values["app"] = appName; actionExecutingContext.RouteData.Values["app"] = appName;
next = () => next = () =>
@ -67,6 +62,8 @@ namespace Squidex.Web.Pipeline
[Fact] [Fact]
public async Task Should_return_not_found_if_app_not_found() public async Task Should_return_not_found_if_app_not_found()
{ {
SetupUser();
A.CallTo(() => appProvider.GetAppAsync(appName, false)) A.CallTo(() => appProvider.GetAppAsync(appName, false))
.Returns(Task.FromResult<IAppEntity?>(null)); .Returns(Task.FromResult<IAppEntity?>(null));
@ -76,13 +73,31 @@ namespace Squidex.Web.Pipeline
Assert.False(isNextCalled); Assert.False(isNextCalled);
} }
[Fact]
public async Task Should_return_401_if_user_is_anonymous()
{
var user = SetupUser(null);
var app = CreateApp(appName);
A.CallTo(() => appProvider.GetAppAsync(appName, false))
.Returns(app);
await sut.OnActionExecutionAsync(actionExecutingContext, next);
Assert.IsType<UnauthorizedResult>(actionExecutingContext.Result);
Assert.False(isNextCalled);
}
[Fact] [Fact]
public async Task Should_resolve_app_from_user() public async Task Should_resolve_app_from_user()
{ {
var user = SetupUser();
var app = CreateApp(appName, appUser: "user1"); var app = CreateApp(appName, appUser: "user1");
userIdentiy.AddClaim(new Claim(OpenIdClaims.Subject, "user1")); user.AddClaim(new Claim(OpenIdClaims.Subject, "user1"));
userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.my-app")); user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.my-app"));
A.CallTo(() => appProvider.GetAppAsync(appName, true)) A.CallTo(() => appProvider.GetAppAsync(appName, true))
.Returns(app); .Returns(app);
@ -97,9 +112,28 @@ namespace Squidex.Web.Pipeline
[Fact] [Fact]
public async Task Should_resolve_app_from_client() public async Task Should_resolve_app_from_client()
{ {
var user = SetupUser();
user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1"));
var app = CreateApp(appName, appClient: "client1"); var app = CreateApp(appName, appClient: "client1");
userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); A.CallTo(() => appProvider.GetAppAsync(appName, true))
.Returns(app);
await sut.OnActionExecutionAsync(actionExecutingContext, next);
Assert.Same(app, httpContext.Context().App);
Assert.True(user.Claims.Count() > 2);
Assert.True(isNextCalled);
}
[Fact]
public async Task Should_resolve_app_from_anonymous_client()
{
var user = SetupUser();
var app = CreateApp(appName, appClient: "client1", allowAnonymous: true);
A.CallTo(() => appProvider.GetAppAsync(appName, true)) A.CallTo(() => appProvider.GetAppAsync(appName, true))
.Returns(app); .Returns(app);
@ -112,12 +146,14 @@ namespace Squidex.Web.Pipeline
} }
[Fact] [Fact]
public async Task Should_resolve_app_if_anonymous_but_not_permissions() public async Task Should_resolve_app_if_action_allows_anonymous_but_user_has_no_permissions()
{ {
var app = CreateApp(appName); var user = SetupUser();
userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1"));
userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app"));
var app = CreateApp(appName);
actionContext.ActionDescriptor.EndpointMetadata.Add(new AllowAnonymousAttribute()); actionContext.ActionDescriptor.EndpointMetadata.Add(new AllowAnonymousAttribute());
@ -134,10 +170,12 @@ namespace Squidex.Web.Pipeline
[Fact] [Fact]
public async Task Should_return_not_found_if_user_has_no_permissions() public async Task Should_return_not_found_if_user_has_no_permissions()
{ {
var app = CreateApp(appName); var user = SetupUser();
user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1"));
user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app"));
userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); var app = CreateApp(appName);
userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app"));
A.CallTo(() => appProvider.GetAppAsync(appName, false)) A.CallTo(() => appProvider.GetAppAsync(appName, false))
.Returns(app); .Returns(app);
@ -151,9 +189,11 @@ namespace Squidex.Web.Pipeline
[Fact] [Fact]
public async Task Should_return_not_found_if_client_is_from_another_app() public async Task Should_return_not_found_if_client_is_from_another_app()
{ {
var app = CreateApp(appName, appClient: "client1"); var user = SetupUser();
user.AddClaim(new Claim(OpenIdClaims.ClientId, "other:client1"));
userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, "other:client1")); var app = CreateApp(appName, appClient: "client1");
A.CallTo(() => appProvider.GetAppAsync(appName, false)) A.CallTo(() => appProvider.GetAppAsync(appName, false))
.Returns(app); .Returns(app);
@ -177,7 +217,17 @@ namespace Squidex.Web.Pipeline
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
private static IAppEntity CreateApp(string name, string? appUser = null, string? appClient = null) private ClaimsIdentity SetupUser(string? type = "OIDC")
{
var userIdentity = new ClaimsIdentity(type);
var userPrincipal = new ClaimsPrincipal(userIdentity);
actionExecutingContext.HttpContext.User = userPrincipal;
return userIdentity;
}
private static IAppEntity CreateApp(string name, string? appUser = null, string? appClient = null, bool? allowAnonymous = null)
{ {
var appEntity = A.Fake<IAppEntity>(); var appEntity = A.Fake<IAppEntity>();
@ -195,7 +245,7 @@ namespace Squidex.Web.Pipeline
if (appClient != null) if (appClient != null)
{ {
A.CallTo(() => appEntity.Clients) A.CallTo(() => appEntity.Clients)
.Returns(AppClients.Empty.Add(appClient, "secret")); .Returns(AppClients.Empty.Add(appClient, "secret").Update(appClient, allowAnonymous: allowAnonymous));
} }
else else
{ {

19
frontend/app/features/settings/pages/clients/client.component.html

@ -64,6 +64,25 @@
</div> </div>
<div class="col-auto cell-actions"></div> <div class="col-auto cell-actions"></div>
</div> </div>
<div class="form-group row">
<div class="col cell-input offset-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="{{client.id}}_allowAnonymous"
[disabled]="!client.canUpdate"
[ngModel]="client.allowAnonymous"
(ngModelChange)="updateAccess($event)" />
<label class="form-check-label" for="{{client.id}}_allowAnonymous">
Allow anonymous access.
</label>
</div>
<sqx-form-hint>
Allow access to the API without an access token to all resources that are configured via the role of this client. Do not give more than one client anonymous access.
</sqx-form-hint>
</div>
<div class="col-auto cell-actions"></div>
</div>
</div> </div>
</div> </div>
</div> </div>

4
frontend/app/features/settings/pages/clients/client.component.ts

@ -37,6 +37,10 @@ export class ClientComponent {
this.clientsState.update(this.client, { role }); this.clientsState.update(this.client, { role });
} }
public updateAccess(allowAnonymous: boolean) {
this.clientsState.update(this.client, { allowAnonymous });
}
public rename(name: string) { public rename(name: string) {
this.clientsState.update(this.client, { name }); this.clientsState.update(this.client, { name });
} }

3
frontend/app/shared/services/clients.service.spec.ts

@ -165,6 +165,7 @@ describe('ClientsService', () => {
name: `Client ${id}`, name: `Client ${id}`,
role: `Role${id}`, role: `Role${id}`,
secret: `secret${id}`, secret: `secret${id}`,
allowAnonymous: true,
_links: { _links: {
update: { method: 'PUT', href: `/clients/id${id}` } update: { method: 'PUT', href: `/clients/id${id}` }
} }
@ -191,5 +192,5 @@ export function createClient(id: number) {
update: { method: 'PUT', href: `/clients/id${id}` } update: { method: 'PUT', href: `/clients/id${id}` }
}; };
return new ClientDto(links, `id${id}`, `Client ${id}`, `secret${id}`, `Role${id}`); return new ClientDto(links, `id${id}`, `Client ${id}`, `secret${id}`, `Role${id}`, true);
} }

7
frontend/app/shared/services/clients.service.ts

@ -29,7 +29,8 @@ export class ClientDto {
public readonly id: string, public readonly id: string,
public readonly name: string, public readonly name: string,
public readonly secret: string, public readonly secret: string,
public readonly role: string public readonly role: string,
public readonly allowAnonymous: boolean
) { ) {
this._links = links; this._links = links;
@ -53,6 +54,7 @@ export interface CreateClientDto {
export interface UpdateClientDto { export interface UpdateClientDto {
readonly name?: string; readonly name?: string;
readonly role?: string; readonly role?: string;
readonly allowAnonymous?: boolean;
} }
@Injectable() @Injectable()
@ -144,7 +146,8 @@ function parseClients(response: any): ClientsPayload {
item.id, item.id,
item.name || item.id, item.name || item.id,
item.secret, item.secret,
item.role)); item.role,
item.allowAnonymous));
const _links = response._links; const _links = response._links;

Loading…
Cancel
Save