From ff4493e4affd4d65a78259315d40c0d43c59fc00 Mon Sep 17 00:00:00 2001 From: Daniel Peinhopf <84123899+sevensolutions@users.noreply.github.com> Date: Sun, 6 Oct 2024 17:15:21 +0200 Subject: [PATCH] Better authorization for Management API using JWT tokens. --- examples/agent.dev.hcl | 9 ++- src/NomadIIS/ManagementApi/Authentication.cs | 58 +++++++++++++++---- src/NomadIIS/ManagementApi/Authorization.cs | 23 ++++++++ .../ManagementApi/ManagementApiController.cs | 30 ++++++++++ src/NomadIIS/NomadIIS.csproj | 1 + src/NomadIIS/Program.cs | 5 +- 6 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 src/NomadIIS/ManagementApi/Authorization.cs diff --git a/examples/agent.dev.hcl b/examples/agent.dev.hcl index 4286b3b..d080b84 100644 --- a/examples/agent.dev.hcl +++ b/examples/agent.dev.hcl @@ -3,8 +3,15 @@ log_level = "TRACE" +# Example JWT Token: +# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InN0YXRpYyJ9.eyJpc3MiOiJOb21hZElJUyIsImF1ZCI6Ik1hbmFnZW1lbnRBcGkiLCJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjk5OTk5OTk5OTksImpvYiI6WyIqIl0sImFsbG9jSWQiOlsiKiJdfQ.8MZL54z4pBw9pFk3jP4Yqy7kuKLgjeEXdaEdWI6GmgM + plugin "nomad_iis" { - args = ["--management-api-port=5004", "--management-api-key=12345"] + args = [ + "--management-api-port=5004", + "--management-api-key=12345", + "--management-api-jwt-secret=VETkEPWkaVTxWf7J4Mm20KJWOx2cK4S7VvoP3ybjh6fr9P9PXvyhlY8HV2Jgxm2O" + ] config { enabled = true, fingerprint_interval = "10s" diff --git a/src/NomadIIS/ManagementApi/Authentication.cs b/src/NomadIIS/ManagementApi/Authentication.cs index 2ebf17c..158f5b5 100644 --- a/src/NomadIIS/ManagementApi/Authentication.cs +++ b/src/NomadIIS/ManagementApi/Authentication.cs @@ -2,9 +2,12 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using NomadIIS.ManagementApi; using System; +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Text; using System.Text.Encodings.Web; using System.Threading.Tasks; @@ -19,6 +22,7 @@ public sealed class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { public string HeaderName { get; set; } = "X-Api-Key"; public string? ApiKey { get; set; } + public string? ApiJwtSecret { get; set; } } public sealed class ApiKeyAuthenticationHandler : AuthenticationHandler @@ -28,28 +32,60 @@ public ApiKeyAuthenticationHandler ( IOptionsMonitor HandleAuthenticateAsync () + protected override async Task HandleAuthenticateAsync () { - if ( string.IsNullOrEmpty( Options.ApiKey ) ) + if ( string.IsNullOrEmpty( Options.ApiKey ) && string.IsNullOrEmpty( Options.ApiJwtSecret ) ) { - return Task.FromResult( - AuthenticateResult.Success( - new AuthenticationTicket( new ClaimsPrincipal( new ClaimsIdentity( Scheme.Name ) ), Scheme.Name ) ) ); + return AuthenticateResult.Success( + new AuthenticationTicket( new ClaimsPrincipal( new ClaimsIdentity( Scheme.Name ) ), Scheme.Name ) ); } if ( !Request.Headers.ContainsKey( Options.HeaderName ) ) - return Task.FromResult( AuthenticateResult.Fail( $"Missing header {Options.HeaderName}." ) ); + return AuthenticateResult.Fail( $"Missing header {Options.HeaderName}." ); string headerValue = Request.Headers[Options.HeaderName]!; - if ( headerValue != Options.ApiKey ) - return Task.FromResult( AuthenticateResult.Fail( "Invalid token." ) ); + if ( !string.IsNullOrEmpty( Options.ApiKey ) && headerValue == Options.ApiKey ) + { + var principal = new ClaimsPrincipal( new ClaimsIdentity( Scheme.Name ) ); + + var ticket = new AuthenticationTicket( principal, Scheme.Name ); + + return AuthenticateResult.Success( ticket ); + } + + if ( !string.IsNullOrEmpty( Options.ApiJwtSecret ) ) + { + var validationResult = await new JwtSecurityTokenHandler() + .ValidateTokenAsync( headerValue, new TokenValidationParameters() + { + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes( Options.ApiJwtSecret ) ) + { + KeyId = "static" + }, + ValidIssuer = "NomadIIS", + ValidateIssuer = true, + ValidAudience = "ManagementApi", + ValidateAudience = true + } ); + + if ( !validationResult.IsValid ) + { + Logger.LogWarning( validationResult.Exception, "Received invalid JWT token for Management API." ); + + return AuthenticateResult.Fail( "Invalid token." ); + } - var principal = new ClaimsPrincipal( new ClaimsIdentity( Scheme.Name ) ); + var principal = new ClaimsPrincipal( validationResult.ClaimsIdentity ); - var ticket = new AuthenticationTicket( principal, Scheme.Name ); + var ticket = new AuthenticationTicket( principal, Scheme.Name ); + + return AuthenticateResult.Success( ticket ); + } - return Task.FromResult( AuthenticateResult.Success( ticket ) ); + return AuthenticateResult.Fail( "Invalid token." ); } } } diff --git a/src/NomadIIS/ManagementApi/Authorization.cs b/src/NomadIIS/ManagementApi/Authorization.cs new file mode 100644 index 0000000..9685b36 --- /dev/null +++ b/src/NomadIIS/ManagementApi/Authorization.cs @@ -0,0 +1,23 @@ +#if MANAGEMENT_API +using Microsoft.AspNetCore.Http; +using NomadIIS.Services; +using System; + +namespace NomadIIS.ManagementApi; + +public static class Authorization +{ + public static bool IsAuthorized ( this IisTaskHandle taskHandle, HttpContext httpContext ) + { + var jobName = taskHandle.TaskConfig?.JobName ?? throw new InvalidOperationException(); + var allocId = taskHandle.TaskConfig?.AllocId ?? throw new InvalidOperationException(); + + if ( !httpContext.User.HasClaim( "job", jobName ) && !httpContext.User.HasClaim( "job", "*" ) ) + return false; + if ( !httpContext.User.HasClaim( "allocId", allocId ) && !httpContext.User.HasClaim( "allocId", "*" ) ) + return false; + + return true; + } +} +#endif diff --git a/src/NomadIIS/ManagementApi/ManagementApiController.cs b/src/NomadIIS/ManagementApi/ManagementApiController.cs index 42a0072..c2b3d02 100644 --- a/src/NomadIIS/ManagementApi/ManagementApiController.cs +++ b/src/NomadIIS/ManagementApi/ManagementApiController.cs @@ -28,6 +28,9 @@ public async Task GetStatus ( string allocId, string taskName ) if ( taskHandle is null ) return NotFound(); + if ( !taskHandle.IsAuthorized( HttpContext ) ) + return Forbid(); + var status = await taskHandle.GetStatusAsync(); return Ok( status ); @@ -41,6 +44,9 @@ public async Task StartAppPool ( string allocId, string taskName if ( taskHandle is null ) return NotFound(); + if ( !taskHandle.IsAuthorized( HttpContext ) ) + return Forbid(); + await taskHandle.StartAppPoolAsync(); return Ok(); @@ -53,6 +59,9 @@ public async Task StopAppPool ( string allocId, string taskName ) if ( taskHandle is null ) return NotFound(); + if ( !taskHandle.IsAuthorized( HttpContext ) ) + return Forbid(); + await taskHandle.StopAppPoolAsync(); return Ok(); @@ -65,6 +74,9 @@ public async Task RecycleAppPool ( string allocId, string taskNam if ( taskHandle is null ) return NotFound(); + if ( !taskHandle.IsAuthorized( HttpContext ) ) + return Forbid(); + await taskHandle.RecycleAppPoolAsync(); return Ok(); @@ -78,6 +90,9 @@ public async Task GetFileAsync ( string allocId, string taskName, if ( taskHandle is null ) return NotFound(); + if ( !taskHandle.IsAuthorized( HttpContext ) ) + return Forbid(); + path = HttpUtility.UrlDecode( path ); await taskHandle.DownloadFileAsync( HttpContext.Response, path ); @@ -92,6 +107,9 @@ public async Task PutFileAsync ( string allocId, string taskName, if ( taskHandle is null ) return NotFound(); + if ( !taskHandle.IsAuthorized( HttpContext ) ) + return Forbid(); + path = HttpUtility.UrlDecode( path ); var isZip = HttpContext.Request.ContentType == "application/zip"; @@ -108,6 +126,9 @@ public async Task PatchFileAsync ( string allocId, string taskNam if ( taskHandle is null ) return NotFound(); + if ( !taskHandle.IsAuthorized( HttpContext ) ) + return Forbid(); + path = HttpUtility.UrlDecode( path ); var isZip = HttpContext.Request.ContentType == "application/zip"; @@ -124,6 +145,9 @@ public async Task DeleteFileAsync ( string allocId, string taskNa if ( taskHandle is null ) return NotFound(); + if ( !taskHandle.IsAuthorized( HttpContext ) ) + return Forbid(); + path = HttpUtility.UrlDecode( path ); await taskHandle.DeleteFileAsync( path ); @@ -140,6 +164,9 @@ public async Task DeleteFileAsync ( string allocId, string taskNa if ( taskHandle is null ) return NotFound(); + if ( !taskHandle.IsAuthorized( HttpContext ) ) + return Forbid(); + var screenshot = await taskHandle.TakeScreenshotAsync( path, cancellationToken ); if ( screenshot is null ) @@ -156,6 +183,9 @@ public async Task DeleteFileAsync ( string allocId, string taskNa if ( taskHandle is null ) return NotFound(); + if ( !taskHandle.IsAuthorized( HttpContext ) ) + return Forbid(); + var dumpFile = new FileInfo( Path.GetTempFileName() + ".dmp" ); try diff --git a/src/NomadIIS/NomadIIS.csproj b/src/NomadIIS/NomadIIS.csproj index a076368..5d12bb7 100644 --- a/src/NomadIIS/NomadIIS.csproj +++ b/src/NomadIIS/NomadIIS.csproj @@ -27,6 +27,7 @@ + diff --git a/src/NomadIIS/Program.cs b/src/NomadIIS/Program.cs index d854dfc..f948fbc 100644 --- a/src/NomadIIS/Program.cs +++ b/src/NomadIIS/Program.cs @@ -91,6 +91,8 @@ #if MANAGEMENT_API var mgmtApiKey = builder.Configuration.GetValue( "management-api-key" ); +var mgmtApiJwtSecret = builder.Configuration.GetValue( "management-api-jwt-secret" ); +var needsAuthorization = !string.IsNullOrEmpty( mgmtApiKey ) || !string.IsNullOrEmpty( mgmtApiJwtSecret ); if ( managementApiPort > 0 ) { @@ -102,6 +104,7 @@ .AddApiKey( config => { config.ApiKey = mgmtApiKey; + config.ApiJwtSecret = mgmtApiJwtSecret; } ); builder.Services.AddAuthorization(); @@ -141,7 +144,7 @@ .MapControllers() .RequireHost( $"*:{managementApiPort}" ); - if ( !string.IsNullOrEmpty( mgmtApiKey ) ) + if ( needsAuthorization ) epApi.RequireAuthorization(); } #endif