Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47683ddd0f | |||
| 7c5faabc2d | |||
| 582782b0fe |
@@ -0,0 +1,252 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Fronts the external AuthHex identity service (API_REFERENCE.md) so the
|
||||
/// frontend never calls AuthHex directly. Sessions are delivered as httpOnly
|
||||
/// Secure cookies (docs/02-SECURITY.md §B.2) via <see cref="AuthCookieWriter"/>
|
||||
/// — response bodies never carry raw tokens. Does not inherit
|
||||
/// <see cref="ApiControllerBase"/>: most actions here are pre-session and need
|
||||
/// <see cref="AllowAnonymousAttribute"/>, and the ETag/If-Match handling that
|
||||
/// base provides doesn't apply to auth flows.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Route("api/v1/auth")]
|
||||
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthUserService _users;
|
||||
private readonly IAuthRecoveryService _recovery;
|
||||
private readonly IAuthAltService _alt;
|
||||
|
||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt)
|
||||
{
|
||||
_users = users;
|
||||
_recovery = recovery;
|
||||
_alt = alt;
|
||||
}
|
||||
|
||||
// ---- Session-issuing (UserManager) ------------------------------------
|
||||
|
||||
[HttpPost("register")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AuthSessionResponse>> Register([FromBody] RegisterRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _users.RegisterAsync(request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AuthSessionResponse>> Login([FromBody] LoginRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _users.LoginAsync(request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
[HttpPost("login/otp/verify")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<OtpLoginVerifiedResponse>> VerifyLoginOtp([FromBody] VerifyOtpForLoginRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _users.VerifyOtpForLoginAsync(request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
[HttpPost("refresh-token")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AuthSessionResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!Request.Cookies.TryGetValue(JwtAuthExtensions.RefreshTokenCookie, out var refreshToken) || string.IsNullOrEmpty(refreshToken))
|
||||
throw new DomainException(ErrorCodes.RefreshTokenMissing, "No refresh session cookie present.", 401);
|
||||
|
||||
var result = await _users.RefreshTokenAsync(refreshToken, request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
// ---- Profile / sessions (UserManager) ---------------------------------
|
||||
|
||||
[HttpGet("users/{userId:guid}")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(GetUserDetailsResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<GetUserDetailsResponse>> GetUserDetails(Guid userId, CancellationToken ct)
|
||||
=> Ok(await _users.GetUserDetailsAsync(userId, ct));
|
||||
|
||||
[HttpGet("sessions")]
|
||||
[ProducesResponseType(typeof(List<SessionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<SessionDto>>> GetSessions(CancellationToken ct)
|
||||
=> Ok(await _users.GetUserSessionsAsync(RequireBearerToken(), ct));
|
||||
|
||||
[HttpPost("status")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ChangeStatus([FromBody] ChangeUserStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.ChangeUserStatusAsync(request, RequireBearerToken(), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("lock")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Lock([FromBody] LockUserAccountRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.LockUserAccountAsync(request, RequireBearerToken(), ct);
|
||||
AuthCookieWriter.ClearSession(Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("change-password")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct);
|
||||
AuthCookieWriter.ClearSession(Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("verify-password")]
|
||||
[ProducesResponseType(typeof(VerifyPasswordResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
|
||||
|
||||
[HttpPost("logout")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.LogoutUserAsync(request, ct);
|
||||
AuthCookieWriter.ClearSession(Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("me")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<UserSummaryDto?>> UpdateMe([FromBody] UpdateUserRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.UpdateUserAsync(request, RequireBearerToken(), ct));
|
||||
|
||||
// ---- 2FA (UserManager) -------------------------------------------------
|
||||
|
||||
[HttpPost("2fa/initiate")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(typeof(TwoFaSetupResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TwoFaSetupResponse>> InitiateTwoFa(CancellationToken ct)
|
||||
=> Ok(await _users.InitiateTwoFaSetupAsync(RequireBearerToken(), ct));
|
||||
|
||||
[HttpPost("2fa/complete")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(typeof(CompleteTwoFaSetupResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CompleteTwoFaSetupResponse>> CompleteTwoFa([FromBody] CompleteTwoFaSetupRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.CompleteTwoFaSetupAsync(request, RequireBearerToken(), ct));
|
||||
|
||||
[HttpPost("2fa/verify")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> VerifyTwoFa([FromBody] VerifyTwoFaRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.VerifyTwoFaAsync(request, RequireBearerToken(), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("2fa/disable")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> DisableTwoFa([FromBody] DisableTwoFaRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.DisableTwoFaAsync(request, RequireBearerToken(), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("2fa/status")]
|
||||
[ProducesResponseType(typeof(TwoFaStatusResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TwoFaStatusResponse>> GetTwoFaStatus(CancellationToken ct)
|
||||
=> Ok(await _users.GetTwoFaStatusAsync(RequireBearerToken(), ct));
|
||||
|
||||
// ---- Recovery -----------------------------------------------------------
|
||||
|
||||
[HttpPost("recovery/forgot-password")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ForgotPasswordResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ForgotPasswordResponse>> ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken ct)
|
||||
=> Ok(await _recovery.ForgotPasswordAsync(request, ct));
|
||||
|
||||
[HttpPost("recovery/verify-otp")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(VerifyRecoveryOtpResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<VerifyRecoveryOtpResponse>> VerifyRecoveryOtp([FromBody] VerifyRecoveryOtpRequest request, CancellationToken ct)
|
||||
=> Ok(await _recovery.VerifyOtpAsync(request, ct));
|
||||
|
||||
[HttpPost("recovery/reset-password")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken ct)
|
||||
{
|
||||
await _recovery.ResetPasswordAsync(request, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("recovery/reset-password-token")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetPasswordWithToken([FromBody] ResetPasswordWithTokenRequest request, CancellationToken ct)
|
||||
{
|
||||
await _recovery.ResetPasswordWithTokenAsync(request, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ---- Availability / OTP (AltOptionManager) -----------------------------
|
||||
|
||||
[HttpPost("availability")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(IsAvailableResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IsAvailableResponse>> CheckAvailability([FromBody] IsAvailableRequest request, CancellationToken ct)
|
||||
=> Ok(await _alt.IsAvailableAsync(request, ct));
|
||||
|
||||
[HttpPost("otp/send")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(SendOtpResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SendOtpResponse>> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct)
|
||||
=> Ok(await _alt.SendOtpAsync(request, ct));
|
||||
|
||||
[HttpPost("otp/verify")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<OtpLoginVerifiedResponse>> VerifyAltOtp([FromBody] VerifyAltOtpRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _alt.VerifyOtpAsync(request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------
|
||||
|
||||
/// <summary>The token that authenticated this request — Bearer header if present, else the session cookie.</summary>
|
||||
private string RequireBearerToken()
|
||||
{
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (!string.IsNullOrEmpty(header) && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return header["Bearer ".Length..];
|
||||
|
||||
if (Request.Cookies.TryGetValue(JwtAuthExtensions.AccessTokenCookie, out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
|
||||
return cookieToken;
|
||||
|
||||
// [Authorize] already guaranteed one of the above was present to authenticate this request.
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "No bearer token found on an authenticated request.", 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ERPCore.Dtos.Auth;
|
||||
|
||||
public sealed class IsAvailableRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
public string? Recovery { get; set; }
|
||||
}
|
||||
|
||||
public sealed class IsAvailableResponse
|
||||
{
|
||||
public bool? IsAvailable { get; set; }
|
||||
public string? Message { get; set; }
|
||||
/// <summary>Passed through as-is when `Recovery` matched existing users — shape isn't in the documented catalog.</summary>
|
||||
public JsonElement? ExistingUsers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SendOtpRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
public int NumberOfDigits { get; set; } = 6;
|
||||
public bool NewUser { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SendOtpResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? RecoveryType { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyAltOtpRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required] public string OtpCode { get; set; } = string.Empty;
|
||||
public string? Identifier { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool NewUser { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Auth;
|
||||
|
||||
public sealed class ForgotPasswordRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
public bool UseResetLink { get; set; }
|
||||
public int NumberOfDigits { get; set; } = 6;
|
||||
public bool Welcome { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForgotPasswordResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? RecoveryType { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyRecoveryOtpRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required] public string OtpCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class VerifyRecoveryOtpResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResetPasswordRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
|
||||
[Required] public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ResetPasswordWithTokenRequest
|
||||
{
|
||||
[Required] public string ResetToken { get; set; } = string.Empty;
|
||||
[Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
|
||||
[Required] public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ERPCore.Dtos.Auth;
|
||||
|
||||
/// <summary>Shared AuthHex user projection (API_REFERENCE.md §3). Field set is
|
||||
/// AuthHex's best-documented subset; unknown fields are ignored on deserialize.</summary>
|
||||
public sealed class UserSummaryDto
|
||||
{
|
||||
public Guid? UserId { get; set; }
|
||||
public Guid? RoleId { get; set; }
|
||||
public Guid? UserTypeId { get; set; }
|
||||
public string? Fullname { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? Nic { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? MobileNumber { get; set; }
|
||||
public bool? EmailVerified { get; set; }
|
||||
public bool? MobileNumberVerified { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public bool? IsLocked { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Body returned by every session-issuing endpoint. Tokens never appear
|
||||
/// here — they are delivered only as httpOnly cookies (docs/02-SECURITY.md §B.2).</summary>
|
||||
public sealed class AuthSessionResponse
|
||||
{
|
||||
public UserSummaryDto? User { get; set; }
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegisterRequest
|
||||
{
|
||||
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
|
||||
public Guid? UserId { get; set; }
|
||||
[Required] public Guid RoleId { get; set; }
|
||||
[Required] public Guid UserTypeId { get; set; }
|
||||
public string? Fullname { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? Nic { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? MobileNumber { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
public bool? ChkUser { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LoginRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
[Required] public string Password { get; set; } = string.Empty;
|
||||
public Guid? UserTypeId { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyOtpForLoginRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required] public string OtpCode { get; set; } = string.Empty;
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtpLoginVerifiedResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
public UserSummaryDto? User { get; set; }
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RefreshTokenRequest
|
||||
{
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GetUserDetailsResponse
|
||||
{
|
||||
public UserSummaryDto? User { get; set; }
|
||||
/// <summary>Passed through as-is — AuthHex's Role/UserType shapes aren't in the documented catalog.</summary>
|
||||
public JsonElement? Role { get; set; }
|
||||
public JsonElement? UserType { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SessionDto
|
||||
{
|
||||
public string? SessionId { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
public string? Browser { get; set; }
|
||||
public string? OS { get; set; }
|
||||
public string? IPAddress { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChangeUserStatusRequest
|
||||
{
|
||||
[Required] public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LockUserAccountRequest
|
||||
{
|
||||
[Required] public bool IsLocked { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChangeUserPasswordRequest
|
||||
{
|
||||
[Required] public string CurrentPassword { get; set; } = string.Empty;
|
||||
[Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class VerifyPasswordRequest
|
||||
{
|
||||
[Required] public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class VerifyPasswordResponse
|
||||
{
|
||||
public bool Valid { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUserRequest
|
||||
{
|
||||
public string? FullName { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? Nic { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Optional1 { get; set; }
|
||||
public string? Optional2 { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? MobileNumber { get; set; }
|
||||
public string? NewPassword { get; set; }
|
||||
public string? CurrentPassword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Passthrough — TOTP secret/QR payload shape is only loosely documented
|
||||
/// ("secret key, QR/otpauth URL ... from the third-party service").</summary>
|
||||
public sealed class TwoFaSetupResponse
|
||||
{
|
||||
public JsonElement Data { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CompleteTwoFaSetupRequest
|
||||
{
|
||||
[Required] public string SecretKey { get; set; } = string.Empty;
|
||||
[Required] public string VerificationCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CompleteTwoFaSetupResponse
|
||||
{
|
||||
public List<string> BackupCodes { get; set; } = new();
|
||||
public UserSummaryDto? User { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyTwoFaRequest
|
||||
{
|
||||
[Required] public string VerificationCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class DisableTwoFaRequest
|
||||
{
|
||||
[Required] public string VerificationCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class TwoFaStatusResponse
|
||||
{
|
||||
public bool IsMfaEnabled { get; set; }
|
||||
public bool IsVerified { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
public bool HasBackupCodes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LogoutRequest
|
||||
{
|
||||
[Required] public Guid UserId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Writes/clears the httpOnly session cookies + non-httpOnly CSRF cookie
|
||||
/// AuthController issues on every session-establishing call (docs/02-SECURITY.md
|
||||
/// §B.2). SameSite=Strict assumes frontend and ERPCore share a registrable
|
||||
/// domain (e.g. both on `localhost`, different ports) — a cross-domain
|
||||
/// deployment would need SameSite=None (+ Secure, which is already set).
|
||||
/// </summary>
|
||||
public static class AuthCookieWriter
|
||||
{
|
||||
public static void WriteSession(HttpResponse response, string accessToken, string refreshToken, int expiresInSeconds)
|
||||
{
|
||||
response.Cookies.Append(JwtAuthExtensions.AccessTokenCookie, accessToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromSeconds(expiresInSeconds)
|
||||
});
|
||||
|
||||
response.Cookies.Append(JwtAuthExtensions.RefreshTokenCookie, refreshToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/api/v1/auth/refresh-token",
|
||||
MaxAge = TimeSpan.FromDays(30)
|
||||
});
|
||||
|
||||
response.Cookies.Append(JwtAuthExtensions.CsrfCookie, GenerateCsrfToken(), new CookieOptions
|
||||
{
|
||||
HttpOnly = false,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromSeconds(expiresInSeconds)
|
||||
});
|
||||
}
|
||||
|
||||
public static void ClearSession(HttpResponse response)
|
||||
{
|
||||
response.Cookies.Delete(JwtAuthExtensions.AccessTokenCookie, new CookieOptions { Path = "/" });
|
||||
response.Cookies.Delete(JwtAuthExtensions.RefreshTokenCookie, new CookieOptions { Path = "/api/v1/auth/refresh-token" });
|
||||
response.Cookies.Delete(JwtAuthExtensions.CsrfCookie, new CookieOptions { Path = "/" });
|
||||
}
|
||||
|
||||
private static string GenerateCsrfToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Infra.Auth.AuthHex;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP implementation of <see cref="IAuthHexClient"/>. Registered as a typed
|
||||
/// client (`AddHttpClient<IAuthHexClient, AuthHexClient>`) with its
|
||||
/// `BaseAddress` bound from `AuthHex:BaseUrl`. Every call POSTs AuthHex's
|
||||
/// `{ functionName, payload, reference }` envelope to the matching manager
|
||||
/// route and unwraps the `{ statusCode, success, message, data }` response,
|
||||
/// translating upstream failures into <see cref="DomainException"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthHexClient : IAuthHexClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public AuthHexClient(HttpClient http) => _http = http;
|
||||
|
||||
// ---- UserManager --------------------------------------------------
|
||||
|
||||
public Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "VerifyOtpForLogin", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "refreshToken", new { refreshToken, deviceName }, null, ct);
|
||||
|
||||
public Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct)
|
||||
=> CallAsync<GetUserDetailsResponse>("user", "getUserDetails", new { userId }, null, ct);
|
||||
|
||||
public Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<List<SessionDto>>("user", "getUserSessions", new { }, bearerToken, ct);
|
||||
|
||||
public Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "ChangeUserStatus", new { isActive }, bearerToken, ct);
|
||||
|
||||
public Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "LockUserAccount", new { isLocked }, bearerToken, ct);
|
||||
|
||||
public Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "ChangeUserPassword", request, bearerToken, ct);
|
||||
|
||||
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<VerifyPasswordResponse>("user", "VerifyPassword", request, bearerToken, ct);
|
||||
|
||||
public Task LogoutUserAsync(Guid userId, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "LogoutUser", new { userId }, null, ct);
|
||||
|
||||
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<UserSummaryDto?>("user", "UpdateUser", request, bearerToken, ct);
|
||||
|
||||
public Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<TwoFaSetupResponse>("user", "initiateTwoFASetup", new { }, bearerToken, ct);
|
||||
|
||||
public Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<CompleteTwoFaSetupResponse>("user", "completeTwoFASetup", request, bearerToken, ct);
|
||||
|
||||
public Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "verifyTwoFA", request, bearerToken, ct);
|
||||
|
||||
public Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "disableTwoFA", request, bearerToken, ct);
|
||||
|
||||
public Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
|
||||
|
||||
// ---- RecoveryManager ------------------------------------------------
|
||||
|
||||
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
|
||||
=> CallAsync<ForgotPasswordResponse>("recovery", "forgotPassword", request, null, ct);
|
||||
|
||||
public Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<VerifyRecoveryOtpResponse>("recovery", "verifyOTP", request, null, ct);
|
||||
|
||||
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct)
|
||||
=> CallVoidAsync("recovery", "resetPassword", request, null, ct);
|
||||
|
||||
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct)
|
||||
=> CallVoidAsync("recovery", "resetPasswordWithToken", request, null, ct);
|
||||
|
||||
// ---- AltOptionManager -------------------------------------------------
|
||||
|
||||
public Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct)
|
||||
=> CallAsync<IsAvailableResponse>("alt", "IsAvailable", request, null, ct);
|
||||
|
||||
public Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<SendOtpResponse>("alt", "sendOtp", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
|
||||
|
||||
// ---- Transport --------------------------------------------------------
|
||||
|
||||
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
|
||||
=> await CallAsync<JsonElement?>(routeGroup, functionName, payload, bearerToken, ct);
|
||||
|
||||
private async Task<T> CallAsync<T>(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/{routeGroup}");
|
||||
if (!string.IsNullOrEmpty(bearerToken))
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
|
||||
httpRequest.Content = JsonContent.Create(
|
||||
new AuthHexRequestBody { FunctionName = functionName, Payload = payload, Reference = string.Empty },
|
||||
options: JsonOptions);
|
||||
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _http.SendAsync(httpRequest, ct);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503);
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503);
|
||||
}
|
||||
|
||||
AuthHexEnvelope<T>? envelope;
|
||||
try
|
||||
{
|
||||
envelope = await httpResponse.Content.ReadFromJsonAsync<AuthHexEnvelope<T>>(JsonOptions, ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service returned an unreadable response.", 502);
|
||||
}
|
||||
|
||||
if (envelope is null)
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service returned an empty response.", 502);
|
||||
|
||||
// Trust the outer HTTP status over envelope.Success — AuthHex has been observed
|
||||
// returning HTTP 500 with `success:true, data:null` on business failures (e.g.
|
||||
// invalid credentials), which would otherwise slip through as a "success" and
|
||||
// hand a null payload to the caller.
|
||||
if (!httpResponse.IsSuccessStatusCode || !envelope.Success)
|
||||
{
|
||||
var statusCode = !httpResponse.IsSuccessStatusCode
|
||||
? (int)httpResponse.StatusCode
|
||||
: envelope.StatusCode is >= 400 and < 600 ? envelope.StatusCode : 400;
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, envelope.Message ?? "Authentication request failed.", statusCode);
|
||||
}
|
||||
|
||||
return envelope.Data!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
|
||||
namespace ERPCore.Infra.Auth.AuthHex;
|
||||
|
||||
/// <summary>Wire shape of AuthHex's `ApiResponse` envelope (API_REFERENCE.md §1).</summary>
|
||||
public sealed class AuthHexEnvelope<T>
|
||||
{
|
||||
public int StatusCode { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public T? Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Wire shape of AuthHex's `ApiRequest` envelope (API_REFERENCE.md §1).</summary>
|
||||
public sealed class AuthHexRequestBody
|
||||
{
|
||||
public string FunctionName { get; set; } = string.Empty;
|
||||
public object Payload { get; set; } = new { };
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape shared by every session-issuing AuthHex function (register, login,
|
||||
/// OTP login verify, refresh, alt OTP verify). Carries the raw tokens — kept
|
||||
/// internal so they never leak into a public Dtos/Auth response; AuthController
|
||||
/// extracts them into httpOnly cookies and returns only <see cref="AuthSessionResponse"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthHexSessionResult
|
||||
{
|
||||
public string? AccessToken { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public int ExpiresIn { get; set; }
|
||||
public UserSummaryDto? User { get; set; }
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool? Verified { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
|
||||
namespace ERPCore.Infra.Auth.AuthHex;
|
||||
|
||||
/// <summary>
|
||||
/// Typed client for the external AuthHex identity service (API_REFERENCE.md).
|
||||
/// Hides AuthHex's `functionName` dispatcher entirely — callers get one method
|
||||
/// per function. Internal: only Services/Auth consumes this; the controller
|
||||
/// boundary only ever sees Dtos/Auth types.
|
||||
/// </summary>
|
||||
public interface IAuthHexClient
|
||||
{
|
||||
// UserManager (POST /api/user)
|
||||
Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct);
|
||||
Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct);
|
||||
Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct);
|
||||
Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct);
|
||||
Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct);
|
||||
Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct);
|
||||
Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct);
|
||||
Task LogoutUserAsync(Guid userId, CancellationToken ct);
|
||||
Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct);
|
||||
Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct);
|
||||
Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct);
|
||||
Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct);
|
||||
Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct);
|
||||
Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct);
|
||||
|
||||
// RecoveryManager (POST /api/recovery)
|
||||
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
|
||||
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
|
||||
Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct);
|
||||
Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct);
|
||||
|
||||
// AltOptionManager (POST /api/alt)
|
||||
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
|
||||
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
|
||||
}
|
||||
@@ -18,6 +18,18 @@ public static class JwtAuthExtensions
|
||||
/// <summary>Authorization policy applied to every v1 controller (via ApiControllerBase).</summary>
|
||||
public const string ErpAccessPolicy = "ErpAccess";
|
||||
|
||||
/// <summary>httpOnly cookie AuthController writes the AuthHex access token into (docs/02-SECURITY.md §B.2).</summary>
|
||||
public const string AccessTokenCookie = "erp_at";
|
||||
|
||||
/// <summary>httpOnly cookie AuthController writes the AuthHex refresh token into, scoped to the refresh route.</summary>
|
||||
public const string RefreshTokenCookie = "erp_rt";
|
||||
|
||||
/// <summary>Non-httpOnly CSRF cookie for the double-submit check on cookie-authenticated mutations.</summary>
|
||||
public const string CsrfCookie = "XSRF-TOKEN";
|
||||
|
||||
/// <summary>Header the frontend echoes the CSRF cookie value back through.</summary>
|
||||
public const string CsrfHeader = "X-XSRF-TOKEN";
|
||||
|
||||
public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config)
|
||||
{
|
||||
var issuer = config["Auth:Issuer"];
|
||||
@@ -49,6 +61,23 @@ public static class JwtAuthExtensions
|
||||
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
// BFF cookie fallback: browsers hitting ERPCore through AuthController's
|
||||
// httpOnly cookie session carry no Authorization header. Only used when
|
||||
// that header is absent, so Bearer callers (Swagger, service-to-service,
|
||||
// AuthHexClient forwarding) are unaffected.
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(context.Token) &&
|
||||
context.Request.Cookies.TryGetValue(AccessTokenCookie, out var cookieToken) &&
|
||||
!string.IsNullOrEmpty(cookieToken))
|
||||
{
|
||||
context.Token = cookieToken;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing
|
||||
/// AuthController actions (docs/02-SECURITY.md §B.2). Bearer-token callers
|
||||
/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a
|
||||
/// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c>
|
||||
/// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController
|
||||
/// issues alongside the session cookies.
|
||||
/// </summary>
|
||||
public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
|
||||
{
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var request = context.HttpContext.Request;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString()))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken))
|
||||
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
|
||||
|
||||
var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString();
|
||||
if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal))
|
||||
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403);
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
@@ -35,6 +37,17 @@ builder.Services.AddExceptionHandler<DomainExceptionHandler>();
|
||||
// Auth: validate external AuthHex RS256 tokens + ERP door policy (docs/10 A.4)
|
||||
builder.Services.AddErpJwtAuth(builder.Configuration);
|
||||
|
||||
// AuthController proxy → AuthHex (docs/11 §2.0)
|
||||
builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(c =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["AuthHex:BaseUrl"]
|
||||
?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
|
||||
c.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.AddScoped<IAuthUserService, AuthUserService>();
|
||||
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
|
||||
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
|
||||
|
||||
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
||||
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services.Auth;
|
||||
|
||||
/// <inheritdoc cref="IAuthAltService"/>
|
||||
public sealed class AuthAltService : IAuthAltService
|
||||
{
|
||||
private readonly IAuthHexClient _authHex;
|
||||
|
||||
public AuthAltService(IAuthHexClient authHex) => _authHex = authHex;
|
||||
|
||||
public Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default)
|
||||
=> _authHex.IsAvailableAsync(request, ct);
|
||||
|
||||
public Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct = default)
|
||||
=> _authHex.SendOtpAsync(request, ct);
|
||||
|
||||
public async Task<OtpAuthSessionResult> VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _authHex.VerifyAltOtpAsync(request, ct);
|
||||
if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
|
||||
|
||||
return new OtpAuthSessionResult
|
||||
{
|
||||
AccessToken = result.AccessToken,
|
||||
RefreshToken = result.RefreshToken,
|
||||
Body = new OtpLoginVerifiedResponse
|
||||
{
|
||||
ReferenceNumber = result.ReferenceNumber,
|
||||
UserId = result.UserId,
|
||||
Verified = result.Verified ?? true,
|
||||
User = result.User,
|
||||
ExpiresIn = result.ExpiresIn
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Services.Auth;
|
||||
|
||||
/// <inheritdoc cref="IAuthRecoveryService"/>
|
||||
public sealed class AuthRecoveryService : IAuthRecoveryService
|
||||
{
|
||||
private readonly IAuthHexClient _authHex;
|
||||
|
||||
public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex;
|
||||
|
||||
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ForgotPasswordAsync(request, ct);
|
||||
|
||||
public Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
|
||||
=> _authHex.VerifyRecoveryOtpAsync(request, ct);
|
||||
|
||||
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ResetPasswordAsync(request, ct);
|
||||
|
||||
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ResetPasswordWithTokenAsync(request, ct);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
|
||||
namespace ERPCore.Services.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Carries a freshly issued AuthHex session from a Services/Auth method back to
|
||||
/// AuthController. Never serialized directly — the controller pulls
|
||||
/// AccessToken/RefreshToken into httpOnly cookies (see AuthCookieWriter) and
|
||||
/// returns only <see cref="Body"/> in the response.
|
||||
/// </summary>
|
||||
public sealed class AuthSessionResult
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required AuthSessionResponse Body { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Same purpose as <see cref="AuthSessionResult"/>, for the two OTP-verify
|
||||
/// flows whose body also carries ReferenceNumber/Verified alongside the user/session.</summary>
|
||||
public sealed class OtpAuthSessionResult
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required OtpLoginVerifiedResponse Body { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services.Auth;
|
||||
|
||||
/// <inheritdoc cref="IAuthUserService"/>
|
||||
public sealed class AuthUserService : IAuthUserService
|
||||
{
|
||||
private readonly IAuthHexClient _authHex;
|
||||
|
||||
public AuthUserService(IAuthHexClient authHex) => _authHex = authHex;
|
||||
|
||||
public async Task<AuthSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
request.UserId ??= Guid.NewGuid();
|
||||
var result = await _authHex.RegisterAsync(request, ct);
|
||||
return ToSessionResult(result);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResult> LoginAsync(LoginRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _authHex.LoginAsync(request, ct);
|
||||
return ToSessionResult(result);
|
||||
}
|
||||
|
||||
public async Task<OtpAuthSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _authHex.VerifyOtpForLoginAsync(request, ct);
|
||||
return ToOtpSessionResult(result);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResult> RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _authHex.RefreshTokenAsync(refreshToken, request.DeviceName, ct);
|
||||
return ToSessionResult(result);
|
||||
}
|
||||
|
||||
public Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default)
|
||||
=> _authHex.GetUserDetailsAsync(userId, ct);
|
||||
|
||||
public Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.GetUserSessionsAsync(bearerToken, ct);
|
||||
|
||||
public Task ChangeUserStatusAsync(ChangeUserStatusRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.ChangeUserStatusAsync(request.IsActive, bearerToken, ct);
|
||||
|
||||
public Task LockUserAccountAsync(LockUserAccountRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.LockUserAccountAsync(request.IsLocked, bearerToken, ct);
|
||||
|
||||
public Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.ChangeUserPasswordAsync(request, bearerToken, ct);
|
||||
|
||||
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.VerifyPasswordAsync(request, bearerToken, ct);
|
||||
|
||||
public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default)
|
||||
=> _authHex.LogoutUserAsync(request.UserId, ct);
|
||||
|
||||
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
|
||||
|
||||
public Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.InitiateTwoFaSetupAsync(bearerToken, ct);
|
||||
|
||||
public Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.CompleteTwoFaSetupAsync(request, bearerToken, ct);
|
||||
|
||||
public Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.VerifyTwoFaAsync(request, bearerToken, ct);
|
||||
|
||||
public Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.DisableTwoFaAsync(request, bearerToken, ct);
|
||||
|
||||
public Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.GetTwoFaStatusAsync(bearerToken, ct);
|
||||
|
||||
private static AuthSessionResult ToSessionResult(AuthHexSessionResult? result)
|
||||
{
|
||||
if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
|
||||
|
||||
return new AuthSessionResult
|
||||
{
|
||||
AccessToken = result.AccessToken,
|
||||
RefreshToken = result.RefreshToken,
|
||||
Body = new AuthSessionResponse { User = result.User, ExpiresIn = result.ExpiresIn }
|
||||
};
|
||||
}
|
||||
|
||||
private static OtpAuthSessionResult ToOtpSessionResult(AuthHexSessionResult? result)
|
||||
{
|
||||
if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
|
||||
|
||||
return new OtpAuthSessionResult
|
||||
{
|
||||
AccessToken = result.AccessToken,
|
||||
RefreshToken = result.RefreshToken,
|
||||
Body = new OtpLoginVerifiedResponse
|
||||
{
|
||||
ReferenceNumber = result.ReferenceNumber,
|
||||
UserId = result.UserId,
|
||||
Verified = result.Verified ?? true,
|
||||
User = result.User,
|
||||
ExpiresIn = result.ExpiresIn
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Services.Auth;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>AltOptionManager proxy business logic (API_REFERENCE.md §5), fronting AuthHex.</summary>
|
||||
public interface IAuthAltService
|
||||
{
|
||||
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default);
|
||||
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct = default);
|
||||
Task<OtpAuthSessionResult> VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>RecoveryManager proxy business logic (API_REFERENCE.md §4), fronting AuthHex.</summary>
|
||||
public interface IAuthRecoveryService
|
||||
{
|
||||
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default);
|
||||
Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default);
|
||||
Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default);
|
||||
Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Services.Auth;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>UserManager proxy business logic (API_REFERENCE.md §3), fronting AuthHex.</summary>
|
||||
public interface IAuthUserService
|
||||
{
|
||||
Task<AuthSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct = default);
|
||||
Task<AuthSessionResult> LoginAsync(LoginRequest request, CancellationToken ct = default);
|
||||
Task<OtpAuthSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default);
|
||||
Task<AuthSessionResult> RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default);
|
||||
Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct = default);
|
||||
Task ChangeUserStatusAsync(ChangeUserStatusRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task LockUserAccountAsync(LockUserAccountRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default);
|
||||
Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default);
|
||||
Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default);
|
||||
}
|
||||
@@ -24,4 +24,10 @@ public static class ErrorCodes
|
||||
public const string ReasonCodeRequired = "REASON_CODE_REQUIRED";
|
||||
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
|
||||
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
|
||||
|
||||
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
|
||||
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
|
||||
public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE";
|
||||
public const string CsrfTokenMismatch = "CSRF_TOKEN_MISMATCH";
|
||||
public const string RefreshTokenMissing = "REFRESH_TOKEN_MISSING";
|
||||
}
|
||||
|
||||
@@ -7,5 +7,8 @@
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
},
|
||||
"Jwt": {
|
||||
"SigningKey": ""
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"RequiredUserTypeCode": "",
|
||||
"RequiredRoleCode": ""
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "CHANGE_ME"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- [x] Audit log on every mutation (who/when/old→new) — `AuditLog` (jsonb `changeSet`), written by an `ErpDbContext.SaveChanges` override (`AuditScribe`): Create captures the field set, Update captures **only changed fields as {old,new}**, Delete captures the prior row; PK/RowVersion excluded; ledger/layer/seq/self/journal excluded. Actor from `ICurrentUser` (system=1 until auth). Read via `GET /audit-logs`. **Verified** (Item create+update old→new; StockAdjustment create). This is the **AR-01 compensating control** (02-SECURITY B.3) — app-level append-only; DB-role UPDATE/DELETE revoke still deferred.
|
||||
- [x] Document numbering sequences (per type, per year) — `NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO.
|
||||
- [x] Auth: **external AuthHex IdP integration** (2026-07-14) — ERPCore is a resource server. `JwtAuthExtensions` validates **RS256** against AuthHex's RSA **public** key (config `Auth:RsaPublicKeyXml` → `RsaSecurityKey`; `MapInboundClaims=false`), issuer `AuthHex`, audience `AuthHexClient` (no JWKS → static key). `[Authorize(ErpAccess)]` on `ApiControllerBase` gates every v1 endpoint; the `ErpAccess` policy `RequireAuthenticatedUser` + optional `RequireClaim(UserTypeCode/RoleCode)` from `Auth:RequiredUserTypeCode`/`RequiredRoleCode` (empty ⇒ any valid ERP token — AuthHex is ERP-dedicated). **Shadow-user JIT provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`) maps the token's `UserId` **GUID** → a local `users` row (`auth_user_id` unique; Username/DisplayName = `NIC`), idempotent, and injects the local `int` id as `nameid` so `ICurrentUser.AuditUserId` resolves the real actor. Migration `AddAuthUserId`. **Verified:** no token→401; `/health`,`/api/meta`,Swagger anonymous; valid token→200; shadow user provisioned (User 2, Username=NIC, AuthUserId=GUID); item Create **audited as the shadow user (id 2, not system)**; re-request reuses the same user; door gate → **403** on UserType mismatch, **200** on match.
|
||||
- [x] Auth proxy: **`AuthController` fronting AuthHex** (2026-07-16) — the frontend no longer calls AuthHex directly; `Controllers/AuthController.cs` + `Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}` proxy all 24 AuthHex functions (register/login/OTP-login/refresh/sessions/status/lock/change-password/verify-password/logout/update/2FA×5/recovery×4/alt×3) via `Infra/Auth/AuthHex/{IAuthHexClient,AuthHexClient}` (`AuthHex:BaseUrl` config). Sessions delivered as httpOnly Secure `erp_at`/`erp_rt` cookies + `XSRF-TOKEN` double-submit cookie (`Infra/Auth/AuthCookieWriter.cs`, 02-SECURITY §B.2); `ValidateCsrfAttribute` guards every mutating action; the JWT bearer handler now also accepts `erp_at` as a fallback (`JwtAuthExtensions`'s `OnMessageReceived`) so every other v1 controller keeps working unchanged. See `docs/11-BACKEND-PHASE1.md §2.0` for the full route table and `docs/02-SECURITY.md` AR-07/AR-08 for the two carried-over exposures (anonymous `getUserDetails`/`LogoutUser`, no rate limiting yet). **Not done this pass:** CORS (needed once a browser frontend calls these endpoints cross-origin), rate limiting, and the frontend wiring itself (`lib/api/auth.ts` + login/OTP/reset pages) — all deliberately deferred follow-ups.
|
||||
- [x] JournalEntryStub emitted per stock movement (data only) — `JournalEntryStub` written in `FifoCostingService.PostLedgerAsync` for every ledger entry (In → Dr Inventory `1300` / Cr Clearing `2100`; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read via `GET /journal-entries`. **Verified** (GRN In 700, ADJ Out 70).
|
||||
- [x] Negative-stock policy enforcement (default block) — enforced in `FifoCostingService.ConsumeAsync` → `409 STOCK_NEGATIVE_BLOCKED` (verified). Per-item override still a config stub.
|
||||
- [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built.
|
||||
@@ -133,3 +134,16 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- **Shadow-user provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`, scoped) maps token `UserId` GUID → local `users` row (`auth_user_id` unique, Username/DisplayName=`NIC`), idempotent w/ race-safe re-read, injects local `int` id as `nameid`. `User.AuthUserId` (Guid?) + `AuthHexClaims` consts + migration `AddAuthUserId`.
|
||||
- **Verified (minted AuthHex-shaped RS256 token, signed with AuthHex's real private key):** no token→401; `/health`,`/api/meta`,Swagger→200 anon; valid token→200; POST item→201 **audited as shadow user id 2** (Username=NIC, AuthUserId=GUID), not system; repeat request reuses user (1 provision); door gate `RequiredUserTypeCode=WAREHOUSE` → ERP-type token **403**, WAREHOUSE-type token **200**. Build clean; migration applied.
|
||||
- **§6 COMPLETE.** Only intentional deferral left in Phase 1: FEFO pick-ordering (§6, `[~]`). Follow-ups: set the real ERP `Auth:RequiredUserTypeCode`/`RoleCode` for production; secure the RSA key rotation process.
|
||||
|
||||
### 2026-07-16 — Auth proxy: `AuthController` fronting AuthHex (frontend no longer calls AuthHex directly)
|
||||
- **Architecture reversal:** the 2026-07-14 "resource-server-only, no login proxy" decision (docs/10, docs/11 §2.0) is reversed — the frontend was found to have **zero** existing AuthHex integration (login/OTP/reset screens were UI-only mocks with no network calls), so this was greenfield backend work, not a migration. `docs/10-BACKEND-PHASE1.md` (header, A.4, NFR-03) and `docs/11-BACKEND-PHASE1.md §2.0` updated in place; `docs/02-SECURITY.md` gained AR-07/AR-08 and ticked 3 of 5 B.2 boxes.
|
||||
- **`Infra/Auth/AuthHex/`** — `IAuthHexClient`/`AuthHexClient` (typed `HttpClient`, `AuthHex:BaseUrl` config = `http://localhost:5011` dev), one C# method per AuthHex `functionName`, hides the `{functionName,payload,reference}`/`{statusCode,success,message,data}` dispatcher envelope entirely; upstream failures → `DomainException` (`AUTH_UPSTREAM_ERROR`/`AUTH_SERVICE_UNAVAILABLE`).
|
||||
- **`Dtos/Auth/*`** — REST-shaped request/response DTOs per function (not a functionName-dispatcher passthrough), matching ERPCore's existing DTO-at-boundary convention. Session-issuing responses (`AuthSessionResponse`, `OtpLoginVerifiedResponse`) deliberately omit tokens.
|
||||
- **`Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}`** — orchestrate `IAuthHexClient` calls; `AuthSessionResult`/`OtpAuthSessionResult` (`Services/Auth/AuthSessionResult.cs`) carry tokens from service → controller only, never serialized.
|
||||
- **`Controllers/AuthController.cs`** — `api/v1/auth/*`, 24 actions (see `docs/11 §2.0` table); inherits `ControllerBase` directly (not `ApiControllerBase`) since most actions need `[AllowAnonymous]` and its ETag/If-Match handling doesn't apply here.
|
||||
- **Cookie/CSRF (`Infra/Auth/AuthCookieWriter.cs`, `ValidateCsrfAttribute.cs`, `JwtAuthExtensions.cs`):** `erp_at` (Path `/`), `erp_rt` (Path `/api/v1/auth/refresh-token`, scoped so it's only sent to the refresh call), `XSRF-TOKEN` (non-httpOnly) — all `HttpOnly`(except CSRF)/`Secure`/`SameSite=Strict`. `ValidateCsrfAttribute` double-submit-checks `X-XSRF-TOKEN` against the cookie on every mutating action, exempting Bearer-header callers. `JwtAuthExtensions`'s `OnMessageReceived` falls back to the `erp_at` cookie when no `Authorization` header is present — every existing v1 controller keeps working unchanged under either auth mode.
|
||||
- **Verified:** `dotnet build` clean (0 warn/0 err) after two passes — first pass hit `CS0051` (a public interface/constructor can't expose an `internal` parameter type) on `IAuthHexClient` and its supporting `AuthHex*` wire types, fixed by making them `public`; second pass caught a cookie-path bug (`erp_rt`'s `Path` was written as `/api/auth/refresh-token`, not matching the actual `/api/v1/auth/refresh-token` route — the browser would never have sent the cookie back on refresh) before it shipped.
|
||||
- **Not done this pass (tracked as follow-ups, not silently skipped):** CORS (needed once a browser frontend calls cross-origin — `docs/02-SECURITY.md §B.2` left unticked), rate limiting on the anonymous endpoints (`docs/02-SECURITY.md` AR-08), and the frontend wiring itself (`lib/api/auth.ts` + wiring `app/login/**`'s mock pages to these endpoints) — deliberately out of scope per user decision.
|
||||
- **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register` → `200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN` → `403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed.
|
||||
- **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing.
|
||||
- **Login/refresh/logout left unverified live** — `loginUser` currently fails with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` on AuthHex **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react"
|
||||
@@ -13,7 +13,8 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data"
|
||||
import { useProductConfig } from "@/hooks/use-product-config"
|
||||
import { Category, Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -49,10 +50,12 @@ export default function ItemDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const itemId = Number(params.id)
|
||||
const { config } = useProductConfig()
|
||||
const subcategoriesEnabled = !!config?.subcategories
|
||||
|
||||
const [item, setItem] = useState<Item | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
||||
const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([])
|
||||
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
||||
@@ -63,6 +66,7 @@ export default function ItemDetailPage() {
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||
const [itemType, setItemType] = useState<ItemType>("Stocked")
|
||||
@@ -117,7 +121,7 @@ export default function ItemDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(itemId)) return
|
||||
load()
|
||||
Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
Promise.all([categoriesApi.list({ pageSize: 200 }), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([cat, uo, ve, wh]) => {
|
||||
setCategories(cat.items)
|
||||
setUoms(uo.items)
|
||||
@@ -128,10 +132,42 @@ export default function ItemDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemId])
|
||||
|
||||
// Once both the item and the full category list are in, split a subcategory
|
||||
// item's categoryId into its top-level Category + Subcategory pair for the UI.
|
||||
useEffect(() => {
|
||||
if (!item || categories.length === 0) return
|
||||
if (!subcategoriesEnabled) {
|
||||
setSubCategoryId(null)
|
||||
return
|
||||
}
|
||||
const current = categories.find((c) => c.categoryId === item.categoryId)
|
||||
if (current && current.parentId !== null) {
|
||||
setCategoryId(current.parentId)
|
||||
setSubCategoryId(current.categoryId)
|
||||
} else {
|
||||
setSubCategoryId(null)
|
||||
}
|
||||
}, [item, categories, subcategoriesEnabled])
|
||||
|
||||
const topCategories = useMemo(
|
||||
() => (subcategoriesEnabled ? categories.filter((c) => c.parentId === null) : categories),
|
||||
[categories, subcategoriesEnabled]
|
||||
)
|
||||
const subCategoryOptions = useMemo(
|
||||
() => categories.filter((c) => c.parentId === categoryId),
|
||||
[categories, categoryId]
|
||||
)
|
||||
const effectiveCategoryId = subcategoriesEnabled ? (subCategoryId ?? categoryId) : categoryId
|
||||
|
||||
function handleCategoryChange(value: number | null) {
|
||||
setCategoryId(value)
|
||||
setSubCategoryId(null)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!item || !etag) return
|
||||
setSaveError(null)
|
||||
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId })
|
||||
const nextErrors = validateItemForm({ sku, name, categoryId: effectiveCategoryId, baseUomId })
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
@@ -139,7 +175,7 @@ export default function ItemDetailPage() {
|
||||
try {
|
||||
const result = await itemsApi.update(
|
||||
item.itemId,
|
||||
{ sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null },
|
||||
{ sku, name, description: description || null, categoryId: effectiveCategoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null },
|
||||
etag
|
||||
)
|
||||
applyItem(result.data)
|
||||
@@ -339,12 +375,12 @@ export default function ItemDetailPage() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict}>
|
||||
<Select<number | null> value={categoryId} onValueChange={handleCategoryChange} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((c) => (
|
||||
{topCategories.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
@@ -353,6 +389,27 @@ export default function ItemDetailPage() {
|
||||
</Select>
|
||||
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
||||
</div>
|
||||
{subcategoriesEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Subcategory (optional)</Label>
|
||||
<Select<number | null>
|
||||
value={subCategoryId}
|
||||
onValueChange={setSubCategoryId}
|
||||
disabled={conflict || subCategoryOptions.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subCategoryOptions.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { categoriesApi } from "@/lib/api/categories"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useProductConfig } from "@/hooks/use-product-config"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { Category } from "@/types/master-data"
|
||||
|
||||
@@ -26,10 +27,17 @@ type SortOrder = "asc" | "desc"
|
||||
const PAGE_SIZE = 5
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const { config } = useProductConfig()
|
||||
const subcategoriesEnabled = !!config?.subcategories
|
||||
|
||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Unfiltered/unpaginated copy used to populate the parent-category picker and to
|
||||
// resolve a row's parent name regardless of which page it's showing.
|
||||
const [allCategories, setAllCategories] = useState<Category[]>([])
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
@@ -38,6 +46,7 @@ export default function CategoriesPage() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Category | null>(null)
|
||||
const [name, setName] = useState("")
|
||||
const [parentId, setParentId] = useState<number | null>(null)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
@@ -62,13 +71,28 @@ export default function CategoriesPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
function loadAllCategories() {
|
||||
categoriesApi
|
||||
.list({ pageSize: 500 })
|
||||
.then((res) => setAllCategories(res.items))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(load, [search, sortOrder, page])
|
||||
useEffect(loadAllCategories, [])
|
||||
|
||||
const hasFilters = search.trim().length > 0
|
||||
const parentOptions = allCategories.filter((c) => c.parentId === null && c.categoryId !== editing?.categoryId)
|
||||
|
||||
function parentName(category: Category): string {
|
||||
if (category.parentId === null) return "—"
|
||||
return allCategories.find((c) => c.categoryId === category.parentId)?.name ?? `#${category.parentId}`
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditing(null)
|
||||
setName("")
|
||||
setParentId(null)
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
@@ -76,6 +100,7 @@ export default function CategoriesPage() {
|
||||
function openEditDialog(category: Category) {
|
||||
setEditing(category)
|
||||
setName(category.name)
|
||||
setParentId(category.parentId)
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
@@ -88,14 +113,16 @@ export default function CategoriesPage() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const category = editing
|
||||
? await categoriesApi.update(editing.categoryId, { name })
|
||||
: await categoriesApi.create({ name })
|
||||
? await categoriesApi.update(editing.categoryId, { name, parentId: subcategoriesEnabled ? parentId : null })
|
||||
: await categoriesApi.create({ name, parentId: subcategoriesEnabled ? parentId : null })
|
||||
toast.success(editing ? "Category updated" : "Category created", category.name)
|
||||
setOpen(false)
|
||||
setName("")
|
||||
setParentId(null)
|
||||
setEditing(null)
|
||||
setErrors({})
|
||||
load()
|
||||
loadAllCategories()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
|
||||
@@ -135,7 +162,9 @@ export default function CategoriesPage() {
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>{editing ? "Edit category" : "New category"}</DialogTitle>
|
||||
<DialogDescription>Give the category a name.</DialogDescription>
|
||||
<DialogDescription>
|
||||
{subcategoriesEnabled ? "Give the category a name and, optionally, a parent." : "Give the category a name."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
@@ -143,6 +172,23 @@ export default function CategoriesPage() {
|
||||
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
{subcategoriesEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cat-parent">Parent category (optional)</FieldLabel>
|
||||
<Select<number | null> value={parentId} onValueChange={setParentId}>
|
||||
<SelectTrigger id="cat-parent" className="w-full">
|
||||
<SelectValue placeholder="None (top-level category)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentOptions.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
@@ -206,6 +252,7 @@ export default function CategoriesPage() {
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
|
||||
{subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm text-indigo-700">Parent</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -215,6 +262,9 @@ export default function CategoriesPage() {
|
||||
<TableRow key={c.categoryId}>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||
{subcategoriesEnabled && (
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{parentName(c)}</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Boxes, Package, Tag } from "lucide-react"
|
||||
|
||||
import { productConfigApi } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ProductConfig } from "@/types/settings"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface ToggleField {
|
||||
key: keyof ProductConfig
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface Section {
|
||||
title: string
|
||||
icon: typeof Package
|
||||
fields: ToggleField[]
|
||||
}
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
{
|
||||
title: "Product Capabilities",
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
key: "subcategories",
|
||||
label: "Subcategories",
|
||||
description: "Category hierarchy includes a subcategory level. Off ⇒ products attach directly to a main category.",
|
||||
},
|
||||
{ key: "brands", label: "Brands", description: "Products may carry a brand." },
|
||||
{ key: "productImages", label: "Product images", description: "Enable image upload on products." },
|
||||
{
|
||||
key: "serialNumbers",
|
||||
label: "Serial numbers",
|
||||
description: "Track individual units by serial number (captured at receipt, selected at sale).",
|
||||
},
|
||||
{ key: "batchLotTracking", label: "Batch / lot tracking", description: "Maintain stock batch/lot-wise." },
|
||||
{
|
||||
key: "expiryMfgDates",
|
||||
label: "Expiry / mfg dates",
|
||||
description: "Track expiry & manufacture dates (typically implies batch; drives FEFO).",
|
||||
},
|
||||
{
|
||||
key: "warranty",
|
||||
label: "Warranty",
|
||||
description: "Capture warranty period/terms (independent of serial & batch).",
|
||||
},
|
||||
{ key: "serviceItems", label: "Service items", description: "Sell non-stock service items (no stock/costing)." },
|
||||
{
|
||||
key: "adHocSaleLines",
|
||||
label: "Ad-hoc sale lines",
|
||||
description: "Allow non-inventory typed lines (name + price) on sales/quotations.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Pricing & Quantity",
|
||||
icon: Tag,
|
||||
fields: [
|
||||
{
|
||||
key: "minPriceFloor",
|
||||
label: "Minimum price floor",
|
||||
description: "Enforce a per-variant minimum sale price (even after discount).",
|
||||
},
|
||||
{
|
||||
key: "maxPriceCeiling",
|
||||
label: "Maximum price ceiling",
|
||||
description: "Enforce a per-variant maximum sale price.",
|
||||
},
|
||||
{
|
||||
key: "freePricingProducts",
|
||||
label: "Free-pricing products",
|
||||
description: "Products may be flagged free-pricing (operator sets any price, bypasses min/max).",
|
||||
},
|
||||
{
|
||||
key: "fractionalQuantities",
|
||||
label: "Fractional quantities",
|
||||
description: "Allow fractional quantities (e.g. 1.5 kg) where the unit permits.",
|
||||
},
|
||||
{
|
||||
key: "packConversion",
|
||||
label: "Pack conversion (UoM)",
|
||||
description: "Per-product pack units (buy Box / sell Piece). Stock is always stored in one base unit.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Stock Behaviour",
|
||||
icon: Boxes,
|
||||
fields: [
|
||||
{
|
||||
key: "allowNegativeStock",
|
||||
label: "Allow negative stock",
|
||||
description:
|
||||
"Company default for allowing negative stock (a product may narrow). Costed items are blocked from going negative regardless.",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function ProductConfigurationPage() {
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
productConfigApi
|
||||
.get()
|
||||
.then(setConfig)
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
function toggle(key: keyof ProductConfig, checked: boolean) {
|
||||
setConfig((prev) => (prev ? { ...prev, [key]: checked } : prev))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!config) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const saved = await productConfigApi.update(config)
|
||||
setConfig(saved)
|
||||
toast.success("Product configuration saved")
|
||||
} catch (err) {
|
||||
toast.error("Could not save product configuration", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Product Configuration</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Company-wide toggles that control which product & category capabilities are available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="lg" onClick={handleSave} disabled={!config || saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && !config && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-5 rounded-2xl bg-card p-6 ring-1 ring-foreground/10">
|
||||
<div className="flex items-center gap-2.5 border-b pb-4">
|
||||
<section.icon className="size-5 text-indigo-600" />
|
||||
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
{section.fields.map((field) => (
|
||||
<label
|
||||
key={field.key}
|
||||
htmlFor={field.key}
|
||||
className="flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<Switch
|
||||
id={field.key}
|
||||
checked={config[field.key]}
|
||||
onCheckedChange={(checked) => toggle(field.key, checked)}
|
||||
className={cn("mt-0.5")}
|
||||
/>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-base font-semibold text-foreground">{field.label}</span>
|
||||
<span className="text-sm text-muted-foreground">{field.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { variantCategoriesApi } from "@/lib/api/variants"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useProductConfig } from "@/hooks/use-product-config"
|
||||
import { Category, VariantCategory } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -57,6 +58,8 @@ const DEFAULT_BASE_UOM_ID = 1
|
||||
|
||||
export default function NewItemPage() {
|
||||
const router = useRouter()
|
||||
const { config } = useProductConfig()
|
||||
const subcategoriesEnabled = !!config?.subcategories
|
||||
|
||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||
const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null)
|
||||
@@ -92,7 +95,10 @@ export default function NewItemPage() {
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories])
|
||||
const topCategories = useMemo(
|
||||
() => (subcategoriesEnabled ? (categories ?? []).filter((c) => c.parentId === null) : (categories ?? [])),
|
||||
[categories, subcategoriesEnabled]
|
||||
)
|
||||
const subCategoryOptions = useMemo(
|
||||
() => (categories ?? []).filter((c) => c.parentId === categoryId),
|
||||
[categories, categoryId]
|
||||
@@ -259,21 +265,23 @@ export default function NewItemPage() {
|
||||
</Select>
|
||||
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Subcategory (optional)</Label>
|
||||
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subCategoryOptions.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{subcategoriesEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Subcategory (optional)</Label>
|
||||
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subCategoryOptions.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Brand (optional)</Label>
|
||||
<Select<number | null> value={brandId} onValueChange={setBrandId}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Package,
|
||||
PackageCheck,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
ShoppingCart,
|
||||
SwatchBook,
|
||||
Tag,
|
||||
@@ -44,6 +45,7 @@ const navItems: {
|
||||
{ title: "Category", href: "/dashboard/products/categories", icon: ListTree },
|
||||
{ title: "Brand", href: "/dashboard/products/brands", icon: Tag },
|
||||
{ title: "Variant", href: "/dashboard/products/variants", icon: SwatchBook },
|
||||
{ title: "Product Configuration", href: "/dashboard/products/configuration", icon: SlidersHorizontal },
|
||||
],
|
||||
},
|
||||
{ title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-transparent bg-input transition-colors outline-none focus-visible:ring-3 focus-visible:ring-purple-500/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:bg-purple-600 dark:bg-input/60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block size-5 translate-x-0.5 rounded-full bg-background shadow-sm ring-0 transition-transform data-checked:translate-x-5.5 dark:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import { productConfigApi } from "@/lib/api/product-config"
|
||||
import { ProductConfig } from "@/types/settings"
|
||||
|
||||
/** Fetches the company's Product Configuration once on mount. `null` while loading. */
|
||||
export function useProductConfig() {
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return productConfigApi.get().then(setConfig)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
return { config, refresh }
|
||||
}
|
||||
@@ -49,6 +49,16 @@ export const categoriesApi = {
|
||||
if (!name) return Promise.reject(new Error("Category name is required."))
|
||||
const category = mockCategories.find((c) => c.categoryId === categoryId)
|
||||
if (!category) return Promise.reject(new Error("Category not found."))
|
||||
if (request.parentId !== undefined) {
|
||||
const parentId = request.parentId
|
||||
if (parentId === categoryId) {
|
||||
return Promise.reject(new Error("A category cannot be its own parent."))
|
||||
}
|
||||
if (parentId !== null && !mockCategories.some((c) => c.categoryId === parentId)) {
|
||||
return Promise.reject(new Error("Selected parent category does not exist."))
|
||||
}
|
||||
category.parentId = parentId
|
||||
}
|
||||
category.name = name
|
||||
return mockDelay(category)
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Bin, Brand, Category, Item, Uom, Vendor, VariantCategory, Warehouse } from "@/types/master-data"
|
||||
import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement"
|
||||
import { Grn } from "@/types/grn"
|
||||
import { ProductConfig } from "@/types/settings"
|
||||
import {
|
||||
AdjustmentStatus,
|
||||
CountStatus,
|
||||
@@ -14,6 +15,26 @@ import {
|
||||
TransferStatus,
|
||||
} from "@/types/stock"
|
||||
|
||||
// Company-level Product Configuration (Settings → Product Configuration). Off by
|
||||
// default except the tracking-related toggles, matching the shipped design.
|
||||
export const mockProductConfig: ProductConfig = {
|
||||
subcategories: false,
|
||||
brands: false,
|
||||
productImages: false,
|
||||
serialNumbers: true,
|
||||
batchLotTracking: true,
|
||||
expiryMfgDates: true,
|
||||
warranty: true,
|
||||
serviceItems: true,
|
||||
adHocSaleLines: true,
|
||||
minPriceFloor: false,
|
||||
maxPriceCeiling: false,
|
||||
freePricingProducts: false,
|
||||
fractionalQuantities: false,
|
||||
packConversion: true,
|
||||
allowNegativeStock: false,
|
||||
}
|
||||
|
||||
export const mockWarehouses: Warehouse[] = [
|
||||
{ warehouseId: 1, code: "WH-MAIN", name: "Main Warehouse - Negombo" },
|
||||
{ warehouseId: 2, code: "WH-COLOMBO", name: "Colombo Distribution Center" },
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Product Configuration client, mirroring lib/api/brands.ts. A single company-level
|
||||
// settings object rather than a list — in-memory sample data (lib/api/mock-data.ts),
|
||||
// no backend API calls.
|
||||
import { ProductConfig, UpdateProductConfigRequest } from "@/types/settings"
|
||||
import { mockDelay, mockProductConfig } from "@/lib/api/mock-data"
|
||||
|
||||
export const productConfigApi = {
|
||||
get(): Promise<ProductConfig> {
|
||||
return mockDelay({ ...mockProductConfig })
|
||||
},
|
||||
|
||||
update(request: UpdateProductConfigRequest): Promise<ProductConfig> {
|
||||
Object.assign(mockProductConfig, request)
|
||||
return mockDelay({ ...mockProductConfig })
|
||||
},
|
||||
}
|
||||
@@ -144,6 +144,7 @@ export interface CreateCategoryRequest {
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
name: string
|
||||
parentId?: number | null
|
||||
}
|
||||
|
||||
export interface Brand {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Company-level Product Configuration — capability toggles that gate optional
|
||||
// product/category behaviour across the app (frontend-only, no backend yet).
|
||||
|
||||
export interface ProductConfig {
|
||||
// Product Capabilities
|
||||
subcategories: boolean
|
||||
brands: boolean
|
||||
productImages: boolean
|
||||
serialNumbers: boolean
|
||||
batchLotTracking: boolean
|
||||
expiryMfgDates: boolean
|
||||
warranty: boolean
|
||||
serviceItems: boolean
|
||||
adHocSaleLines: boolean
|
||||
// Pricing & Quantity
|
||||
minPriceFloor: boolean
|
||||
maxPriceCeiling: boolean
|
||||
freePricingProducts: boolean
|
||||
fractionalQuantities: boolean
|
||||
packConversion: boolean
|
||||
// Stock Behaviour
|
||||
allowNegativeStock: boolean
|
||||
}
|
||||
|
||||
export type UpdateProductConfigRequest = Partial<ProductConfig>
|
||||
+7
-5
@@ -18,6 +18,8 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin
|
||||
| **AR-04** | **GRN-without-PO** — receive (and value) goods with no order; a fraud vector. | Emergency/direct receipt is useful (FR-GRN-02). | Permission-gated (when RBAC on); flagged for review; audit; cost entered here gets extra scrutiny (see C.3). | With RBAC / policy on direct receipts. |
|
||||
| **AR-05** | **In-transit loss window** — dispatched-but-not-received stock is untracked shrinkage risk. | Inherent to in-transit transfers (FR-STK-05). | In-transit aging monitoring report. | Add stuck-transfer alert (Part D). |
|
||||
| **AR-06** | **Localhost dev secrets** in `appsettings.Development.json`. | Local-dev convenience, current phase. | `.gitignore` + localhost only. | Before any shared/staging/prod → User Secrets / env vars; rotate. |
|
||||
| **AR-07** | **`getUserDetails` / `LogoutUser` callable without a bearer token** — `GET /api/v1/auth/users/{userId}` and `POST /api/v1/auth/logout` resolve the target user from the URL/payload, not the caller's session, so any anonymous caller can fetch a profile or log out an arbitrary user's sessions by GUID. | Carried over verbatim from AuthHex's own dispatcher contract (API_REFERENCE.md §3) — ERPCore's `AuthController` proxies it as-is rather than silently tightening a contract it doesn't own. | GUIDs are not enumerable; every call is written to `AuthEventLogs` upstream in AuthHex. | Revisit once AuthHex exposes a token-scoped variant, or add ERPCore-side rate limiting / auth requirement ahead of AuthHex. |
|
||||
| **AR-08** | **No rate limiting on `AuthController`'s anonymous endpoints** (login, register, refresh, recovery, OTP send/verify) — brute-force and account-enumeration exposure. | Not built in this pass (docs/11 §2.0, added 2026-07-16); AuthHex may rate-limit server-side but ERPCore does not add its own layer yet. | AuthHex's own lockout/backoff (per docs/10 NFR-03), immutable audit trail. | Add ASP.NET Core rate limiting middleware to `AuthController` before any non-local deployment. |
|
||||
|
||||
---
|
||||
|
||||
@@ -33,11 +35,11 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin
|
||||
- [ ] Generic auth-failure messages (no account-enumeration signal)
|
||||
|
||||
### B.2 Token storage & CSRF *(httpOnly-cookie decision)*
|
||||
- [ ] Token in an **httpOnly, Secure** cookie (never localStorage) — removes XSS token theft
|
||||
- [ ] `SameSite=Strict` (or `Lax`) on the auth cookie
|
||||
- [ ] **CSRF protection on every state-changing request** (anti-forgery / double-submit token) — cookies are sent automatically, so CSRF is now in scope
|
||||
- [ ] CORS locked to the known frontend origin(s); credentials mode aligned with the cookie
|
||||
- [ ] Cookie scoped minimally (path/domain), Secure flag on
|
||||
- [x] Token in an **httpOnly, Secure** cookie (never localStorage) — removes XSS token theft (`AuthCookieWriter.WriteSession`, `erp_at`/`erp_rt`, 2026-07-16)
|
||||
- [x] `SameSite=Strict` on the auth cookie (`AuthCookieWriter`; assumes frontend + ERPCore share a registrable domain — revisit if deployed cross-domain)
|
||||
- [x] **CSRF protection on every state-changing request** (double-submit `XSRF-TOKEN` cookie + `X-XSRF-TOKEN` header, `ValidateCsrfAttribute`, applied to every mutating `AuthController` action; Bearer-header callers exempt since they aren't cookie-driven)
|
||||
- [ ] CORS locked to the known frontend origin(s); credentials mode aligned with the cookie — **not yet configured**; required before any browser frontend can call these endpoints cross-origin (tracked with the frontend-wiring follow-up)
|
||||
- [x] Cookie scoped minimally (path/domain), Secure flag on (`erp_rt` scoped to `/api/v1/auth/refresh-token`; all three cookies `Secure=true`)
|
||||
|
||||
### B.3 Audit integrity *(this is the compensating control for AR-01 — it must hold)*
|
||||
- [ ] Audit log **and** stock ledger are append-only **at the DB level** (the app's DB role has no `UPDATE`/`DELETE` on those tables)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **Authoritative for:** backend architecture, business rules, and the data model (the 38-entity schema).
|
||||
> **Navigation:** you arrived here from `00-CORE.md`. API request/response contracts are in `11-BACKEND-PHASE1.md`. Frontend rules are in `20-FRONTEND.md`. Record work in `Backend/PROGRESS.md`.
|
||||
> **Scope basis:** SRS v1.1. Costing = FIFO · Multi-warehouse · Single-tenant · RBAC deferred (user identity stamped) · approvals auto/config-gated · vendor invoice + 3-way match deferred to Accounting.
|
||||
> **Authentication:** delegated to the **external AuthHex identity provider** (separate service). ERPCore is a **resource server** that only *validates* AuthHex's RS256 JWTs — it does not issue tokens or own a login endpoint. See A.4 (Authentication / Audit actor). RBAC (per-endpoint) still deferred.
|
||||
> **Authentication:** identity is owned by the **external AuthHex identity provider** (separate service), but as of 2026-07-16 the frontend no longer calls AuthHex directly — all login/registration/recovery/2FA/session traffic is proxied through ERPCore's own `AuthController` (`Controllers/AuthController.cs`, `Services/Auth/*`), which forwards to AuthHex and delivers the resulting session as httpOnly Secure cookies (docs/02-SECURITY.md §B.2). ERPCore still does not mint or sign tokens itself — it only forwards to and validates AuthHex's RS256 JWTs. See A.4 (Authentication / Audit actor) and `11-BACKEND-PHASE1.md §2.0` for the endpoint list. RBAC (per-endpoint) still deferred.
|
||||
|
||||
---
|
||||
|
||||
@@ -49,7 +49,8 @@ HTTP ─► Controller ─► Service ─► Repository ─► UnitOfWork / ErpD
|
||||
|
||||
## A.4 Cross-cutting
|
||||
- **Errors:** RFC 7807 `ProblemDetails` (framework default). Domain exceptions in `System/Errors` carry a stable `code`; a middleware maps them to `ProblemDetails`. Catalog in `11-BACKEND-PHASE1.md §7`.
|
||||
- **Authentication:** ERPCore is a **resource server**. It validates JWTs issued by the **external AuthHex IdP** — algorithm **RS256** (asymmetric RSA), issuer `AuthHex`, audience `AuthHexClient`. AuthHex exposes **no JWKS/OIDC discovery**, so ERPCore is configured with AuthHex's **RSA public key statically** (rotation is a manual config update). Tokens live ~1000 min (prod) / 60 min (dev). A single **door authorization policy** requires an ERP `UserTypeCode`/`RoleCode` claim (AuthHex is a shared IdP, so a valid token alone is not enough); **per-endpoint RBAC stays deferred**.
|
||||
- **Authentication:** ERPCore validates JWTs issued by the **external AuthHex IdP** — algorithm **RS256** (asymmetric RSA), issuer `AuthHex`, audience `AuthHexClient`. AuthHex exposes **no JWKS/OIDC discovery**, so ERPCore is configured with AuthHex's **RSA public key statically** (rotation is a manual config update). Tokens live ~1000 min (prod) / 60 min (dev). A single **door authorization policy** requires an ERP `UserTypeCode`/`RoleCode` claim (AuthHex is a shared IdP, so a valid token alone is not enough); **per-endpoint RBAC stays deferred**.
|
||||
- **Auth proxy:** `AuthController` (`Controllers/AuthController.cs`) is ERPCore's only endpoint group that talks to AuthHex over HTTP — via `IAuthHexClient` (`Infra/Auth/AuthHex/AuthHexClient.cs`, `AuthHex:BaseUrl` config) — and the only place that issues httpOnly `erp_at`/`erp_rt` session cookies (`Infra/Auth/AuthCookieWriter.cs`) plus the `XSRF-TOKEN` double-submit cookie checked by `ValidateCsrfAttribute` on mutating actions. The JWT bearer handler also accepts the `erp_at` cookie as a fallback (`JwtAuthExtensions`'s `OnMessageReceived`) when no `Authorization` header is present, so every other controller keeps working unchanged whether a caller sends a Bearer header or relies on the cookie session.
|
||||
- **Audit actor:** the token carries no `sub`/`nameid`; identity is AuthHex's custom **`UserId` (GUID)** claim. An `ICurrentUser` abstraction (`Infra/Auth`) resolves the acting user from a **local shadow user** — the GUID is mapped (JIT-provisioned) to a local `int` `users.user_id` that all FKs reference (see C.7). Services stamp mutations with it. **Never** trust a `createdBy` from the request body.
|
||||
- **Concurrency:** mutable resources carry a `RowVersion` (`[Timestamp] byte[]`), surfaced as `ETag`; `PUT`/`PATCH` require `If-Match` → `412` on mismatch.
|
||||
- **Numbering:** document numbers come from `NumberSequence` (per doc type, per year), issued inside the same transaction as the document.
|
||||
@@ -200,7 +201,7 @@ UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-f
|
||||
|---|---|---|
|
||||
| NFR-01 | Performance | Single item/warehouse enquiry + valuation < 2s under normal load; ledger posting transactional, < 1s per line. |
|
||||
| NFR-02 | Integrity | FIFO layer consumption atomic and concurrency-safe; no double-consumption of remaining qty. |
|
||||
| NFR-03 | Security | Users authenticated via the external AuthHex IdP; **password hashing (BCrypt) is AuthHex's responsibility** — ERPCore validates tokens only. Every action attributed to a user and logged. (Role-based enforcement deferred, FR-X-01.) |
|
||||
| NFR-03 | Security | Users authenticated via the external AuthHex IdP, proxied through ERPCore's `AuthController` (docs/11 §2.0); **password hashing (BCrypt) is AuthHex's responsibility** — ERPCore forwards credentials and validates the resulting tokens only, never storing or hashing passwords itself. Every action attributed to a user and logged. (Role-based enforcement deferred, FR-X-01.) |
|
||||
| NFR-04 | Auditability | Audit trail immutable, retained per policy; ledger append-only. |
|
||||
| NFR-05 | Reliability | No stock transaction partially commits; full rollback on failure. |
|
||||
| NFR-06 | Scalability | Growth in items/warehouses/ledger without redesign; ledger indexed for time-series queries. |
|
||||
|
||||
+45
-10
@@ -18,7 +18,7 @@ Path-based versioning. Breaking changes bump the major version.
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
- Every endpoint requires a valid **Bearer JWT**; unauthenticated → `401`. Tokens are issued by the **external AuthHex IdP** (not ERPCore) — **RS256**, issuer `AuthHex`, audience `AuthHexClient`. ERPCore validates them against AuthHex's static RSA public key (no JWKS) and admits only holders of the configured ERP `UserType`/`Role` (door policy) → otherwise `403`.
|
||||
- Every endpoint requires a valid **Bearer JWT**, sent either as an `Authorization: Bearer <token>` header or as the `erp_at` httpOnly cookie issued by `AuthController` (§2.0); unauthenticated → `401`. Tokens are issued by the **external AuthHex IdP** (not ERPCore) — **RS256**, issuer `AuthHex`, audience `AuthHexClient`. ERPCore validates them against AuthHex's static RSA public key (no JWKS) and admits only holders of the configured ERP `UserType`/`Role` (door policy) → otherwise `403`.
|
||||
- **Per-endpoint RBAC is NOT enforced in Phase 1** (FR-X-01): any ERP-admitted user may call any endpoint.
|
||||
- The **audit actor** is AuthHex's custom **`UserId` (GUID)** claim, mapped to a local shadow user (`int`). Clients never send `createdBy`; the server derives it (docs/10 A.4).
|
||||
|
||||
@@ -59,15 +59,50 @@ Domain errors add a stable `code` (catalog §7):
|
||||
|
||||
## 2. Master Data
|
||||
|
||||
### 2.0 Auth — **external (AuthHex IdP); not an ERPCore endpoint**
|
||||
> **Superseded (2026-07-14).** ERPCore no longer exposes `/auth/login`. Login, registration and recovery are owned by the
|
||||
> separate **AuthHex** service (e.g. `POST /api/loginUser` with `{ identifier, password }`), which returns an **RS256** JWT
|
||||
> (issuer `AuthHex`, audience `AuthHexClient`; claims `UserId` (GUID), `UserTypeCode`, `RoleCode`, `NIC`, …). ERPCore only
|
||||
> **validates** that Bearer token and provisions a local shadow user (docs/10 A.4). The old shape is retained here for history:
|
||||
```json
|
||||
POST {AuthHex}/api/loginUser → { "identifier": "…", "password": "••••••••" }
|
||||
// 200 → an RS256 access token (Bearer). Bad credentials → 401. Token → ERPCore Authorization: Bearer <token>.
|
||||
```
|
||||
### 2.0 Auth — **`AuthController`, proxying the external AuthHex IdP**
|
||||
> **Superseding note (2026-07-16).** Un-superseded: the frontend no longer calls AuthHex directly. All of the endpoints
|
||||
> below live on ERPCore under `/api/v1/auth/*` (`Controllers/AuthController.cs`), each forwarding to the matching AuthHex
|
||||
> `functionName` (see the project-root `API_REFERENCE.md` for AuthHex's own contract) via `IAuthHexClient`. Session-issuing
|
||||
> endpoints deliver AuthHex's access/refresh tokens as **httpOnly Secure cookies** (`erp_at`, `erp_rt`) plus a non-httpOnly
|
||||
> `XSRF-TOKEN` cookie (docs/02-SECURITY.md §B.2) — response bodies never contain raw tokens. Mutating, cookie-authenticated
|
||||
> requests must echo the CSRF cookie value in an `X-XSRF-TOKEN` header or receive `403 CSRF_TOKEN_MISMATCH`; Bearer-header
|
||||
> callers (Swagger, service-to-service) are exempt. The JWT bearer handler also accepts the `erp_at` cookie in place of an
|
||||
> `Authorization` header (docs/10 A.4 Auth proxy), so every other `/api/v1/*` controller keeps working unchanged either way.
|
||||
|
||||
| Route | AuthHex function | Auth |
|
||||
|---|---|---|
|
||||
| `POST /api/v1/auth/register` | registerUser | Anonymous |
|
||||
| `POST /api/v1/auth/login` | loginUser | Anonymous |
|
||||
| `POST /api/v1/auth/login/otp/verify` | VerifyOtpForLogin | Anonymous |
|
||||
| `POST /api/v1/auth/refresh-token` | refreshToken | Anonymous (reads `erp_rt` cookie) |
|
||||
| `GET /api/v1/auth/users/{userId}` | getUserDetails | Anonymous* |
|
||||
| `GET /api/v1/auth/sessions` | getUserSessions | Required |
|
||||
| `POST /api/v1/auth/status` | ChangeUserStatus | Required + CSRF |
|
||||
| `POST /api/v1/auth/lock` | LockUserAccount | Required + CSRF |
|
||||
| `POST /api/v1/auth/change-password` | ChangeUserPassword | Required + CSRF |
|
||||
| `POST /api/v1/auth/verify-password` | VerifyPassword | Required |
|
||||
| `POST /api/v1/auth/logout` | LogoutUser | Anonymous* |
|
||||
| `PUT /api/v1/auth/me` | UpdateUser | Required + CSRF |
|
||||
| `POST /api/v1/auth/2fa/initiate` | initiateTwoFASetup | Required + CSRF |
|
||||
| `POST /api/v1/auth/2fa/complete` | completeTwoFASetup | Required + CSRF |
|
||||
| `POST /api/v1/auth/2fa/verify` | verifyTwoFA | Required + CSRF |
|
||||
| `POST /api/v1/auth/2fa/disable` | disableTwoFA | Required + CSRF |
|
||||
| `GET /api/v1/auth/2fa/status` | getTwoFAStatus | Required |
|
||||
| `POST /api/v1/auth/recovery/forgot-password` | forgotPassword | Anonymous |
|
||||
| `POST /api/v1/auth/recovery/verify-otp` | verifyOTP | Anonymous |
|
||||
| `POST /api/v1/auth/recovery/reset-password` | resetPassword | Anonymous |
|
||||
| `POST /api/v1/auth/recovery/reset-password-token` | resetPasswordWithToken | Anonymous |
|
||||
| `POST /api/v1/auth/availability` | IsAvailable | Anonymous |
|
||||
| `POST /api/v1/auth/otp/send` | sendOtp | Anonymous |
|
||||
| `POST /api/v1/auth/otp/verify` | VerifyOTP (Alt) | Anonymous |
|
||||
|
||||
\* `getUserDetails` and `LogoutUser` are anonymous because AuthHex itself resolves them from the request payload rather
|
||||
than the bearer token — carried over from AuthHex's own design, not introduced by this proxy. Tracked as an accepted risk
|
||||
in docs/02-SECURITY.md Part A.
|
||||
|
||||
Request/response field shapes match AuthHex's own payloads one-for-one (project-root `API_REFERENCE.md` §3–§5), except
|
||||
session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered instead) and `refreshToken` is read from the
|
||||
`erp_rt` cookie rather than the request body.
|
||||
|
||||
### 2.1 Items
|
||||
#### `GET /items`
|
||||
|
||||
Reference in New Issue
Block a user