Merge pull request #139 from aaronpo97/request-validation

Add request validation and DTOs
This commit is contained in:
Aaron Po
2026-02-11 20:01:50 -05:00
committed by GitHub
9 changed files with 213 additions and 102 deletions

View File

@@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,4 @@
namespace API.Core.Contracts.Auth;
public record UserDTO(Guid UserAccountId, string Username);
public record AuthPayload(UserDTO User, string AccessToken, DateTime CreatedAt, DateTime ExpiresAt);

View File

@@ -0,0 +1,23 @@
using API.Core.Contracts.Common;
using FluentValidation;
namespace API.Core.Contracts.Auth;
public record LoginRequest
{
public string Username { get; init; } = default!;
public string Password { get; init; } = default!;
}
public class LoginRequestValidator : AbstractValidator<LoginRequest>
{
public LoginRequestValidator()
{
RuleFor(x => x.Username)
.NotEmpty().WithMessage("Username is required");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required");
}
}

View File

@@ -0,0 +1,54 @@
using API.Core.Contracts.Common;
using FluentValidation;
namespace API.Core.Contracts.Auth;
public record RegisterRequest(
string Username,
string FirstName,
string LastName,
string Email,
DateTime DateOfBirth,
string Password
);
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
{
public RegisterRequestValidator()
{
RuleFor(x => x.Username)
.NotEmpty().WithMessage("Username is required")
.Length(3, 64).WithMessage("Username must be between 3 and 64 characters")
.Matches("^[a-zA-Z0-9._-]+$")
.WithMessage("Username can only contain letters, numbers, dots, underscores, and hyphens");
RuleFor(x => x.FirstName)
.NotEmpty().WithMessage("First name is required")
.MaximumLength(128).WithMessage("First name cannot exceed 128 characters");
RuleFor(x => x.LastName)
.NotEmpty().WithMessage("Last name is required")
.MaximumLength(128).WithMessage("Last name cannot exceed 128 characters");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format")
.MaximumLength(128).WithMessage("Email cannot exceed 128 characters");
RuleFor(x => x.DateOfBirth)
.NotEmpty().WithMessage("Date of birth is required")
.LessThan(DateTime.Today.AddYears(-19))
.WithMessage("You must be at least 19 years old to register");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required")
.MinimumLength(8).WithMessage("Password must be at least 8 characters")
.Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter")
.Matches("[a-z]").WithMessage("Password must contain at least one lowercase letter")
.Matches("[0-9]").WithMessage("Password must contain at least one number")
.Matches("[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character");
}
}

View File

@@ -0,0 +1,12 @@
namespace API.Core.Contracts.Common;
public record ResponseBody<T>
{
public required string Message { get; init; }
public required T Payload { get; init; }
}
public record ResponseBody
{
public required string Message { get; init; }
}

View File

@@ -1,3 +1,5 @@
using API.Core.Contracts.Auth;
using API.Core.Contracts.Common;
using Domain.Core.Entities; using Domain.Core.Entities;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Service.Core.Auth; using Service.Core.Auth;
@@ -9,27 +11,12 @@ namespace API.Core.Controllers
[Route("api/[controller]")] [Route("api/[controller]")]
public class AuthController(IAuthService auth, IJwtService jwtService) : ControllerBase public class AuthController(IAuthService auth, IJwtService jwtService) : ControllerBase
{ {
public record RegisterRequest(
string Username,
string FirstName,
string LastName,
string Email,
DateTime DateOfBirth,
string Password
);
public record LoginRequest
{
public string Username { get; init; } = default!;
public string Password { get; init; } = default!;
}
private record ResponseBody(string Message, object? Payload);
[HttpPost("register")] [HttpPost("register")]
public async Task<ActionResult<UserAccount>> Register([FromBody] RegisterRequest req) public async Task<ActionResult<UserAccount>> Register([FromBody] RegisterRequest req)
{ {
var user = new UserAccount
var created = await auth.RegisterAsync(new UserAccount
{ {
UserAccountId = Guid.Empty, UserAccountId = Guid.Empty,
Username = req.Username, Username = req.Username,
@@ -37,10 +24,23 @@ namespace API.Core.Controllers
LastName = req.LastName, LastName = req.LastName,
Email = req.Email, Email = req.Email,
DateOfBirth = req.DateOfBirth DateOfBirth = req.DateOfBirth
}; }, req.Password);
var created = await auth.RegisterAsync(user, req.Password); var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
return CreatedAtAction(nameof(Register), new { id = created.UserAccountId }, created); var jwt = jwtService.GenerateJwt(created.UserAccountId, created.Username, jwtExpiresAt
);
var response = new ResponseBody<AuthPayload>
{
Message = "User registered successfully.",
Payload = new AuthPayload(
new UserDTO(created.UserAccountId, created.Username),
jwt,
DateTime.UtcNow,
jwtExpiresAt)
};
return Created("/", response);
} }
[HttpPost("login")] [HttpPost("login")]
@@ -49,12 +49,22 @@ namespace API.Core.Controllers
var userAccount = await auth.LoginAsync(req.Username, req.Password); var userAccount = await auth.LoginAsync(req.Username, req.Password);
if (userAccount is null) if (userAccount is null)
{ {
return Unauthorized(new ResponseBody("Invalid username or password.", null)); return Unauthorized(new ResponseBody
{
Message = "Invalid username or password."
});
} }
var jwt = jwtService.GenerateJwt(userAccount.UserAccountId, userAccount.Username, userAccount.DateOfBirth); UserDTO dto = new(userAccount.UserAccountId, userAccount.Username);
return Ok(new ResponseBody("Logged in successfully.", new { AccessToken = jwt })); var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
var jwt = jwtService.GenerateJwt(userAccount.UserAccountId, userAccount.Username, jwtExpiresAt);
return Ok(new ResponseBody<AuthPayload>
{
Message = "Logged in successfully.",
Payload = new AuthPayload(dto, jwt, DateTime.UtcNow, jwtExpiresAt)
});
} }
} }
} }

View File

@@ -1,3 +1,4 @@
using FluentValidation;
using Repository.Core.Repositories.Auth; using Repository.Core.Repositories.Auth;
using Repository.Core.Repositories.UserAccount; using Repository.Core.Repositories.UserAccount;
using Repository.Core.Sql; using Repository.Core.Sql;
@@ -13,6 +14,9 @@ builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
// Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// Add health checks // Add health checks
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();

View File

@@ -32,25 +32,20 @@ So that I can log in and access authenticated routes
Then the response has HTTP status 409 Then the response has HTTP status 409
And the response JSON should have "message" equal "Email already in use." And the response JSON should have "message" equal "Email already in use."
@Ignore
Scenario: Registration fails with missing required fields Scenario: Registration fails with missing required fields
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| | New | User | | | Password1! | | | New | User | | | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
And the response JSON should have "message" equal "Username is required."
@Ignore
Scenario: Registration fails with invalid email format Scenario: Registration fails with invalid email format
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | invalidemail | 1990-01-01 | Password1! | | newuser | New | User | invalidemail | 1990-01-01 | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
And the response JSON should have "message" equal "Invalid email format."
@Ignore
Scenario: Registration fails with weak password Scenario: Registration fails with weak password
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
@@ -59,17 +54,14 @@ So that I can log in and access authenticated routes
Then the response has HTTP status 400 Then the response has HTTP status 400
And the response JSON should have "message" equal "Password does not meet complexity requirements." And the response JSON should have "message" equal "Password does not meet complexity requirements."
@Ignore
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement) Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| younguser | Young | User | younguser@example.com | | Password1! | | younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
And the response JSON should have "message" equal "You must be at least 19 years old to register."
Scenario: Registration endpoint only accepts POST requests Scenario: Registration endpoint only accepts POST requests
Given the API is running Given the API is running
When I submit a registration request using a GET request When I submit a registration request using a GET request
Then the response has HTTP status 404 Then the response has HTTP status 404
And the response JSON should have "message" equal "Not Found."

View File

@@ -164,16 +164,27 @@ public class AuthSteps(ScenarioContext scenario)
var client = GetClient(); var client = GetClient();
var row = table.Rows[0]; var row = table.Rows[0];
var username = row["Username"] ?? "";
var firstName = row["FirstName"] ?? "";
var lastName = row["LastName"] ?? "";
var email = row["Email"] ?? "";
var dateOfBirth = row["DateOfBirth"] ?? "";
if (dateOfBirth == "{underage_date}")
{
dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd");
}
var password = row["Password"];
var registrationData = new var registrationData = new
{ {
username = row.TryGetValue("Username", out var value) ? value : null, username,
firstName = row.TryGetValue("FirstName", out var value1) ? value1 : null, firstName,
lastName = row.TryGetValue("LastName", out var value2) ? value2 : null, lastName,
email = row.TryGetValue("Email", out var value3) ? value3 : null, email,
dateOfBirth = row.ContainsKey("DateOfBirth") && !string.IsNullOrEmpty(row["DateOfBirth"]) dateOfBirth,
? row["DateOfBirth"] password
: null,
password = row.ContainsKey("Password") ? row["Password"] : null
}; };
var body = JsonSerializer.Serialize(registrationData); var body = JsonSerializer.Serialize(registrationData);