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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copy to .env and fill in values. Never commit .env.

# PostgreSQL password used by compose.yaml
POSTGRES_PASSWORD=change-me
513 changes: 179 additions & 334 deletions README.md

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions src/DistroFlow.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using DistroFlow.Application.DTOs.Auth;
using DistroFlow.Application.DTOs.Common;
using DistroFlow.Application.Interfaces.Identity;
using DistroFlow.Application.Interfaces.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace DistroFlow.Api.Controllers;

[ApiController]
[Route("api/v1/auth")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;

public AuthController(IAuthService authService)
{
_authService = authService;
}

/// <summary>Self-signup for a new distributor and its owner (first Administrator).</summary>
[AllowAnonymous]
[HttpPost("register")]
public async Task<IActionResult> Register(
[FromBody] RegisterDistributorRequest request,
CancellationToken cancellationToken)
{
var result = await _authService.RegisterDistributorAsync(request, cancellationToken);

return result.Error switch
{
CreateUserError.None => StatusCode(
StatusCodes.Status201Created,
ApiResponse<RegisterDistributorResponse>.SuccessResponse(
result.Response,
"Distributor registered successfully.")),

CreateUserError.EmailInUse => Conflict(
ApiResponse<object>.FailureResponse(result.Messages[0])),

CreateUserError.ValidationFailed => BadRequest(
ApiResponse<object>.FailureResponse(
"Validation failed.",
new Dictionary<string, string[]>
{
[nameof(RegisterDistributorRequest.Password)] = result.Messages.ToArray()
})),

_ => throw new InvalidOperationException($"Unhandled registration error: {result.Error}")
};
}

/// <summary>Exchanges email + password for a JWT access token.</summary>
[AllowAnonymous]
[HttpPost("login")]
public async Task<IActionResult> Login(
[FromBody] LoginRequest request,
CancellationToken cancellationToken)
{
var result = await _authService.LoginAsync(request, cancellationToken);

return result.Error switch
{
LoginError.None => Ok(
ApiResponse<LoginResponse>.SuccessResponse(result.Response, "Login successful.")),

LoginError.InvalidCredentials => Unauthorized(
ApiResponse<object>.FailureResponse("Invalid email or password.")),

LoginError.TenantSuspended => StatusCode(
StatusCodes.Status403Forbidden,
ApiResponse<object>.FailureResponse(
"This distributor account is suspended. Contact support.")),

_ => throw new InvalidOperationException($"Unhandled login error: {result.Error}")
};
}
}
30 changes: 30 additions & 0 deletions src/DistroFlow.Api/Controllers/MeController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using DistroFlow.Application.Common;
using DistroFlow.Application.DTOs.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace DistroFlow.Api.Controllers;

/// <summary>
/// The signed-in employee. For now returns what the token says; profile data
/// (name, phone) is added with employee management.
/// </summary>
[ApiController]
[Authorize]
[Route("api/v1/me")]
public class MeController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
var me = new
{
UserId = Guid.Parse(User.FindFirst(AppClaimTypes.UserId)!.Value),
EmployeeId = Guid.Parse(User.FindFirst(AppClaimTypes.EmployeeId)!.Value),
TenantId = Guid.Parse(User.FindFirst(AppClaimTypes.TenantId)!.Value),
Roles = User.FindAll(AppClaimTypes.Role).Select(c => c.Value).ToArray()
};

return Ok(ApiResponse<object>.SuccessResponse(me));
}
}
57 changes: 57 additions & 0 deletions src/DistroFlow.Api/Controllers/TenantController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using DistroFlow.Application.DTOs.Common;
using DistroFlow.Application.DTOs.Tenants;
using DistroFlow.Application.Interfaces.Services;
using DistroFlow.Domain.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace DistroFlow.Api.Controllers;

/// <summary>
/// The caller's own distributor. There is no id in the route: the tenant comes from the
/// signed token, so a distributor cannot even address another distributor's record.
/// Tenants are created via POST /api/v1/auth/register.
/// </summary>
[ApiController]
[Authorize]
[Route("api/v1/tenant")]
public class TenantController : ControllerBase
{
private readonly ITenantService _tenantService;

public TenantController(ITenantService tenantService)
{
_tenantService = tenantService;
}

/// <summary>Any employee may read their distributor's settings (currency, time zone...).</summary>
[HttpGet]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
{
var tenant = await _tenantService.GetCurrentAsync(cancellationToken);

if (tenant is null)
{
return NotFound(ApiResponse<TenantResponse>.FailureResponse("Tenant not found."));
}

return Ok(ApiResponse<TenantResponse>.SuccessResponse(tenant, "Tenant retrieved successfully."));
}

/// <summary>Only an Administrator may change the distributor's settings.</summary>
[HttpPut]
[Authorize(Roles = EmployeeRoles.Administrator)]
public async Task<IActionResult> Update(
[FromBody] UpdateTenantRequest request,
CancellationToken cancellationToken)
{
var tenant = await _tenantService.UpdateCurrentAsync(request, cancellationToken);

if (tenant is null)
{
return NotFound(ApiResponse<TenantResponse>.FailureResponse("Tenant not found."));
}

return Ok(ApiResponse<TenantResponse>.SuccessResponse(tenant, "Tenant updated successfully."));
}
}
109 changes: 0 additions & 109 deletions src/DistroFlow.Api/Controllers/TenantsController.cs

This file was deleted.

1 change: 1 addition & 0 deletions src/DistroFlow.Api/DistroFlow.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.14">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
101 changes: 101 additions & 0 deletions src/DistroFlow.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Text;
using DistroFlow.Application.Common;
using DistroFlow.Api.Services;
using DistroFlow.Application.DTOs.Common;
using DistroFlow.Application.Interfaces.Identity;
using DistroFlow.Infrastructure.Identity;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace DistroFlow.Api.Extensions;

/// <summary>HTTP-layer registrations: controllers and how incoming requests are authenticated.</summary>
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApiControllers(this IServiceCollection services)
{
services
.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
// Return DTO validation failures ([Required], [EmailAddress], ...) in the ApiResponse shape.
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(x => x.Value?.Errors.Count > 0)
.ToDictionary(
x => x.Key,
x => x.Value!.Errors.Select(e => e.ErrorMessage).ToArray());

return new BadRequestObjectResult(
ApiResponse<object>.FailureResponse("Validation failed.", errors));
};
});

return services;
}

/// <summary>Who is calling: the current tenant, read from the request's JWT.</summary>
public static IServiceCollection AddCurrentTenant(this IServiceCollection services)
{
// Makes IHttpContextAccessor injectable, so services can reach the current request.
services.AddHttpContextAccessor();

// Scoped = one per HTTP request, matching the request it reads from.
services.AddScoped<ICurrentTenant, HttpCurrentTenant>();

return services;
}

/// <summary>
/// Validates incoming bearer tokens with the same validated <see cref="JwtOptions"/>
/// used to issue them, so issuing and validation can never drift apart.
/// </summary>
public static IServiceCollection AddJwtAuthentication(this IServiceCollection services)
{
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();

services
.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<IOptions<JwtOptions>>((bearer, jwtOptions) =>
{
var jwt = jwtOptions.Value;

// Keep claim names exactly as issued ("sub", "role", "tenant_id") instead of
// remapping them to long XML-namespace URIs.
bearer.MapInboundClaims = false;

bearer.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt.Issuer,
ValidateAudience = true,
ValidAudience = jwt.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)),
// Only accept the algorithm we sign with (blocks algorithm-confusion attacks).
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
ValidateLifetime = true,
// Default tolerance is 5 minutes; 30 seconds is enough for clock drift.
ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = AppClaimTypes.UserId,
RoleClaimType = AppClaimTypes.Role
};
});

// Secure by default: every endpoint requires an authenticated user unless it
// explicitly opts out with [AllowAnonymous] / .AllowAnonymous().
// Forgetting [Authorize] on a new endpoint therefore cannot expose it.
services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());

return services;
}
}
Loading
Loading