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..0f22ea5 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 = "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
+ {
+ Message = "Logged in successfully.",
+ 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();
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
+}