Skip to content

Commit

Permalink
Added JWT issuing endpoint
Browse files Browse the repository at this point in the history
- Added ability to exchange username and password for a token
- Added ITokenService that can be used from tests and from this new endpoint to generate JWT tokens
  • Loading branch information
davidfowl committed Nov 25, 2022
1 parent 63c221d commit f7235f9
Show file tree
Hide file tree
Showing 13 changed files with 165 additions and 127 deletions.
10 changes: 10 additions & 0 deletions Requests/todo.http
Expand Up @@ -9,6 +9,16 @@ Content-Type: application/json
"password": "{{password}}"
}

### Get a token

POST http://localhost:5000/users/token
Content-Type: application/json

{
"username": "myuser",
"password": "{{password}}"
}

### Get Todos
@token = <put JWT token here>

Expand Down
77 changes: 0 additions & 77 deletions TodoApi.Tests/JwtIssuer.cs

This file was deleted.

1 change: 1 addition & 0 deletions TodoApi.Tests/TodoApi.Tests.csproj
Expand Up @@ -23,6 +23,7 @@
<ItemGroup>
<Using Include="System.Net.Http.Json" />
<Using Include="System.Net.Http" />
<Using Include="System.Net" />
<Using Include="Xunit" />
</ItemGroup>

Expand Down
1 change: 0 additions & 1 deletion TodoApi.Tests/TodoApiTests.cs
@@ -1,4 +1,3 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;

namespace TodoApi.Tests;
Expand Down
46 changes: 4 additions & 42 deletions TodoApi.Tests/TodoApplication.cs
Expand Up @@ -8,7 +8,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;

namespace TodoApi.Tests;

Expand All @@ -23,12 +22,12 @@ public TodoDbContext CreateTodoDbContext()
return db;
}

public async Task CreateUserAsync(string username)
public async Task CreateUserAsync(string username, string? password = null)
{
using var scope = Services.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<TodoUser>>();
var newUser = new TodoUser { UserName = username };
var result = await userManager.CreateAsync(newUser, Guid.NewGuid().ToString());
var result = await userManager.CreateAsync(newUser, password ?? Guid.NewGuid().ToString());
Assert.True(result.Succeeded);
}

Expand Down Expand Up @@ -88,46 +87,9 @@ private string CreateToken(string id, bool isAdmin = false)
{
// Read the user JWTs configuration for testing so unit tests can generate
// JWT tokens.
var tokenService = Services.GetRequiredService<ITokenService>();

var configuration = Services.GetRequiredService<IConfiguration>();
var bearerSection = configuration.GetSection("Authentication:Schemes:Bearer");
var section = bearerSection.GetSection("SigningKeys:0");
var issuer = section["Issuer"];
var signingKeyBase64 = section["Value"];

Assert.NotNull(issuer);
Assert.NotNull(signingKeyBase64);

var signingKeyBytes = Convert.FromBase64String(signingKeyBase64);

var audiences = bearerSection.GetSection("ValidAudiences").GetChildren().Select(s =>
{
var audience = s.Value;
Assert.NotNull(audience);
return audience;
}).ToList();

var jwtIssuer = new JwtIssuer(issuer, signingKeyBytes);

var roles = new List<string>();

if (isAdmin)
{
roles.Add("admin");
}

var token = jwtIssuer.Create(new(
JwtBearerDefaults.AuthenticationScheme,
Name: id,
Audiences: audiences,
Issuer: jwtIssuer.Issuer,
NotBefore: DateTime.UtcNow,
ExpiresOn: DateTime.UtcNow.AddDays(1),
Roles: roles,
Scopes: new List<string> { },
Claims: new Dictionary<string, string> { }));

return JwtIssuer.WriteToken(token);
return tokenService.GenerateToken(id, isAdmin);
}

protected override void Dispose(bool disposing)
Expand Down
40 changes: 39 additions & 1 deletion TodoApi.Tests/UserApiTests.cs
Expand Up @@ -9,7 +9,7 @@ public async Task CanCreateAUser()
using var db = application.CreateTodoDbContext();

var client = application.CreateClient();
var response = await client.PostAsJsonAsync("/users", new NewUser { Username = "todouser", Password = "@pwd" });
var response = await client.PostAsJsonAsync("/users", new UserInfo { Username = "todouser", Password = "@pwd" });

Assert.True(response.IsSuccessStatusCode);

Expand All @@ -18,4 +18,42 @@ public async Task CanCreateAUser()

Assert.Equal("todouser", user.UserName);
}

[Fact]
public async Task CanGetATokenForValidUser()
{
await using var application = new TodoApplication();
using var db = application.CreateTodoDbContext();
await application.CreateUserAsync("todouser", "p@assw0rd1");

var client = application.CreateClient();
var response = await client.PostAsJsonAsync("/users/token", new UserInfo { Username = "todouser", Password = "p@assw0rd1" });

Assert.True(response.IsSuccessStatusCode);

var token = await response.Content.ReadFromJsonAsync<AuthToken>();

Assert.NotNull(token);

// Check that the token is indeed valid

var req = new HttpRequestMessage(HttpMethod.Get, "/todos");
req.Headers.Authorization = new("Bearer", token.Token);
response = await client.SendAsync(req);

Assert.True(response.IsSuccessStatusCode);
}

[Fact]
public async Task BadRequestForInvalidCredentials()
{
await using var application = new TodoApplication();
using var db = application.CreateTodoDbContext();
await application.CreateUserAsync("todouser", "p@assw0rd1");

var client = application.CreateClient();
var response = await client.PostAsJsonAsync("/users/token", new UserInfo { Username = "todouser", Password = "prd1" });

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
79 changes: 79 additions & 0 deletions TodoApi/Authentication/TokenService.cs
@@ -0,0 +1,79 @@
using System.Data;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

namespace TodoApi;

public static class AuthenticationServiceExtensions
{
public static IServiceCollection AddTokenService(this IServiceCollection services)
{
return services.AddSingleton<ITokenService, TokenService>();
}
}

public interface ITokenService
{
string GenerateToken(string username, bool isAdmin = false);
}

public class TokenService : ITokenService
{
private readonly string _issuer;
private readonly SigningCredentials _jwtSigningCredentials;
private readonly string[] _audiences;

public TokenService(IAuthenticationConfigurationProvider authenticationConfigurationProvider)
{
var bearerSection = authenticationConfigurationProvider.GetSchemeConfiguration(JwtBearerDefaults.AuthenticationScheme);

var section = bearerSection.GetSection("SigningKeys:0");

_issuer = section["Issuer"] ?? throw new InvalidOperationException("Issuer is not specifed");
var signingKeyBase64 = section["Value"] ?? throw new InvalidOperationException("Signing key is not specified");

_jwtSigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Convert.FromBase64String(signingKeyBase64)),
SecurityAlgorithms.HmacSha256Signature);

_audiences = bearerSection.GetSection("ValidAudiences").GetChildren()
.Where(s => !string.IsNullOrEmpty(s.Value))
.Select(s => s.Value!)
.ToArray();
}

public string GenerateToken(string username, bool isAdmin = false)
{
var identity = new ClaimsIdentity(JwtBearerDefaults.AuthenticationScheme);

identity.AddClaim(new Claim(JwtRegisteredClaimNames.Sub, username));

// REVIEW: Check that this logic is OK for jti claims
var id = Guid.NewGuid().ToString().GetHashCode().ToString("x", CultureInfo.InvariantCulture);

identity.AddClaim(new Claim(JwtRegisteredClaimNames.Jti, id));

if (isAdmin)
{
identity.AddClaim(new Claim(ClaimTypes.Role, "admin"));
}

identity.AddClaims(_audiences.Select(aud => new Claim(JwtRegisteredClaimNames.Aud, aud)));

var handler = new JwtSecurityTokenHandler();

var jwtToken = handler.CreateJwtSecurityToken(
_issuer,
audience: null,
identity,
notBefore: DateTime.UtcNow,
expires: DateTime.UtcNow.AddMinutes(30),
issuedAt: DateTime.UtcNow,
_jwtSigningCredentials);

return handler.WriteToken(jwtToken);
}
}
2 changes: 1 addition & 1 deletion TodoApi/Authorization/CurrentUser.cs
Expand Up @@ -8,6 +8,6 @@ public class CurrentUser
public TodoUser? User { get; set; }
public ClaimsPrincipal Principal { get; set; } = default!;

public string Id => Principal.Identity!.Name!;
public string Id => Principal.FindFirstValue(ClaimTypes.NameIdentifier)!;
public bool IsAdmin => Principal.IsInRole("admin");
}
3 changes: 2 additions & 1 deletion TodoApi/Authorization/CurrentUserExtensions.cs
@@ -1,3 +1,4 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;

namespace TodoApi;
Expand All @@ -19,7 +20,7 @@ public static IApplicationBuilder UseCurrentUser(this IApplicationBuilder app)
currentUser.Principal = context.User;
// Only query the database if the user is authenticated
if (context.User is { Identity: { IsAuthenticated: true, Name: var name and not null } })
if (context.User.FindFirstValue(ClaimTypes.NameIdentifier) is { Length: > 0 } name)
{
// Resolve the user manager and see if the current user is a valid user in the database
// we do this once and store it on the current user.
Expand Down
3 changes: 3 additions & 0 deletions TodoApi/Program.cs
Expand Up @@ -8,6 +8,9 @@
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorizationBuilder().AddCurrentUserHandler();

// Add the service to generate JWT tokens
builder.Services.AddTokenService();

// Configure the database
var connectionString = builder.Configuration.GetConnectionString("Todos") ?? "Data Source=Todos.db";
builder.Services.AddSqlite<TodoDbContext>(connectionString);
Expand Down
3 changes: 3 additions & 0 deletions TodoApi/Users/AuthToken.cs
@@ -0,0 +1,3 @@
namespace TodoApi;

public record AuthToken(string Token);
4 changes: 2 additions & 2 deletions TodoApi/Users/TodoUser.cs
Expand Up @@ -4,10 +4,10 @@ namespace TodoApi;

public class TodoUser : IdentityUser
{

}

public class NewUser
public class UserInfo
{
public string? Username { get; set; }
public string? Password { get; set; }
Expand Down

0 comments on commit f7235f9

Please sign in to comment.