Skip to content

Commit 6a1d292

Browse files
authored
Add Jwt Validation for Authentication/Authorization. (#14)
And, Apply to all controller endpoints. Also, provide a GenerateToken method (typically should be placed in a authorization server) for better testing use. Modify WebTestSupport to add a jwt token for HttpClient before sending each requests. Security Endpoints Integration tests. Closes: #13 Signed-off-by: Jiandong Ma <[email protected]>
1 parent 0a46396 commit 6a1d292

File tree

9 files changed

+183
-3
lines changed

9 files changed

+183
-3
lines changed

src/DotNetUnknown/DotNetUnknown.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0"/>
1515
<PackageReference Include="Serilog.Enrichers.Span" Version="3.1.0"/>
1616
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0"/>
17+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0"/>
1718
</ItemGroup>
1819

1920
</Project>

src/DotNetUnknown/Program.cs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
using Asp.Versioning;
1+
using System.Text;
2+
using Asp.Versioning;
23
using DotNetUnknown.Exception;
34
using DotNetUnknown.Logging;
5+
using DotNetUnknown.Security;
6+
using Microsoft.AspNetCore.Authentication.JwtBearer;
7+
using Microsoft.AspNetCore.Mvc.Authorization;
8+
using Microsoft.IdentityModel.Tokens;
49
using Serilog;
510
using Serilog.Enrichers.Span;
611

@@ -16,7 +21,7 @@
1621
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{SourceContext}] [{ThreadId}] [{TraceId} - {SpanId}] {Message:lj}{NewLine}{Exception}")
1722
);
1823

19-
builder.Services.AddControllers();
24+
builder.Services.AddControllers(options => { options.Filters.Add(new AuthorizeFilter()); });
2025
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
2126
builder.Services.AddProblemDetails();
2227

@@ -26,14 +31,47 @@
2631
options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version");
2732
}).AddMvc();
2833

34+
#region JWT Authentication & Authorization
35+
36+
var jwtSettings = builder.Configuration.GetRequiredSection("JwtSettings");
37+
var jwtKey = Encoding.ASCII.GetBytes(jwtSettings["Key"] ?? throw new InvalidOperationException());
38+
builder.Services.AddAuthentication(options =>
39+
{
40+
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
41+
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
42+
}).AddJwtBearer(options =>
43+
{
44+
options.RequireHttpsMetadata = false; // Set to true in production
45+
options.SaveToken = true;
46+
options.TokenValidationParameters = new TokenValidationParameters
47+
{
48+
ValidateIssuer = true,
49+
ValidateAudience = true,
50+
ValidateLifetime = true,
51+
ValidateIssuerSigningKey = true,
52+
ValidIssuer = jwtSettings["Issuer"],
53+
ValidAudience = jwtSettings["Audience"],
54+
IssuerSigningKey = new SymmetricSecurityKey(jwtKey)
55+
};
56+
});
57+
builder.Services.AddAuthorization();
58+
builder.Services.AddSingleton<JwtTokenUtils>();
59+
60+
#endregion
61+
2962
builder.Services.AddTransient<LoggingUtils>();
3063

3164
var app = builder.Build();
3265

3366
app.UseSerilogRequestLogging();
3467

3568
app.UseExceptionHandler();
69+
3670
app.UseRouting();
71+
72+
app.UseAuthentication();
73+
app.UseAuthorization();
74+
3775
app.MapControllers();
3876

3977
app.Run();
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.IdentityModel.Tokens.Jwt;
2+
using System.Security.Claims;
3+
using System.Text;
4+
using Microsoft.IdentityModel.Tokens;
5+
6+
namespace DotNetUnknown.Security;
7+
8+
public sealed class JwtTokenUtils(IConfiguration configuration)
9+
{
10+
/**
11+
* generate JWT token for client use.
12+
* usually called after a successful login. (in an authorization server)
13+
*/
14+
public string GenerateToken(UserInfo userInfo)
15+
{
16+
var jwtSettings = configuration.GetRequiredSection("JwtSettings");
17+
#nullable disable
18+
var jwtKey = Encoding.ASCII.GetBytes(jwtSettings["Key"]);
19+
#nullable enable
20+
var claims = new List<Claim>
21+
{
22+
new(ClaimTypes.NameIdentifier, userInfo.UserId),
23+
new(ClaimTypes.Name, userInfo.Username),
24+
new(ClaimTypes.Email, userInfo.Email),
25+
new("employee_status", userInfo.Status)
26+
};
27+
claims.AddRange(userInfo.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
28+
29+
var tokenDescriptor = new SecurityTokenDescriptor
30+
{
31+
Subject = new ClaimsIdentity(claims),
32+
Expires = DateTime.UtcNow.AddHours(1),
33+
Issuer = jwtSettings["Issuer"],
34+
Audience = jwtSettings["Audience"],
35+
SigningCredentials =
36+
new SigningCredentials(new SymmetricSecurityKey(jwtKey), SecurityAlgorithms.HmacSha256Signature)
37+
};
38+
var tokenHandler = new JwtSecurityTokenHandler();
39+
var token = tokenHandler.CreateToken(tokenDescriptor);
40+
return tokenHandler.WriteToken(token);
41+
}
42+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace DotNetUnknown.Security;
2+
3+
public record UserInfo(string UserId, string Username, string Email, List<string> Roles, string Status = "Active");

src/DotNetUnknown/appsettings.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,10 @@
55
"Microsoft.AspNetCore": "Warning"
66
}
77
},
8-
"AllowedHosts": "*"
8+
"AllowedHosts": "*",
9+
"JwtSettings": {
10+
"Key": "TXlTdXBlclNlY3JldEtleUZvcldlYkFwcGxpY2F0aW9uMTIzNDU=",
11+
"Issuer": "YourIssuer",
12+
"Audience": "YourAudience"
13+
}
914
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
4+
namespace DotNetUnknown.Tests.Security;
5+
6+
[ApiController]
7+
[Route("security")]
8+
public sealed class SecurityTestController : ControllerBase
9+
{
10+
[HttpGet]
11+
[Route("admin")]
12+
[Authorize(Roles = "Admin")]
13+
public IActionResult AdminData()
14+
{
15+
return Ok("this is admin user data");
16+
}
17+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Net;
2+
using System.Net.Http.Headers;
3+
using DotNetUnknown.Tests.Support;
4+
using Microsoft.AspNetCore.Authentication.JwtBearer;
5+
6+
namespace DotNetUnknown.Tests.Security;
7+
8+
[TestFixture]
9+
internal sealed class SecurityTests : MvcTestSupport
10+
{
11+
[Test]
12+
public async Task TestWithoutJwtToken()
13+
{
14+
// Given
15+
HttpClient.DefaultRequestHeaders.Authorization = null;
16+
// When
17+
var response = await HttpClient.GetAsync("security/admin");
18+
// Then
19+
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
20+
}
21+
22+
[Test]
23+
public async Task TestWithNormalJwtToken_Forbidden()
24+
{
25+
// Given
26+
var jwtTokenTestSupport = GetRequiredService<JwtTokenTestSupport>();
27+
var normalUserToken = jwtTokenTestSupport.NormalUserToken;
28+
HttpClient.DefaultRequestHeaders.Authorization =
29+
new AuthenticationHeaderValue(JwtBearerDefaults.AuthenticationScheme, normalUserToken);
30+
// When
31+
var response = await HttpClient.GetAsync("security/admin");
32+
// Then
33+
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
34+
}
35+
36+
37+
[Test]
38+
public async Task TestWithAdminJwtToken_Ok()
39+
{
40+
// Given
41+
var jwtTokenTestSupport = GetRequiredService<JwtTokenTestSupport>();
42+
var adminUserToken = jwtTokenTestSupport.AdminUserToken;
43+
HttpClient.DefaultRequestHeaders.Authorization =
44+
new AuthenticationHeaderValue(JwtBearerDefaults.AuthenticationScheme, adminUserToken);
45+
// When
46+
var response = await HttpClient.GetAsync("security/admin");
47+
// Then
48+
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
49+
var content = await response.Content.ReadAsStringAsync();
50+
Assert.That(content, Is.EqualTo("this is admin user data"));
51+
}
52+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using DotNetUnknown.Security;
2+
3+
namespace DotNetUnknown.Tests.Support;
4+
5+
public sealed class JwtTokenTestSupport(JwtTokenUtils jwtTokenUtils)
6+
{
7+
private static readonly UserInfo Alice = new("user-id-123", "alice", "[email protected]", ["User"]);
8+
9+
private static readonly UserInfo Bob = new("user-id-456", "bob", "[email protected]", ["User", "Admin"]);
10+
11+
public string NormalUserToken => jwtTokenUtils.GenerateToken(Alice);
12+
public string AdminUserToken => jwtTokenUtils.GenerateToken(Bob);
13+
}

test/DotNetUnknown.Tests/Support/MvcTestSupport.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Net.Http.Headers;
12
using System.Reflection;
3+
using Microsoft.AspNetCore.Authentication.JwtBearer;
24
using Microsoft.AspNetCore.Hosting;
35
using Microsoft.AspNetCore.Mvc.Testing;
46
using Microsoft.Extensions.DependencyInjection;
@@ -15,6 +17,12 @@ public void SetUp()
1517
{
1618
_webAppFactory = new WebAppFactory();
1719
HttpClient = _webAppFactory.CreateClient();
20+
// add a api version before each request
21+
HttpClient.DefaultRequestHeaders.Add("X-Api-Version", "1.0");
22+
// add a jwt token before each request
23+
var jwtTokenUtils = GetRequiredService<JwtTokenTestSupport>();
24+
HttpClient.DefaultRequestHeaders.Authorization =
25+
new AuthenticationHeaderValue(JwtBearerDefaults.AuthenticationScheme, jwtTokenUtils.NormalUserToken);
1826
}
1927

2028
[OneTimeTearDown]
@@ -38,6 +46,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
3846
{
3947
services.AddControllers()
4048
.AddApplicationPart(Assembly.GetExecutingAssembly());
49+
services.AddSingleton<JwtTokenTestSupport>();
4150
});
4251
}
4352
}

0 commit comments

Comments
 (0)