WIP
This commit is contained in:
@@ -78,6 +78,32 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/auth/me (dev-only diagnostic — remove before production)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the claims ASP.NET Core parsed from the Bearer token.
|
||||
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates
|
||||
/// fine; if you then get 403 on /api/users the role claim isn't matching.
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize] // no role restriction — just needs a valid JWT
|
||||
public IActionResult GetMe()
|
||||
{
|
||||
var claims = User.Claims
|
||||
.Select(c => new { c.Type, c.Value })
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
isAuthenticated = User.Identity?.IsAuthenticated,
|
||||
authenticationType = User.Identity?.AuthenticationType,
|
||||
name = User.Identity?.Name,
|
||||
claims,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /api/auth/logout
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -3,26 +3,26 @@ namespace ROLAC.API.DTOs.Members;
|
||||
|
||||
public class CreateMemberRequest
|
||||
{
|
||||
[Required, MaxLength(100)] public string FirstName_en { get; set; } = "";
|
||||
[Required, MaxLength(100)] public string LastName_en { get; set; } = "";
|
||||
[MaxLength(100)] public string? NickName { get; set; }
|
||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||
[MaxLength(10)] public string? Gender { get; set; }
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public DateOnly? BaptismDate { get; set; }
|
||||
[MaxLength(200)] public string? BaptismChurch { get; set; }
|
||||
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
||||
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||
[MaxLength(30)] public string? PhoneHome { get; set; }
|
||||
[MaxLength(500)] public string? Address { get; set; }
|
||||
[MaxLength(100)] public string? City { get; set; }
|
||||
[MaxLength(50)] public string? State { get; set; }
|
||||
[MaxLength(20)] public string? ZipCode { get; set; }
|
||||
[MaxLength(100)] public string Country { get; set; } = "USA";
|
||||
[MaxLength(20)] public string Status { get; set; } = "Member";
|
||||
[MaxLength(10)] public string LanguagePreference { get; set; } = "en";
|
||||
public DateOnly? JoinDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? FamilyUnitId { get; set; }
|
||||
[Required, MaxLength(100)] public string FirstName_en { get; set; } = "";
|
||||
[Required, MaxLength(100)] public string LastName_en { get; set; } = "";
|
||||
[MaxLength(100)] public string? NickName { get; set; }
|
||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||
[MaxLength(10)] public string? Gender { get; set; }
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public DateOnly? BaptismDate { get; set; }
|
||||
[MaxLength(200)] public string? BaptismChurch { get; set; }
|
||||
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
||||
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||
[MaxLength(30)] public string? PhoneHome { get; set; }
|
||||
[MaxLength(500)] public string? Address { get; set; }
|
||||
[MaxLength(100)] public string? City { get; set; }
|
||||
[MaxLength(50)] public string? State { get; set; }
|
||||
[MaxLength(20)] public string? ZipCode { get; set; }
|
||||
[MaxLength(100)] public string Country { get; set; } = "USA";
|
||||
[MaxLength(20)] public string Status { get; set; } = "Member";
|
||||
[MaxLength(10)] public string LanguagePreference { get; set; } = "en";
|
||||
public DateOnly? JoinDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? FamilyUnitId { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace ROLAC.API.DTOs.Users;
|
||||
|
||||
public class CreateUserRequest
|
||||
{
|
||||
[Required] public int MemberId { get; set; }
|
||||
public int? MemberId { get; set; }
|
||||
[Required, EmailAddress] public string Email { get; set; } = "";
|
||||
[Required, MinLength(1)] public List<string> Roles { get; set; } = [];
|
||||
public string LanguagePreference { get; set; } = "en";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ROLAC.API.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Reads <see cref="DateOnly"/> from either "yyyy-MM-dd" or any ISO 8601 date-time
|
||||
/// (the date portion is taken). Writes as "yyyy-MM-dd". Lets JS clients send a Date
|
||||
/// without first formatting it.
|
||||
/// </summary>
|
||||
public sealed class TolerantDateOnlyConverter : JsonConverter<DateOnly>
|
||||
{
|
||||
private const string Format = "yyyy-MM-dd";
|
||||
|
||||
public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var s = reader.GetString();
|
||||
if (string.IsNullOrEmpty(s))
|
||||
throw new JsonException("Expected a date string for DateOnly.");
|
||||
|
||||
if (DateOnly.TryParseExact(s, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d))
|
||||
return d;
|
||||
|
||||
if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dto))
|
||||
return DateOnly.FromDateTime(dto.DateTime);
|
||||
|
||||
throw new JsonException($"Unable to parse '{s}' as DateOnly.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture));
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -6,6 +8,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Json;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -47,6 +50,11 @@ builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(opt =>
|
||||
{
|
||||
// Keep JWT claim names exactly as written ("role", "sub", "email").
|
||||
// Without this, .NET 8's JsonWebTokenHandler may remap "role" to the
|
||||
// long ClaimTypes.Role URI, which conflicts with RoleClaimType = "role".
|
||||
opt.MapInboundClaims = false;
|
||||
|
||||
opt.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
@@ -56,9 +64,37 @@ builder.Services
|
||||
ValidIssuer = config["Jwt:Issuer"],
|
||||
ValidAudience = config["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||
// Roles were written as JWT short name "role"; map to ClaimTypes.Role for [Authorize].
|
||||
NameClaimType = "sub",
|
||||
RoleClaimType = "role",
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
// Diagnostic events — visible in the API console while debugging 401s.
|
||||
opt.Events = new JwtBearerEvents
|
||||
{
|
||||
OnAuthenticationFailed = ctx =>
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[JWT] Auth failed: {ctx.Exception.GetType().Name} — {ctx.Exception.Message}");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnChallenge = ctx =>
|
||||
{
|
||||
// Fires when a 401 challenge is about to be sent.
|
||||
Console.WriteLine(
|
||||
$"[JWT] Challenge: error={ctx.Error}, description={ctx.ErrorDescription}");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnForbidden = ctx =>
|
||||
{
|
||||
// Fires when user IS authenticated but lacks the required role (403).
|
||||
Console.WriteLine(
|
||||
$"[JWT] Forbidden: user={ctx.HttpContext.User.Identity?.Name}, " +
|
||||
$"roles=[{string.Join(',', ctx.HttpContext.User.Claims
|
||||
.Where(c => c.Type == "role")
|
||||
.Select(c => c.Value))}]");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -86,7 +122,16 @@ builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||||
// ---------------------------------------------------------------------------
|
||||
// Swagger / MVC
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddControllers();
|
||||
builder.Services
|
||||
.AddControllers()
|
||||
.AddJsonOptions(opt =>
|
||||
{
|
||||
// camelCase in/out + tolerant DateOnly (accepts "yyyy-MM-dd" or full ISO datetime).
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
opt.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
|
||||
opt.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
opt.JsonSerializerOptions.Converters.Add(new TolerantDateOnlyConverter());
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(opt =>
|
||||
{
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.5.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||
|
||||
</Project>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.0.1" />
|
||||
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -118,15 +118,17 @@ public class UserManagementService : IUserManagementService
|
||||
|
||||
public async Task<CreateUserResult> CreateAsync(CreateUserRequest request)
|
||||
{
|
||||
// Validate Member exists (FindAsync bypasses query filter — intentional)
|
||||
var member = await _db.Members.FindAsync(request.MemberId)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Member {request.MemberId} does not exist.");
|
||||
// Validate member and uniqueness only when a MemberId is provided
|
||||
if (request.MemberId.HasValue)
|
||||
{
|
||||
var member = await _db.Members.FindAsync(request.MemberId.Value)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Member {request.MemberId} does not exist.");
|
||||
|
||||
// One user per member
|
||||
if (_userManager.Users.Any(u => u.MemberId == request.MemberId))
|
||||
throw new InvalidOperationException(
|
||||
"This member already has a user account.");
|
||||
if (_userManager.Users.Any(u => u.MemberId == request.MemberId))
|
||||
throw new InvalidOperationException(
|
||||
"This member already has a user account.");
|
||||
}
|
||||
|
||||
// Unique email
|
||||
if (await _userManager.FindByEmailAsync(request.Email) is not null)
|
||||
|
||||
Reference in New Issue
Block a user