Skip to content

Commit

Permalink
Add role based authorization (#574)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kaliumhexacyanoferrat authored Dec 8, 2024
1 parent 041949a commit 0571d47
Show file tree
Hide file tree
Showing 9 changed files with 241 additions and 9 deletions.
11 changes: 7 additions & 4 deletions API/Content/Authentication/IUser.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
namespace GenHTTP.Api.Content.Authentication;

/// <summary>
/// Information about a user that is associated with
/// the currently handled request.
/// Information about a user that is associated with the currently handled request.
/// </summary>
public interface IUser
{

/// <summary>
/// The name of the user as it should be shown on
/// the UI or in log files.
/// The name of the user as it should be shown on the UI or in log files.
/// </summary>
string DisplayName { get; }

/// <summary>
/// The roles of this user.
/// </summary>
string[]? Roles => null;

}
5 changes: 4 additions & 1 deletion Modules/Authentication/ApiKey/ApiKeyUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ public class ApiKeyUser : IUser

#region Initialization

public ApiKeyUser(string key)
public ApiKeyUser(string key, params string[] roles)
{
Key = key;
Roles = roles;
}

#endregion
Expand All @@ -20,6 +21,8 @@ public ApiKeyUser(string key)

public string Key { get; }

public string[] Roles { get; }

#endregion

}
3 changes: 2 additions & 1 deletion Modules/Authentication/Basic/BasicAuthenticationUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

namespace GenHTTP.Modules.Authentication.Basic;

public record BasicAuthenticationUser(string Name) : IUser
public record BasicAuthenticationUser(string Name, params string[] Roles) : IUser
{

public string DisplayName => Name;

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public sealed class ClientCertificateUser : IUser
/// </summary>
public X509Certificate Certificate { get; }

public string[] Roles { get; }

#endregion

#region Initialization
Expand All @@ -30,9 +32,10 @@ public sealed class ClientCertificateUser : IUser
/// Creates a new user instance for the given certificate.
/// </summary>
/// <param name="clientCertificate">The certificate the client authenticated with</param>
public ClientCertificateUser(X509Certificate clientCertificate)
public ClientCertificateUser(X509Certificate clientCertificate, params string[] roles)
{
Certificate = clientCertificate;
Roles = roles;
}

#endregion
Expand Down
4 changes: 3 additions & 1 deletion Modules/Authentication/GenHTTP.Modules.Authentication.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@

<ProjectReference Include="..\..\API\GenHTTP.Api.csproj"/>

<ProjectReference Include="..\Reflection\GenHTTP.Modules.Reflection.csproj" />

<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.2.1" />

<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All"/>

<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />

</ItemGroup>

</Project>
20 changes: 20 additions & 0 deletions Modules/Authentication/RequireRoleAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using GenHTTP.Modules.Authentication.Roles;
using GenHTTP.Modules.Reflection;

namespace GenHTTP.Modules.Authentication;

/// <summary>
/// When annotated on a service method, requests will only be allowed
/// if the authenticated user has the specified roles.
/// </summary>
/// <param name="roles">The roles which need to be present in order to let the request pass</param>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class RequireRoleAttribute(params string[] roles) : InterceptWithAttribute<RoleInterceptor>
{

/// <summary>
/// The roles which need to be present in order to let the request pass.
/// </summary>
public string[] Roles => roles;

}
61 changes: 61 additions & 0 deletions Modules/Authentication/Roles/RoleInterceptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using GenHTTP.Api.Content;
using GenHTTP.Api.Content.Authentication;
using GenHTTP.Api.Protocol;

using GenHTTP.Modules.Reflection;
using GenHTTP.Modules.Reflection.Operations;

namespace GenHTTP.Modules.Authentication.Roles;

public class RoleInterceptor : IOperationInterceptor
{
private string[]? _Roles;

public void Configure(object attribute)
{
if (attribute is RequireRoleAttribute roleAttribute)
{
_Roles = roleAttribute.Roles;
}
}

public ValueTask<InterceptionResult?> InterceptAsync(IRequest request, Operation operation, IReadOnlyDictionary<string, object?> arguments)
{
if (_Roles?.Length > 0)
{
var user = request.GetUser<IUser>();

if (user == null)
{
throw new ProviderException(ResponseStatus.Unauthorized, "Authorization required to access this endpoint");
}

var userRoles = user.Roles;

var missing = new List<string>(_Roles.Length);

if (userRoles != null)
{
foreach (var role in _Roles)
{
if (!userRoles.Contains(role, StringComparer.OrdinalIgnoreCase))
{
missing.Add(role);
}
}
}
else
{
missing.AddRange(_Roles);
}

if (missing.Count > 0)
{
throw new ProviderException(ResponseStatus.Forbidden, $"User is not authorized to access this endpoint.");
}
}

return default;
}

}
2 changes: 1 addition & 1 deletion Modules/Reflection/InterceptWithAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
/// <remarks>
/// Allows to implement concerns on operation level such as authorization.
/// </remarks>
[AttributeUsage(AttributeTargets.Method)]
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class InterceptWithAttribute<T> : Attribute where T : IOperationInterceptor, new()
{

Expand Down
139 changes: 139 additions & 0 deletions Testing/Acceptance/Modules/Authentication/RoleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
using System.Net;

using GenHTTP.Api.Content;
using GenHTTP.Api.Content.Authentication;
using GenHTTP.Api.Protocol;

using GenHTTP.Modules.Authentication;
using GenHTTP.Modules.Functional;

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace GenHTTP.Testing.Acceptance.Modules.Authentication;

[TestClass]
public class RoleTests
{

#region Supporting data structures

public class UserSettingConcernBuilder(IUser? user) : IConcernBuilder
{

public IConcern Build(IHandler content) => new UserSettingConcern(content, user);

}

public class UserSettingConcern : IConcern
{

public IHandler Content { get; }

public IUser? User { get; }

public UserSettingConcern(IHandler content, IUser? user)
{
Content = content;
User = user;
}

public ValueTask PrepareAsync() => Content.PrepareAsync();

public ValueTask<IResponse?> HandleAsync(IRequest request)
{
if (User != null)
{
request.SetUser(User);
}

return Content.HandleAsync(request);
}

}

public class RoleUser(string[]? roles) : IUser
{

public string DisplayName => "Role User";

public string[]? Roles => roles;

}

#endregion

#region Tests

[TestMethod]
[MultiEngineTest]
public async Task TestNoUser(TestEngine engine)
{
using var response = await RunAsync(null, engine);

await response.AssertStatusAsync(HttpStatusCode.Unauthorized);
}

[TestMethod]
[MultiEngineTest]
public async Task TestNoRoles(TestEngine engine)
{
using var response = await RunAsync(new RoleUser(null), engine);

await response.AssertStatusAsync(HttpStatusCode.Forbidden);
}

[TestMethod]
[MultiEngineTest]
public async Task TestInsufficientRoles(TestEngine engine)
{
using var response = await RunAsync(new RoleUser(["ADMIN"]), engine);

await response.AssertStatusAsync(HttpStatusCode.Forbidden);
}

[TestMethod]
[MultiEngineTest]
public async Task TestSufficientRoles(TestEngine engine)
{
using var response = await RunAsync(new RoleUser(["ADMIN", "SUPER_ADMIN"]), engine);

await response.AssertStatusAsync(HttpStatusCode.OK);
}

[TestMethod]
[MultiEngineTest]
public async Task TestCasingDoesNotMatter(TestEngine engine)
{
using var response = await RunAsync(new RoleUser(["admin", "Super_Admin"]), engine);

await response.AssertStatusAsync(HttpStatusCode.OK);
}


[TestMethod]
[MultiEngineTest]
public async Task TestOtherRolesDoNotMatter(TestEngine engine)
{
using var response = await RunAsync(new RoleUser(["ADMIN", "USER", "SUPER_ADMIN"]), engine);

await response.AssertStatusAsync(HttpStatusCode.OK);
}

#endregion

#region Helpers

private static async Task<HttpResponseMessage> RunAsync(IUser? user, TestEngine engine)
{
var app = Inline.Create()
.Get([RequireRole("ADMIN", "SUPER_ADMIN")]() => 42)
.Add(new UserSettingConcernBuilder(user));

await using var host = await TestHost.RunAsync(app, engine: engine);

return await host.GetResponseAsync();
}

#endregion

}

0 comments on commit 0571d47

Please sign in to comment.