mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 02:39:03 +00:00
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:
@@ -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 }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
39
src/Core/API/API.Specs/Features/Auth.feature
Normal file
39
src/Core/API/API.Specs/Features/Auth.feature
Normal 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
|
||||||
@@ -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."
|
||||||
99
src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs
Normal file
99
src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
160
src/Core/API/API.Specs/Steps/AuthSteps.cs
Normal file
160
src/Core/API/API.Specs/Steps/AuthSteps.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -146,4 +126,4 @@ namespace DataAccessLayer.Repositories.UserAccount
|
|||||||
command.Parameters.Add(p);
|
command.Parameters.Add(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ public class UserAccountRepositoryTest
|
|||||||
("DateOfBirth", typeof(DateTime)),
|
("DateOfBirth", typeof(DateTime)),
|
||||||
("Timer", typeof(byte[]))
|
("Timer", typeof(byte[]))
|
||||||
).AddRow(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
).AddRow(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||||
"yerb","Aaron","Po","aaronpo@example.com",
|
"yerb", "Aaron", "Po", "aaronpo@example.com",
|
||||||
new DateTime(2020,1,1), null,
|
new DateTime(2020, 1, 1), null,
|
||||||
new DateTime(1990,1,1), null));
|
new DateTime(1990, 1, 1), null));
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
var repo = CreateRepo(conn);
|
||||||
var result = await repo.GetByIdAsync(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
|
var result = await repo.GetByIdAsync(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
|
||||||
@@ -45,18 +45,20 @@ public class UserAccountRepositoryTest
|
|||||||
var conn = new MockDbConnection();
|
var conn = new MockDbConnection();
|
||||||
conn.Mocks
|
conn.Mocks
|
||||||
.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
|
.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
|
||||||
.ReturnsTable(MockTable.WithColumns(
|
.ReturnsTable(MockTable.WithColumns(
|
||||||
("UserAccountId", typeof(Guid)),
|
("UserAccountId", typeof(Guid)),
|
||||||
("Username", typeof(string)),
|
("Username", typeof(string)),
|
||||||
("FirstName", typeof(string)),
|
("FirstName", typeof(string)),
|
||||||
("LastName", typeof(string)),
|
("LastName", typeof(string)),
|
||||||
("Email", typeof(string)),
|
("Email", typeof(string)),
|
||||||
("CreatedAt", typeof(DateTime)),
|
("CreatedAt", typeof(DateTime)),
|
||||||
("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,11 +108,12 @@ 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");
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result!.Username.Should().Be("byemail");
|
result!.Username.Should().Be("byemail");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|
||||||
var credential = new UserCredential
|
|
||||||
{
|
|
||||||
UserAccountId = userAccount.UserAccountId,
|
|
||||||
Hash = PasswordHasher.Hash(password)
|
|
||||||
};
|
|
||||||
|
|
||||||
await credRepo.RotateCredentialAsync(userAccount.UserAccountId, credential);
|
|
||||||
|
|
||||||
return userAccount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> LoginAsync(string usernameOrEmail, string password)
|
public async Task<UserAccount?> LoginAsync(string username, string password)
|
||||||
{
|
{
|
||||||
// Attempt lookup by username, then email
|
// Attempt lookup by username
|
||||||
var user = await userRepo.GetByUsernameAsync(usernameOrEmail)
|
var user = await userRepo.GetByUsernameAsync(username);
|
||||||
?? await userRepo.GetByEmailAsync(usernameOrEmail);
|
|
||||||
|
// the user was not found
|
||||||
if (user is null) return false;
|
if (user is null) return null;
|
||||||
|
|
||||||
|
// @todo handle expired passwords
|
||||||
var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
|
var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
|
||||||
if (activeCred is null) return false;
|
|
||||||
|
if (activeCred is null) return null;
|
||||||
return PasswordHasher.Verify(password, activeCred.Hash);
|
if (!PasswordHasher.Verify(password, activeCred.Hash)) return null;
|
||||||
|
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InvalidateAsync(Guid userAccountId)
|
public async Task InvalidateAsync(Guid userAccountId)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
6
src/Core/Service/Service.Core/Services/IJwtService.cs
Normal file
6
src/Core/Service/Service.Core/Services/IJwtService.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ServiceCore.Services;
|
||||||
|
|
||||||
|
public interface IJwtService
|
||||||
|
{
|
||||||
|
string GenerateJwt(Guid userId, string username, DateTime expiry);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
38
src/Core/Service/Service.Core/Services/JwtService.cs
Normal file
38
src/Core/Service/Service.Core/Services/JwtService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -15,12 +14,7 @@ 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user