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
9 changes: 9 additions & 0 deletions src/SnackFlow.Api/Attributes/RequirePermissionAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Authorization;

namespace SnackFlow.Api.Attributes;

public sealed class RequirePermissionAttribute : AuthorizeAttribute
{
public RequirePermissionAttribute(params string[] permissions)
=> Policy = $"RequirePermissions:{string.Join(",", permissions)}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Authorization;

namespace SnackFlow.Api.Authorizations.Permissions;

/* --------------------------------------------------------------------------
* A interface IAuthorizationRequirement serve para informar ao ASP.NET Core
* que aquela classe pode ser usada como um requisito de autorização.
* -------------------------------------------------------------------------- */
public sealed record PermissionAuthorization(params string[] Permissions)
: IAuthorizationRequirement;
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Authorization;

namespace SnackFlow.Api.Authorizations.Permissions;

/* --------------------------------------------------------------------------
* A classe AuthorizationHandler<PermissionAuthorization> informa ao ASP.NET
* Core que ela é o manipulador (handler) responsável por avaliar o requisito
* personalizado PermissionAuthorization.
* -------------------------------------------------------------------------- */
public sealed class PermissionAuthorizationHandler
: AuthorizationHandler<PermissionAuthorization>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, // Contexto de autorização
PermissionAuthorization requirement) // Permissões necessárias
{
// Permissões do usuário autenticado
var userPermissions = context.User.FindAll("permission")
.Select(c => c.Value)
.ToList();

// Verifica se o usuário possui todas as permissões necessárias
var hasAllPermissions = requirement.Permissions.All(p => userPermissions.Contains(p));

if (hasAllPermissions)
context.Succeed(requirement);

return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;

namespace SnackFlow.Api.Authorizations.Permissions;

public sealed class PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
: IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallbackPolicyProvider = new(options);

public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (!policyName.StartsWith("RequirePermissions:", StringComparison.OrdinalIgnoreCase))
return _fallbackPolicyProvider.GetPolicyAsync(policyName);

var permissions = policyName["RequirePermissions:".Length..] // Substring para remover o prefixo
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

var policy = new AuthorizationPolicyBuilder()
.AddRequirements(new PermissionAuthorization(permissions))
.Build();

return Task.FromResult<AuthorizationPolicy?>(policy);
}

public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
=> _fallbackPolicyProvider.GetDefaultPolicyAsync();

public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
=> _fallbackPolicyProvider.GetFallbackPolicyAsync();
}
8 changes: 7 additions & 1 deletion src/SnackFlow.Api/Common/Api/AppExtension.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using SnackFlow.Api.Middlewares;

namespace SnackFlow.Api.Common.Api;

Expand All @@ -9,6 +10,7 @@ public static void UsePipeline(this WebApplication app)
{
app.UseConfigurations();
app.UseSecurity();
app.UseMiddlewares();
app.UseHealthChecks();
app.MapControllers();
}
Expand All @@ -18,6 +20,11 @@ private static void UseSecurity(this WebApplication app)
app.UseAuthentication();
app.UseAuthorization();
}

private static void UseMiddlewares(this WebApplication app)
{
app.UseMiddleware<SecurityStampMiddleware>();
}

private static void UseConfigurations(this WebApplication app)
{
Expand All @@ -32,7 +39,6 @@ private static void UseConfigurations(this WebApplication app)
app.UseHttpsRedirection();

app.UseRequestTimeouts();
app.UseCors();
}

private static void UseHealthChecks(this WebApplication app)
Expand Down
43 changes: 36 additions & 7 deletions src/SnackFlow.Api/Common/Api/BuilderExtension.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authorization;
using SnackFlow.Application;
using SnackFlow.Infrastructure;
using Microsoft.AspNetCore.Http.Timeouts;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using SnackFlow.Api.Authorizations.Permissions;
using SnackFlow.Api.Middlewares;
using SnackFlow.Application.Abstractions.Services;
using SnackFlow.Infrastructure.Persistence;
using SnackFlow.Infrastructure.Persistence.Identity;
Expand All @@ -19,7 +22,8 @@ public static void AddPipeline(this WebApplicationBuilder builder)
{
builder.AddDocumentationApi();
builder.AddDependencyInjection();
builder.AddConfigurations();
builder.AddConfigurations();
builder.AddMiddleware();
builder.AddSecurity();
}

Expand All @@ -28,6 +32,11 @@ private static void AddDependencyInjection(this WebApplicationBuilder builder)
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration, builder.Logging);
}

private static void AddMiddleware(this WebApplicationBuilder builder)
{
builder.Services.AddScoped<SecurityStampMiddleware>();
}

private static void AddConfigurations(this WebApplicationBuilder builder)
{
Expand Down Expand Up @@ -129,6 +138,8 @@ private static void AddSecurity(this WebApplicationBuilder builder)
};
});

builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddAuthorization();
}

Expand All @@ -142,14 +153,32 @@ private static void AddDocumentationApi(this WebApplicationBuilder builder)
Title = "SnackFlow Documentation API",
Version = "v1"
});

c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "JWT Authorization header using the Bearer scheme", // ← Texto de ajuda
Name = "Authorization", // ← Nome do header HTTP
In = ParameterLocation.Header, // ← Aonde vai: no header da requisição
Type = SecuritySchemeType.ApiKey, // ← Tipo: chave de API
Scheme = "Bearer" // ← Esquema: Bearer token
Description = "JWT Authorization header usando o esquema Bearer. Exemplo: \"Bearer {token}\"",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});

c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
},
Scheme = "Bearer",
Name = "Authorization",
In = ParameterLocation.Header,
},
new List<string>()
}
});
});
}
Expand Down
35 changes: 8 additions & 27 deletions src/SnackFlow.Api/Common/Api/GlobalExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
using System.Text.Json;
using Microsoft.AspNetCore.Diagnostics;
using SnackFlow.Application.Common;
using Microsoft.AspNetCore.Diagnostics;
using SnackFlow.Api.Extensions;
using SnackFlow.Application.Exceptions;
using SnackFlow.Domain.Abstractions;
using SnackFlow.Domain.Exceptions;
using Layer = SnackFlow.Application.Exceptions;

Expand Down Expand Up @@ -35,29 +33,12 @@ public async ValueTask<bool> TryHandleAsync(
_ => ("An unexpected error occurred", StatusCodes.Status500InternalServerError, null)
};

httpContext.Response.StatusCode = statusCode;
httpContext.Response.ContentType = "application/json";

var result = Result.Failure(new Error(GetErrorCode(statusCode), message));
var response = new
{
isSuccess = result.IsSuccess,
isFailure = result.IsFailure,
error = new
{
code = result.Error.Code,
message = result.Error.Message,
details = errors
}
};

var jsonResponse = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});

await httpContext.Response.WriteAsync(jsonResponse, cancellationToken);
await httpContext.WriteUnauthorizedResponse(
title: GetErrorCode(statusCode),
message: message,
code: statusCode,
errors: errors?.ToArray() ?? []
);
return true;
}

Expand Down
23 changes: 23 additions & 0 deletions src/SnackFlow.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Mediator;
using Microsoft.AspNetCore.Http.Timeouts;
using Microsoft.AspNetCore.Mvc;
using SnackFlow.Application.Common;
using SnackFlow.Application.Features.Auths.Commands.Login;

namespace SnackFlow.Api.Controllers;

[ApiController]
[Route("api/auth")]
public sealed class AuthController(IMediator mediator) : ControllerBase
{
[HttpPost("login")]
[RequestTimeout(("standard"))]
[ProducesResponseType<Result<LoginCommandResponse>>(StatusCodes.Status200OK)]
public async Task<IActionResult> Login(
[FromBody] LoginCommand command,
CancellationToken cancellationToken)
{
var response = await mediator.Send(command, cancellationToken);
return Ok(response);
}
}
24 changes: 12 additions & 12 deletions src/SnackFlow.Api/Controllers/CompanyController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Timeouts;
using Microsoft.AspNetCore.Mvc;
using SnackFlow.Api.Attributes;
using SnackFlow.Application.Common;
using SnackFlow.Application.Features.Companies.Commands.CreateCompany;
using SnackFlow.Application.Features.Companies.Commands.CreateCompanyWithAdmin;
using SnackFlow.Application.Features.Companies.Commands.UpdateCompany;
using SnackFlow.Application.Features.Companies.Queries.GetCompanyById;
using SnackFlow.Infrastructure.Settings;

namespace SnackFlow.Api.Controllers;

[ApiController]
[Produces("application/json")]
[Route("api/company")]
public class CompanyController(IMediator mediator) : ControllerBase
{
[HttpPost]
[RequestTimeout(("standard"))]
[ProducesResponseType<Result<CreateCompanyCommandResponse>>(StatusCodes.Status201Created)]
[ProducesResponseType<Result<CreateCompanyCommandResponse>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<Result<CreateCompanyWithAdminCommandResponse>>(StatusCodes.Status201Created)]
public async Task<IActionResult> CreateCompany(
[FromBody] CreateCompanyCommand command,
[FromBody] CreateCompanyWithAdminCommand withAdminCommand,
CancellationToken cancellationToken)
{
var response = await mediator.Send(command, cancellationToken);
var response = await mediator.Send(withAdminCommand, cancellationToken);
return Created($"/api/company/{response.Value.CompanyId}", response);
}

[HttpPut]
[Authorize]
[RequestTimeout("standard")]
[Authorize(AuthenticationSchemes = nameof(AccessTokenSettings))]
[ProducesResponseType<Result<UpdateCompanyCommandResponse>>(StatusCodes.Status200OK)]
[ProducesResponseType<Result<UpdateCompanyCommandResponse>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<Result<UpdateCompanyCommandResponse>>(StatusCodes.Status404NotFound)]
[RequirePermission(PermissionsSettings.Companies.Read, PermissionsSettings.Companies.Update)]
[RequestTimeout("standard")]
public async Task<IActionResult> UpdateCompany(
[FromBody] UpdateCompanyCommand command,
CancellationToken cancellationToken)
Expand All @@ -41,10 +42,9 @@ public async Task<IActionResult> UpdateCompany(

[HttpGet("{id:guid}")]
[Authorize]
[RequestTimeout("standard")]
[ProducesResponseType<Result<GetCompanyByIdQueryResponse>>(StatusCodes.Status200OK)]
[ProducesResponseType<Result<GetCompanyByIdQueryResponse>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<Result<GetCompanyByIdQueryResponse>>(StatusCodes.Status404NotFound)]
[RequirePermission(PermissionsSettings.Companies.Read)]
[RequestTimeout("standard")]
public async Task<IActionResult> GetCompanyById(
[FromRoute] Guid id,
CancellationToken cancellationToken)
Expand Down
4 changes: 1 addition & 3 deletions src/SnackFlow.Api/Controllers/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,11 @@ public class UserController(IMediator mediator) : ControllerBase
[HttpPost]
[RequestTimeout("standard")]
[ProducesResponseType<Result<CreateUserCommandResponse>>(StatusCodes.Status201Created)]
[ProducesResponseType<Result<CreateUserCommandResponse>>(StatusCodes.Status400BadRequest)]

public async Task<IActionResult> CreateUser(
[FromBody] CreateUserCommand command,
CancellationToken cancellationToken)
{
var response = await mediator.Send(command, cancellationToken);
return Ok(response);
return Created($"/api/user/{response.Value.Email}", response);
}
}
40 changes: 40 additions & 0 deletions src/SnackFlow.Api/Extensions/HttpContextExtension.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using SnackFlow.Application.Common;
using SnackFlow.Domain.Abstractions;

namespace SnackFlow.Api.Extensions;

public static class HttpContextExtension
{
public static async Task WriteUnauthorizedResponse(
this HttpContext context,
string title,
string message,
int code,
params string[]? errors)
{
var result = Result.Failure(new Error(title, message));
var response = new
{
isSuccess = result.IsSuccess,
isFailure = result.IsFailure,
error = new
{
code = result.Error.Code,
message = result.Error.Message,
details = errors ?? []
}
};

var jsonResponse = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});

context.Response.StatusCode = code;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(jsonResponse);
}
}
Loading