Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ API RESTful construída com **.NET 10** seguindo os princípios de **Clean Archi
- [Infrastructure](#infrastructure)
- [API](#api)
- [Endpoints](#endpoints)
- [Autenticação](#autenticação)
- [Usuários](#usuários)
- [Convenções](#convenções)
- [CQRS com MediatR](#cqrs-com-mediatr)
Expand Down Expand Up @@ -286,6 +287,39 @@ Ponto de entrada da aplicação.

## Endpoints

### Autenticação

#### `POST /auth/login` — Login

Autentica um usuário com e-mail e senha e retorna um JWT token.

**Request body:**
```json
{
"email": "joao@example.com",
"password": "MinhaS3nh@Segura"
}
```

| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `email` | string | ✅ | E-mail do usuário |
| `password` | string | ✅ | Senha do usuário |

**Respostas:**

| Status | Descrição |
|---|---|
| `200 OK` | `{ "token": "<JWT>" }` |
| `400 Bad Request` | Erro de validação (e-mail vazio/inválido ou senha vazia) |
| `401 Unauthorized` | E-mail ou senha inválidos |

**Observações de segurança:**
- Em caso de credenciais inválidas, a resposta 401 não indica se o e-mail existe ou não (mensagem genérica).
- O token JWT gerado deve ser enviado no cabeçalho `Authorization: Bearer <token>` nas requisições autenticadas.

---

### Usuários

#### `POST /api/users` — Criar usuário
Expand Down
26 changes: 26 additions & 0 deletions src/Voltiq.API/Controllers/Auth/AuthController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
using Voltiq.Application.Features.Auth.Commands.Login;

namespace Voltiq.API.Controllers.Auth;

[Route("auth")]
public sealed class AuthController : BaseApiController
{
/// <summary>Authenticates a user and returns a JWT token.</summary>
/// <response code="200">Authentication successful.</response>
/// <response code="400">Validation error.</response>
/// <response code="401">Invalid credentials.</response>
[HttpPost("login")]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Login(
[FromBody] LoginRequest request,
CancellationToken cancellationToken)
{
var command = new LoginCommand(request.Email, request.Password);
var result = await Sender.Send(command, cancellationToken);

return result.IsFailure ? ToErrorResult(result) : Ok(result.Value);
}
}
6 changes: 6 additions & 0 deletions src/Voltiq.API/Controllers/BaseApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ protected IActionResult ToErrorResult(Result result)
statusCode: StatusCodes.Status409Conflict,
instance: HttpContext.Request.Path),

UnauthorizedError unauthorized => Problem(
title: ResourceErrorMessages.TITULO_NAO_AUTORIZADO,
detail: unauthorized.Message,
statusCode: StatusCodes.Status401Unauthorized,
instance: HttpContext.Request.Path),

_ => Problem(
title: ResourceErrorMessages.TITULO_ERRO_INESPERADO,
statusCode: StatusCodes.Status500InternalServerError,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using MediatR;
using Voltiq.Domain.Common;

namespace Voltiq.Application.Features.Auth.Commands.Login;

public sealed record LoginCommand(string Email, string Password) : IRequest<Result<LoginResponse>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using MediatR;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Domain.Common;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories.User;
using Voltiq.Domain.ValueObjects;
using Voltiq.Exceptions.Errors;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Auth.Commands.Login;

public sealed class LoginCommandHandler(
IUserRepository userRepository,
IPasswordHasher passwordHasher,
ITokenService tokenService)
: IRequestHandler<LoginCommand, Result<LoginResponse>>
{
public async Task<Result<LoginResponse>> Handle(LoginCommand request, CancellationToken cancellationToken)
{
var email = Email.Create(request.Email).Value;

var user = await userRepository.GetByEmailAsync(email, cancellationToken);

if (user is null || !passwordHasher.Verify(request.Password, user.PasswordHash))
return Result<LoginResponse>.Failure(
new UnauthorizedError(ResourceErrorMessages.LOGIN_CREDENCIAIS_INVALIDAS));

var token = tokenService.GenerateToken(user.Id.ToString(), user.Name, []);

return Result<LoginResponse>.Success(new LoginResponse(token));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using FluentValidation;
using Voltiq.Domain.ValueObjects;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Auth.Commands.Login;

public sealed class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage(ResourceErrorMessages.USUARIO_EMAIL_OBRIGATORIO)
.Must(email => Email.TryParse(email, out _, out _))
.WithMessage(ResourceErrorMessages.USUARIO_EMAIL_INVALIDO);

RuleFor(x => x.Password)
.NotEmpty().WithMessage(ResourceErrorMessages.USUARIO_SENHA_OBRIGATORIA);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Voltiq.Application.Features.Auth.Commands.Login;

public sealed record LoginRequest(string Email, string Password);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Voltiq.Application.Features.Auth.Commands.Login;

public sealed record LoginResponse(string Token);
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using Voltiq.Domain.ValueObjects;
using Voltiq.Domain.ValueObjects;

namespace Voltiq.Domain.Interfaces.Repositories.User;

public interface IUserRepository : IRepository<Entities.User>
{
Task<bool> ExistsUserAsync(Document document, Email email, CancellationToken ct =
Task<bool> ExistsUserAsync(Document document, Email email, CancellationToken ct =
default);

Task<Entities.User?> GetByEmailAsync(Email email, CancellationToken ct = default);
}
3 changes: 3 additions & 0 deletions src/Voltiq.Exceptions/Errors/UnauthorizedError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Voltiq.Exceptions.Errors;

public sealed class UnauthorizedError(string message) : Error(message);

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@
<value>Já existe um usuário cadastrado com este documento.</value>
</data>

<!-- ═══════════════════════════════ Aplicação — Auth ════════════════════ -->

<data name="LOGIN_CREDENCIAIS_INVALIDAS" xml:space="preserve">
<value>E-mail ou senha inválidos.</value>
</data>

<!-- ═══════════════════════════════ API — títulos HTTP ════════════════════ -->

<data name="TITULO_FALHA_VALIDACAO" xml:space="preserve">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,11 @@ public async Task<bool> ExistsUserAsync(Document document, Email email,
user.Document == document || user.Email == email,
cancellationToken: ct);
}

public async Task<Domain.Entities.User?> GetByEmailAsync(Email email,
CancellationToken ct = default)
{
return await Context.Users.AsNoTracking()
.FirstOrDefaultAsync(user => user.Email == email, cancellationToken: ct);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Moq;
using Shouldly;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Application.Features.Auth.Commands.Login;
using Voltiq.Domain.Entities;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories.User;
using Voltiq.Domain.ValueObjects;
using Voltiq.Exceptions.Errors;

namespace Voltiq.Application.Tests.Features.Auth;

public class LoginCommandHandlerTests
{
private readonly Mock<IUserRepository> _userRepoMock = new();
private readonly Mock<IPasswordHasher> _passwordHasherMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();

private LoginCommandHandler CreateHandler() =>
new(_userRepoMock.Object, _passwordHasherMock.Object, _tokenServiceMock.Object);

private static LoginCommand ValidCommand() =>
new("joao@example.com", "S3cur3P@ssw0rd!");

private static User MakeUser()
{
var email = Email.Create("joao@example.com").Value;
var document = Document.Create("529.982.247-25").Value;
return User.Create("João Silva", email, document, "$argon2id$hashed");
}

[Fact]
public async Task Handle_WithValidCredentials_ShouldReturnSuccessWithToken()
{
var user = MakeUser();

_userRepoMock
.Setup(r => r.GetByEmailAsync(It.IsAny<Email>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(user);

_passwordHasherMock
.Setup(h => h.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(true);

_tokenServiceMock
.Setup(t => t.GenerateToken(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>()))
.Returns("jwt.token.here");

var handler = CreateHandler();
var result = await handler.Handle(ValidCommand(), CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
result.Value.Token.ShouldBe("jwt.token.here");
}

[Fact]
public async Task Handle_WhenUserNotFound_ShouldReturnUnauthorizedError()
{
_userRepoMock
.Setup(r => r.GetByEmailAsync(It.IsAny<Email>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((User?)null);

var handler = CreateHandler();
var result = await handler.Handle(ValidCommand(), CancellationToken.None);

result.IsFailure.ShouldBeTrue();
result.FirstError.ShouldBeOfType<UnauthorizedError>();
_tokenServiceMock.Verify(
t => t.GenerateToken(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>()),
Times.Never);
}

[Fact]
public async Task Handle_WhenPasswordIsInvalid_ShouldReturnUnauthorizedError()
{
var user = MakeUser();

_userRepoMock
.Setup(r => r.GetByEmailAsync(It.IsAny<Email>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(user);

_passwordHasherMock
.Setup(h => h.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(false);

var handler = CreateHandler();
var result = await handler.Handle(ValidCommand(), CancellationToken.None);

result.IsFailure.ShouldBeTrue();
result.FirstError.ShouldBeOfType<UnauthorizedError>();
_tokenServiceMock.Verify(
t => t.GenerateToken(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>()),
Times.Never);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using FluentValidation.TestHelper;
using Voltiq.Application.Features.Auth.Commands.Login;

namespace Voltiq.Application.Tests.Features.Auth;

public class LoginCommandValidatorTests
{
private readonly LoginCommandValidator _validator = new();

[Fact]
public void Validate_WithValidCommand_ShouldNotHaveErrors()
{
var command = new LoginCommand("joao@example.com", "senha123");
var result = _validator.TestValidate(command);
result.ShouldNotHaveAnyValidationErrors();
}

[Fact]
public void Validate_WithEmptyEmail_ShouldHaveEmailError()
{
var command = new LoginCommand("", "senha123");
var result = _validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(x => x.Email);
}

[Fact]
public void Validate_WithInvalidEmailFormat_ShouldHaveEmailError()
{
var command = new LoginCommand("nao-e-email", "senha123");
var result = _validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(x => x.Email);
}

[Fact]
public void Validate_WithEmptyPassword_ShouldHavePasswordError()
{
var command = new LoginCommand("joao@example.com", "");
var result = _validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(x => x.Password);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,32 @@ public async Task ExistsUserAsync_ShouldReturnTrue_WhenEmailOrDocumentExists()

exists.ShouldBeTrue();
}

[Fact]
public async Task GetByEmailAsync_ShouldReturnUser_WhenEmailExists()
{
var email = Email.Create("carlos@example.com").Value;
var document = Document.Create("153.509.460-56").Value;
var user = User.Create("Carlos Souza", email, document, "$argon2id$hash");

await _repository.AddAsync(user);
await _unitOfWork.SaveChangesAsync();

var found = await _userRepository.GetByEmailAsync(email);

found.ShouldNotBeNull();
found!.Id.ShouldBe(user.Id);
found.Name.ShouldBe("Carlos Souza");
found.Email.Value.ShouldBe("carlos@example.com");
}

[Fact]
public async Task GetByEmailAsync_ShouldReturnNull_WhenEmailNotFound()
{
var email = Email.Create("naoexiste@example.com").Value;

var found = await _userRepository.GetByEmailAsync(email);

found.ShouldBeNull();
}
}