mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 02:39:03 +00:00
Refactor data layer, add business layer
This commit is contained in:
@@ -4,4 +4,8 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../DataAccessLayer/DataAccessLayer.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
15
BusinessLayer/Services/IService.cs
Normal file
15
BusinessLayer/Services/IService.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace BusinessLayer.Services
|
||||||
|
{
|
||||||
|
public interface IService<T>
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
IEnumerable<T> GetAll(int? limit, int? offset);
|
||||||
|
T? GetById(Guid id);
|
||||||
|
void Add(T entity);
|
||||||
|
void Update(T entity);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
BusinessLayer/Services/IUserService.cs
Normal file
10
BusinessLayer/Services/IUserService.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using DataAccessLayer.Entities;
|
||||||
|
|
||||||
|
namespace BusinessLayer.Services
|
||||||
|
{
|
||||||
|
public interface IUserService : IService<UserAccount>
|
||||||
|
{
|
||||||
|
UserAccount? GetByUsername(string username);
|
||||||
|
UserAccount? GetByEmail(string email);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
BusinessLayer/Services/UserService.cs
Normal file
50
BusinessLayer/Services/UserService.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using DataAccessLayer;
|
||||||
|
using DataAccessLayer.Entities;
|
||||||
|
|
||||||
|
namespace BusinessLayer.Services
|
||||||
|
{
|
||||||
|
public class UserService : IUserService
|
||||||
|
{
|
||||||
|
private readonly IUserAccountRepository _userAccountRepository;
|
||||||
|
|
||||||
|
public UserService(IUserAccountRepository userAccountRepository)
|
||||||
|
{
|
||||||
|
_userAccountRepository = userAccountRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<UserAccount> GetAll(int? limit, int? offset)
|
||||||
|
{
|
||||||
|
return _userAccountRepository.GetAll(limit, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserAccount? GetById(Guid id)
|
||||||
|
{
|
||||||
|
return _userAccountRepository.GetById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserAccount? GetByUsername(string username)
|
||||||
|
{
|
||||||
|
return _userAccountRepository.GetByUsername(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserAccount? GetByEmail(string email)
|
||||||
|
{
|
||||||
|
return _userAccountRepository.GetByEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(UserAccount userAccount)
|
||||||
|
{
|
||||||
|
_userAccountRepository.Add(userAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update(UserAccount userAccount)
|
||||||
|
{
|
||||||
|
_userAccountRepository.Update(userAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Delete(Guid id)
|
||||||
|
{
|
||||||
|
_userAccountRepository.Delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,11 +144,72 @@ namespace DALTests
|
|||||||
_repository.Add(user2);
|
_repository.Add(user2);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var allUsers = _repository.GetAll();
|
var allUsers = _repository.GetAll(null, null);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(allUsers);
|
Assert.NotNull(allUsers);
|
||||||
Assert.True(allUsers.Count() >= 2);
|
Assert.True(allUsers.Count() >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAll_WithPagination_ShouldRespectLimit()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var users = new List<UserAccount>
|
||||||
|
{
|
||||||
|
new UserAccount
|
||||||
|
{
|
||||||
|
UserAccountID = Guid.NewGuid(),
|
||||||
|
Username = $"pageuser_{Guid.NewGuid():N}",
|
||||||
|
FirstName = "Page",
|
||||||
|
LastName = "User",
|
||||||
|
Email = $"pageuser_{Guid.NewGuid():N}@example.com",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
DateOfBirth = new DateTime(1991, 4, 4),
|
||||||
|
},
|
||||||
|
new UserAccount
|
||||||
|
{
|
||||||
|
UserAccountID = Guid.NewGuid(),
|
||||||
|
Username = $"pageuser_{Guid.NewGuid():N}",
|
||||||
|
FirstName = "Page",
|
||||||
|
LastName = "User",
|
||||||
|
Email = $"pageuser_{Guid.NewGuid():N}@example.com",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
DateOfBirth = new DateTime(1992, 5, 5),
|
||||||
|
},
|
||||||
|
new UserAccount
|
||||||
|
{
|
||||||
|
UserAccountID = Guid.NewGuid(),
|
||||||
|
Username = $"pageuser_{Guid.NewGuid():N}",
|
||||||
|
FirstName = "Page",
|
||||||
|
LastName = "User",
|
||||||
|
Email = $"pageuser_{Guid.NewGuid():N}@example.com",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
DateOfBirth = new DateTime(1993, 6, 6),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
_repository.Add(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var page = _repository.GetAll(2, 0).ToList();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, page.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAll_WithPagination_ShouldValidateArguments()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(
|
||||||
|
() => _repository.GetAll(0, 0).ToList()
|
||||||
|
);
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(
|
||||||
|
() => _repository.GetAll(1, -1).ToList()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ namespace DataAccessLayer
|
|||||||
public interface IRepository<T>
|
public interface IRepository<T>
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
|
|
||||||
void Add(T entity);
|
void Add(T entity);
|
||||||
|
|
||||||
|
IEnumerable<T> GetAll(int? limit, int? offset);
|
||||||
|
|
||||||
T? GetById(Guid id);
|
T? GetById(Guid id);
|
||||||
void Update(T entity);
|
void Update(T entity);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace DataAccessLayer
|
|||||||
{
|
{
|
||||||
public interface IUserAccountRepository : IRepository<UserAccount>
|
public interface IUserAccountRepository : IRepository<UserAccount>
|
||||||
{
|
{
|
||||||
IEnumerable<UserAccount> GetAll();
|
|
||||||
UserAccount? GetByUsername(string username);
|
UserAccount? GetByUsername(string username);
|
||||||
UserAccount? GetByEmail(string email);
|
UserAccount? GetByEmail(string email);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,14 +66,46 @@ namespace DataAccessLayer
|
|||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<UserAccount> GetAll()
|
public IEnumerable<UserAccount> GetAll(int? limit, int? offset)
|
||||||
{
|
{
|
||||||
|
if (limit.HasValue && limit <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(limit),
|
||||||
|
"Limit must be greater than zero."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(offset),
|
||||||
|
"Offset cannot be negative."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset.HasValue && !limit.HasValue)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(offset),
|
||||||
|
"Offset cannot be provided without a limit."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
using SqlConnection connection = new(_connectionString);
|
using SqlConnection connection = new(_connectionString);
|
||||||
using SqlCommand command = new(
|
using SqlCommand command = new(
|
||||||
"usp_GetAllUserAccounts",
|
"usp_GetAllUserAccounts",
|
||||||
connection
|
connection
|
||||||
);
|
);
|
||||||
command.CommandType = System.Data.CommandType.StoredProcedure;
|
command.CommandType = System.Data.CommandType.StoredProcedure;
|
||||||
|
if (limit.HasValue)
|
||||||
|
{
|
||||||
|
command.Parameters.AddWithValue("@Limit", limit.Value);
|
||||||
|
}
|
||||||
|
if (offset.HasValue)
|
||||||
|
{
|
||||||
|
command.Parameters.AddWithValue("@Offset", offset.Value);
|
||||||
|
}
|
||||||
connection.Open();
|
connection.Open();
|
||||||
|
|
||||||
using SqlDataReader reader = command.ExecuteReader();
|
using SqlDataReader reader = command.ExecuteReader();
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
USE Biergarten;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER PROCEDURE usp_CreateUserAccount
|
|
||||||
(
|
|
||||||
@UserAccountId UNIQUEIDENTIFIER = NULL,
|
|
||||||
@Username VARCHAR(64),
|
|
||||||
@FirstName NVARCHAR(128),
|
|
||||||
@LastName NVARCHAR(128),
|
|
||||||
@DateOfBirth DATETIME,
|
|
||||||
@Email VARCHAR(128)
|
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON
|
|
||||||
SET XACT_ABORT ON
|
|
||||||
BEGIN TRANSACTION
|
|
||||||
|
|
||||||
INSERT INTO UserAccount
|
|
||||||
(
|
|
||||||
UserAccountID,
|
|
||||||
Username,
|
|
||||||
FirstName,
|
|
||||||
LastName,
|
|
||||||
DateOfBirth,
|
|
||||||
Email
|
|
||||||
)
|
|
||||||
VALUES
|
|
||||||
(
|
|
||||||
COALESCE(@UserAccountId, NEWID()),
|
|
||||||
@Username,
|
|
||||||
@FirstName,
|
|
||||||
@LastName,
|
|
||||||
@DateOfBirth,
|
|
||||||
@Email
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
COMMIT TRANSACTION
|
|
||||||
END;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount
|
|
||||||
(
|
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON
|
|
||||||
SET XACT_ABORT ON
|
|
||||||
BEGIN TRANSACTION
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
|
||||||
BEGIN
|
|
||||||
RAISERROR('UserAccount with the specified ID does not exist.', 16,
|
|
||||||
1);
|
|
||||||
ROLLBACK TRANSACTION
|
|
||||||
RETURN
|
|
||||||
END
|
|
||||||
|
|
||||||
DELETE FROM UserAccount
|
|
||||||
WHERE UserAccountId = @UserAccountId;
|
|
||||||
COMMIT TRANSACTION
|
|
||||||
END;
|
|
||||||
GO
|
|
||||||
|
|
||||||
|
|
||||||
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount
|
|
||||||
(
|
|
||||||
@Username VARCHAR(64),
|
|
||||||
@FirstName NVARCHAR(128),
|
|
||||||
@LastName NVARCHAR(128),
|
|
||||||
@DateOfBirth DATETIME,
|
|
||||||
@Email VARCHAR(128),
|
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON
|
|
||||||
SET XACT_ABORT ON
|
|
||||||
BEGIN TRANSACTION
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
|
||||||
BEGIN
|
|
||||||
RAISERROR('UserAccount with the specified ID does not exist.', 16,
|
|
||||||
1);
|
|
||||||
ROLLBACK TRANSACTION
|
|
||||||
RETURN
|
|
||||||
END
|
|
||||||
|
|
||||||
UPDATE UserAccount
|
|
||||||
SET
|
|
||||||
Username = @Username,
|
|
||||||
FirstName = @FirstName,
|
|
||||||
LastName = @LastName,
|
|
||||||
DateOfBirth = @DateOfBirth,
|
|
||||||
Email = @Email
|
|
||||||
WHERE UserAccountId = @UserAccountId;
|
|
||||||
|
|
||||||
COMMIT TRANSACTION
|
|
||||||
END;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountById
|
|
||||||
(
|
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
|
|
||||||
SELECT UserAccountID,
|
|
||||||
Username,
|
|
||||||
FirstName,
|
|
||||||
LastName,
|
|
||||||
Email,
|
|
||||||
CreatedAt,
|
|
||||||
UpdatedAt,
|
|
||||||
DateOfBirth,
|
|
||||||
Timer
|
|
||||||
FROM dbo.UserAccount
|
|
||||||
WHERE UserAccountID = @UserAccountId;
|
|
||||||
END;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
|
|
||||||
SELECT UserAccountID,
|
|
||||||
Username,
|
|
||||||
FirstName,
|
|
||||||
LastName,
|
|
||||||
Email,
|
|
||||||
CreatedAt,
|
|
||||||
UpdatedAt,
|
|
||||||
DateOfBirth,
|
|
||||||
Timer
|
|
||||||
FROM dbo.UserAccount;
|
|
||||||
END;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername
|
|
||||||
(
|
|
||||||
@Username VARCHAR(64)
|
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
|
|
||||||
SELECT UserAccountID,
|
|
||||||
Username,
|
|
||||||
FirstName,
|
|
||||||
LastName,
|
|
||||||
Email,
|
|
||||||
CreatedAt,
|
|
||||||
UpdatedAt,
|
|
||||||
DateOfBirth,
|
|
||||||
Timer
|
|
||||||
FROM dbo.UserAccount
|
|
||||||
WHERE Username = @Username;
|
|
||||||
END;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail
|
|
||||||
(
|
|
||||||
@Email VARCHAR(128)
|
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
|
|
||||||
SELECT UserAccountID,
|
|
||||||
Username,
|
|
||||||
FirstName,
|
|
||||||
LastName,
|
|
||||||
Email,
|
|
||||||
CreatedAt,
|
|
||||||
UpdatedAt,
|
|
||||||
DateOfBirth,
|
|
||||||
Timer
|
|
||||||
FROM dbo.UserAccount
|
|
||||||
WHERE Email = @Email;
|
|
||||||
END;
|
|
||||||
GO
|
|
||||||
@@ -5,11 +5,14 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference
|
<PackageReference Include="dbup" Version="5.0.41" />
|
||||||
Include="Konscious.Security.Cryptography.Argon2"
|
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
|
||||||
Version="1.3.1"
|
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||||
/>
|
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="scripts/**/*.sql" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
125
DataLayer/Program.cs
Normal file
125
DataLayer/Program.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using idunno.Password;
|
||||||
|
using Konscious.Security.Cryptography;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes USP_AddUserCredentials to add missing user credentials using a table-valued parameter
|
||||||
|
/// consisting of the user account id and a generated Argon2 hash.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open SQL connection.</param>
|
||||||
|
/// <param name="credentialTable">A table-valued parameter payload containing user IDs and hashes.</param>
|
||||||
|
static async Task ExecuteCredentialProcedureAsync(SqlConnection connection, DataTable credentialTable)
|
||||||
|
{
|
||||||
|
await using var command = new SqlCommand("dbo.USP_AddUserCredentials", connection)
|
||||||
|
{
|
||||||
|
CommandType = CommandType.StoredProcedure
|
||||||
|
};
|
||||||
|
|
||||||
|
// Must match your stored proc parameter name:
|
||||||
|
var tvpParameter = command.Parameters.Add("@Hash", SqlDbType.Structured);
|
||||||
|
tvpParameter.TypeName = "dbo.TblUserHashes";
|
||||||
|
tvpParameter.Value = credentialTable;
|
||||||
|
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a DataTable of user account IDs and generated Argon2 password hashes for users that do not yet
|
||||||
|
/// have credentials.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open SQL connection.</param>
|
||||||
|
/// <returns>A DataTable matching dbo.TblUserHashes with user IDs and hashes.</returns>
|
||||||
|
static async Task<DataTable> BuildCredentialTableAsync(SqlConnection connection)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT ua.UserAccountID
|
||||||
|
FROM dbo.UserAccount AS ua
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.UserCredential AS uc
|
||||||
|
WHERE uc.UserAccountID = ua.UserAccountID
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var command = new SqlCommand(sql, connection);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync();
|
||||||
|
|
||||||
|
// IMPORTANT: column names/types/order should match dbo.TblUserHashes
|
||||||
|
var table = new DataTable();
|
||||||
|
table.Columns.Add("UserAccountID", typeof(Guid));
|
||||||
|
table.Columns.Add("Hash", typeof(string));
|
||||||
|
|
||||||
|
var generator = new PasswordGenerator();
|
||||||
|
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
Guid userId = reader.GetGuid(0);
|
||||||
|
|
||||||
|
// idunno.Password PasswordGenerator signature:
|
||||||
|
// Generate(length, numberOfDigits, numberOfSymbols, noUpper, allowRepeat)
|
||||||
|
string pwd = generator.Generate(
|
||||||
|
length: 64,
|
||||||
|
numberOfDigits: 10,
|
||||||
|
numberOfSymbols: 10
|
||||||
|
);
|
||||||
|
|
||||||
|
string hash = GeneratePasswordHash(pwd);
|
||||||
|
|
||||||
|
var row = table.NewRow();
|
||||||
|
row["UserAccountID"] = userId;
|
||||||
|
row["Hash"] = hash;
|
||||||
|
table.Rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates an Argon2id hash for the given password.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pwd">The plaintext password.</param>
|
||||||
|
/// <returns>A string in the format "base64(salt):base64(hash)".</returns>
|
||||||
|
static string GeneratePasswordHash(string pwd)
|
||||||
|
{
|
||||||
|
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||||
|
|
||||||
|
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))
|
||||||
|
{
|
||||||
|
Salt = salt,
|
||||||
|
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
|
||||||
|
MemorySize = 65536,
|
||||||
|
Iterations = 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
byte[] hash = argon2.GetBytes(32);
|
||||||
|
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the seed process to add test users and generate missing credentials.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open SQL connection.</param>
|
||||||
|
static async Task RunSeedAsync(SqlConnection connection)
|
||||||
|
{
|
||||||
|
//run add test users
|
||||||
|
await using var insertCommand = new SqlCommand("dbo.USP_SeedTestUsers", connection)
|
||||||
|
{
|
||||||
|
CommandType = CommandType.StoredProcedure
|
||||||
|
};
|
||||||
|
await insertCommand.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
Console.WriteLine("Inserted or refreshed test users.");
|
||||||
|
|
||||||
|
DataTable credentialRows = await BuildCredentialTableAsync(connection);
|
||||||
|
if (credentialRows.Rows.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("No new credentials required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteCredentialProcedureAsync(connection, credentialRows);
|
||||||
|
Console.WriteLine($"Generated {credentialRows.Rows.Count} credential hashes.");
|
||||||
|
}
|
||||||
36
DataLayer/scripts/crud/USP_CreateUserAccount.sql
Normal file
36
DataLayer/scripts/crud/USP_CreateUserAccount.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE usp_CreateUserAccount
|
||||||
|
(
|
||||||
|
@UserAccountId UNIQUEIDENTIFIER = NULL,
|
||||||
|
@Username VARCHAR(64),
|
||||||
|
@FirstName NVARCHAR(128),
|
||||||
|
@LastName NVARCHAR(128),
|
||||||
|
@DateOfBirth DATETIME,
|
||||||
|
@Email VARCHAR(128)
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
SET XACT_ABORT ON
|
||||||
|
BEGIN TRANSACTION
|
||||||
|
|
||||||
|
INSERT INTO UserAccount
|
||||||
|
(
|
||||||
|
UserAccountID,
|
||||||
|
Username,
|
||||||
|
FirstName,
|
||||||
|
LastName,
|
||||||
|
DateOfBirth,
|
||||||
|
Email
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
COALESCE(@UserAccountId, NEWID()),
|
||||||
|
@Username,
|
||||||
|
@FirstName,
|
||||||
|
@LastName,
|
||||||
|
@DateOfBirth,
|
||||||
|
@Email
|
||||||
|
);
|
||||||
|
COMMIT TRANSACTION
|
||||||
|
END;
|
||||||
23
DataLayer/scripts/crud/USP_DeleteUserAccount.sql
Normal file
23
DataLayer/scripts/crud/USP_DeleteUserAccount.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount
|
||||||
|
(
|
||||||
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
SET XACT_ABORT ON
|
||||||
|
BEGIN TRANSACTION
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('UserAccount with the specified ID does not exist.', 16,
|
||||||
|
1);
|
||||||
|
ROLLBACK TRANSACTION
|
||||||
|
RETURN
|
||||||
|
END
|
||||||
|
|
||||||
|
DELETE FROM UserAccount
|
||||||
|
WHERE UserAccountId = @UserAccountId;
|
||||||
|
COMMIT TRANSACTION
|
||||||
|
END;
|
||||||
17
DataLayer/scripts/crud/USP_GetAllUserAccounts.sql
Normal file
17
DataLayer/scripts/crud/USP_GetAllUserAccounts.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
SELECT UserAccountID,
|
||||||
|
Username,
|
||||||
|
FirstName,
|
||||||
|
LastName,
|
||||||
|
Email,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
DateOfBirth,
|
||||||
|
Timer
|
||||||
|
FROM dbo.UserAccount;
|
||||||
|
END;
|
||||||
21
DataLayer/scripts/crud/USP_GetUserAccountByEmail.sql
Normal file
21
DataLayer/scripts/crud/USP_GetUserAccountByEmail.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail
|
||||||
|
(
|
||||||
|
@Email VARCHAR(128)
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
SELECT UserAccountID,
|
||||||
|
Username,
|
||||||
|
FirstName,
|
||||||
|
LastName,
|
||||||
|
Email,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
DateOfBirth,
|
||||||
|
Timer
|
||||||
|
FROM dbo.UserAccount
|
||||||
|
WHERE Email = @Email;
|
||||||
|
END;
|
||||||
21
DataLayer/scripts/crud/USP_GetUserAccountById.sql
Normal file
21
DataLayer/scripts/crud/USP_GetUserAccountById.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE usp_GetUserAccountById
|
||||||
|
(
|
||||||
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
SELECT UserAccountID,
|
||||||
|
Username,
|
||||||
|
FirstName,
|
||||||
|
LastName,
|
||||||
|
Email,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
DateOfBirth,
|
||||||
|
Timer
|
||||||
|
FROM dbo.UserAccount
|
||||||
|
WHERE UserAccountID = @UserAccountId;
|
||||||
|
END;
|
||||||
21
DataLayer/scripts/crud/USP_GetUserAccountByUsername.sql
Normal file
21
DataLayer/scripts/crud/USP_GetUserAccountByUsername.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername
|
||||||
|
(
|
||||||
|
@Username VARCHAR(64)
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
SELECT UserAccountID,
|
||||||
|
Username,
|
||||||
|
FirstName,
|
||||||
|
LastName,
|
||||||
|
Email,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
DateOfBirth,
|
||||||
|
Timer
|
||||||
|
FROM dbo.UserAccount
|
||||||
|
WHERE Username = @Username;
|
||||||
|
END;
|
||||||
35
DataLayer/scripts/crud/USP_UpdateUserAccount.sql
Normal file
35
DataLayer/scripts/crud/USP_UpdateUserAccount.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount
|
||||||
|
(
|
||||||
|
@Username VARCHAR(64),
|
||||||
|
@FirstName NVARCHAR(128),
|
||||||
|
@LastName NVARCHAR(128),
|
||||||
|
@DateOfBirth DATETIME,
|
||||||
|
@Email VARCHAR(128),
|
||||||
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
SET XACT_ABORT ON
|
||||||
|
BEGIN TRANSACTION
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('UserAccount with the specified ID does not exist.', 16,
|
||||||
|
1);
|
||||||
|
ROLLBACK TRANSACTION
|
||||||
|
RETURN
|
||||||
|
END
|
||||||
|
|
||||||
|
UPDATE UserAccount
|
||||||
|
SET
|
||||||
|
Username = @Username,
|
||||||
|
FirstName = @FirstName,
|
||||||
|
LastName = @LastName,
|
||||||
|
DateOfBirth = @DateOfBirth,
|
||||||
|
Email = @Email
|
||||||
|
WHERE UserAccountId = @UserAccountId;
|
||||||
|
|
||||||
|
COMMIT TRANSACTION
|
||||||
|
END;
|
||||||
@@ -1,26 +1,22 @@
|
|||||||
----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
USE master;
|
-- USE master;
|
||||||
|
|
||||||
IF EXISTS (SELECT name
|
-- IF EXISTS (SELECT name
|
||||||
FROM sys.databases
|
-- FROM sys.databases
|
||||||
WHERE name = N'Biergarten')
|
-- WHERE name = N'Biergarten')
|
||||||
BEGIN
|
-- BEGIN
|
||||||
ALTER DATABASE Biergarten SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
-- ALTER DATABASE Biergarten SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||||
END
|
-- END
|
||||||
GO
|
|
||||||
|
|
||||||
DROP DATABASE IF EXISTS Biergarten;
|
-- DROP DATABASE IF EXISTS Biergarten;
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE DATABASE Biergarten;
|
-- CREATE DATABASE Biergarten;
|
||||||
GO
|
|
||||||
|
|
||||||
USE Biergarten;
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
CREATE TABLE dbo.UserAccount
|
CREATE TABLE dbo.UserAccount
|
||||||
(
|
(
|
||||||
@@ -553,6 +549,3 @@ CREATE TABLE BeerPostComment
|
|||||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||||
ON BeerPostComment(BeerPostID)
|
ON BeerPostComment(BeerPostID)
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
-- EOF
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
USE Biergarten;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
||||||
(
|
(
|
||||||
@CountryCode NVARCHAR(2)
|
@CountryCode NVARCHAR(2)
|
||||||
@@ -16,4 +13,3 @@ BEGIN
|
|||||||
|
|
||||||
RETURN @CountryId;
|
RETURN @CountryId;
|
||||||
END;
|
END;
|
||||||
GO
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
USE Biergarten;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
||||||
(
|
(
|
||||||
@StateProvinceCode NVARCHAR(6)
|
@StateProvinceCode NVARCHAR(6)
|
||||||
@@ -14,4 +11,3 @@ BEGIN
|
|||||||
WHERE ISO3616_2 = @StateProvinceCode;
|
WHERE ISO3616_2 = @StateProvinceCode;
|
||||||
RETURN @StateProvinceId;
|
RETURN @StateProvinceId;
|
||||||
END;
|
END;
|
||||||
GO
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
USE Biergarten;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER PROCEDURE dbo.USP_AddLocations
|
CREATE OR ALTER PROCEDURE dbo.USP_AddLocations
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -501,4 +498,3 @@ BEGIN
|
|||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END;
|
END;
|
||||||
GO
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
USE Biergarten;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER PROCEDURE dbo.USP_AddTestUsers
|
CREATE OR ALTER PROCEDURE dbo.USP_AddTestUsers
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -133,4 +130,3 @@ BEGIN
|
|||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END;
|
END;
|
||||||
GO
|
|
||||||
@@ -1,15 +1,3 @@
|
|||||||
USE Biergarten;
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF TYPE_ID(N'dbo.TblUserHashes') IS NULL
|
|
||||||
EXEC('CREATE TYPE dbo.TblUserHashes AS TABLE
|
|
||||||
(
|
|
||||||
UserAccountId UNIQUEIDENTIFIER NOT NULL,
|
|
||||||
Hash NVARCHAR(MAX) NOT NULL
|
|
||||||
);');
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- Stored procedure to insert Argon2 hashes
|
|
||||||
CREATE OR ALTER PROCEDURE dbo.USP_AddUserCredentials
|
CREATE OR ALTER PROCEDURE dbo.USP_AddUserCredentials
|
||||||
(
|
(
|
||||||
@Hash dbo.TblUserHashes READONLY
|
@Hash dbo.TblUserHashes READONLY
|
||||||
@@ -30,4 +18,3 @@ BEGIN
|
|||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END;
|
END;
|
||||||
GO
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
USE Biergarten;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification
|
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -32,4 +29,3 @@ BEGIN
|
|||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END
|
END
|
||||||
GO
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
using System.Data;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using Konscious.Security.Cryptography;
|
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
|
|
||||||
|
|
||||||
string ConnectionString = Environment.GetEnvironmentVariable(
|
|
||||||
"DB_CONNECTION_STRING"
|
|
||||||
)!;
|
|
||||||
|
|
||||||
static async Task BuildSchema(SqlConnection connection)
|
|
||||||
{
|
|
||||||
string sql = await File.ReadAllTextAsync(GetScriptPath("schema.sql"));
|
|
||||||
await ExecuteScriptAsync(connection, sql);
|
|
||||||
Console.WriteLine("Database schema created or updated successfully.");
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task AddStoredProcsAndFunctions(SqlConnection connection)
|
|
||||||
{
|
|
||||||
string projectRoot = Path.GetFullPath(
|
|
||||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "..")
|
|
||||||
);
|
|
||||||
|
|
||||||
string functionsDir = Path.Combine(projectRoot, "seed", "functions");
|
|
||||||
string proceduresDir = Path.Combine(projectRoot, "seed", "procedures");
|
|
||||||
string crudDir = Path.GetFullPath(
|
|
||||||
Path.Combine(projectRoot, "..", "DataAccessLayer", "Sql", "crud")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Directory.Exists(functionsDir))
|
|
||||||
{
|
|
||||||
foreach (
|
|
||||||
string file in Directory
|
|
||||||
.EnumerateFiles(
|
|
||||||
functionsDir,
|
|
||||||
"*.sql",
|
|
||||||
SearchOption.TopDirectoryOnly
|
|
||||||
)
|
|
||||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
string sql = await File.ReadAllTextAsync(file);
|
|
||||||
await ExecuteScriptAsync(connection, sql);
|
|
||||||
Console.WriteLine(
|
|
||||||
$"Executed function script: {Path.GetFileName(file)}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Directory.Exists(proceduresDir))
|
|
||||||
{
|
|
||||||
foreach (
|
|
||||||
string file in Directory
|
|
||||||
.EnumerateFiles(
|
|
||||||
proceduresDir,
|
|
||||||
"*.sql",
|
|
||||||
SearchOption.TopDirectoryOnly
|
|
||||||
)
|
|
||||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
string sql = await File.ReadAllTextAsync(file);
|
|
||||||
await ExecuteScriptAsync(connection, sql);
|
|
||||||
Console.WriteLine(
|
|
||||||
$"Executed procedure script: {Path.GetFileName(file)}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Directory.Exists(crudDir))
|
|
||||||
{
|
|
||||||
foreach (
|
|
||||||
string file in Directory
|
|
||||||
.EnumerateFiles(
|
|
||||||
crudDir,
|
|
||||||
"*.sql",
|
|
||||||
SearchOption.TopDirectoryOnly
|
|
||||||
)
|
|
||||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
string sql = await File.ReadAllTextAsync(file);
|
|
||||||
await ExecuteScriptAsync(connection, sql);
|
|
||||||
Console.WriteLine(
|
|
||||||
$"Executed CRUD script: {Path.GetFileName(file)}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine(
|
|
||||||
"Functions and stored procedures added or updated successfully."
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task RunSeedAsync(SqlConnection connection)
|
|
||||||
{
|
|
||||||
await ExecuteStoredProcedureAsync(connection, "dbo.USP_AddTestUsers");
|
|
||||||
Console.WriteLine("Inserted or refreshed test users.");
|
|
||||||
|
|
||||||
DataTable credentialRows = await BuildCredentialTableAsync(connection);
|
|
||||||
if (credentialRows.Rows.Count > 0)
|
|
||||||
{
|
|
||||||
await ExecuteCredentialProcedureAsync(connection, credentialRows);
|
|
||||||
Console.WriteLine(
|
|
||||||
$"Generated {credentialRows.Rows.Count} credential hashes."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("No new credentials required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExecuteStoredProcedureAsync(
|
|
||||||
connection,
|
|
||||||
"dbo.USP_CreateUserVerification"
|
|
||||||
);
|
|
||||||
Console.WriteLine("Ensured verification rows exist for all users.");
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task ExecuteStoredProcedureAsync(
|
|
||||||
SqlConnection connection,
|
|
||||||
string storedProcedureName
|
|
||||||
)
|
|
||||||
{
|
|
||||||
await using SqlCommand command = new SqlCommand(
|
|
||||||
storedProcedureName,
|
|
||||||
connection
|
|
||||||
);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
|
||||||
await command.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task ExecuteCredentialProcedureAsync(
|
|
||||||
SqlConnection connection,
|
|
||||||
DataTable credentialTable
|
|
||||||
)
|
|
||||||
{
|
|
||||||
await using SqlCommand command = new SqlCommand(
|
|
||||||
"dbo.USP_AddUserCredentials",
|
|
||||||
connection
|
|
||||||
);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
|
||||||
|
|
||||||
SqlParameter tvpParameter = command.Parameters.Add(
|
|
||||||
"@Hash",
|
|
||||||
SqlDbType.Structured
|
|
||||||
);
|
|
||||||
tvpParameter.TypeName = "dbo.TblUserHashes";
|
|
||||||
tvpParameter.Value = credentialTable;
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<DataTable> BuildCredentialTableAsync(SqlConnection connection)
|
|
||||||
{
|
|
||||||
const string sql = """
|
|
||||||
SELECT ua.UserAccountID,
|
|
||||||
ua.Username
|
|
||||||
FROM dbo.UserAccount AS ua
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM dbo.UserCredential AS uc
|
|
||||||
WHERE uc.UserAccountID = ua.UserAccountID);
|
|
||||||
""";
|
|
||||||
|
|
||||||
await using SqlCommand command = new(sql, connection);
|
|
||||||
await using SqlDataReader reader = await command.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
DataTable table = new();
|
|
||||||
table.Columns.Add("UserAccountId", typeof(Guid));
|
|
||||||
table.Columns.Add("Hash", typeof(string));
|
|
||||||
|
|
||||||
while (await reader.ReadAsync())
|
|
||||||
{
|
|
||||||
Guid userId = reader.GetGuid(0);
|
|
||||||
string username = reader.GetString(1);
|
|
||||||
|
|
||||||
string password = CreatePlainTextPassword(username);
|
|
||||||
string hash = GeneratePasswordHash(password);
|
|
||||||
|
|
||||||
DataRow row = table.NewRow();
|
|
||||||
row["UserAccountId"] = userId;
|
|
||||||
row["Hash"] = hash;
|
|
||||||
table.Rows.Add(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
static string CreatePlainTextPassword(string username) => $"{username}#2025!";
|
|
||||||
|
|
||||||
static string GeneratePasswordHash(string password)
|
|
||||||
{
|
|
||||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
|
||||||
|
|
||||||
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
|
|
||||||
{
|
|
||||||
Salt = salt,
|
|
||||||
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
|
|
||||||
MemorySize = 65536,
|
|
||||||
Iterations = 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
byte[] hash = argon2.GetBytes(32);
|
|
||||||
string saltBase64 = Convert.ToBase64String(salt);
|
|
||||||
string hashBase64 = Convert.ToBase64String(hash);
|
|
||||||
|
|
||||||
// Store salt and hash together so verification can rebuild the key material.
|
|
||||||
return $"{saltBase64}:{hashBase64}";
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task ExecuteScriptAsync(SqlConnection connection, string sql)
|
|
||||||
{
|
|
||||||
foreach (string batch in SplitSqlBatches(sql))
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(batch))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await using SqlCommand command = new(batch, connection);
|
|
||||||
await command.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static IEnumerable<string> SplitSqlBatches(string sql)
|
|
||||||
{
|
|
||||||
using StringReader reader = new(sql);
|
|
||||||
StringBuilder buffer = new();
|
|
||||||
|
|
||||||
string? line;
|
|
||||||
while ((line = reader.ReadLine()) is not null)
|
|
||||||
{
|
|
||||||
if (line.Trim().Equals("GO", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
yield return buffer.ToString();
|
|
||||||
buffer.Clear();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.AppendLine(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buffer.Length > 0)
|
|
||||||
{
|
|
||||||
yield return buffer.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static string GetScriptPath(string fileName)
|
|
||||||
{
|
|
||||||
string projectRoot = Path.GetFullPath(
|
|
||||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "..")
|
|
||||||
);
|
|
||||||
string candidate = Path.Combine(projectRoot, fileName);
|
|
||||||
|
|
||||||
if (File.Exists(candidate))
|
|
||||||
{
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new FileNotFoundException(
|
|
||||||
$"SQL script '{fileName}' was not found.",
|
|
||||||
candidate
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using SqlConnection connection = new(ConnectionString);
|
|
||||||
await connection.OpenAsync();
|
|
||||||
Console.WriteLine("Connection to database established successfully.");
|
|
||||||
|
|
||||||
await BuildSchema(connection);
|
|
||||||
await AddStoredProcsAndFunctions(connection);
|
|
||||||
await RunSeedAsync(connection);
|
|
||||||
Console.WriteLine("Seeding complete.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Seeding failed: {ex.Message}");
|
|
||||||
Environment.ExitCode = 1;
|
|
||||||
}
|
|
||||||
@@ -32,14 +32,14 @@ The repositories are currently responsible for:
|
|||||||
## Database schema and seed
|
## Database schema and seed
|
||||||
|
|
||||||
- `DataLayer/schema.sql` contains the database schema definitions.
|
- `DataLayer/schema.sql` contains the database schema definitions.
|
||||||
- `DataLayer/seed/SeedDB.cs` provides seeding and stored procedure/function loading.
|
- `DataLayer/database/` holds application functions and CRUD procedures.
|
||||||
- Stored procedure scripts are organized under `DataAccessLayer/Sql/crud/` (UserAccount and related).
|
- `DataLayer/seed/` holds seed-only procedures and the `SeedDB.cs` entry point.
|
||||||
|
- `SeedDB.cs` honors `SEED_MODE` (`database`, `seed`, or `all`) to control which scripts run.
|
||||||
|
|
||||||
## Key conventions
|
## Key conventions
|
||||||
|
|
||||||
- **Environment variables**: `DB_CONNECTION_STRING` is required for DAL and seed tooling.
|
- **Environment variables**: `DB_CONNECTION_STRING` is required for DAL and seed tooling.
|
||||||
- **Stored procedures**: CRUD operations use `usp_*` procedures.
|
- **Stored procedures**: CRUD operations use `usp_*` procedures.
|
||||||
- **Rowversion** columns are represented as `byte[]` in entities (e.g., `Timer`).
|
|
||||||
|
|
||||||
## Suggested dependency direction
|
## Suggested dependency direction
|
||||||
|
|
||||||
|
|||||||
16
WebAPI/Controllers/NotFoundController.cs
Normal file
16
WebAPI/Controllers/NotFoundController.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace WebAPI.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
|
[Route("error")] // ← required
|
||||||
|
public class NotFoundController : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("404")] // ← required
|
||||||
|
public IActionResult Handle404()
|
||||||
|
{
|
||||||
|
return NotFound(new { message = "Route not found." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using DataAccessLayer;
|
|
||||||
using DataAccessLayer.Entities;
|
using DataAccessLayer.Entities;
|
||||||
|
using BusinessLayer.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace WebAPI.Controllers
|
namespace WebAPI.Controllers
|
||||||
@@ -8,40 +8,57 @@ namespace WebAPI.Controllers
|
|||||||
[Route("api/users")]
|
[Route("api/users")]
|
||||||
public class UsersController : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IUserAccountRepository _userAccountRepository;
|
private readonly IUserService _userService;
|
||||||
|
|
||||||
public UsersController()
|
public UsersController(IUserService userService)
|
||||||
{
|
{
|
||||||
_userAccountRepository = new UserAccountRepository();
|
_userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// all users
|
// all users
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[HttpGet("users")]
|
public IActionResult GetAllUsers(
|
||||||
public IActionResult GetAllUsers()
|
[FromQuery] int? limit,
|
||||||
|
[FromQuery] int? offset
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var users = _userAccountRepository.GetAll();
|
if (offset.HasValue && !limit.HasValue)
|
||||||
|
{
|
||||||
|
return BadRequest("Limit is required when offset is provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limit.HasValue && limit <= 0)
|
||||||
|
{
|
||||||
|
return BadRequest("Limit must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset.HasValue && offset < 0)
|
||||||
|
{
|
||||||
|
return BadRequest("Offset cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var users = _userService.GetAll(limit, offset);
|
||||||
return Ok(users);
|
return Ok(users);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public IActionResult GetUserById(Guid id)
|
public IActionResult GetUserById(Guid id)
|
||||||
{
|
{
|
||||||
var user = _userAccountRepository.GetById(id);
|
var user = _userService.GetById(id);
|
||||||
return user is null ? NotFound() : Ok(user);
|
return user is null ? NotFound() : Ok(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("by-username/{username}")]
|
[HttpGet("username/{username}")]
|
||||||
public IActionResult GetUserByUsername(string username)
|
public IActionResult GetUserByUsername(string username)
|
||||||
{
|
{
|
||||||
var user = _userAccountRepository.GetByUsername(username);
|
var user = _userService.GetByUsername(username);
|
||||||
return user is null ? NotFound() : Ok(user);
|
return user is null ? NotFound() : Ok(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("by-email/{email}")]
|
[HttpGet("email/{email}")]
|
||||||
public IActionResult GetUserByEmail(string email)
|
public IActionResult GetUserByEmail(string email)
|
||||||
{
|
{
|
||||||
var user = _userAccountRepository.GetByEmail(email);
|
var user = _userService.GetByEmail(email);
|
||||||
return user is null ? NotFound() : Ok(user);
|
return user is null ? NotFound() : Ok(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +70,7 @@ namespace WebAPI.Controllers
|
|||||||
userAccount.UserAccountID = Guid.NewGuid();
|
userAccount.UserAccountID = Guid.NewGuid();
|
||||||
}
|
}
|
||||||
|
|
||||||
_userAccountRepository.Add(userAccount);
|
_userService.Add(userAccount);
|
||||||
return CreatedAtAction(
|
return CreatedAtAction(
|
||||||
nameof(GetUserById),
|
nameof(GetUserById),
|
||||||
new { id = userAccount.UserAccountID },
|
new { id = userAccount.UserAccountID },
|
||||||
@@ -70,14 +87,14 @@ namespace WebAPI.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
userAccount.UserAccountID = id;
|
userAccount.UserAccountID = id;
|
||||||
_userAccountRepository.Update(userAccount);
|
_userService.Update(userAccount);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public IActionResult DeleteUser(Guid id)
|
public IActionResult DeleteUser(Guid id)
|
||||||
{
|
{
|
||||||
_userAccountRepository.Delete(id);
|
_userService.Delete(id);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
using BusinessLayer.Services;
|
||||||
|
using DataAccessLayer;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
||||||
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -16,5 +21,6 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
app.MapFallbackToController("Handle404", "NotFound");
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../DataAccessLayer/DataAccessLayer.csproj" />
|
<ProjectReference Include="../BusinessLayer/BusinessLayer.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
// See https://aka.ms/new-console-template for more information
|
|
||||||
Console.WriteLine("Hello, World!");
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -13,8 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BusinessLayer", "BusinessLa
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DALTests", "DALTests\DALTests.csproj", "{99A04D79-A1A9-4BF5-9B70-58FC2B5D2E8A}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DALTests", "DALTests\DALTests.csproj", "{99A04D79-A1A9-4BF5-9B70-58FC2B5D2E8A}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebCrawler", "WebCrawler\WebCrawler.csproj", "{5B37FCDB-1BD0-439A-A840-61322353EAAE}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|||||||
Reference in New Issue
Block a user