mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 02:39:03 +00:00
Merge pull request #139 from aaronpo97/request-validation
Add request validation and DTOs
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
4
src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs
Normal file
4
src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs
Normal 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);
|
||||
23
src/Core/API/API.Core/Contracts/Auth/Login.cs
Normal file
23
src/Core/API/API.Core/Contracts/Auth/Login.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
||||
54
src/Core/API/API.Core/Contracts/Auth/Register.cs
Normal file
54
src/Core/API/API.Core/Contracts/Auth/Register.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
12
src/Core/API/API.Core/Contracts/Common/ResponseBody.cs
Normal file
12
src/Core/API/API.Core/Contracts/Common/ResponseBody.cs
Normal 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; }
|
||||
}
|
||||
@@ -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<ActionResult<UserAccount>> 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<AuthPayload>
|
||||
{
|
||||
Message = "User registered successfully.",
|
||||
Payload = new AuthPayload(
|
||||
new UserDTO(created.UserAccountId, created.Username),
|
||||
jwt,
|
||||
DateTime.UtcNow,
|
||||
jwtExpiresAt)
|
||||
};
|
||||
return Created("/", response);
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
@@ -49,12 +49,22 @@ 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(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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Program>();
|
||||
|
||||
// Add health checks
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user