Fail closed on malformed local auth
This commit is contained in:
@@ -32,16 +32,16 @@ namespace JobTrackerApi.Data
|
|||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity<Company>()
|
modelBuilder.Entity<Company>()
|
||||||
.HasQueryFilter(c => CurrentUserId == null || c.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(c => CurrentUserId != null && c.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
modelBuilder.Entity<JobApplication>()
|
modelBuilder.Entity<JobApplication>()
|
||||||
.HasQueryFilter(j => CurrentUserId == null || j.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(j => CurrentUserId != null && j.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
modelBuilder.Entity<UserRuleSettings>()
|
modelBuilder.Entity<UserRuleSettings>()
|
||||||
.HasKey(x => x.OwnerUserId);
|
.HasKey(x => x.OwnerUserId);
|
||||||
|
|
||||||
modelBuilder.Entity<UserRuleSettings>()
|
modelBuilder.Entity<UserRuleSettings>()
|
||||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
modelBuilder.Entity<RuleSettings>()
|
modelBuilder.Entity<RuleSettings>()
|
||||||
.HasData(new RuleSettings { Id = 1 });
|
.HasData(new RuleSettings { Id = 1 });
|
||||||
@@ -65,10 +65,10 @@ namespace JobTrackerApi.Data
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
modelBuilder.Entity<GmailConnection>()
|
modelBuilder.Entity<GmailConnection>()
|
||||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
modelBuilder.Entity<GmailReviewDecision>()
|
modelBuilder.Entity<GmailReviewDecision>()
|
||||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
|
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
|
||||||
|
|
||||||
@@ -92,13 +92,13 @@ namespace JobTrackerApi.Data
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
modelBuilder.Entity<CvUploadArtifact>()
|
modelBuilder.Entity<CvUploadArtifact>()
|
||||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
modelBuilder.Entity<CvUploadArtifact>()
|
modelBuilder.Entity<CvUploadArtifact>()
|
||||||
.HasIndex(x => new { x.OwnerUserId, x.UploadedAtUtc });
|
.HasIndex(x => new { x.OwnerUserId, x.UploadedAtUtc });
|
||||||
|
|
||||||
modelBuilder.Entity<CvExtractionRun>()
|
modelBuilder.Entity<CvExtractionRun>()
|
||||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
modelBuilder.Entity<CvExtractionRun>()
|
modelBuilder.Entity<CvExtractionRun>()
|
||||||
.HasIndex(x => new { x.OwnerUserId, x.StartedAtUtc });
|
.HasIndex(x => new { x.OwnerUserId, x.StartedAtUtc });
|
||||||
@@ -110,7 +110,7 @@ namespace JobTrackerApi.Data
|
|||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
modelBuilder.Entity<TailoredCvDraft>()
|
modelBuilder.Entity<TailoredCvDraft>()
|
||||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
modelBuilder.Entity<TailoredCvDraft>()
|
modelBuilder.Entity<TailoredCvDraft>()
|
||||||
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using JobTrackerApi.Tests.TestSupport;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class LocalAuthIdentityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetRequiredUserId_returns_null_when_subject_claim_is_missing()
|
||||||
|
{
|
||||||
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Email, "ghost@example.com")
|
||||||
|
}, "local"));
|
||||||
|
|
||||||
|
var userId = LocalAuthIdentity.GetRequiredUserId(principal);
|
||||||
|
|
||||||
|
Assert.Null(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetRequiredUserId_returns_nameidentifier_when_present()
|
||||||
|
{
|
||||||
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "user-123")
|
||||||
|
}, "local"));
|
||||||
|
|
||||||
|
var userId = LocalAuthIdentity.GetRequiredUserId(principal);
|
||||||
|
|
||||||
|
Assert.Equal("user-123", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Owner_scoped_query_filters_fail_closed_when_current_user_is_missing()
|
||||||
|
{
|
||||||
|
await using var db = TestHostFactory.CreateInMemoryDb(null);
|
||||||
|
db.Companies.Add(new Company { Name = "Secret Co", OwnerUserId = "user-1" });
|
||||||
|
db.JobApplications.Add(new JobApplication { JobTitle = "Secret Job", Status = "Applied", OwnerUserId = "user-1" });
|
||||||
|
db.UserRuleSettings.Add(new UserRuleSettings { OwnerUserId = "user-1", AppliedFollowUpDays = 5 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Empty(await db.Companies.ToListAsync());
|
||||||
|
Assert.Empty(await db.JobApplications.ToListAsync());
|
||||||
|
Assert.Empty(await db.UserRuleSettings.ToListAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ public static class TestHostFactory
|
|||||||
{
|
{
|
||||||
// Keep the EF-backed controller tests on the same minimal setup so they fail for product
|
// Keep the EF-backed controller tests on the same minimal setup so they fail for product
|
||||||
// reasons, not because each file drifted into a slightly different fake host configuration.
|
// reasons, not because each file drifted into a slightly different fake host configuration.
|
||||||
public static JobTrackerContext CreateInMemoryDb(string userId = "user-1")
|
public static JobTrackerContext CreateInMemoryDb(string? userId = "user-1")
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
|||||||
@@ -253,6 +253,17 @@ builder.Services.AddAuthentication(options =>
|
|||||||
context.Token = cookieToken;
|
context.Token = cookieToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnTokenValidated = context =>
|
||||||
|
{
|
||||||
|
var userId = LocalAuthIdentity.GetRequiredUserId(context.Principal);
|
||||||
|
if (userId is not null)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Fail("Local tokens must include a subject/nameidentifier claim.");
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,14 +16,5 @@ public sealed class CurrentUserService : ICurrentUserService
|
|||||||
_http = http;
|
_http = http;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? UserId
|
public string? UserId => LocalAuthIdentity.GetRequiredUserId(_http.HttpContext?.User);
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var u = _http.HttpContext?.User;
|
|
||||||
if (u is null) return null;
|
|
||||||
if (u.Identity?.IsAuthenticated != true) return null;
|
|
||||||
return u.FindFirstValue(ClaimTypes.NameIdentifier) ?? u.FindFirstValue("sub");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
public static class LocalAuthIdentity
|
||||||
|
{
|
||||||
|
public static string? GetRequiredUserId(ClaimsPrincipal? user)
|
||||||
|
{
|
||||||
|
if (user?.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub");
|
||||||
|
return string.IsNullOrWhiteSpace(userId) ? null : userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user