From 109ade474c52302c86587495fb93b3686b25a86b Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Wed, 11 Feb 2026 17:36:27 -0500 Subject: [PATCH 1/2] Add validation and dtos --- src/Core/API/API.Core/API.Core.csproj | 1 + .../API/API.Core/Contracts/Auth/AuthDTO.cs | 4 ++ src/Core/API/API.Core/Contracts/Auth/Login.cs | 23 ++++++++ .../API/API.Core/Contracts/Auth/Register.cs | 54 +++++++++++++++++++ .../API.Core/Contracts/Common/ResponseBody.cs | 12 +++++ .../API.Core/Controllers/AuthController.cs | 53 ++++++++++-------- src/Core/API/API.Core/Program.cs | 4 ++ 7 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs create mode 100644 src/Core/API/API.Core/Contracts/Auth/Login.cs create mode 100644 src/Core/API/API.Core/Contracts/Auth/Register.cs create mode 100644 src/Core/API/API.Core/Contracts/Common/ResponseBody.cs diff --git a/src/Core/API/API.Core/API.Core.csproj b/src/Core/API/API.Core/API.Core.csproj index badce45..0d83a68 100644 --- a/src/Core/API/API.Core/API.Core.csproj +++ b/src/Core/API/API.Core/API.Core.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs b/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs new file mode 100644 index 0000000..0d08dee --- /dev/null +++ b/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs @@ -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); diff --git a/src/Core/API/API.Core/Contracts/Auth/Login.cs b/src/Core/API/API.Core/Contracts/Auth/Login.cs new file mode 100644 index 0000000..96a8126 --- /dev/null +++ b/src/Core/API/API.Core/Contracts/Auth/Login.cs @@ -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 +{ + public LoginRequestValidator() + { + RuleFor(x => x.Username) + .NotEmpty().WithMessage("Username is required"); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Password is required"); + } +} + diff --git a/src/Core/API/API.Core/Contracts/Auth/Register.cs b/src/Core/API/API.Core/Contracts/Auth/Register.cs new file mode 100644 index 0000000..012575d --- /dev/null +++ b/src/Core/API/API.Core/Contracts/Auth/Register.cs @@ -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 +{ + 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"); + } +} + + + diff --git a/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs b/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs new file mode 100644 index 0000000..e5a6e71 --- /dev/null +++ b/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs @@ -0,0 +1,12 @@ +namespace API.Core.Contracts.Common; + +public record ResponseBody +{ + public required string Message { get; init; } + public required T Payload { get; init; } +} + +public record ResponseBody +{ + public required string Message { get; init; } +} diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index a0caf65..201c491 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -1,3 +1,5 @@ +using API.Core.Contracts.Auth; +using API.Core.Contracts.Common; using Domain.Core.Entities; using Microsoft.AspNetCore.Mvc; using Service.Core.Auth; @@ -9,27 +11,12 @@ namespace API.Core.Controllers [Route("api/[controller]")] 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")] public async Task> Register([FromBody] RegisterRequest req) { - var user = new UserAccount + + var created = await auth.RegisterAsync(new UserAccount { UserAccountId = Guid.Empty, Username = req.Username, @@ -37,10 +24,23 @@ namespace API.Core.Controllers LastName = req.LastName, Email = req.Email, DateOfBirth = req.DateOfBirth - }; + }, req.Password); - var created = await auth.RegisterAsync(user, req.Password); - return CreatedAtAction(nameof(Register), new { id = created.UserAccountId }, created); + var jwtExpiresAt = DateTime.UtcNow.AddHours(1); + var jwt = jwtService.GenerateJwt(created.UserAccountId, created.Username, jwtExpiresAt + + ); + + var response = new ResponseBody + { + Message = "Registration successful.", + Payload = new AuthPayload( + new UserDTO(created.UserAccountId, created.Username), + jwt, + DateTime.UtcNow, + jwtExpiresAt) + }; + return Created("/", response); } [HttpPost("login")] @@ -49,12 +49,19 @@ namespace API.Core.Controllers var userAccount = await auth.LoginAsync(req.Username, req.Password); if (userAccount is null) { - return Unauthorized(new ResponseBody("Invalid username or password.", null)); + return Unauthorized(); } - 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 + { + Message = "Login successful.", + Payload = new AuthPayload(dto, jwt, DateTime.UtcNow, jwtExpiresAt) + }); } } } diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index 4dfb8cd..93b762f 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Repository.Core.Repositories.Auth; using Repository.Core.Repositories.UserAccount; using Repository.Core.Sql; @@ -13,6 +14,9 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddOpenApi(); +// Add FluentValidation +builder.Services.AddValidatorsFromAssemblyContaining(); + // Add health checks builder.Services.AddHealthChecks(); From b2cf21399b79fc4425918d31f813b839ade5ca96 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Wed, 11 Feb 2026 19:59:54 -0500 Subject: [PATCH 2/2] Update request validation --- .../API.Core/Controllers/AuthController.cs | 9 +- .../API.Specs/Features/Registration.feature | 124 ++++++++---------- src/Core/API/API.Specs/Steps/AuthSteps.cs | 37 ++++-- 3 files changed, 88 insertions(+), 82 deletions(-) diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index 201c491..0f22ea5 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -33,7 +33,7 @@ namespace API.Core.Controllers var response = new ResponseBody { - Message = "Registration successful.", + Message = "User registered successfully.", Payload = new AuthPayload( new UserDTO(created.UserAccountId, created.Username), jwt, @@ -49,7 +49,10 @@ namespace API.Core.Controllers var userAccount = await auth.LoginAsync(req.Username, req.Password); if (userAccount is null) { - return Unauthorized(); + return Unauthorized(new ResponseBody + { + Message = "Invalid username or password." + }); } UserDTO dto = new(userAccount.UserAccountId, userAccount.Username); @@ -59,7 +62,7 @@ namespace API.Core.Controllers return Ok(new ResponseBody { - Message = "Login successful.", + Message = "Logged in successfully.", Payload = new AuthPayload(dto, jwt, DateTime.UtcNow, jwtExpiresAt) }); } diff --git a/src/Core/API/API.Specs/Features/Registration.feature b/src/Core/API/API.Specs/Features/Registration.feature index 5e09b31..6140bea 100644 --- a/src/Core/API/API.Specs/Features/Registration.feature +++ b/src/Core/API/API.Specs/Features/Registration.feature @@ -1,75 +1,67 @@ Feature: User Registration -As a new user -I want to register an account -So that I can log in and access authenticated routes + As a new user + I want to register an account + So that I can log in and access authenticated routes - Scenario: Successful registration with valid details - Given the API is running - When I submit a registration request with values: - | Username | FirstName | LastName | Email | DateOfBirth | Password | - | newuser | New | User | newuser@example.com | 1990-01-01 | Password1! | - Then the response has HTTP status 201 - And the response JSON should have "message" equal "User registered successfully." - And the response JSON should have an access token + Scenario: Successful registration with valid details + Given the API is running + When I submit a registration request with values: + | Username | FirstName | LastName | Email | DateOfBirth | Password | + | newuser | New | User | newuser@example.com | 1990-01-01 | Password1! | + Then the response has HTTP status 201 + And the response JSON should have "message" equal "User registered successfully." + And the response JSON should have an access token - @Ignore - Scenario: Registration fails with existing username - Given the API is running - And I have an existing account with username "existinguser" - When I submit a registration request with values: - | Username | FirstName | LastName | Email | DateOfBirth | Password | - | existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! | - Then the response has HTTP status 409 - And the response JSON should have "message" equal "Username already exists." + @Ignore + Scenario: Registration fails with existing username + Given the API is running + And I have an existing account with username "existinguser" + When I submit a registration request with values: + | Username | FirstName | LastName | Email | DateOfBirth | Password | + | existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! | + Then the response has HTTP status 409 + And the response JSON should have "message" equal "Username already exists." - @Ignore - Scenario: Registration fails with existing email - Given the API is running - And I have an existing account with email "existing@example.com" - When I submit a registration request with values: - | Username | FirstName | LastName | Email | DateOfBirth | Password | - | newuser | New | User | existing@example.com | 1990-01-01 | Password1! | - Then the response has HTTP status 409 - And the response JSON should have "message" equal "Email already in use." + @Ignore + Scenario: Registration fails with existing email + Given the API is running + And I have an existing account with email "existing@example.com" + When I submit a registration request with values: + | Username | FirstName | LastName | Email | DateOfBirth | Password | + | newuser | New | User | existing@example.com | 1990-01-01 | Password1! | + Then the response has HTTP status 409 + And the response JSON should have "message" equal "Email already in use." - @Ignore - Scenario: Registration fails with missing required fields - Given the API is running - When I submit a registration request with values: - | Username | FirstName | LastName | Email | DateOfBirth | Password | - | | New | User | | | Password1! | - Then the response has HTTP status 400 - And the response JSON should have "message" equal "Username is required." + Scenario: Registration fails with missing required fields + Given the API is running + When I submit a registration request with values: + | Username | FirstName | LastName | Email | DateOfBirth | Password | + | | New | User | | | Password1! | + Then the response has HTTP status 400 - @Ignore - Scenario: Registration fails with invalid email format - Given the API is running - When I submit a registration request with values: - | Username | FirstName | LastName | Email | DateOfBirth | Password | - | newuser | New | User | invalidemail | 1990-01-01 | Password1! | - Then the response has HTTP status 400 - And the response JSON should have "message" equal "Invalid email format." + Scenario: Registration fails with invalid email format + Given the API is running + When I submit a registration request with values: + | Username | FirstName | LastName | Email | DateOfBirth | Password | + | newuser | New | User | invalidemail | 1990-01-01 | Password1! | + Then the response has HTTP status 400 - @Ignore - Scenario: Registration fails with weak password - Given the API is running - When I submit a registration request with values: - | Username | FirstName | LastName | Email | DateOfBirth | Password | - | newuser | New | User | newuser@example.com | 1990-01-01 | weakpass | - Then the response has HTTP status 400 - And the response JSON should have "message" equal "Password does not meet complexity requirements." + Scenario: Registration fails with weak password + Given the API is running + When I submit a registration request with values: + | Username | FirstName | LastName | Email | DateOfBirth | Password | + | newuser | New | User | newuser@example.com | 1990-01-01 | weakpass | + Then the response has HTTP status 400 + 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) - Given the API is running - When I submit a registration request with values: - | Username | FirstName | LastName | Email | DateOfBirth | Password | - | younguser | Young | User | younguser@example.com | | Password1! | - 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: Cannot register a user younger than 19 years of age (regulatory requirement) + Given the API is running + When I submit a registration request with values: + | Username | FirstName | LastName | Email | DateOfBirth | Password | + | younguser | Young | User | younguser@example.com | {underage_date} | Password1! | + Then the response has HTTP status 400 - Scenario: Registration endpoint only accepts POST requests - Given the API is running - When I submit a registration request using a GET request - Then the response has HTTP status 404 - And the response JSON should have "message" equal "Not Found." \ No newline at end of file + Scenario: Registration endpoint only accepts POST requests + Given the API is running + When I submit a registration request using a GET request + Then the response has HTTP status 404 diff --git a/src/Core/API/API.Specs/Steps/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs index bc745f4..b2e6fdb 100644 --- a/src/Core/API/API.Specs/Steps/AuthSteps.cs +++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs @@ -163,17 +163,28 @@ public class AuthSteps(ScenarioContext scenario) { var client = GetClient(); 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 { - username = row.TryGetValue("Username", out var value) ? value : null, - firstName = row.TryGetValue("FirstName", out var value1) ? value1 : null, - lastName = row.TryGetValue("LastName", out var value2) ? value2 : null, - email = row.TryGetValue("Email", out var value3) ? value3 : null, - dateOfBirth = row.ContainsKey("DateOfBirth") && !string.IsNullOrEmpty(row["DateOfBirth"]) - ? row["DateOfBirth"] - : null, - password = row.ContainsKey("Password") ? row["Password"] : null + username, + firstName, + lastName, + email, + dateOfBirth, + password }; var body = JsonSerializer.Serialize(registrationData); @@ -189,17 +200,17 @@ public class AuthSteps(ScenarioContext scenario) scenario[ResponseKey] = response; scenario[ResponseBodyKey] = responseBody; } - + [Given("I have an existing account with username {string}")] public void GivenIHaveAnExistingAccountWithUsername(string username) { - + } [Given("I have an existing account with email {string}")] public void GivenIHaveAnExistingAccountWithEmail(string email) { - + } [When("I submit a registration request using a GET request")] @@ -217,4 +228,4 @@ public class AuthSteps(ScenarioContext scenario) scenario[ResponseKey] = response; scenario[ResponseBodyKey] = responseBody; } -} \ No newline at end of file +}