Skip to content

Commit

Permalink
OpenID Connect authentication (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielgerlag authored Dec 26, 2019
1 parent 526b92e commit 4012809
Show file tree
Hide file tree
Showing 21 changed files with 520 additions and 93 deletions.
35 changes: 35 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Authentication

Conductor supports integrated authentication using the [OpenID Connect](https://openid.net/connect/) protocol.

By default, authentication is disabled. To enable it,
* Set the `auth` environment variable to `'true'`
* Set the `alg` environment variable to the signing algorithm (`RS256` or `ES256`)
* Set the `publickey` variable to a Base64 encoded public key.

If authentication is enabled then you need to include a signed [JWT bearer token](https://jwt.io/) along with every request. The is done by adding the `Authorization: Bearer <<token>>` header to each request.
The token should be a valid JWT token that was signed with the corresponding private key to the public one in the environment variable.

The token must also include a scope claim that indicate the level of access. The following scopes are used within Conductor.

* `conductor:admin` - Adminstrative tasks.
* `conductor:author` - Authoring of workflow definitions and steps.
* `conductor:controller` - Starting, stopping, suspending and resuming workflows.
* `conductor:viewer` - Querying the status of a workflow.

A minimal JWT payload the include all the scopes would look as follows

```json
{
"scope": "conductor:admin conductor:author conductor:controller conductor:viewer"
}
```

Some authentication servers that support [OpenID Connect](https://openid.net/connect/) include

* [Auth0](https://auth0.com/) - A cloud service
* [Okta](https://www.okta.com/) - A cloud service
* [Keycloak](https://github.com/keycloak/keycloak/) - Open source auth server
* [Identity Server](https://identityserver.io/) - Open source auth server
* [Dex](https://github.com/dexidp/dex) - Open source auth server

8 changes: 4 additions & 4 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Conductor uses MongoDB as it's datastore, you will also need an instance of Mong
Use this command to start a container (with the API available on port 5001) that points to `mongodb://my-mongo-server:27017/` as it's datastore.

```
$ docker run -p 127.0.0.1:5001:80/tcp --env DBHOST=mongodb://my-mongo-server:27017/ danielgerlag/conductor
$ docker run -p 127.0.0.1:5001:80/tcp --env dbhost=mongodb://my-mongo-server:27017/ danielgerlag/conductor
```

If you wish to run a fleet of Conductor nodes, then you also need to have a Redis instance, which they will use as a backplane. This is not required if you are only running one instance.
Expand All @@ -18,8 +18,8 @@ Simply have all your conductor instances point to the same MongoDB and Redis ins
You can configure the database and Redis backplane by setting environment variables.

```
DBHOST: <<insert connection string to your MongoDB server>>
REDIS: <<insert connection string to your Redis server>> (optional)
dbhost: <<insert connection string to your MongoDB server>>
redis: <<insert connection string to your Redis server>> (optional)
```

If you would like to setup a conductor container (API on port 5001) and a MongoDB container at the same time and have them linked, use this docker compose file:
Expand All @@ -34,7 +34,7 @@ services:
links:
- mongo
environment:
DBHOST: mongodb://mongo:27017/
dbhost: mongodb://mongo:27017/
mongo:
image: mongo
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ nav:
- Custom Steps: custom-steps.md
- Http Requests: http-walkthru.md
- API Reference: api-reference.md
- Authentication: auth.md
- Roadmap: roadmap.md
theme: readthedocs
8 changes: 4 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Conductor uses MongoDB as it's datastore, you will also need an instance of Mong
Use this command to start a container (with the API available on port 5001) that points to `mongodb://my-mongo-server:27017/` as it's datastore.

```bash
$ docker run -p 127.0.0.1:5001:80/tcp --env DBHOST=mongodb://my-mongo-server:27017/ danielgerlag/conductor
$ docker run -p 127.0.0.1:5001:80/tcp --env dbhost=mongodb://my-mongo-server:27017/ danielgerlag/conductor
```

If you wish to run a fleet of Conductor nodes, then you also need to have a Redis instance, which they will use as a backplane. This is not required if you are only running one instance.
Expand All @@ -23,8 +23,8 @@ Simply have all your conductor instances point to the same MongoDB and Redis ins

You can configure the database and Redis backplane by setting environment variables.
```
DBHOST: <<insert connection string to your MongoDB server>>
REDIS: <<insert connection string to your Redis server>> (optional)
dbhost: <<insert connection string to your MongoDB server>>
redis: <<insert connection string to your Redis server>> (optional)
```

If you would like to setup a conductor container (API on port 5001) and a MongoDB container at the same time and have them linked, use this docker compose file:
Expand All @@ -39,7 +39,7 @@ services:
links:
- mongo
environment:
DBHOST: mongodb://mongo:27017/
dbhost: mongodb://mongo:27017/
mongo:
image: mongo
```
Expand Down
137 changes: 137 additions & 0 deletions src/Conductor/Auth/AuthExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Security;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Threading.Tasks;

namespace Conductor.Auth
{
public static class AuthExtensions
{

public static AuthenticationBuilder AddJwtAuth(this AuthenticationBuilder builder, IConfiguration config)
{
var signingKey = LoadKey(config);

builder.AddJwtBearer(options =>
{
options.IncludeErrorDetails = true;
options.RequireHttpsMetadata = false;

options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
ValidateIssuer = false,
ValidateAudience = false,
RequireExpirationTime = false
};

options.Validate();
});

return builder;
}

public static AuthenticationBuilder AddBypassAuth(this AuthenticationBuilder builder)
{
var tokenHandler = new JwtSecurityTokenHandler();
var securityKey = new SymmetricSecurityKey(new byte[121]);
var sc = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim("scope", $"{Permissions.Admin} {Permissions.Author} {Permissions.Controller} {Permissions.Viewer}")
}),
SigningCredentials = sc,
};

var token = tokenHandler.CreateToken(tokenDescriptor);
var tokenString = tokenHandler.WriteToken(token);

builder.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = false,
IssuerSigningKey = securityKey,
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = false,
};
options.RequireHttpsMetadata = false;
options.Events = new JwtBearerEvents()
{
OnMessageReceived = context =>
{
context.Token = tokenString;
return Task.CompletedTask;
}
};
options.Validate();
});

return builder;
}

public static void AddPolicies(this IServiceCollection services)
{
services.AddAuthorization(options =>
{
options.AddPolicy(Policies.Admin, policy => policy.RequireAssertion(context => context.User.Claims.Any(x => x.Type == "scope" && x.Value.Split(' ').Contains(Permissions.Admin))));
options.AddPolicy(Policies.Author, policy => policy.RequireAssertion(context => context.User.Claims.Any(x => x.Type == "scope" && x.Value.Split(' ').Contains(Permissions.Author))));
options.AddPolicy(Policies.Controller, policy => policy.RequireAssertion(context => context.User.Claims.Any(x => x.Type == "scope" && x.Value.Split(' ').Contains(Permissions.Controller))));
options.AddPolicy(Policies.Viewer, policy => policy.RequireAssertion(context => context.User.Claims.Any(x => x.Type == "scope" && x.Value.Split(' ').Contains(Permissions.Viewer))));
});
}

private static SecurityKey LoadKey(IConfiguration config)
{
var publicKeyBase64 = EnvironmentVariables.PublicKey;
if (string.IsNullOrEmpty(publicKeyBase64))
publicKeyBase64 = config.GetSection("Auth").GetValue<string>("PublicKey");
var publicKey = Convert.FromBase64String(publicKeyBase64);

var algName = EnvironmentVariables.Alg;
if (string.IsNullOrEmpty(algName))
algName = config.GetSection("Auth").GetValue<string>("Algorithm");

if (algName.StartsWith("RS"))
{
var rsa = RSA.Create();
try
{
rsa.ImportSubjectPublicKeyInfo(publicKey, out _);
}
catch
{
rsa.ImportRSAPublicKey(publicKey, out _);
}
return new RsaSecurityKey(rsa);
}

if (algName.StartsWith("ES"))
{
var e1 = ECDsa.Create();
e1.ImportSubjectPublicKeyInfo(publicKey, out _);
return new ECDsaSecurityKey(e1);
}

throw new ArgumentException("Only RSA and ECDSA algorithms are supported");
}
}

}
15 changes: 15 additions & 0 deletions src/Conductor/Auth/Permissions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Conductor.Auth
{
public static class Permissions
{
public const string Admin = "conductor:admin";
public const string Viewer = "conductor:viewer";
public const string Controller = "conductor:controller";
public const string Author = "conductor:author";
}
}
15 changes: 15 additions & 0 deletions src/Conductor/Auth/Policies.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Conductor.Auth
{
public static class Policies
{
public const string Admin = "admin";
public const string Viewer = "viewer";
public const string Controller = "controller";
public const string Author = "author";
}
}
3 changes: 3 additions & 0 deletions src/Conductor/Conductor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@

<ItemGroup>
<PackageReference Include="AutoMapper" Version="9.0.0" />
<PackageReference Include="BouncyCastle.NetCore" Version="1.8.5" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="3.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.0" />
Expand Down
6 changes: 6 additions & 0 deletions src/Conductor/Controllers/DefinitionController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Conductor.Auth;
using Conductor.Domain.Interfaces;
using Conductor.Domain.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

Expand All @@ -21,12 +23,14 @@ public DefinitionController(IDefinitionService service)
}

[HttpGet]
[Authorize(Policy = Policies.Author)]
public ActionResult<IEnumerable<string>> Get()
{
return new string[] { "value1", "value2" };
}

[HttpGet("{id}")]
[Authorize(Policy = Policies.Author)]
public ActionResult<Definition> Get(string id)
{
var result = _service.GetDefinition(id);
Expand All @@ -38,6 +42,7 @@ public ActionResult<Definition> Get(string id)
}

[HttpPost]
[Authorize(Policy = Policies.Author)]
public void Post([FromBody] Definition value)
{
_service.RegisterNewDefinition(value);
Expand All @@ -52,6 +57,7 @@ public void Post([FromBody] Definition value)

// DELETE api/values/5
[HttpDelete("{id}")]
[Authorize(Policy = Policies.Author)]
public void Delete(int id)
{
}
Expand Down
5 changes: 3 additions & 2 deletions src/Conductor/Controllers/EventController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
using System.Dynamic;
using System.Linq;
using System.Threading.Tasks;
using Conductor.Auth;
using Conductor.Domain.Interfaces;
using Conductor.Domain.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
Expand All @@ -23,13 +25,12 @@ public EventController(IWorkflowController workflowController)
_workflowController = workflowController;
}

[Authorize(Policy = Policies.Controller)]
[HttpPost("{name}/{key}")]
public async Task Post(string name, string key, [FromBody] object data)
{
await _workflowController.PublishEvent(name, key, data);
Response.StatusCode = 204;
}


}
}
5 changes: 3 additions & 2 deletions src/Conductor/Controllers/InfoController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
using System.Reflection;
using System.Threading.Tasks;
using Conductor.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Conductor.Controllers
{
[Route("api/[controller]")]
[ApiController]
[ApiController]
public class InfoController : ControllerBase
{
[HttpGet]
[HttpGet]
public ActionResult<DiagnosticInfo> Get()
{
var process = Process.GetCurrentProcess();
Expand Down
Loading

0 comments on commit 4012809

Please sign in to comment.