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

@@ -1,75 +1,67 @@
Feature: User Registration Feature: User Registration
As a new user As a new user
I want to register an account I want to register an account
So that I can log in and access authenticated routes So that I can log in and access authenticated routes
Scenario: Successful registration with valid details Scenario: Successful registration with valid details
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 | newuser@example.com | 1990-01-01 | Password1! | | newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 201 Then the response has HTTP status 201
And the response JSON should have "message" equal "User registered successfully." And the response JSON should have "message" equal "User registered successfully."
And the response JSON should have an access token And the response JSON should have an access token
@Ignore @Ignore
Scenario: Registration fails with existing username Scenario: Registration fails with existing username
Given the API is running Given the API is running
And I have an existing account with username "existinguser" And I have an existing account with username "existinguser"
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 |
| existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! | | existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 409 Then the response has HTTP status 409
And the response JSON should have "message" equal "Username already exists." And the response JSON should have "message" equal "Username already exists."
@Ignore @Ignore
Scenario: Registration fails with existing email Scenario: Registration fails with existing email
Given the API is running Given the API is running
And I have an existing account with email "existing@example.com" And I have an existing account with email "existing@example.com"
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 | existing@example.com | 1990-01-01 | Password1! | | newuser | New | User | existing@example.com | 1990-01-01 | Password1! |
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: | Username | FirstName | LastName | Email | DateOfBirth | Password |
| Username | FirstName | LastName | Email | DateOfBirth | Password | | newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass | 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 | {underage_date} | Password1! |
| younguser | Young | User | younguser@example.com | | 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);