diff --git a/Backend/ERPCore/Controllers/AuthController.cs b/Backend/ERPCore/Controllers/AuthController.cs new file mode 100644 index 0000000..5900a4a --- /dev/null +++ b/Backend/ERPCore/Controllers/AuthController.cs @@ -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; + +/// +/// 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 +/// — response bodies never carry raw tokens. Does not inherit +/// : most actions here are pre-session and need +/// , and the ETag/If-Match handling that +/// base provides doesn't apply to auth flows. +/// +[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> 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> 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> 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> 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> GetUserDetails(Guid userId, CancellationToken ct) + => Ok(await _users.GetUserDetailsAsync(userId, ct)); + + [HttpGet("sessions")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetSessions(CancellationToken ct) + => Ok(await _users.GetUserSessionsAsync(RequireBearerToken(), ct)); + + [HttpPost("status")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ChangeStatus([FromBody] ChangeUserStatusRequest request, CancellationToken ct) + { + await _users.ChangeUserStatusAsync(request, RequireBearerToken(), ct); + return NoContent(); + } + + [HttpPost("lock")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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 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> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct) + => Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct)); + + [HttpPost("logout")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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> 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> InitiateTwoFa(CancellationToken ct) + => Ok(await _users.InitiateTwoFaSetupAsync(RequireBearerToken(), ct)); + + [HttpPost("2fa/complete")] + [ValidateCsrf] + [ProducesResponseType(typeof(CompleteTwoFaSetupResponse), StatusCodes.Status200OK)] + public async Task> CompleteTwoFa([FromBody] CompleteTwoFaSetupRequest request, CancellationToken ct) + => Ok(await _users.CompleteTwoFaSetupAsync(request, RequireBearerToken(), ct)); + + [HttpPost("2fa/verify")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task VerifyTwoFa([FromBody] VerifyTwoFaRequest request, CancellationToken ct) + { + await _users.VerifyTwoFaAsync(request, RequireBearerToken(), ct); + return NoContent(); + } + + [HttpPost("2fa/disable")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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> GetTwoFaStatus(CancellationToken ct) + => Ok(await _users.GetTwoFaStatusAsync(RequireBearerToken(), ct)); + + // ---- Recovery ----------------------------------------------------------- + + [HttpPost("recovery/forgot-password")] + [AllowAnonymous] + [ProducesResponseType(typeof(ForgotPasswordResponse), StatusCodes.Status200OK)] + public async Task> 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> VerifyRecoveryOtp([FromBody] VerifyRecoveryOtpRequest request, CancellationToken ct) + => Ok(await _recovery.VerifyOtpAsync(request, ct)); + + [HttpPost("recovery/reset-password")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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 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> CheckAvailability([FromBody] IsAvailableRequest request, CancellationToken ct) + => Ok(await _alt.IsAvailableAsync(request, ct)); + + [HttpPost("otp/send")] + [AllowAnonymous] + [ProducesResponseType(typeof(SendOtpResponse), StatusCodes.Status200OK)] + public async Task> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct) + => Ok(await _alt.SendOtpAsync(request, ct)); + + [HttpPost("otp/verify")] + [AllowAnonymous] + [ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)] + public async Task> 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 -------------------------------------------------------------- + + /// The token that authenticated this request — Bearer header if present, else the session cookie. + 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); + } +} diff --git a/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs new file mode 100644 index 0000000..26df8d5 --- /dev/null +++ b/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs @@ -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; } + /// Passed through as-is when `Recovery` matched existing users — shape isn't in the documented catalog. + 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; } +} diff --git a/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs new file mode 100644 index 0000000..7aa7ba8 --- /dev/null +++ b/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs @@ -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; +} diff --git a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs new file mode 100644 index 0000000..f75c193 --- /dev/null +++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs @@ -0,0 +1,179 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace ERPCore.Dtos.Auth; + +/// Shared AuthHex user projection (API_REFERENCE.md §3). Field set is +/// AuthHex's best-documented subset; unknown fields are ignored on deserialize. +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; } +} + +/// Body returned by every session-issuing endpoint. Tokens never appear +/// here — they are delivered only as httpOnly cookies (docs/02-SECURITY.md §B.2). +public sealed class AuthSessionResponse +{ + public UserSummaryDto? User { get; set; } + public int ExpiresIn { get; set; } +} + +public sealed class RegisterRequest +{ + /// Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted. + 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; } + /// Passed through as-is — AuthHex's Role/UserType shapes aren't in the documented catalog. + 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; } +} + +/// Passthrough — TOTP secret/QR payload shape is only loosely documented +/// ("secret key, QR/otpauth URL ... from the third-party service"). +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 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; } +} diff --git a/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs b/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs new file mode 100644 index 0000000..bc1ba9f --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; + +namespace ERPCore.Infra.Auth; + +/// +/// 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). +/// +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)); +} diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs new file mode 100644 index 0000000..125230f --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs @@ -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; + +/// +/// HTTP implementation of . 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 . +/// +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 RegisterAsync(RegisterRequest request, CancellationToken ct) + => CallAsync("user", "registerUser", request, null, ct); + + public Task LoginAsync(LoginRequest request, CancellationToken ct) + => CallAsync("user", "loginUser", request, null, ct); + + public Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct) + => CallAsync("user", "VerifyOtpForLogin", request, null, ct); + + public Task RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct) + => CallAsync("user", "refreshToken", new { refreshToken, deviceName }, null, ct); + + public Task GetUserDetailsAsync(Guid userId, CancellationToken ct) + => CallAsync("user", "getUserDetails", new { userId }, null, ct); + + public Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct) + => CallAsync>("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 VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct) + => CallAsync("user", "VerifyPassword", request, bearerToken, ct); + + public Task LogoutUserAsync(Guid userId, CancellationToken ct) + => CallVoidAsync("user", "LogoutUser", new { userId }, null, ct); + + public Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct) + => CallAsync("user", "UpdateUser", request, bearerToken, ct); + + public Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct) + => CallAsync("user", "initiateTwoFASetup", new { }, bearerToken, ct); + + public Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct) + => CallAsync("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 GetTwoFaStatusAsync(string bearerToken, CancellationToken ct) + => CallAsync("user", "getTwoFAStatus", new { }, bearerToken, ct); + + // ---- RecoveryManager ------------------------------------------------ + + public Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct) + => CallAsync("recovery", "forgotPassword", request, null, ct); + + public Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct) + => CallAsync("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 IsAvailableAsync(IsAvailableRequest request, CancellationToken ct) + => CallAsync("alt", "IsAvailable", request, null, ct); + + public Task SendOtpAsync(SendOtpRequest request, CancellationToken ct) + => CallAsync("alt", "sendOtp", request, null, ct); + + public Task VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct) + => CallAsync("alt", "VerifyOTP", request, null, ct); + + // ---- Transport -------------------------------------------------------- + + private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct) + => await CallAsync(routeGroup, functionName, payload, bearerToken, ct); + + private async Task CallAsync(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? envelope; + try + { + envelope = await httpResponse.Content.ReadFromJsonAsync>(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!; + } +} diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs new file mode 100644 index 0000000..02f7794 --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs @@ -0,0 +1,37 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Infra.Auth.AuthHex; + +/// Wire shape of AuthHex's `ApiResponse` envelope (API_REFERENCE.md §1). +public sealed class AuthHexEnvelope +{ + public int StatusCode { get; set; } + public bool Success { get; set; } + public string? Message { get; set; } + public T? Data { get; set; } +} + +/// Wire shape of AuthHex's `ApiRequest` envelope (API_REFERENCE.md §1). +public sealed class AuthHexRequestBody +{ + public string FunctionName { get; set; } = string.Empty; + public object Payload { get; set; } = new { }; + public string Reference { get; set; } = string.Empty; +} + +/// +/// 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 . +/// +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; } +} diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs new file mode 100644 index 0000000..081f74c --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs @@ -0,0 +1,42 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Infra.Auth.AuthHex; + +/// +/// 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. +/// +public interface IAuthHexClient +{ + // UserManager (POST /api/user) + Task RegisterAsync(RegisterRequest request, CancellationToken ct); + Task LoginAsync(LoginRequest request, CancellationToken ct); + Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct); + Task RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct); + Task GetUserDetailsAsync(Guid userId, CancellationToken ct); + Task> 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 VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct); + Task LogoutUserAsync(Guid userId, CancellationToken ct); + Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct); + Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct); + Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct); + Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct); + Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct); + Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct); + + // RecoveryManager (POST /api/recovery) + Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct); + Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct); + Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct); + Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct); + + // AltOptionManager (POST /api/alt) + Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct); + Task SendOtpAsync(SendOtpRequest request, CancellationToken ct); + Task VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct); +} diff --git a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs index ea879ca..658433f 100644 --- a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs +++ b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs @@ -18,6 +18,18 @@ public static class JwtAuthExtensions /// Authorization policy applied to every v1 controller (via ApiControllerBase). public const string ErpAccessPolicy = "ErpAccess"; + /// httpOnly cookie AuthController writes the AuthHex access token into (docs/02-SECURITY.md §B.2). + public const string AccessTokenCookie = "erp_at"; + + /// httpOnly cookie AuthController writes the AuthHex refresh token into, scoped to the refresh route. + public const string RefreshTokenCookie = "erp_rt"; + + /// Non-httpOnly CSRF cookie for the double-submit check on cookie-authenticated mutations. + public const string CsrfCookie = "XSRF-TOKEN"; + + /// Header the frontend echoes the CSRF cookie value back through. + 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 => diff --git a/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs b/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs new file mode 100644 index 0000000..cf4a631 --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs @@ -0,0 +1,35 @@ +using ERPCore.System.Errors; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace ERPCore.Infra.Auth; + +/// +/// 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 X-XSRF-TOKEN +/// header to match the non-httpOnly XSRF-TOKEN cookie AuthController +/// issues alongside the session cookies. +/// +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(); + } +} diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 635ec52..75b3156 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -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(); // 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(c => +{ + var baseUrl = builder.Configuration["AuthHex:BaseUrl"] + ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured."); + c.BaseAddress = new Uri(baseUrl); +}); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // 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(); diff --git a/Backend/ERPCore/Services/Auth/AuthAltService.cs b/Backend/ERPCore/Services/Auth/AuthAltService.cs new file mode 100644 index 0000000..15cb7ec --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthAltService.cs @@ -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; + +/// +public sealed class AuthAltService : IAuthAltService +{ + private readonly IAuthHexClient _authHex; + + public AuthAltService(IAuthHexClient authHex) => _authHex = authHex; + + public Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default) + => _authHex.IsAvailableAsync(request, ct); + + public Task SendOtpAsync(SendOtpRequest request, CancellationToken ct = default) + => _authHex.SendOtpAsync(request, ct); + + public async Task 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 + } + }; + } +} diff --git a/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs b/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs new file mode 100644 index 0000000..863b69f --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs @@ -0,0 +1,25 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Infra.Auth.AuthHex; +using ERPCore.Services.Interfaces; + +namespace ERPCore.Services.Auth; + +/// +public sealed class AuthRecoveryService : IAuthRecoveryService +{ + private readonly IAuthHexClient _authHex; + + public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex; + + public Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default) + => _authHex.ForgotPasswordAsync(request, ct); + + public Task 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); +} diff --git a/Backend/ERPCore/Services/Auth/AuthSessionResult.cs b/Backend/ERPCore/Services/Auth/AuthSessionResult.cs new file mode 100644 index 0000000..0622e4e --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthSessionResult.cs @@ -0,0 +1,25 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Services.Auth; + +/// +/// 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 in the response. +/// +public sealed class AuthSessionResult +{ + public required string AccessToken { get; init; } + public required string RefreshToken { get; init; } + public required AuthSessionResponse Body { get; init; } +} + +/// Same purpose as , for the two OTP-verify +/// flows whose body also carries ReferenceNumber/Verified alongside the user/session. +public sealed class OtpAuthSessionResult +{ + public required string AccessToken { get; init; } + public required string RefreshToken { get; init; } + public required OtpLoginVerifiedResponse Body { get; init; } +} diff --git a/Backend/ERPCore/Services/Auth/AuthUserService.cs b/Backend/ERPCore/Services/Auth/AuthUserService.cs new file mode 100644 index 0000000..277d493 --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthUserService.cs @@ -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; + +/// +public sealed class AuthUserService : IAuthUserService +{ + private readonly IAuthHexClient _authHex; + + public AuthUserService(IAuthHexClient authHex) => _authHex = authHex; + + public async Task RegisterAsync(RegisterRequest request, CancellationToken ct = default) + { + request.UserId ??= Guid.NewGuid(); + var result = await _authHex.RegisterAsync(request, ct); + return ToSessionResult(result); + } + + public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) + { + var result = await _authHex.LoginAsync(request, ct); + return ToSessionResult(result); + } + + public async Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default) + { + var result = await _authHex.VerifyOtpForLoginAsync(request, ct); + return ToOtpSessionResult(result); + } + + public async Task RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default) + { + var result = await _authHex.RefreshTokenAsync(refreshToken, request.DeviceName, ct); + return ToSessionResult(result); + } + + public Task GetUserDetailsAsync(Guid userId, CancellationToken ct = default) + => _authHex.GetUserDetailsAsync(userId, ct); + + public Task> 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 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 UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.UpdateUserAsync(request, bearerToken, ct); + + public Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default) + => _authHex.InitiateTwoFaSetupAsync(bearerToken, ct); + + public Task 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 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 + } + }; + } +} diff --git a/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs b/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs new file mode 100644 index 0000000..10c5c8d --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Services.Auth; + +namespace ERPCore.Services.Interfaces; + +/// AltOptionManager proxy business logic (API_REFERENCE.md §5), fronting AuthHex. +public interface IAuthAltService +{ + Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default); + Task SendOtpAsync(SendOtpRequest request, CancellationToken ct = default); + Task VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs b/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs new file mode 100644 index 0000000..cb807c8 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Services.Interfaces; + +/// RecoveryManager proxy business logic (API_REFERENCE.md §4), fronting AuthHex. +public interface IAuthRecoveryService +{ + Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default); + Task VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default); + Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default); + Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs b/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs new file mode 100644 index 0000000..785b50c --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs @@ -0,0 +1,26 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Services.Auth; + +namespace ERPCore.Services.Interfaces; + +/// UserManager proxy business logic (API_REFERENCE.md §3), fronting AuthHex. +public interface IAuthUserService +{ + Task RegisterAsync(RegisterRequest request, CancellationToken ct = default); + Task LoginAsync(LoginRequest request, CancellationToken ct = default); + Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default); + Task RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default); + Task GetUserDetailsAsync(Guid userId, CancellationToken ct = default); + Task> 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 VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default); + Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default); + Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default); + Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default); + Task 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 GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index 48a544b..a86e142 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -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"; } diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json index 3d13631..28ac658 100644 --- a/Backend/ERPCore/appsettings.Development.json +++ b/Backend/ERPCore/appsettings.Development.json @@ -7,5 +7,8 @@ }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root" + }, + "AuthHex": { + "BaseUrl": "http://localhost:5011" } } diff --git a/Backend/ERPCore/appsettings.Production.json b/Backend/ERPCore/appsettings.Production.json index 838af85..f2762ae 100644 --- a/Backend/ERPCore/appsettings.Production.json +++ b/Backend/ERPCore/appsettings.Production.json @@ -4,5 +4,8 @@ }, "Jwt": { "SigningKey": "" + }, + "AuthHex": { + "BaseUrl": "" } } diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json index d49cb2d..8645532 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -15,5 +15,8 @@ "RequiredUserTypeCode": "", "RequiredRoleCode": "" }, + "AuthHex": { + "BaseUrl": "CHANGE_ME" + }, "AllowedHosts": "*" } diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index 7e710c6..fa8ccd2 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -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. diff --git a/docs/02-SECURITY.md b/docs/02-SECURITY.md index 0adb00f..f8c166d 100644 --- a/docs/02-SECURITY.md +++ b/docs/02-SECURITY.md @@ -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) diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 591e8cb..089f757 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -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. | diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index 8d1a94d..a570116 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -18,7 +18,7 @@ Path-based versioning. Breaking changes bump the major version. ``` Authorization: Bearer ``` -- 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 ` 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 . -``` +### 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`