This commit is contained in:
2026-07-16 14:23:23 +05:30
parent 582782b0fe
commit 7c5faabc2d
26 changed files with 1225 additions and 18 deletions
@@ -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);
}
}
+42
View File
@@ -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;
}
+179
View File
@@ -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&lt;IAuthHexClient, AuthHexClient&gt;`) 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();
}
}
+13
View File
@@ -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": ""
}
}
+3
View File
@@ -15,5 +15,8 @@
"RequiredUserTypeCode": "",
"RequiredRoleCode": ""
},
"AuthHex": {
"BaseUrl": "CHANGE_ME"
},
"AllowedHosts": "*"
}