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,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();
}
}