Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdc1f415b7 | |||
| 13f9e7be7f | |||
| 87c315c6fd | |||
| 26d7d83811 | |||
| 4ba76288b5 | |||
| 97baf266a8 | |||
| 0f9a7aba5c | |||
| a3ca12da26 | |||
| b52d75591b | |||
| bb373a6b8e |
@@ -54,13 +54,36 @@ public class ClubRoleClaimsTransformation : IClaimsTransformation
|
|||||||
return Task.FromResult(principal);
|
return Task.FromResult(principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEW: Skip DB role lookup if user is a global admin ---
|
// --- NEW: Skip DB role lookup if user is a global admin ---
|
||||||
var realmAccess = principal.FindFirst("realm_access")?.Value;
|
var realmAccess = principal.FindFirst("realm_access")?.Value;
|
||||||
if (!string.IsNullOrEmpty(realmAccess) && realmAccess.Contains("\"admin\"", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(realmAccess) && IsAdminUser(realmAccess))
|
||||||
|
{
|
||||||
|
return Task.FromResult(principal);
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
static bool IsAdminUser(string realmAccess)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(realmAccess);
|
||||||
|
if (doc.RootElement.TryGetProperty("roles", out var rolesElement) &&
|
||||||
|
rolesElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
return Task.FromResult(principal);
|
foreach (var role in rolesElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (role.GetString()?.Equals("admin", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If JSON parsing fails, fallback to string contains check
|
||||||
|
return realmAccess.Contains("admin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Look up the user's role in the database for the requested tenant
|
// Look up the user's role in the database for the requested tenant
|
||||||
_httpContextAccessor.HttpContext!.Items["TenantId"] = tenantId;
|
_httpContextAccessor.HttpContext!.Items["TenantId"] = tenantId;
|
||||||
|
|||||||
@@ -22,14 +22,16 @@ public class TenantValidationMiddleware
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exempt bootstrap and admin endpoints from tenant validation
|
// Exempt bootstrap, admin, debug, and Keycloak OIDC endpoints from tenant validation
|
||||||
if (context.Request.Path.StartsWithSegments("/api/clubs/me") ||
|
if (context.Request.Path.StartsWithSegments("/api/clubs/me") ||
|
||||||
context.Request.Path.StartsWithSegments("/api/admin"))
|
context.Request.Path.StartsWithSegments("/api/admin") ||
|
||||||
{
|
context.Request.Path.StartsWithSegments("/api/debug") ||
|
||||||
_logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path);
|
context.Request.Path.StartsWithSegments("/realms"))
|
||||||
await _next(context);
|
{
|
||||||
return;
|
_logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path);
|
||||||
}
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantIdHeader) ||
|
if (!context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantIdHeader) ||
|
||||||
string.IsNullOrWhiteSpace(tenantIdHeader))
|
string.IsNullOrWhiteSpace(tenantIdHeader))
|
||||||
@@ -44,13 +46,36 @@ public class TenantValidationMiddleware
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(clubsClaim))
|
if (string.IsNullOrEmpty(clubsClaim))
|
||||||
{
|
{
|
||||||
// NEW: Skip check if user is a global admin
|
// NEW: Skip check if user is a global admin
|
||||||
var realmAccess = context.User.FindFirst("realm_access")?.Value;
|
var realmAccess = context.User.FindFirst("realm_access")?.Value;
|
||||||
if (!string.IsNullOrEmpty(realmAccess) && realmAccess.Contains("\"admin\"", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(realmAccess) && IsAdminUser(realmAccess))
|
||||||
{
|
{
|
||||||
await _next(context);
|
await _next(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool IsAdminUser(string realmAccess)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(realmAccess);
|
||||||
|
if (doc.RootElement.TryGetProperty("roles", out var rolesElement) &&
|
||||||
|
rolesElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var role in rolesElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (role.GetString()?.Equals("admin", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If JSON parsing fails, fallback to string contains check
|
||||||
|
return realmAccess.Contains("admin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
await context.Response.WriteAsJsonAsync(new { error = "User does not have clubs claim" });
|
await context.Response.WriteAsJsonAsync(new { error = "User does not have clubs claim" });
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ builder.Services.AddScoped<MemberSyncService>();
|
|||||||
builder.Services.AddScoped<TenantDbTransactionInterceptor>();
|
builder.Services.AddScoped<TenantDbTransactionInterceptor>();
|
||||||
builder.Services.AddSingleton<SaveChangesTenantInterceptor>();
|
builder.Services.AddSingleton<SaveChangesTenantInterceptor>();
|
||||||
|
|
||||||
|
// Add CORS to allow frontend requests
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("AllowFrontend", policy =>
|
||||||
|
{
|
||||||
|
policy.WithOrigins("http://localhost:3000")
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowCredentials();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(options =>
|
.AddJwtBearer(options =>
|
||||||
{
|
{
|
||||||
@@ -38,12 +50,75 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
options.Audience = builder.Configuration["Keycloak:Audience"];
|
options.Audience = builder.Configuration["Keycloak:Audience"];
|
||||||
options.RequireHttpsMetadata = false;
|
options.RequireHttpsMetadata = false;
|
||||||
options.MapInboundClaims = false;
|
options.MapInboundClaims = false;
|
||||||
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
|
||||||
|
// For Docker internal communication, configure metadata and signing key resolution
|
||||||
|
// to bypass the hostname mismatch in Keycloak's discovery endpoint
|
||||||
|
var keycloakAuthority = builder.Configuration["Keycloak:Authority"];
|
||||||
|
var keycloakInternalUrl = "http://keycloak:8081";
|
||||||
|
|
||||||
|
if (keycloakAuthority?.Contains("keycloak:") == true)
|
||||||
{
|
{
|
||||||
ValidateIssuer = false, // Disabled for local dev - external clients use localhost:8080, internal use keycloak:8080
|
// Set metadata address to internal Keycloak URL
|
||||||
ValidateAudience = true,
|
options.MetadataAddress = $"{keycloakAuthority}/.well-known/openid-configuration";
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true
|
// Configure custom signing key resolver to fetch from internal Keycloak URL
|
||||||
|
// This overrides the URLs returned in the discovery document
|
||||||
|
var httpClient = new HttpClient();
|
||||||
|
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = false,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
|
||||||
|
{
|
||||||
|
// Fetch JWKS from internal Keycloak URL
|
||||||
|
var jwksUrl = $"{keycloakInternalUrl}/realms/workclub/protocol/openid-connect/certs";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = httpClient.GetStringAsync(jwksUrl).GetAwaiter().GetResult();
|
||||||
|
var jwks = new Microsoft.IdentityModel.Tokens.JsonWebKeySet(response);
|
||||||
|
return jwks.Keys;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Failed to fetch JWKS from {jwksUrl}: {ex.Message}");
|
||||||
|
return Array.Empty<Microsoft.IdentityModel.Tokens.SecurityKey>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = false,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"JWT Authentication Failed: {context.Exception.Message}");
|
||||||
|
if (context.Exception.InnerException != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Inner Exception: {context.Exception.InnerException.Message}");
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnTokenValidated = context =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"JWT Token Validated for user: {context.Principal?.Identity?.Name ?? "unknown"}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnChallenge = context =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"JWT Challenge: {context.Error}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +128,29 @@ builder.Services.AddAuthorizationBuilder()
|
|||||||
.AddPolicy("RequireGlobalAdmin", policy => policy.RequireAssertion(context =>
|
.AddPolicy("RequireGlobalAdmin", policy => policy.RequireAssertion(context =>
|
||||||
{
|
{
|
||||||
var realmAccess = context.User.FindFirst("realm_access")?.Value;
|
var realmAccess = context.User.FindFirst("realm_access")?.Value;
|
||||||
return realmAccess != null && realmAccess.Contains("\"admin\"");
|
if (string.IsNullOrEmpty(realmAccess))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(realmAccess);
|
||||||
|
if (doc.RootElement.TryGetProperty("roles", out var rolesElement) &&
|
||||||
|
rolesElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var role in rolesElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (role.GetString() == "admin")
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If JSON parsing fails, fallback to string contains check
|
||||||
|
return realmAccess.Contains("admin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}))
|
}))
|
||||||
.AddPolicy("RequireManager", policy => policy.RequireRole("Manager"))
|
.AddPolicy("RequireManager", policy => policy.RequireRole("Manager"))
|
||||||
.AddPolicy("RequireMember", policy => policy.RequireRole("Manager", "Member"))
|
.AddPolicy("RequireMember", policy => policy.RequireRole("Manager", "Member"))
|
||||||
@@ -89,9 +186,14 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseCors("AllowFrontend");
|
||||||
|
|
||||||
|
// IMPORTANT: Order matters!
|
||||||
|
// 1. Authentication must come before tenant validation so JWT middleware can fetch JWKS
|
||||||
|
// 2. Tenant validation should come after auth but before endpoints
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
|
||||||
app.UseMiddleware<TenantValidationMiddleware>();
|
app.UseMiddleware<TenantValidationMiddleware>();
|
||||||
|
app.UseAuthorization();
|
||||||
app.UseMiddleware<MemberSyncMiddleware>();
|
app.UseMiddleware<MemberSyncMiddleware>();
|
||||||
|
|
||||||
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||||
@@ -121,9 +223,30 @@ app.MapGet("/weatherforecast", () =>
|
|||||||
})
|
})
|
||||||
.WithName("GetWeatherForecast");
|
.WithName("GetWeatherForecast");
|
||||||
|
|
||||||
|
// Simple test endpoint for middleware validation tests
|
||||||
app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
|
app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
|
||||||
.RequireAuthorization();
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/debug/claims", (HttpContext context) =>
|
||||||
|
{
|
||||||
|
var claims = context.User.Claims.Select(c => new { c.Type, c.Value }).ToList();
|
||||||
|
var realmAccess = context.User.FindFirst("realm_access")?.Value;
|
||||||
|
|
||||||
|
// Check if the authorization header is present
|
||||||
|
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
isAuthenticated = context.User.Identity?.IsAuthenticated ?? false,
|
||||||
|
authenticationType = context.User.Identity?.AuthenticationType,
|
||||||
|
claimCount = claims.Count,
|
||||||
|
claims = claims,
|
||||||
|
realmAccess = realmAccess,
|
||||||
|
hasAuthHeader = !string.IsNullOrEmpty(authHeader),
|
||||||
|
authHeaderPrefix = authHeader?.Substring(0, Math.Min(20, authHeader?.Length ?? 0))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.MapTaskEndpoints();
|
app.MapTaskEndpoints();
|
||||||
app.MapShiftEndpoints();
|
app.MapShiftEndpoints();
|
||||||
app.MapClubEndpoints();
|
app.MapClubEndpoints();
|
||||||
|
|||||||
+6
-4
@@ -43,6 +43,9 @@ services:
|
|||||||
KC_HOSTNAME_STRICT: "false"
|
KC_HOSTNAME_STRICT: "false"
|
||||||
KC_PROXY: "edge"
|
KC_PROXY: "edge"
|
||||||
KC_HTTP_PORT: "8081"
|
KC_HTTP_PORT: "8081"
|
||||||
|
# Additional hostname for internal Docker communication
|
||||||
|
KC_HOSTNAME_ADMIN: "http://keycloak:8081"
|
||||||
|
KC_SPI_HOSTNAME_DEFAULT_ADMIN: "keycloak:8081"
|
||||||
ports:
|
ports:
|
||||||
- "8080:8081"
|
- "8080:8081"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -64,15 +67,14 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Development
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=workclub;Username=workclub;Password=dev_password_change_in_production"
|
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=workclub;Username=workclub;Password=dev_password_change_in_production"
|
||||||
Keycloak__Authority: "http://192.168.65.254:8080/realms/workclub"
|
Keycloak__Authority: "http://keycloak:8081/realms/workclub"
|
||||||
Keycloak__Audience: "workclub-api"
|
Keycloak__Audience: "workclub-api"
|
||||||
Keycloak__TokenValidationParameters__ValidateIssuer: "false"
|
Keycloak__TokenValidationParameters__ValidateIssuer: "false"
|
||||||
ports:
|
ports:
|
||||||
- "5001:8080"
|
- "5001:8080"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "localhost:host-gateway"
|
- "localhost:172.18.0.1"
|
||||||
- "127.0.0.1:host-gateway"
|
- "127.0.0.1:172.18.0.1"
|
||||||
- "keycloak:host-gateway"
|
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app:cached
|
- ./backend:/app:cached
|
||||||
|
|||||||
Reference in New Issue
Block a user