shoc-backend/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs
Alexandre Brandizzi 77a10e38ca fix(auth): cap reset abuse per account, key code hashes, send reset email off the request path
- Forgot Password is limited to 3 codes an hour and 10 a day per email, and
  an account gets 10 failed code checks a day across every code it is sent,
  so new client addresses and new codes no longer buy more guesses. Refused
  requests answer exactly like accepted ones.
- The reset email is queued to a background sender, and unregistered
  addresses store a row no code can match, so both paths do the same work
  and return without waiting on the mail provider. Each request also clears
  expired codes.
- Code hashes are HMAC-SHA256 under a key derived with HKDF from the JWT
  signing secret; rows in the previous unkeyed format stop matching.
- Email and code are read only from the JSON body.
2026-09-25 13:00:03 -03:00

201 lines
7.6 KiB
C#

using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Runtime.CompilerServices;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[ApiController]
[Route("api/Authentication")]
public class AuthenticationController : Controller
{
private readonly IAuthenticationService _authenticationService;
private readonly ILogger<AuthenticationController> _logger;
public AuthenticationController(IAuthenticationService authenticationService, ILogger<AuthenticationController> logger)
{
_authenticationService = authenticationService;
_logger = logger;
}
[AllowAnonymous]
[HttpPost]
[Route("login")]
public async Task<IActionResult> Login([FromBody] LoginModel model, CancellationToken cancellationToken)
{
try
{
var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken);
if (result != null)
{
return Ok(new
{
token = result.Token,
expiration = result.Expiration,
email = result.Email,
userRoles = result.UserRole,
phoneNumber = result.PhoneNumber,
fullname = result.Fullname,
id = result.Id
});
}
return Unauthorized();
}
catch (Exception ex)
{
return StatusCode(500, _logger.Sanitize(ex));
}
}
[Route("ChangePassword")]
[HttpPost]
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken)
{
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken);
if (succeeded)
{
return Ok(new Response { Status = "Success ", Message = "Password successfully changed" });
}
else
return BadRequest(new Response { Status = "Old Password is incorrect" });
}
[HttpPost]
[Route("UpdateProfile")]
public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken)
{
try
{
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var dto = new UpdateProfileRequestDTO
{
Name = model.Name,
Email = model.Email,
Contact = model.Contact
};
var data = await _authenticationService.UpdateProfileAsync(userid, dto, cancellationToken);
return Ok(new DataResponse
{
Message = "Introduction Updated Successfully",
Status = "200",
Data = data == null ? null : new
{
data.FirstName,
data.Email,
data.Contact
}
});
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
#region Forget Password Area
public const string ForgetPasswordMessage =
"If that email belongs to an account, a reset code has been sent to it.";
// Email and code are read only from the JSON body, so they never appear in URLs
// or in proxy and load balancer access logs.
[AllowAnonymous]
[HttpPost()]
[Route("ForgetPassword")]
[EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)]
public async Task<IActionResult> ForgetPassword(
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ForgetPasswordRequest_Dto? body,
CancellationToken cancellationToken)
{
try
{
await _authenticationService.ForgetPasswordAsync(body?.Email, cancellationToken);
}
catch (Exception ex)
{
// Same answer as success: a failure only a registered address can hit
// must not reveal that the address is registered.
LogResetFailure(ex);
}
return Ok(new Response { Status = "Success ", Message = ForgetPasswordMessage });
}
[AllowAnonymous]
[HttpPost()]
[Route("VerificationCode")]
[EnableRateLimiting(PasswordResetRateLimiting.VerificationCodePolicy)]
public async Task<IActionResult> VerificationCode(
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] VerificationCode_Dto? body,
CancellationToken cancellationToken)
{
try
{
if (await _authenticationService.VerifyCodeAsync(body?.Email, body?.Code, cancellationToken))
{
return Ok(new Response { Status = "Success ", Message = "Code Matched" });
}
else
{
return BadRequest(new Response { Status = "Error", Message = "Code Not Matched" });
}
}
catch (Exception ex)
{
LogResetFailure(ex);
return BadRequest(new Response { Status = "Error", Message = "Code Not Matched" });
}
}
[AllowAnonymous]
[HttpPost()]
[Route("ResetPassword")]
[EnableRateLimiting(PasswordResetRateLimiting.ResetPasswordPolicy)]
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken)
{
try
{
if (await _authenticationService.ResetPasswordAsync(fpdto.Email, fpdto.Code, fpdto.Password, cancellationToken))
{
return Ok(new Response { Status = "Success ", Message = "password changed" });
}
else
{
return BadRequest(new Response { Status = "Error", Message = "Your email or code not found please check" });
}
}
catch (Exception ex)
{
LogResetFailure(ex);
return BadRequest(new Response { Status = "Error", Message = "Your email or code not found please check" });
}
}
// These endpoints answer failures exactly like a wrong code or an unknown email, so
// an error only a registered account can trigger reveals nothing. Exception
// messages here can echo the email or code, so only the type is logged.
private void LogResetFailure(Exception exception, [CallerMemberName] string operation = "")
{
_logger.LogError(
"Password reset {Operation} failed with {ExceptionType}.",
operation,
exception.GetType().FullName);
}
#endregion
}
}