diff --git a/src/Argon.Api/Argon.Api.csproj b/src/Argon.Api/Argon.Api.csproj index cfcf0e9..7b60522 100644 --- a/src/Argon.Api/Argon.Api.csproj +++ b/src/Argon.Api/Argon.Api.csproj @@ -1,42 +1,46 @@ - + net8.0 enable enable Linux + b90ebea2-7ea4-447f-b92f-46da1cfd6437 - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + + + + + + + - - + + @@ -45,4 +49,8 @@ + + + + diff --git a/src/Argon.Api/Extensions/JwtExtension.cs b/src/Argon.Api/Extensions/JwtExtension.cs deleted file mode 100644 index a7cdcd0..0000000 --- a/src/Argon.Api/Extensions/JwtExtension.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace Argon.Api.Extensions; - -using System.Text; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.IdentityModel.Tokens; - -public static class JwtExtension -{ - public static WebApplicationBuilder AddJwt(this WebApplicationBuilder builder) - { - builder.Services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; - }).AddJwtBearer(o => - { - o.TokenValidationParameters = new TokenValidationParameters - { - ValidIssuer = builder.Configuration["Jwt:Issuer"], - ValidAudience = builder.Configuration["Jwt:Audience"], - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])), - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true - }; - - o.Events = new JwtBearerEvents - { - OnMessageReceived = ctx => - { - if (ctx.Request.Headers.TryGetValue("x-argon-token", out var value)) - { - ctx.Token = value; - return Task.CompletedTask; - } - - ctx.Response.StatusCode = 401; - return Task.CompletedTask; - } - }; - }); - return builder; - } - -} \ No newline at end of file diff --git a/src/Argon.Api/Features/Jwt/JwtOptions.cs b/src/Argon.Api/Features/Jwt/JwtOptions.cs new file mode 100644 index 0000000..02abcee --- /dev/null +++ b/src/Argon.Api/Features/Jwt/JwtOptions.cs @@ -0,0 +1,74 @@ +namespace Argon.Api.Features.Jwt; + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection.Extensions; + +public record JwtOptions +{ + public required string Issuer { get; set; } + public required string Audience { get; set; } + // TODO use cert in production + public required string Key { get; set; } + public required TimeSpan Expires { get; set; } + + public void Deconstruct(out string issuer, out string audience, out string key) + { + audience = this.Audience; + issuer = this.Issuer; + key = this.Key; + } +} + + +public static class JwtFeature +{ + public static IServiceCollection AddJwt(this WebApplicationBuilder builder) + { + builder.Services.Configure(builder.Configuration.GetSection("Jwt")); + builder.Services.AddKeyedSingleton(JwtBearerDefaults.AuthenticationScheme, + (services, _) => + { + var options = services.GetRequiredService>(); + var (issuer, audience, key) = options.Value; + return new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidIssuer = issuer, + ValidAudience = audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), + ClockSkew = TimeSpan.Zero + }; + }); + + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(o => + { + o.Events = new JwtBearerEvents + { + OnMessageReceived = ctx => + { + if (ctx.Request.Headers.TryGetValue("x-argon-token", out var value)) + { + ctx.Token = value; + return Task.CompletedTask; + } + + ctx.Response.StatusCode = 401; + return Task.CompletedTask; + } + }; + }); + return builder.Services; + } +} \ No newline at end of file diff --git a/src/Argon.Api/Features/Rpc/FusionAuthorizationMiddleware.cs b/src/Argon.Api/Features/Rpc/FusionAuthorizationMiddleware.cs new file mode 100644 index 0000000..3fa8441 --- /dev/null +++ b/src/Argon.Api/Features/Rpc/FusionAuthorizationMiddleware.cs @@ -0,0 +1,57 @@ +namespace Argon.Api.Features.Rpc; + +using ActualLab.Rpc.Infrastructure; +using ActualLab.Rpc; +using MemoryPack; +using Microsoft.Extensions.Caching.Distributed; +using ActualLab; +using ActualLab.Reflection; +using Grains; +using Grains.Persistence.States; +using Microsoft.AspNetCore.Authorization; +using Orleans; + +public class FusionAuthorizationMiddleware(IServiceProvider Services, IGrainFactory GrainFactory) : RpcInboundMiddleware(Services) +{ + public AsyncLocal Token = new AsyncLocal(); + public override async Task OnBeforeCall(RpcInboundCall call) + { + var existAttribute = call.MethodDef.Method.GetAttributes(true, true).Count != 0; + + if (!existAttribute) + { + await base.OnBeforeCall(call); + return; + } + + var grain = GrainFactory.GetGrain(call.Context.Peer.Id); + + var state = await grain.GetState(); + if (state.IsAuthorized) + { + await base.OnBeforeCall(call); + return; + } + + call.Cancel(); + return; + } +} + +public class FusionServiceContext(IGrainFactory GrainFactory) : IFusionServiceContext +{ + public ValueTask GetSessionState() + { + var current = RpcInboundContext.GetCurrent(); + var peerId = current.Peer.Id; + + var grain = GrainFactory.GetGrain(peerId); + + return grain.GetState(); + } +} + +public interface IFusionServiceContext +{ + ValueTask GetSessionState(); +} \ No newline at end of file diff --git a/src/Argon.Api/Grains.Persistence.States/FusionSession.cs b/src/Argon.Api/Grains.Persistence.States/FusionSession.cs new file mode 100644 index 0000000..b3e81c0 --- /dev/null +++ b/src/Argon.Api/Grains.Persistence.States/FusionSession.cs @@ -0,0 +1,14 @@ +namespace Argon.Api.Grains.Persistence.States; + +using Argon.Sfu; +using MemoryPack; + +[GenerateSerializer] +[Serializable] +[MemoryPackable] +[Alias(nameof(FusionSession))] +public partial class FusionSession +{ + [Id(0)] public required Guid Id { get; set; } = Guid.Empty; + [Id(1)] public required bool IsAuthorized { get; set; } +} diff --git a/src/Argon.Api/Grains/FusionGrain.cs b/src/Argon.Api/Grains/FusionGrain.cs new file mode 100644 index 0000000..eeb7637 --- /dev/null +++ b/src/Argon.Api/Grains/FusionGrain.cs @@ -0,0 +1,40 @@ +namespace Argon.Api.Grains; + +using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.Tokens; +using Persistence.States; + +public class FusionGrain( + [PersistentState("sessions", "OrleansStorage")] + IPersistentState sessionStorage, + TokenValidationParameters JwtParameters) : Grain, IFusionSession +{ + public async ValueTask AuthorizeAsync(string token) + { + var tokenHandler = new JwtSecurityTokenHandler(); + tokenHandler.ValidateToken(token, JwtParameters, out SecurityToken validatedToken); + var jwt = (JwtSecurityToken)validatedToken; + + sessionStorage.State.Id = Guid.Parse(jwt.Id); + sessionStorage.State.IsAuthorized = true; + await sessionStorage.WriteStateAsync(); + return true; + } + + public async ValueTask GetState() + { + await sessionStorage.ReadStateAsync(); + return sessionStorage.State; + } + +} + + +public interface IFusionSession : IGrainWithGuidKey +{ + [Alias("AuthorizeAsync")] + ValueTask AuthorizeAsync(string token); + + [Alias("GetState")] + ValueTask GetState(); +} \ No newline at end of file diff --git a/src/Argon.Api/Program.cs b/src/Argon.Api/Program.cs index 343dfc0..f70f542 100644 --- a/src/Argon.Api/Program.cs +++ b/src/Argon.Api/Program.cs @@ -1,8 +1,12 @@ using ActualLab.Fusion; +using ActualLab.Fusion.Extensions; using ActualLab.Rpc; +using ActualLab.Rpc.Infrastructure; using ActualLab.Rpc.Server; using Argon.Api.Entities; using Argon.Api.Extensions; +using Argon.Api.Features.Jwt; +using Argon.Api.Features.Rpc; using Argon.Api.Filters; using Argon.Api.Migrations; using Argon.Api.Services; @@ -11,6 +15,7 @@ var builder = WebApplication.CreateBuilder(args); +builder.AddJwt(); builder.AddServiceDefaults(); builder.AddRedisOutputCache("cache"); builder.AddRabbitMQClient("rmq"); @@ -19,12 +24,13 @@ builder.Services.AddFusion(RpcServiceMode.Server, true) .Rpc.AddServer() .AddServer() + .AddInboundMiddleware() .AddWebSocketServer(true); builder.AddSwaggerWithAuthHeader(); -builder.AddJwt(); builder.Services.AddAuthorization(); builder.AddSelectiveForwardingUnit(); builder.Services.AddTransient(); +builder.Services.AddTransient(); builder.AddOrleans(); var app = builder.Build(); app.UseSwagger(); @@ -34,7 +40,8 @@ app.UseAuthorization(); app.MapControllers(); app.MapDefaultEndpoints(); +app.UseWebSockets(); app.MapRpcWebSocketServer(); var buildTime = File.GetLastWriteTimeUtc(typeof(Program).Assembly.Location); app.MapGet("/", () => new { buildTime }); -await app.WarpUp().RunAsync(); \ No newline at end of file +await app.WarpUp().RunAsync(); diff --git a/src/Argon.Api/Properties/launchSettings.json b/src/Argon.Api/Properties/launchSettings.json index d3290ae..8f8432f 100644 --- a/src/Argon.Api/Properties/launchSettings.json +++ b/src/Argon.Api/Properties/launchSettings.json @@ -12,7 +12,7 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "swagger", "applicationUrl": "http://localhost:5100", "environmentVariables": { @@ -22,7 +22,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "swagger", "applicationUrl": "https://localhost:7206;http://localhost:5100", "environmentVariables": { @@ -31,7 +31,7 @@ }, "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development"