Merge pull request #123 from aaronpo97/116-add-loginauth-token-feature-to-net-api

116: Add login/auth token feature to API
This commit is contained in:
Aaron Po
2026-02-01 12:53:32 -05:00
committed by GitHub
24 changed files with 444 additions and 277 deletions

View File

@@ -1,12 +1,13 @@
using BusinessLayer.Services; using System.Net;
using DataAccessLayer.Entities; using DataAccessLayer.Entities;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ServiceCore.Services;
namespace WebAPI.Controllers namespace WebAPI.Controllers
{ {
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class AuthController(IAuthService auth) : ControllerBase public class AuthController(IAuthService auth, IJwtService jwtService) : ControllerBase
{ {
public record RegisterRequest( public record RegisterRequest(
string Username, string Username,
@@ -17,7 +18,13 @@ namespace WebAPI.Controllers
string Password string Password
); );
public record LoginRequest(string UsernameOrEmail, 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)
@@ -39,9 +46,15 @@ namespace WebAPI.Controllers
[HttpPost("login")] [HttpPost("login")]
public async Task<ActionResult> Login([FromBody] LoginRequest req) public async Task<ActionResult> Login([FromBody] LoginRequest req)
{ {
var ok = await auth.LoginAsync(req.UsernameOrEmail, req.Password); var userAccount = await auth.LoginAsync(req.Username, req.Password);
if (!ok) return Unauthorized(); if (userAccount is null)
return Ok(new { success = true }); {
return Unauthorized(new ResponseBody("Invalid username or password.", null));
}
var jwt = jwtService.GenerateJwt(userAccount.UserAccountId, userAccount.Username, userAccount.DateOfBirth);
return Ok(new ResponseBody("Logged in successfully.", new { AccessToken = jwt }));
} }
} }
} }

View File

@@ -1,6 +1,6 @@
using BusinessLayer.Services;
using DataAccessLayer.Entities; using DataAccessLayer.Entities;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ServiceCore.Services;
namespace WebAPI.Controllers namespace WebAPI.Controllers
{ {

View File

@@ -1,7 +1,7 @@
using BusinessLayer.Services;
using DataAccessLayer.Repositories.UserAccount; using DataAccessLayer.Repositories.UserAccount;
using DataAccessLayer.Repositories.UserCredential; using DataAccessLayer.Repositories.UserCredential;
using DataAccessLayer.Sql; using DataAccessLayer.Sql;
using ServiceCore.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -16,6 +16,7 @@ builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserCredentialRepository, UserCredentialRepository>(); builder.Services.AddScoped<IUserCredentialRepository, UserCredentialRepository>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IJwtService, JwtService>();
var app = builder.Build(); var app = builder.Build();
app.UseSwagger(); app.UseSwagger();

View File

@@ -1,23 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5069",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7002;http://localhost:5069",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,39 @@
Feature: User Login
As a registered user
I want to log in to my account
So that I receive an authentication token to access authenticated routes
Scenario: Successful login with valid credentials
Given the API is running
And I have an existing account
When I submit a login request with a username and password
Then the response has HTTP status 200
And the response JSON should have "message" equal "Logged in successfully."
And the response JSON should have an access token
Scenario: Login fails with invalid credentials
Given the API is running
And I do not have an existing account
When I submit a login request with a username and password
Then the response has HTTP status 401
And the response JSON should have "message" equal "Invalid username or password."
Scenario: Login fails when required missing username
Given the API is running
When I submit a login request with a missing username
Then the response has HTTP status 400
Scenario: Login fails when required missing password
Given the API is running
When I submit a login request with a missing password
Then the response has HTTP status 400
Scenario: Login fails when both username and password are missing
Given the API is running
When I submit a login request with both username and password missing
Then the response has HTTP status 400
Scenario: Login endpoint only accepts POST requests
Given the API is running
When I submit a login request using a GET request
Then the response has HTTP status 404

View File

@@ -1,10 +1,10 @@
Feature: NotFound API Feature: NotFound Responses
As a client of the API As a client of the API
I want consistent 404 responses I want consistent 404 responses
So that consumers can handle missing routes So that consumers can gracefully handle missing routes
Scenario: GET error 404 returns NotFound message Scenario: GET request to an invalid route returns 404
Given the API is running Given the API is running
When I GET "/error/404" When I send an HTTP request "GET" to "/invalid-route"
Then the response status code should be 404 Then the response has HTTP status 404
And the response JSON should have "message" equal "Route not found." And the response JSON should have "message" equal "Route not found."

View File

@@ -0,0 +1,99 @@
using System.Text.Json;
using Reqnroll;
using FluentAssertions;
using API.Specs;
namespace API.Specs.Steps;
[Binding]
public class ApiGeneralSteps(ScenarioContext scenario)
{
private const string ClientKey = "client";
private const string FactoryKey = "factory";
private const string ResponseKey = "response";
private const string ResponseBodyKey = "responseBody";
private HttpClient GetClient()
{
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client))
{
return client;
}
var factory = scenario.TryGetValue<TestApiFactory>(FactoryKey, out var f) ? f : new TestApiFactory();
scenario[FactoryKey] = factory;
client = factory.CreateClient();
scenario[ClientKey] = client;
return client;
}
[Given("the API is running")]
public void GivenTheApiIsRunning()
{
GetClient();
}
[When("I send an HTTP request {string} to {string} with body:")]
public async Task WhenISendAnHttpRequestStringToStringWithBody(string method, string url, string jsonBody)
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url)
{
Content = new StringContent(jsonBody, System.Text.Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I send an HTTP request {string} to {string}")]
public async Task WhenISendAnHttpRequestStringToString(string method, string url)
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[Then("the response status code should be {int}")]
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
{
scenario.TryGetValue<HttpResponseMessage>(ResponseKey, out var response).Should().BeTrue();
((int)response!.StatusCode).Should().Be(expected);
}
[Then("the response has HTTP status {int}")]
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
{
scenario.TryGetValue<HttpResponseMessage>(ResponseKey, out var response).Should().BeTrue("No response was received from the API");
((int)response!.StatusCode).Should().Be(expectedCode);
}
[Then("the response JSON should have {string} equal {string}")]
public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected)
{
scenario.TryGetValue<HttpResponseMessage>(ResponseKey, out var response).Should().BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out var responseBody).Should().BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
if (!root.TryGetProperty(field, out var value))
{
root.TryGetProperty("payload", out var payloadElem).Should().BeTrue("Expected field '{0}' to be present either at the root or inside 'payload'", field);
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
payloadElem.TryGetProperty(field, out value).Should().BeTrue("Expected field '{0}' to be present inside 'payload'", field);
}
value.ValueKind.Should().Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
value.GetString().Should().Be(expected);
}
}

View File

@@ -1,51 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using Reqnroll;
using FluentAssertions;
namespace API.Specs.Steps;
[Binding]
public class ApiSteps
{
private readonly TestApiFactory _factory;
private HttpClient? _client;
private HttpResponseMessage? _response;
public ApiSteps()
{
_factory = new TestApiFactory();
}
[Given("the API is running")]
public void GivenTheApiIsRunning()
{
_client = _factory.CreateClient();
}
// No user service assumptions needed for 404 tests
[When("I GET {string}")]
public async Task WhenIGet(string path)
{
_client.Should().NotBeNull("API client must be initialized");
_response = await _client!.GetAsync(path);
}
[Then("the response status code should be {int}")]
public void ThenStatusCodeShouldBe(int expected)
{
_response.Should().NotBeNull();
((int)_response!.StatusCode).Should().Be(expected);
}
[Then("the response JSON should have {string} equal {string}")]
public async Task ThenResponseJsonShouldHaveFieldEqual(string field, string expected)
{
_response.Should().NotBeNull();
var dict = await _response!.Content.ReadFromJsonAsync<Dictionary<string, object>>();
dict.Should().NotBeNull();
dict!.TryGetValue(field, out var value).Should().BeTrue();
(value?.ToString()).Should().Be(expected);
}
}

View File

@@ -0,0 +1,160 @@
using System.Text.Json;
using Reqnroll;
using FluentAssertions;
using API.Specs;
namespace API.Specs.Steps;
[Binding]
public class AuthSteps(ScenarioContext scenario)
{
private const string ClientKey = "client";
private const string FactoryKey = "factory";
private const string ResponseKey = "response";
private const string ResponseBodyKey = "responseBody";
private const string TestUserKey = "testUser";
private HttpClient GetClient()
{
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client))
{
return client;
}
var factory = scenario.TryGetValue<TestApiFactory>(FactoryKey, out var f) ? f : new TestApiFactory();
scenario[FactoryKey] = factory;
client = factory.CreateClient();
scenario[ClientKey] = client;
return client;
}
[Given("I have an existing account")]
public void GivenIHaveAnExistingAccount()
{
scenario[TestUserKey] = ("test.user", "password");
}
[Given("I do not have an existing account")]
public void GivenIDoNotHaveAnExistingAccount()
{
scenario[TestUserKey] = ("Failing", "User");
}
[When("I submit a login request with a username and password")]
public async Task WhenISubmitALoginRequestWithAUsernameAndPassword()
{
var client = GetClient();
var (username, password) = scenario.TryGetValue<(string username, string password)>(TestUserKey, out var user)
? user
: ("test.user", "password");
var body = JsonSerializer.Serialize(new { username, password });
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login")
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a login request with a missing username")]
public async Task WhenISubmitALoginRequestWithAMissingUsername()
{
var client = GetClient();
var body = JsonSerializer.Serialize(new { password = "test" });
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login")
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a login request with a missing password")]
public async Task WhenISubmitALoginRequestWithAMissingPassword()
{
var client = GetClient();
var body = JsonSerializer.Serialize(new { username = "test" });
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login")
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a login request with both username and password missing")]
public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing()
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login")
{
Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[Then("the response JSON should have an access token")]
public void ThenTheResponseJsonShouldHaveAnAccessToken()
{
scenario.TryGetValue<HttpResponseMessage>(ResponseKey, out var response).Should().BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out var responseBody).Should().BeTrue();
var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
JsonElement tokenElem = default;
var hasToken = false;
if (root.TryGetProperty("payload", out var payloadElem) && payloadElem.ValueKind == JsonValueKind.Object)
{
hasToken = payloadElem.TryGetProperty("accessToken", out tokenElem)
|| payloadElem.TryGetProperty("AccessToken", out tokenElem);
}
hasToken.Should().BeTrue("Expected an access token either at the root or inside 'payload'");
var token = tokenElem.GetString();
token.Should().NotBeNullOrEmpty();
}
[When("I submit a login request using a GET request")]
public async Task WhenISubmitALoginRequestUsingAgetRequest()
{
var client = GetClient();
// testing GET
var requestMessage = new HttpRequestMessage(HttpMethod.Get, "/api/auth/login")
{
Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
}

View File

@@ -126,7 +126,23 @@ namespace DBSeed
int createdCredentials = 0; int createdCredentials = 0;
int createdVerifications = 0; int createdVerifications = 0;
{
const string firstName = "Test";
const string lastName = "User";
const string email = "test.user@thebiergarten.app";
var dob = new DateTime(1985, 03, 01);
var hash = GeneratePasswordHash("password");
var userAccountId = await RegisterUserAsync(
connection,
$"{firstName}.{lastName}",
firstName,
lastName,
dob,
email,
hash
);
}
foreach (var (firstName, lastName) in SeedNames) foreach (var (firstName, lastName) in SeedNames)
{ {
// prepare user fields // prepare user fields
@@ -142,6 +158,7 @@ namespace DBSeed
); );
string hash = GeneratePasswordHash(pwd); string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential) // register the user (creates account + credential)
var userAccountId = await RegisterUserAsync( var userAccountId = await RegisterUserAsync(
connection, connection,

View File

@@ -13,12 +13,6 @@ namespace DataAccessLayer.Repositories
return connection; return connection;
} }
public abstract Task AddAsync(T entity);
public abstract Task<IEnumerable<T>> GetAllAsync(int? limit, int? offset);
public abstract Task<T?> GetByIdAsync(Guid id);
public abstract Task UpdateAsync(T entity);
public abstract Task DeleteAsync(Guid id);
protected abstract T MapToEntity(DbDataReader reader); protected abstract T MapToEntity(DbDataReader reader);
} }
} }

View File

@@ -4,7 +4,6 @@ namespace DataAccessLayer.Repositories.UserAccount
{ {
public interface IUserAccountRepository public interface IUserAccountRepository
{ {
Task AddAsync(Entities.UserAccount userAccount);
Task<Entities.UserAccount?> GetByIdAsync(Guid id); Task<Entities.UserAccount?> GetByIdAsync(Guid id);
Task<IEnumerable<Entities.UserAccount>> GetAllAsync(int? limit, int? offset); Task<IEnumerable<Entities.UserAccount>> GetAllAsync(int? limit, int? offset);
Task UpdateAsync(Entities.UserAccount userAccount); Task UpdateAsync(Entities.UserAccount userAccount);

View File

@@ -7,27 +7,7 @@ namespace DataAccessLayer.Repositories.UserAccount
public class UserAccountRepository(ISqlConnectionFactory connectionFactory) public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Entities.UserAccount>(connectionFactory), IUserAccountRepository : Repository<Entities.UserAccount>(connectionFactory), IUserAccountRepository
{ {
/** public async Task<Entities.UserAccount?> GetByIdAsync(Guid id)
* @todo update the create user account stored proc to add user credential creation in
* a single transaction, use that transaction instead.
*/
public override async Task AddAsync(Entities.UserAccount userAccount)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_CreateUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccount.UserAccountId);
AddParameter(command, "@Username", userAccount.Username);
AddParameter(command, "@FirstName", userAccount.FirstName);
AddParameter(command, "@LastName", userAccount.LastName);
AddParameter(command, "@Email", userAccount.Email);
AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth);
await command.ExecuteNonQueryAsync();
}
public override async Task<Entities.UserAccount?> GetByIdAsync(Guid id)
{ {
await using var connection = await CreateConnection(); await using var connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
@@ -40,7 +20,7 @@ namespace DataAccessLayer.Repositories.UserAccount
return await reader.ReadAsync() ? MapToEntity(reader) : null; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
public override async Task<IEnumerable<Entities.UserAccount>> GetAllAsync(int? limit, int? offset) public async Task<IEnumerable<Entities.UserAccount>> GetAllAsync(int? limit, int? offset)
{ {
await using var connection = await CreateConnection(); await using var connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
@@ -64,7 +44,7 @@ namespace DataAccessLayer.Repositories.UserAccount
return users; return users;
} }
public override async Task UpdateAsync(Entities.UserAccount userAccount) public async Task UpdateAsync(Entities.UserAccount userAccount)
{ {
await using var connection = await CreateConnection(); await using var connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
@@ -81,7 +61,7 @@ namespace DataAccessLayer.Repositories.UserAccount
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
public override async Task DeleteAsync(Guid id) public async Task DeleteAsync(Guid id)
{ {
await using var connection = await CreateConnection(); await using var connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();

View File

@@ -14,7 +14,7 @@ namespace DataAccessLayer.Repositories.UserCredential
command.CommandText = "USP_RotateUserCredential"; command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId); AddParameter(command, "@UserAccountId_", userAccountId);
AddParameter(command, "@Hash", credential.Hash); AddParameter(command, "@Hash", credential.Hash);
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
@@ -40,25 +40,10 @@ namespace DataAccessLayer.Repositories.UserCredential
command.CommandText = "USP_InvalidateUserCredential"; command.CommandText = "USP_InvalidateUserCredential";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId); AddParameter(command, "@UserAccountId_", userAccountId);
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
public override Task AddAsync(Entities.UserCredential entity)
=> throw new NotSupportedException("Use RotateCredentialAsync for adding/rotating credentials.");
public override Task<IEnumerable<Entities.UserCredential>> GetAllAsync(int? limit, int? offset)
=> throw new NotSupportedException("Listing credentials is not supported.");
public override Task<Entities.UserCredential?> GetByIdAsync(Guid id)
=> throw new NotSupportedException("Fetching credential by ID is not supported.");
public override Task UpdateAsync(Entities.UserCredential entity)
=> throw new NotSupportedException("Use RotateCredentialAsync to update credentials.");
public override Task DeleteAsync(Guid id)
=> throw new NotSupportedException("Deleting a credential by ID is not supported.");
protected override Entities.UserCredential MapToEntity(DbDataReader reader) protected override Entities.UserCredential MapToEntity(DbDataReader reader)
{ {
var entity = new Entities.UserCredential var entity = new Entities.UserCredential

View File

@@ -55,8 +55,10 @@ public class UserAccountRepositoryTest
("UpdatedAt", typeof(DateTime?)), ("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)), ("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[])) ("Timer", typeof(byte[]))
).AddRow(Guid.NewGuid(), "a","A","A","a@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null) ).AddRow(Guid.NewGuid(), "a", "A", "A", "a@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date,
.AddRow(Guid.NewGuid(), "b","B","B","b@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); null)
.AddRow(Guid.NewGuid(), "b", "B", "B", "b@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date,
null));
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var results = (await repo.GetAllAsync(null, null)).ToList(); var results = (await repo.GetAllAsync(null, null)).ToList();
@@ -64,27 +66,6 @@ public class UserAccountRepositoryTest
results.Select(r => r.Username).Should().BeEquivalentTo(new[] { "a", "b" }); results.Select(r => r.Username).Should().BeEquivalentTo(new[] { "a", "b" });
} }
[Fact]
public async Task AddAsync_ExecutesStoredProcedure()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_CreateUserAccount")
.ReturnsScalar(1);
var repo = CreateRepo(conn);
var user = new DataAccessLayer.Entities.UserAccount
{
UserAccountId = Guid.NewGuid(),
Username = "newuser",
FirstName = "New",
LastName = "User",
Email = "newuser@example.com",
DateOfBirth = new DateTime(1991,1,1)
};
await repo.AddAsync(user);
}
[Fact] [Fact]
public async Task GetByUsername_ReturnsRow() public async Task GetByUsername_ReturnsRow()
@@ -102,7 +83,8 @@ public class UserAccountRepositoryTest
("UpdatedAt", typeof(DateTime?)), ("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)), ("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[])) ("Timer", typeof(byte[]))
).AddRow(Guid.NewGuid(), "lookupuser","L","U","lookup@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); ).AddRow(Guid.NewGuid(), "lookupuser", "L", "U", "lookup@example.com", DateTime.UtcNow, null,
DateTime.UtcNow.Date, null));
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetByUsernameAsync("lookupuser"); var result = await repo.GetByUsernameAsync("lookupuser");
@@ -126,7 +108,8 @@ public class UserAccountRepositoryTest
("UpdatedAt", typeof(DateTime?)), ("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)), ("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[])) ("Timer", typeof(byte[]))
).AddRow(Guid.NewGuid(), "byemail","B","E","byemail@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); ).AddRow(Guid.NewGuid(), "byemail", "B", "E", "byemail@example.com", DateTime.UtcNow, null,
DateTime.UtcNow.Date, null));
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetByEmailAsync("byemail@example.com"); var result = await repo.GetByEmailAsync("byemail@example.com");

View File

@@ -1,61 +1,11 @@
using Apps72.Dev.Data.DbMocker; using Apps72.Dev.Data.DbMocker;
using DataAccessLayer.Repositories.UserCredential; using DataAccessLayer.Repositories.UserCredential;
using DataAccessLayer.Sql;
using FluentAssertions;
using Moq;
using Repository.Tests.Database; using Repository.Tests.Database;
namespace Repository.Tests.UserCredential; namespace Repository.Tests.UserCredential;
public class UserCredentialRepositoryTests public class UserCredentialRepositoryTests
{ {
private static UserCredentialRepository CreateRepo()
{
var factoryMock = new Mock<ISqlConnectionFactory>(MockBehavior.Strict);
// NotSupported methods do not use the factory; keep strict to ensure no unexpected calls.
return new UserCredentialRepository(factoryMock.Object);
}
[Fact]
public async Task AddAsync_ShouldThrow_NotSupported()
{
var repo = CreateRepo();
var act = async () => await repo.AddAsync(new DataAccessLayer.Entities.UserCredential());
await act.Should().ThrowAsync<NotSupportedException>();
}
[Fact]
public async Task GetAllAsync_ShouldThrow_NotSupported()
{
var repo = CreateRepo();
var act = async () => await repo.GetAllAsync(null, null);
await act.Should().ThrowAsync<NotSupportedException>();
}
[Fact]
public async Task GetByIdAsync_ShouldThrow_NotSupported()
{
var repo = CreateRepo();
var act = async () => await repo.GetByIdAsync(Guid.NewGuid());
await act.Should().ThrowAsync<NotSupportedException>();
}
[Fact]
public async Task UpdateAsync_ShouldThrow_NotSupported()
{
var repo = CreateRepo();
var act = async () => await repo.UpdateAsync(new DataAccessLayer.Entities.UserCredential());
await act.Should().ThrowAsync<NotSupportedException>();
}
[Fact]
public async Task DeleteAsync_ShouldThrow_NotSupported()
{
var repo = CreateRepo();
var act = async () => await repo.DeleteAsync(Guid.NewGuid());
await act.Should().ThrowAsync<NotSupportedException>();
}
[Fact] [Fact]
public async Task RotateCredentialAsync_ExecutesWithoutError() public async Task RotateCredentialAsync_ExecutesWithoutError()
{ {
@@ -70,6 +20,5 @@ public class UserCredentialRepositoryTests
Hash = "hashed_password" Hash = "hashed_password"
}; };
await repo.RotateCredentialAsync(Guid.NewGuid(), credential); await repo.RotateCredentialAsync(Guid.NewGuid(), credential);
} }
} }

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>BusinessLayer</RootNamespace> <RootNamespace>ServiceCore</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,43 +1,30 @@
using DataAccessLayer.Entities; using DataAccessLayer.Entities;
using DataAccessLayer.Repositories.UserAccount; using DataAccessLayer.Repositories.UserAccount;
using DataAccessLayer.Repositories.UserCredential;
namespace BusinessLayer.Services namespace ServiceCore.Services
{ {
public class AuthService(IUserAccountRepository userRepo, IUserCredentialRepository credRepo) : IAuthService public class AuthService(IUserAccountRepository userRepo, IUserCredentialRepository credRepo) : IAuthService
{ {
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password) public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
{ {
if (userAccount.UserAccountId == Guid.Empty) throw new NotImplementedException();
{
userAccount.UserAccountId = Guid.NewGuid();
} }
await userRepo.AddAsync(userAccount); public async Task<UserAccount?> LoginAsync(string username, string password)
var credential = new UserCredential
{ {
UserAccountId = userAccount.UserAccountId, // Attempt lookup by username
Hash = PasswordHasher.Hash(password) var user = await userRepo.GetByUsernameAsync(username);
};
await credRepo.RotateCredentialAsync(userAccount.UserAccountId, credential); // the user was not found
if (user is null) return null;
return userAccount;
}
public async Task<bool> LoginAsync(string usernameOrEmail, string password)
{
// Attempt lookup by username, then email
var user = await userRepo.GetByUsernameAsync(usernameOrEmail)
?? await userRepo.GetByEmailAsync(usernameOrEmail);
if (user is null) return false;
// @todo handle expired passwords
var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
if (activeCred is null) return false;
return PasswordHasher.Verify(password, activeCred.Hash); if (activeCred is null) return null;
if (!PasswordHasher.Verify(password, activeCred.Hash)) return null;
return user;
} }
public async Task InvalidateAsync(Guid userAccountId) public async Task InvalidateAsync(Guid userAccountId)

View File

@@ -1,11 +1,10 @@
using DataAccessLayer.Entities; using DataAccessLayer.Entities;
namespace BusinessLayer.Services namespace ServiceCore.Services
{ {
public interface IAuthService public interface IAuthService
{ {
Task<UserAccount> RegisterAsync(UserAccount userAccount, string password); Task<UserAccount> RegisterAsync(UserAccount userAccount, string password);
Task<bool> LoginAsync(string usernameOrEmail, string password); Task<UserAccount?> LoginAsync(string username, string password);
Task InvalidateAsync(Guid userAccountId);
} }
} }

View File

@@ -0,0 +1,6 @@
namespace ServiceCore.Services;
public interface IJwtService
{
string GenerateJwt(Guid userId, string username, DateTime expiry);
}

View File

@@ -1,14 +1,12 @@
using DataAccessLayer.Entities; using DataAccessLayer.Entities;
namespace BusinessLayer.Services namespace ServiceCore.Services
{ {
public interface IUserService public interface IUserService
{ {
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null); Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null);
Task<UserAccount?> GetByIdAsync(Guid id); Task<UserAccount?> GetByIdAsync(Guid id);
Task AddAsync(UserAccount userAccount);
Task UpdateAsync(UserAccount userAccount); Task UpdateAsync(UserAccount userAccount);
} }
} }

View File

@@ -0,0 +1,38 @@
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
namespace ServiceCore.Services;
public class JwtService(IConfiguration config) : IJwtService
{
// private readonly string? _secret = config["Jwt:Secret"];
private readonly string? _secret = "128490218jfklsdajfdsa90f8sd0fid0safasr31jl2k1j4AFSDR!@#$fdsafjdslajfl";
public string GenerateJwt(Guid userId, string username, DateTime expiry)
{
var handler = new JsonWebTokenHandler();
var key = Encoding.UTF8.GetBytes(_secret ?? throw new InvalidOperationException("secret not set"));
// Base claims (always present)
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = expiry,
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256)
};
return handler.CreateToken(tokenDescriptor);
}
}

View File

@@ -2,7 +2,7 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using Konscious.Security.Cryptography; using Konscious.Security.Cryptography;
namespace BusinessLayer.Services namespace ServiceCore.Services
{ {
public static class PasswordHasher public static class PasswordHasher
{ {

View File

@@ -1,8 +1,7 @@
using DataAccessLayer.Entities; using DataAccessLayer.Entities;
using DataAccessLayer.Repositories;
using DataAccessLayer.Repositories.UserAccount; using DataAccessLayer.Repositories.UserAccount;
namespace BusinessLayer.Services namespace ServiceCore.Services
{ {
public class UserService(IUserAccountRepository repository) : IUserService public class UserService(IUserAccountRepository repository) : IUserService
{ {
@@ -16,11 +15,6 @@ namespace BusinessLayer.Services
return await repository.GetByIdAsync(id); return await repository.GetByIdAsync(id);
} }
public async Task AddAsync(UserAccount userAccount)
{
await repository.AddAsync(userAccount);
}
public async Task UpdateAsync(UserAccount userAccount) public async Task UpdateAsync(UserAccount userAccount)
{ {
await repository.UpdateAsync(userAccount); await repository.UpdateAsync(userAccount);