Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8e1d55e
test(auth): add failing tests for refresh token rotation (TDD red)
evans-costa Mar 11, 2026
fb46058
feat(exceptions): add REFRESH_TOKEN_INVALIDO resource message
evans-costa Mar 11, 2026
adeabe5
feat(domain): add RefreshToken entity with rotation support
evans-costa Mar 11, 2026
f4e4612
feat(domain): add IRefreshTokenRepository interface
evans-costa Mar 11, 2026
986ba35
feat(application): rename GenerateToken to GenerateAccessToken and ad…
evans-costa Mar 11, 2026
c30841a
feat(application): update LoginResponse to include AccessToken and Re…
evans-costa Mar 11, 2026
4e1991f
feat(application): update handlers to use GenerateAccessToken and iss…
evans-costa Mar 11, 2026
0691f88
feat(application): add RefreshTokenCommand with handler and validator
evans-costa Mar 11, 2026
cbe2a2f
feat(infrastructure): update TokenService with GenerateAccessToken an…
evans-costa Mar 11, 2026
a5bf9c7
feat(infrastructure): add RefreshTokenConfiguration for EF Core
evans-costa Mar 11, 2026
5129092
feat(infrastructure): add DbSet<RefreshToken> to ApplicationDbContext
evans-costa Mar 11, 2026
d6793e9
feat(infrastructure): add RefreshTokenRepository
evans-costa Mar 11, 2026
11ae7e7
feat(infrastructure): register IRefreshTokenRepository in DI
evans-costa Mar 11, 2026
2918bdd
feat(infrastructure): add migration AddRefreshTokens
evans-costa Mar 11, 2026
512b105
feat(api): add POST /auth/refresh endpoint for token rotation
evans-costa Mar 11, 2026
8c3a660
refactor(domain): convert IsRevoked and IsActive to persisted columns…
evans-costa Mar 12, 2026
1dfaad1
refactor(infrastructure): update RefreshTokenConfiguration to map IsR…
evans-costa Mar 12, 2026
0912e85
refactor(application): introduce AuthResponse DTO for refresh token e…
evans-costa Mar 12, 2026
3d9fc21
refactor(application): add RefreshTokenRequest.ToCommand mapping exte…
evans-costa Mar 12, 2026
b809496
refactor(application): update handler and command to use AuthResponse
evans-costa Mar 12, 2026
ec0d11b
test(application): update RefreshTokenCommandHandler tests to assert …
evans-costa Mar 12, 2026
29482f3
refactor(api): use AuthResponse in refresh endpoint and ToCommand map…
evans-costa Mar 12, 2026
9242155
feat(infrastructure): add migration AddRefreshTokenStatusColumns
evans-costa Mar 12, 2026
79a8236
refactor(api): replace SwaggerGen with Microsoft.OpenApi and SwaggerU…
evans-costa Mar 12, 2026
5ff54d5
test(application): assert specific error message per refresh token fa…
evans-costa Mar 12, 2026
117e666
fix(exceptions): add REFRESH_TOKEN_NAO_ENCONTRADO and REFRESH_TOKEN_E…
evans-costa Mar 12, 2026
5ee71d5
fix(application): separate error responses for expired, revoked, and …
evans-costa Mar 12, 2026
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
22 changes: 20 additions & 2 deletions src/Voltiq.API/Controllers/Auth/AuthController.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Voltiq.Application.Features.Auth.Commands.Login;
using Voltiq.Application.Features.Auth.Commands.Refresh;
using Voltiq.Application.Features.Users.Queries.GetCurrentUser;
using Voltiq.Application.Mappings.Auth;

namespace Voltiq.API.Controllers.Auth;

[Route("auth")]
public sealed class AuthController : BaseApiController
{
/// <summary>Authenticates a user and returns a JWT token.</summary>
/// <summary>Authenticates a user and returns an access token and a refresh token.</summary>
/// <response code="200">Authentication successful.</response>
/// <response code="400">Validation error.</response>
/// <response code="401">Invalid credentials.</response>
Expand All @@ -28,6 +28,24 @@ public async Task<IActionResult> Login(
return result.IsFailure ? ToErrorResult(result) : Ok(result.Value);
}

/// <summary>Exchanges a valid refresh token for a new access token and refresh token (rotation).</summary>
/// <response code="200">Tokens refreshed successfully.</response>
/// <response code="400">Validation error.</response>
/// <response code="401">Refresh token invalid, expired or revoked.</response>
[AllowAnonymous]
[HttpPost("refresh")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Refresh(
[FromBody] RefreshTokenRequest request,
CancellationToken cancellationToken)
{
var result = await Sender.Send(request.ToCommand(), cancellationToken);

return result.IsFailure ? ToErrorResult(result) : Ok(result.Value);
}

/// <summary>Returns the currently authenticated user.</summary>
/// <response code="200">Current user data.</response>
/// <response code="401">Token missing or invalid.</response>
Expand Down
65 changes: 43 additions & 22 deletions src/Voltiq.API/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi;
using Voltiq.API.ExceptionHandlers;
using Voltiq.Application;
using Voltiq.Infrastructure;
Expand All @@ -14,26 +14,44 @@
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
builder.Services.AddOpenApi("v1", o =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Voltiq API", Version = "v1" });

var securityScheme = new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter your JWT token.",
Reference = new OpenApiReference { Id = "Bearer", Type = ReferenceType.SecurityScheme },
};

c.AddSecurityDefinition("Bearer", securityScheme);
c.AddSecurityRequirement(new OpenApiSecurityRequirement
o.AddDocumentTransformer((document, context, cancellationToken) =>
{
{ securityScheme, [] }
document.Info = new OpenApiInfo
{
Title = "Voltiq API",
Description = "API da aplicação Voltiq.",
Version = "v1",
};

document.Servers =
[
new OpenApiServer { Url = "https://localhost:7044/", Description = "Servidor Local" },
];

document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter your JWT token.",
};

document.Security ??= new List<OpenApiSecurityRequirement>();
document.Security.Add(new OpenApiSecurityRequirement
{
{
new OpenApiSecuritySchemeReference("Bearer", document),
[]
}
});

return Task.CompletedTask;
});
});

Expand All @@ -54,15 +72,18 @@

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
app.MapOpenApi("/docs/{documentName}.json");
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/docs/v1.json", "Voltiq API - v1");
});
}

app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireAuthorization();
app.MapControllers().RequireAuthorization().WithGroupName("v1");

app.Run();

Expand Down
3 changes: 2 additions & 1 deletion src/Voltiq.API/Voltiq.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.5" />
</ItemGroup>

<ItemGroup>
Expand Down
3 changes: 2 additions & 1 deletion src/Voltiq.Application/Common/Interfaces/ITokenService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ namespace Voltiq.Application.Common.Interfaces;

public interface ITokenService
{
string GenerateToken(string userId, string userName, IEnumerable<string> roles);
string GenerateAccessToken(string userId, string userName, IEnumerable<string> roles);
string GenerateRefreshToken();
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using MediatR;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Domain.Common;
using Voltiq.Domain.Entities;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories;
using Voltiq.Domain.Interfaces.Repositories.User;
using Voltiq.Domain.ValueObjects;
using Voltiq.Exceptions.Errors;
Expand All @@ -12,7 +14,9 @@ namespace Voltiq.Application.Features.Auth.Commands.Login;
public sealed class LoginCommandHandler(
IUserRepository userRepository,
IPasswordHasher passwordHasher,
ITokenService tokenService)
ITokenService tokenService,
IRefreshTokenRepository refreshTokenRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<LoginCommand, Result<LoginResponse>>
{
public async Task<Result<LoginResponse>> Handle(LoginCommand request, CancellationToken cancellationToken)
Expand All @@ -25,8 +29,13 @@ public async Task<Result<LoginResponse>> Handle(LoginCommand request, Cancellati
return Result<LoginResponse>.Failure(
new UnauthorizedError(ResourceErrorMessages.LOGIN_CREDENCIAIS_INVALIDAS));

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

return Result<LoginResponse>.Success(new LoginResponse(token));
var refreshToken = RefreshToken.Create(rawRefreshToken, user.Id, expiresInDays: 7);
await refreshTokenRepository.AddAsync(refreshToken, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

return Result<LoginResponse>.Success(new LoginResponse(accessToken, rawRefreshToken));
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
namespace Voltiq.Application.Features.Auth.Commands.Login;

public sealed record LoginResponse(string Token);
public sealed record LoginResponse(string AccessToken, string RefreshToken);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Voltiq.Application.Features.Auth.Commands.Refresh;

public sealed record AuthResponse(string AccessToken, string RefreshToken);
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.Refresh;

public sealed record RefreshTokenCommand(string RefreshToken) : IRequest<Result<AuthResponse>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using MediatR;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Domain.Common;
using Voltiq.Domain.Entities;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories;
using Voltiq.Domain.Interfaces.Repositories.User;
using Voltiq.Exceptions.Errors;
using Voltiq.Exceptions.Resources;

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

public sealed class RefreshTokenCommandHandler(
IRefreshTokenRepository refreshTokenRepository,
IUserRepository userRepository,
ITokenService tokenService,
IUnitOfWork unitOfWork)
: IRequestHandler<RefreshTokenCommand, Result<AuthResponse>>
{
public async Task<Result<AuthResponse>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
var refreshToken = await refreshTokenRepository.GetByTokenAsync(request.RefreshToken, cancellationToken);

if (refreshToken is null)
return Result<AuthResponse>.Failure(
new UnauthorizedError(ResourceErrorMessages.REFRESH_TOKEN_NAO_ENCONTRADO));

if (refreshToken.IsExpired)
return Result<AuthResponse>.Failure(
new UnauthorizedError(ResourceErrorMessages.REFRESH_TOKEN_EXPIRADO));

if (!refreshToken.IsActive)
return Result<AuthResponse>.Failure(
new UnauthorizedError(ResourceErrorMessages.REFRESH_TOKEN_INVALIDO));

var user = await userRepository.GetByIdAsync(refreshToken.UserId, cancellationToken);

if (user is null)
return Result<AuthResponse>.Failure(
new UnauthorizedError(ResourceErrorMessages.REFRESH_TOKEN_INVALIDO));

refreshToken.Revoke();

var newAccessToken = tokenService.GenerateAccessToken(user.Id.ToString(), user.Name, []);
var newRawRefreshToken = tokenService.GenerateRefreshToken();

var newRefreshToken = RefreshToken.Create(newRawRefreshToken, user.Id, expiresInDays: 7);
await refreshTokenRepository.AddAsync(newRefreshToken, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

return Result<AuthResponse>.Success(new AuthResponse(newAccessToken, newRawRefreshToken));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentValidation;
using Voltiq.Exceptions.Resources;

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

public sealed class RefreshTokenCommandValidator : AbstractValidator<RefreshTokenCommand>
{
public RefreshTokenCommandValidator()
{
RuleFor(x => x.RefreshToken)
.NotEmpty().WithMessage(ResourceErrorMessages.REFRESH_TOKEN_INVALIDO);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Voltiq.Application.Features.Auth.Commands.Refresh;

public sealed record RefreshTokenRequest(string RefreshToken);
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public async Task<Result<CreateUserResponse>> Handle(CreateUserCommand request,
await userRepository.AddAsync(user, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

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

return Result<CreateUserResponse>.Success(user.ToCreateUserResponse(token));
}
Expand Down
7 changes: 7 additions & 0 deletions src/Voltiq.Application/Mappings/Auth/AuthMappingExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Voltiq.Application.Features.Auth.Commands.Login;
using Voltiq.Application.Features.Auth.Commands.Refresh;

namespace Voltiq.Application.Mappings.Auth;

Expand All @@ -9,4 +10,10 @@ public static class AuthMappingExtensions
public LoginCommand ToCommand() =>
new(request.Email, request.Password);
}

extension(RefreshTokenRequest request)
{
public RefreshTokenCommand ToCommand() =>
new(request.RefreshToken);
}
}
34 changes: 34 additions & 0 deletions src/Voltiq.Domain/Entities/RefreshToken.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace Voltiq.Domain.Entities;

public sealed class RefreshToken : BaseEntity
{
public string Token { get; private set; } = null!;
public Guid UserId { get; private set; }
public DateTime ExpiresAt { get; private set; }
public DateTime? RevokedAt { get; private set; }
public DateTime CreatedAt { get; private set; }
public bool IsRevoked { get; private set; }
public bool IsActive { get; private set; }

public bool IsExpired => DateTime.UtcNow >= ExpiresAt;

private RefreshToken() { }

public static RefreshToken Create(string token, Guid userId, int expiresInDays) =>
new()
{
Token = token,
UserId = userId,
ExpiresAt = DateTime.UtcNow.AddDays(expiresInDays),
CreatedAt = DateTime.UtcNow,
IsRevoked = false,
IsActive = true,
};

public void Revoke()
{
RevokedAt = DateTime.UtcNow;
IsRevoked = true;
IsActive = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Voltiq.Domain.Entities;

namespace Voltiq.Domain.Interfaces.Repositories;

public interface IRefreshTokenRepository
{
Task<RefreshToken?> GetByTokenAsync(string token, CancellationToken cancellationToken = default);
Task AddAsync(RefreshToken refreshToken, CancellationToken cancellationToken = default);
}
12 changes: 12 additions & 0 deletions src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs

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

10 changes: 10 additions & 0 deletions src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@
<data name="LOGIN_CREDENCIAIS_INVALIDAS" xml:space="preserve">
<value>E-mail ou senha inválidos.</value>
</data>
<data name="REFRESH_TOKEN_INVALIDO" xml:space="preserve">
<value>Refresh token inválido.</value>
</data>

<data name="REFRESH_TOKEN_NAO_ENCONTRADO" xml:space="preserve">
<value>Refresh token não encontrado.</value>
</data>
<data name="REFRESH_TOKEN_EXPIRADO" xml:space="preserve">
<value>Refresh token expirado.</value>
</data>

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

Expand Down
Loading
Loading