You have multiple options.
1. Interface/base class
It seems everything you return has a similar structure – a StatusCode property, and some other additional properties that make sense in the context of the given status code.
So the most obvious might be to create a base class or an interface for these, like this:
public interface IOperationResult
{
int StatusCode { get; init; }
object Response { get; }
}
public class Error : IOperationResult
{
public int StatusCode { get; init; }
public string[,] ErrorMessages { get; init; }
public object Response => ErrorMessages;
}
public class AuthSuccessful : IOperationResult
{
public int StatusCode { get; init; }
public string Token { get; init; }
public object Response => Token;
}
This is a well-defined structure that will arguably support more complex business logic, when you might have to check the exact type of the return value and access properties on them in a type-safe manner.
2. Value tuples
Another option that I use a lot these days is returning a value tuple, with one member containing the success/failure, and another the result; like the following. It looks pretty bad in this case, because the format of the error messages aren't defined. But if you used a class or struct for that, it would be quite okay.
public async Task<(int statusCode, object response)> LoginAsync(User user)
{
var existingUser_byEmail = await FindUserByEmailAsync(user.Email);
if (existingUser_byEmail == default)
return (statusCode: 400, response: new[] { new[] { "email", "Nie odnaleziono użytkownika z podanym adresem e-mail" } });
if (BCrypt.Net.BCrypt.EnhancedVerify(user.Password, existingUser_byEmail.Password))
return (statusCode: 200, response: _jwtService.GenerateToken(existingUser_byEmail));
else
return (statusCode: 401, response: new[] { new[] { "password", "Błędne hasło" } });
}
// Then you can do a tuple deconstruction assignment:
[HttpPost("login")]
public async Task<IActionResult> LogIn([FromBody] User user)
{
var (statusCode, response) = await _accountService.LoginAsync(user);
return StatusCode(statusCode, response);
}
3. Do the HTTP code and error message selection outside the service
It's more traditional to return a different flag from authentication services, and then map that to an HTTP code somewhere closer to the controller (or inside the controller). This way you avoid coupling the service to HTTP concerns, which arguably shouldn't be their responsibility.
For example a lot of built-in Identity services use the Microsoft.AspNetCore.Identity.SignInResult class.
In the following implementation I changed the LoginAsync method to return a failed result both in the case of invalid password and invalid email. This is actually a better practice, because if you tell the person trying to log in that an email address does or doesn't have an account, you're leaking out user information.
public async Task<(SignInResult result, string token)> LoginAsync(User user)
{
var existingUser_byEmail = await FindUserByEmailAsync(user.Email);
if (existingUser_byEmail == default)
return (SignInResult.Failed, null);
if (BCrypt.Net.BCrypt.EnhancedVerify(user.Password, existingUser_byEmail.Password))
return (SignInResult.Success, _jwtService.GenerateToken(existingUser_byEmail));
else
return (SignInResult.Failed, null);
}
[HttpPost("login")]
public async Task<IActionResult> LogIn([FromBody] User user)
{
var (result, token) = await _accountService.LoginAsync(user);
if (result.Succeeded)
return Ok(token);
// Handle lock-out and 'login not allowed' situation too, if necessary.
return Unauthorized("Invalid password or email.");
}