Initial commit

This commit is contained in:
Mike 2024-11-17 09:31:01 +00:00
commit f1e46d9d6f
110 changed files with 174753 additions and 0 deletions

25
.dockerignore Normal file
View file

@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
.idea
*.DotSettings.user

View file

@ -0,0 +1,26 @@
meta {
name: Change Password
type: http
seq: 3
}
post {
url: {{Host}}/v1/auth/pwd
body: json
auth: bearer
}
headers {
:
}
auth:bearer {
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyLWlkIjoiYWQyZjI2MmUtOTVhMi00MDUzLTlmMjMtNmMxNjg5NWEwNGE3Iiwicm9sZSI6IlJvb3QiLCJuYmYiOjE3MzE3ODUyMTMsImV4cCI6MTczMTc4NTUxMywiaWF0IjoxNzMxNzg1MjEzLCJpc3MiOiJ5b3VyLWlzc3VlciIsImF1ZCI6InlvdXItYXVkaWVuY2UifQ.udVpVx3maT_Cn9WHkBOCukT_o1oGctqsXboYjL32I3Y
}
body:json {
{
"oldPassword": "{{Password}}",
"newPassword": "MyNewPassword123!"
}
}

View file

@ -0,0 +1,23 @@
meta {
name: Login
type: http
seq: 1
}
post {
url: {{Host}}/v1/auth/login
body: json
auth: none
}
body:json {
{
"username": "{{Username}}",
"password": "{{Password}}"
}
}
body:multipart-form {
username: {{Username}}
password: {{Password}}
}

View file

@ -0,0 +1,17 @@
meta {
name: Refresh
type: http
seq: 2
}
post {
url: {{Host}}/v1/auth/refresh
body: json
auth: none
}
body:json {
{
"refreshToken": "Y1oMF6M9tNjTa2aX01lkXffJo7CrKCqwJ7bOxsjjgEXozVEhUFvNq7KydSQY5rPY"
}
}

View file

@ -0,0 +1,15 @@
meta {
name: Me
type: http
seq: 1
}
get {
url: {{Host}}/v1/me
body: none
auth: none
}
headers {
x-api-key: !eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyLWlkIjoiYWQyZjI2MmUtOTVhMi00MDUzLTlmMjMtNmMxNjg5NWEwNGE3Iiwicm9sZSI6WyJOb3JtYWwiLCJNb2RlcmF0b3IiLCJSb290Il0sIm5iZiI6MTczMTc4MTEyMywiZXhwIjoxNzMxNzgxNDIzLCJpYXQiOjE3MzE3ODExMjN9.pqPt1YDZFsompRT_xGW-UT2SYQBaiNlbu3W6rgg_7S4
}

View file

@ -0,0 +1,9 @@
{
"version": "1",
"name": "Galaeth",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View file

@ -0,0 +1,7 @@
vars {
Host: http://localhost:5000
}
vars:secret [
Username,
Password
]

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Galaeth.CoreApi/Galaeth.CoreApi.csproj", "Galaeth.CoreApi/"]
RUN dotnet restore "Galaeth.CoreApi/Galaeth.CoreApi.csproj"
COPY . .
WORKDIR "/src/Galaeth.CoreApi"
RUN dotnet build "Galaeth.CoreApi.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "Galaeth.CoreApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Galaeth.CoreApi.dll"]

View file

@ -0,0 +1,32 @@
namespace Galaeth.ApiServer.Constants;
/// <summary>
/// Api specific error codes.
/// </summary>
public static class ApiErrorCodes
{
/// <summary>
/// Validation failed.
/// </summary>
public const string UnknownError = "unknown-error";
/// <summary>
/// Illegal method call.
/// </summary>
public const string UnknownMethod = "unknown-method";
/// <summary>
/// Illegal method call.
/// </summary>
public const string IllegalMethod = "illegal-method";
/// <summary>
/// Validation failed.
/// </summary>
public const string UnsupportedBody = "unsupported-body";
/// <summary>
/// Unsupported user agent.
/// </summary>
public const string UnsupportedUserAgent = "unsupported-user-agent";
}

View file

@ -0,0 +1,107 @@
using System.Net;
using System.Net.Sockets;
using Galaeth.ApiServer.Models.Common;
using Galaeth.Core.Constants;
using Microsoft.AspNetCore.Mvc;
namespace Galaeth.ApiServer.Controllers;
/// <summary>
/// Base Api Controller.
/// </summary>
[Consumes("application/json")]
[Produces("application/json")]
public class ApiController : ControllerBase
{
/// <summary>
/// Return a failure status with no data.
/// </summary>
/// <returns>Instance of <see cref="IActionResult"/>.</returns>
protected static IActionResult FailureResult()
{
return new JsonResult(new ResultModel
{
Success = false,
});
}
/// <summary>
/// Return a failure result with additional information.
/// </summary>
/// <param name="errorCode">Api error code.</param>
/// <param name="message">Api error message.</param>
/// <returns>Instance of <see cref="IActionResult"/>.</returns>
protected static IActionResult FailureResult(string errorCode, string message)
{
return new JsonResult(new ErrorResultModel
{
Error = errorCode,
Message = message,
});
}
/// <summary>
/// Returns a failure result indicating the body is missing from the request.
/// </summary>
/// <returns>Instance of <see cref="IActionResult"/>.</returns>
protected static IActionResult MissingBodyResult()
{
return FailureResult(CoreErrorCodes.NoDataProvided, "Missing request body");
}
/// <summary>
/// Return a success status with no data.
/// </summary>
/// <returns>Instance of <see cref="IActionResult"/>.</returns>
protected static IActionResult SuccessResult()
{
return new JsonResult(new ResultModel
{
Success = true,
});
}
/// <summary>
/// Return a success status with data.
/// </summary>
/// <returns>Instance of <see cref="IActionResult"/>.</returns>
/// <typeparam name="TDataType">The primary key type.</typeparam>
/// <param name="data">The data to return in the result.</param>
protected static IActionResult SuccessResult<TDataType>(TDataType data)
where TDataType : class
{
return new JsonResult(new DataResultModel<TDataType>
{
Data = data,
Success = true,
});
}
/// <summary>
/// Fetch IP address of requesting agent.
/// </summary>
/// <returns>Request origin's IP address.</returns>
protected IPAddress ClientIpAddress()
{
IPAddress remoteIpAddress = null;
if (Request.Headers.TryGetValue("X-Forwarded-For", out var value))
{
foreach (var ip in value)
{
if (IPAddress.TryParse(ip, out var address) &&
(address.AddressFamily is AddressFamily.InterNetwork
or AddressFamily.InterNetworkV6))
{
remoteIpAddress = address;
break;
}
}
}
else
{
remoteIpAddress = HttpContext.Connection.RemoteIpAddress?.MapToIPv4();
}
return remoteIpAddress;
}
}

View file

@ -0,0 +1,128 @@
using Galaeth.ApiServer.Constants;
using Galaeth.ApiServer.Models;
using Galaeth.Services.Dtos;
using Galaeth.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MyCSharp.HttpUserAgentParser.AspNetCore;
using Toycloud.AspNetCore.Mvc.ModelBinding;
namespace Galaeth.ApiServer.Controllers;
/// <summary>
/// Authentication API endpoints.
/// </summary>
[ApiController]
[Authorize]
[Route("v1/auth")]
public class AuthenticationController : ApiController
{
private readonly IAuthenticationService _authenticationService;
private readonly IHttpUserAgentParserAccessor _httpUserAgentParserAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationController"/> class.
/// </summary>
/// <param name="authenticationService">Instance of <see cref="IAuthenticationService"/>.</param>
/// <param name="httpUserAgentParserAccessor">Instance of <see cref="IHttpUserAgentParserAccessor"/>.</param>
public AuthenticationController(
IAuthenticationService authenticationService,
IHttpUserAgentParserAccessor httpUserAgentParserAccessor)
{
_authenticationService = authenticationService;
_httpUserAgentParserAccessor = httpUserAgentParserAccessor;
}
/// <summary>
/// Process an authentication (login) request.
/// </summary>
/// <param name="request">Instance of <see cref="AuthenticateRequest"/>.</param>
/// <returns>If successful, instance of <see cref="AccessTokensDto"/>.</returns>
[AllowAnonymous]
[HttpPost("login")]
public async Task<IActionResult> Authenticate([FromBodyOrDefault] AuthenticateRequest request)
{
if (request is null)
{
return MissingBodyResult();
}
var userAgent = _httpUserAgentParserAccessor.Get(Request.HttpContext);
if (userAgent is null)
{
return FailureResult(ApiErrorCodes.UnsupportedUserAgent, "Could not determine user agent");
}
var session = await _authenticationService.AuthenticateUser(new AuthenticateUserDto()
{
Username = request.Username,
Password = request.Password,
IpAddress = ClientIpAddress(),
UserAgent = userAgent.Value,
});
return SuccessResult(session);
}
/// <summary>
/// Process a refresh token request for user authentication.
/// </summary>
/// <param name="userAuthRequest">Instance of <see cref="RefreshUserAuthRequest"/>.</param>
/// <returns>If successful, instance of <see cref="AccessTokensDto"/>.</returns>
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<IActionResult> RefreshSession([FromBodyOrDefault] RefreshUserAuthRequest userAuthRequest)
{
if (userAuthRequest is null)
{
return MissingBodyResult();
}
var userAgent = _httpUserAgentParserAccessor.Get(Request.HttpContext);
if (userAgent is null)
{
return FailureResult(ApiErrorCodes.UnsupportedUserAgent, "Could not determine user agent");
}
var session = await _authenticationService.AuthenticateUser(new RefreshAuthenticationDto()
{
RefreshToken = userAuthRequest.RefreshToken,
IpAddress = ClientIpAddress(),
UserAgent = userAgent.Value,
});
return SuccessResult(session);
}
/// <summary>
/// User's request to change their own password.
/// </summary>
/// <param name="request">Instance of <see cref="ChangePasswordRequest"/>.</param>
[HttpPost("pwd")]
public async Task<IActionResult> ChangePassword([FromBodyOrDefault] ChangePasswordRequest request)
{
if (request is null)
{
return MissingBodyResult();
}
var userAgent = _httpUserAgentParserAccessor.Get(Request.HttpContext);
if (userAgent is null)
{
return FailureResult(ApiErrorCodes.UnsupportedUserAgent, "Could not determine user agent");
}
var newTokens = await _authenticationService.ChangeUserPassword(new ChangePasswordDto()
{
OldPassword = request.OldPassword,
NewPassword = request.NewPassword,
UserAgent = userAgent.Value,
IpAddress = ClientIpAddress(),
});
return SuccessResult(newTokens);
}
}

View file

@ -0,0 +1,44 @@
using Galaeth.Core.Dtos;
using Galaeth.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Galaeth.ApiServer.Controllers;
/// <summary>
/// Api methods for the requesting authorised user.
/// </summary>
[ApiController]
[Authorize]
[Route("v1/me")]
public class UserSelfController : ApiController
{
private readonly IIdentityProvider _identityProvider;
private readonly IUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="UserSelfController"/> class.
/// </summary>
/// <param name="identityProvider">Instance of <see cref="IIdentityProvider"/>.</param>
/// <param name="userService">Instance of <see cref="IUserService"/>.</param>
public UserSelfController(
IIdentityProvider identityProvider,
IUserService userService)
{
_identityProvider = identityProvider;
_userService = userService;
}
/// <summary>
/// Get the requesting user information and return it.
/// </summary>
/// <returns>Instance of <see cref="UserDto"/>.</returns>
[HttpGet]
public async Task<IActionResult> GetMe()
{
var userId = _identityProvider.GetRequestingUserId();
var user = await _userService.FindById(userId);
return SuccessResult(user);
}
}

View file

@ -0,0 +1,32 @@
using System.Text;
using Galaeth.Core.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace Galaeth.ApiServer.Extensions;
/// <summary>
/// Extensions for <see cref="JwtConfiguration"/>.
/// </summary>
public static class JwtConfigurationExtensions
{
/// <summary>
/// Generate an instance of <see cref="TokenValidationParameters"/> from <see cref="JwtConfiguration"/>.
/// </summary>
/// <param name="config">Instance of <see cref="JwtConfiguration"/>.</param>
/// <returns>Instance of <see cref="TokenValidationParameters"/>.</returns>
public static TokenValidationParameters ToTokenValidationParameters(this JwtConfiguration config)
{
return new TokenValidationParameters()
{
ValidateIssuer = config.ValidateIssuer,
ValidateAudience = config.ValidateAudience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidAudience = config.Audience,
ValidAudiences =
[config.Audience],
ValidIssuer = config.Issuer,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SecretKey)),
};
}
}

View file

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<NoDefaultLaunchSettingsFile>True</NoDefaultLaunchSettingsFile>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<CodeAnalysisRuleSet>../Galaeth.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
<PackageReference Include="MyCSharp.HttpUserAgentParser.AspNetCore" Version="3.0.9" />
<PackageReference Include="MyCSharp.HttpUserAgentParser.MemoryCache" Version="3.0.10-g39a7b02192" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Toycloud.AspNetCore.Mvc.ModelBinding.BodyOrDefaultBinding" Version="1.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Galaeth.Core\Galaeth.Core.csproj" />
<ProjectReference Include="..\Galaeth.Services\Galaeth.Services.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,58 @@
using Galaeth.Core.Infrastructure;
using Galaeth.Services.Interfaces;
namespace Galaeth.ApiServer.HostedServices;
/// <summary>
/// Processes to be executed at startup.
/// </summary>
public class StartupService : IHostedService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<StartupService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="StartupService"/> class.
/// </summary>
/// <param name="serviceScopeFactory">Instance of <see cref="IServiceScopeFactory"/>.</param>
/// <param name="logger">Instance of <see cref="ILogger"/>.</param>
public StartupService(
IServiceScopeFactory serviceScopeFactory,
ILogger<StartupService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Running startup processes");
using var scope = _serviceScopeFactory.CreateScope();
// Database migrations.
var dbMigrator = scope.ServiceProvider.GetRequiredService<IDatabaseMigrator>();
await dbMigrator.MigrateDatabaseAsync(AppDomain.CurrentDomain.BaseDirectory + "Migrations");
// DAL setup.
var dalSetup = scope.ServiceProvider.GetService<IDataAccessLayerSetup>();
if (dalSetup is not null)
{
await dalSetup.Setup();
}
// Email address domain blacklist update.
var emDomainBlacklist = scope.ServiceProvider.GetRequiredService<IEmailDomainBlacklistService>();
await emDomainBlacklist.UpdateBlacklist();
// Initial user setup.
var initialUserSetup = scope.ServiceProvider.GetRequiredService<IInitialUserService>();
await initialUserSetup.CreateFirstUserIfRequiredAsync();
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,107 @@
using System.ComponentModel.DataAnnotations;
using System.Net;
using Galaeth.ApiServer.Constants;
using Galaeth.ApiServer.Models.Common;
using Galaeth.Core.Constants;
using Galaeth.Core.Exceptions;
namespace Galaeth.ApiServer.Middleware;
/// <summary>
/// Handle exceptions caught in a request.
/// </summary>
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class.
/// </summary>
/// <param name="next">Instance of <see cref="RequestDelegate"/>.</param>
/// <param name="logger">Instance of <see cref="ILogger"/>.</param>
public ExceptionMiddleware(
RequestDelegate next,
ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
/// <summary>
/// Invoke middleware.
/// </summary>
/// <param name="httpContext">Instance of <see cref="HttpContext"/>.</param>
public async Task InvokeAsync(HttpContext httpContext)
{
try
{
await _next(httpContext);
}
catch (ServiceException exception)
{
await HandleServiceExceptionAsync(httpContext, exception);
}
catch (ValidationException exception)
{
await HandleValidationExceptionAsync(httpContext, exception);
}
catch (Exception exception)
{
_logger.LogError(
"Unhandled Exception: {path} using {method}: {exception}",
httpContext.Request.Path,
httpContext.Request.Method,
exception);
await HandleOtherExceptionAsync(httpContext);
}
}
/// <summary>
/// Api exception.
/// </summary>
/// <param name="context">Instance of <see cref="HttpContext"/>.</param>
/// <param name="exception">Instance of <see cref="ValidationException"/>.</param>
private static async Task HandleServiceExceptionAsync(HttpContext context, ServiceException exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)exception.HttpStatusCode;
await context.Response.WriteAsJsonAsync(new ErrorResultModel
{
Error = exception.ErrorCode,
Message = exception.ErrorMessage,
});
}
/// <summary>
/// Validation failed.
/// </summary>
/// <param name="context">Instance of <see cref="HttpContext"/>.</param>
/// <param name="exception">Instance of <see cref="ValidationException"/>.</param>
private static async Task HandleValidationExceptionAsync(HttpContext context, ValidationException exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
await context.Response.WriteAsJsonAsync(new ErrorResultModel
{
Error = CoreErrorCodes.ValidationError,
Message = exception.Message,
});
}
/// <summary>
/// Unexpected exception.
/// </summary>
/// <param name="context">Instance of <see cref="HttpContext"/>.</param>
private static async Task HandleOtherExceptionAsync(HttpContext context)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
await context.Response.WriteAsJsonAsync(new ErrorResultModel()
{
Success = false,
Error = ApiErrorCodes.UnknownError,
Message = "Something went wrong. Try again later",
});
}
}

View file

@ -0,0 +1,90 @@
using Galaeth.ApiServer.Constants;
using Galaeth.Core.Constants;
namespace Galaeth.ApiServer.Middleware;
/// <summary>
/// Handle status code responses.
/// </summary>
public class StatusCodeMiddleware
{
private readonly RequestDelegate _next;
/// <summary>
/// Initializes a new instance of the <see cref="StatusCodeMiddleware"/> class.
/// </summary>
/// <param name="next">Instance of <see cref="RequestDelegate"/>.</param>
public StatusCodeMiddleware(RequestDelegate next)
{
_next = next;
}
/// <summary>
/// Invoke middleware.
/// </summary>
/// <param name="context">Instance of <see cref="HttpContext"/>.</param>
public Task Invoke(HttpContext context) =>
InvokeAsync(context);
private async Task InvokeAsync(HttpContext context)
{
await _next(context);
switch (context.Response.StatusCode)
{
case 401:
context.Response.Headers.Clear();
context.Response.ContentType = "text/json";
await context.Response.WriteAsJsonAsync(new
{
Success = false,
Error = CoreErrorCodes.Unauthorized,
Message = "You're not authorized to do that",
});
break;
case 404:
context.Response.Headers.Clear();
context.Response.ContentType = "text/json";
await context.Response.WriteAsJsonAsync(new
{
Success = false,
Error = ApiErrorCodes.UnknownMethod,
Message = "Unknown method",
});
break;
case 405:
context.Response.Headers.Clear();
context.Response.ContentType = "text/json";
await context.Response.WriteAsJsonAsync(new
{
Success = false,
Error = ApiErrorCodes.IllegalMethod,
Message = "Illegal method",
});
break;
case 415:
context.Response.Headers.Clear();
context.Response.ContentType = "text/json";
await context.Response.WriteAsJsonAsync(new
{
Success = false,
Error = ApiErrorCodes.UnsupportedBody,
Message = "Unsupported body/media type",
});
break;
case 500:
context.Response.Headers.Clear();
context.Response.ContentType = "text/json";
await context.Response.WriteAsJsonAsync(new
{
Success = false,
Error = ApiErrorCodes.UnknownError,
Message = "Unknown error",
});
break;
}
}
}

View file

@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Galaeth.ApiServer.Models;
/// <summary>
/// Authentication request.
/// </summary>
public class AuthenticateRequest
{
/// <summary>
/// Agent's username.
/// </summary>
[JsonPropertyName("username")]
public string Username { get; set; }
/// <summary>
/// Agent's password.
/// </summary>
[JsonPropertyName("password")]
public string Password { get; set; }
}

View file

@ -0,0 +1,17 @@
namespace Galaeth.ApiServer.Models;
/// <summary>
/// Change password request model.
/// </summary>
public class ChangePasswordRequest
{
/// <summary>
/// The user's old password.
/// </summary>
public string OldPassword { get; set; }
/// <summary>
/// The user's new password.
/// </summary>
public string NewPassword { get; set; }
}

View file

@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace Galaeth.ApiServer.Models.Common;
/// <summary>
/// Result with data model.
/// </summary>
/// <typeparam name="TDataType">The data type being returned.</typeparam>
public class DataResultModel<TDataType> : ResultModel
where TDataType : class
{
/// <summary>
/// Included data payload.
/// </summary>
[JsonPropertyName("data")]
public TDataType Data { get; set; }
}

View file

@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Galaeth.ApiServer.Models.Common;
/// <summary>
/// Error result model.
/// </summary>
public class ErrorResultModel : ResultModel
{
/// <summary>
/// Error code.
/// </summary>
[JsonPropertyName("error")]
public string Error { get; set; }
/// <summary>
/// Error message.
/// </summary>
[JsonPropertyName("message")]
public string Message { get; set; }
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Galaeth.ApiServer.Models.Common;
/// <summary>
/// Basic result model.
/// </summary>
public class ResultModel
{
/// <summary>
/// True if successful, false otherwise.
/// </summary>
[JsonPropertyName("success")]
public bool Success { get; set; }
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Galaeth.ApiServer.Models;
/// <summary>
/// Refresh session with refresh token request.
/// </summary>
public class RefreshUserAuthRequest
{
/// <summary>
/// Refresh token.
/// </summary>
[JsonPropertyName("refreshToken")]
public string RefreshToken { get; set; }
}

View file

@ -0,0 +1,91 @@
using Galaeth.ApiServer.Extensions;
using Galaeth.ApiServer.HostedServices;
using Galaeth.ApiServer.Middleware;
using Galaeth.Core.Configuration;
using Galaeth.Services.Configuration;
using Galaeth.Services.Profiles;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using MyCSharp.HttpUserAgentParser.AspNetCore.DependencyInjection;
using MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection;
using Serilog;
using Serilog.Events;
namespace Galaeth.ApiServer;
/// <summary>
/// Entry point for service.
/// </summary>
internal static class Program
{
/// <summary>
/// Entry point method.
/// </summary>
/// <param name="args">Service arguments.</param>
public static async Task Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
.AddEnvironmentVariables();
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.MinimumLevel.Override("Default", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.WriteTo.Console()
.CreateLogger();
builder.Services.AddSerilog();
builder.Services.AddHttpUserAgentMemoryCachedParser(opt =>
{
opt.CacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(30);
opt.CacheOptions.SizeLimit = 1024;
}).AddHttpUserAgentParserAccessor();
builder.Services.AddHostedService<StartupService>();
builder.Services.AddGalaethCore();
builder.Services.AddGalaethApiServer();
builder.Services.AddGalaethServices();
builder.Services.AddGalaethDAL();
builder.Services.AddHttpContextAccessor();
builder.Services.AddControllers();
// Setup authentication.
var jwtConfig = new JwtConfiguration();
builder.Configuration.Bind("JWT", jwtConfig);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => options.TokenValidationParameters = jwtConfig.ToTokenValidationParameters());
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
// Setup automapping.
builder.Services.AddAutoMapper(typeof(AutomapperProfile).Assembly);
// Setup configuration.
builder.Services.Configure<DatabaseConfiguration>(builder.Configuration.GetSection("Database"));
builder.Services.Configure<PwdHashConfiguration>(builder.Configuration.GetSection("PwdHash"));
builder.Services.Configure<InitialUserConfiguration>(builder.Configuration.GetSection("InitialUser"));
builder.Services.Configure<RegistrationConfiguration>(builder.Configuration.GetSection("Registration"));
builder.Services.Configure<EmailDomainBlacklistConfiguration>(
builder.Configuration.GetSection("EmailDomainBlacklist"));
builder.Services.Configure<JwtConfiguration>(builder.Configuration.GetSection("JWT"));
var app = builder.Build();
// Error handling middleware.
app.UseMiddleware<ExceptionMiddleware>();
app.UseMiddleware<StatusCodeMiddleware>();
app.UseAuthorization();
app.MapControllers();
await app.RunAsync();
}
}

View file

@ -0,0 +1,34 @@
using Galaeth.Services.Constants;
using Galaeth.Services.Interfaces;
using Injectio.Attributes;
namespace Galaeth.ApiServer.Providers;
/// <inheritdoc />
[RegisterScoped]
public class IdentityProvider : IIdentityProvider
{
/// <summary>
/// Initializes a new instance of the <see cref="IdentityProvider"/> class.
/// </summary>
/// <param name="httpContextAccessor">Instance of <see cref="IHttpContextAccessor"/>.</param>
public IdentityProvider(IHttpContextAccessor httpContextAccessor)
{
var claims = httpContextAccessor.HttpContext?.User.Claims;
if (claims is not null)
{
UserId = claims.FirstOrDefault(c => c.Type == ClaimIds.UserId)?.Value;
}
}
/// <summary>
/// User's id.
/// </summary>
private string UserId { get; set; }
/// <inheritdoc />
public string GetRequestingUserId()
{
return UserId;
}
}

View file

@ -0,0 +1,41 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Warning"
}
},
"Database": {
"ConnectionString": ""
},
"PwdHash": {
"DegreeOfParallelism": 4,
"NumberOfIterations": 3,
"MemoryToUseKb": 16,
"SaltSize": 64,
"HashSize": 128
},
"InitialUser": {
"Username": "admin",
"Email": "admin@changeme.com",
"Password": "Change!Me1234"
},
"EmailDomainBlacklist": {
"Enabled": true,
"MasterList": "https://raw.githubusercontent.com/disposable/disposable-email-domains/refs/heads/master/domains.txt"
},
"Registration": {
"RequireEmailValidation": false
},
"JWT": {
"SecretKey": "your-secret-key",
"Issuer": "your-issuer",
"Audience": "your-audience",
"AccessTokenExpiration": "5 mins",
"RefreshTokenExpiration": "3 days",
"ValidateIssuer": true,
"ValidateAudience": true
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,22 @@
namespace Galaeth.Core.Attributes.EntityAnnotation;
/// <summary>
/// Define the column name this property relates to.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class ColumnMappingAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="ColumnMappingAttribute"/> class.
/// </summary>
/// <param name="name">Database column name.</param>
public ColumnMappingAttribute(string name)
{
Name = name;
}
/// <summary>
/// Database table column name.
/// </summary>
public string Name { get; }
}

View file

@ -0,0 +1,22 @@
namespace Galaeth.Core.Attributes.EntityAnnotation;
/// <summary>
/// Treat this property as the primary key in the database.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class PrimaryKeyAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="PrimaryKeyAttribute"/> class.
/// </summary>
/// <param name="autoGenerated">If the primary key auto-generated by the database.</param>
public PrimaryKeyAttribute(bool autoGenerated = false)
{
AutoGenerated = autoGenerated;
}
/// <summary>
/// Should this primary key be auto-generated by the database? (i.e. Auto incrementing).
/// </summary>
public bool AutoGenerated { get; }
}

View file

@ -0,0 +1,22 @@
namespace Galaeth.Core.Attributes.EntityAnnotation;
/// <summary>
/// Define the database table name for this entity.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class TableMappingAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="TableMappingAttribute"/> class.
/// </summary>
/// <param name="name">The database table name.</param>
public TableMappingAttribute(string name)
{
Name = name;
}
/// <summary>
/// Database table name.
/// </summary>
public string Name { get; }
}

View file

@ -0,0 +1,12 @@
namespace Galaeth.Core.Configuration;
/// <summary>
/// Database Configuration.
/// </summary>
public class DatabaseConfiguration
{
/// <summary>
/// Connection string.
/// </summary>
public string ConnectionString { get; init; }
}

View file

@ -0,0 +1,42 @@
namespace Galaeth.Core.Configuration;
/// <summary>
/// Jwt Authentication Configuration.
/// </summary>
public class JwtConfiguration
{
/// <summary>
/// Secret key.
/// </summary>
public string SecretKey { get; set; }
/// <summary>
/// Issuer.
/// </summary>
public string Issuer { get; set; }
/// <summary>
/// Audience.
/// </summary>
public string Audience { get; set; }
/// <summary>
/// Access token expiration.
/// </summary>
public string AccessTokenExpiration { get; set; }
/// <summary>
/// Refresh token expiration.
/// </summary>
public string RefreshTokenExpiration { get; set; }
/// <summary>
/// Validate issuer.
/// </summary>
public bool ValidateIssuer { get; set; }
/// <summary>
/// Validate audience.
/// </summary>
public bool ValidateAudience { get; set; }
}

View file

@ -0,0 +1,22 @@
namespace Galaeth.Core.Constants;
/// <summary>
/// Error codes to be provided in a response.
/// </summary>
public static class CoreErrorCodes
{
/// <summary>
/// Unauthorised access.
/// </summary>
public const string Unauthorized = "unauthorized";
/// <summary>
/// The request body is missing data.
/// </summary>
public const string NoDataProvided = "missing-data";
/// <summary>
/// Validation failed.
/// </summary>
public const string ValidationError = "validation-fail";
}

View file

@ -0,0 +1,14 @@
using Galaeth.Core.Entities;
namespace Galaeth.Core.Constants;
/// <summary>
/// Contexts for <see cref="RefreshToken"/> entities.
/// </summary>
public enum RefreshTokenContext
{
/// <summary>
/// Refresh token for a user's access.
/// </summary>
UserAccess,
}

View file

@ -0,0 +1,22 @@
namespace Galaeth.Core.Constants;
/// <summary>
/// User's role.
/// </summary>
public enum UserRole
{
/// <summary>
/// Normal user.
/// </summary>
Normal,
/// <summary>
/// Instance moderator.
/// </summary>
Moderator,
/// <summary>
/// Root account.
/// </summary>
Root,
}

View file

@ -0,0 +1,27 @@
namespace Galaeth.Core.Constants;
/// <summary>
/// Available user states.
/// </summary>
public enum UserState
{
/// <summary>
/// Normal activated user.
/// </summary>
Normal,
/// <summary>
/// Suspended account.
/// </summary>
Suspended,
/// <summary>
/// Banned account.
/// </summary>
Banned,
/// <summary>
/// Awaiting email activation.
/// </summary>
AwaitingEmailActivation,
}

View file

@ -0,0 +1,44 @@
using Galaeth.Core.Constants;
namespace Galaeth.Core.Dtos;
/// <summary>
/// Dto representing a user.
/// </summary>
public class UserDto
{
/// <summary>
/// User's Id.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// User's name.
/// </summary>
public string Username { get; set; }
/// <summary>
/// User's email address.
/// </summary>
public string EmailAddress { get; set; }
/// <summary>
/// When the user was registered.
/// </summary>
public DateTime RegistrationDate { get; set; }
/// <summary>
/// When the user was registered in ticks.
/// </summary>
public long RegistrationDateTicks { get; set; }
/// <summary>
/// User's role.
/// </summary>
public UserRole Role { get; set; }
/// <summary>
/// User's role as title.
/// </summary>
public string RoleTitle { get; set; }
}

View file

@ -0,0 +1,23 @@
using Galaeth.Core.Attributes.EntityAnnotation;
namespace Galaeth.Core.Entities;
/// <summary>
/// Entity representing a blacklisted email address domain.
/// </summary>
[TableMapping("emailDomainBlacklist")]
public class EmailDomainBlacklist
{
/// <summary>
/// The blacklisted domain.
/// </summary>
[PrimaryKey]
[ColumnMapping("domain")]
public string Domain { get; set; }
/// <summary>
/// When the record was created.
/// </summary>
[ColumnMapping("createdAt")]
public DateTime CreatedAt { get; set; }
}

View file

@ -0,0 +1,61 @@
using Galaeth.Core.Attributes.EntityAnnotation;
using Galaeth.Core.Constants;
using MyCSharp.HttpUserAgentParser;
namespace Galaeth.Core.Entities;
/// <summary>
/// Entity representing a refresh token.
/// </summary>
[TableMapping("refreshTokens")]
public class RefreshToken
{
/// <summary>
/// The token.
/// </summary>
[ColumnMapping("token")]
[PrimaryKey]
public string Token { get; set; }
/// <summary>
/// Token context.
/// </summary>
[ColumnMapping("tokenContext")]
public RefreshTokenContext TokenContext { get; set; }
/// <summary>
/// The owner user's id.
/// </summary>
[ColumnMapping("userId")]
public Guid UserId { get; set; }
/// <summary>
/// When the token expires.
/// </summary>
[ColumnMapping("expires")]
public DateTime Expires { get; set; }
/// <summary>
/// The user agent this refresh token belongs to.
/// </summary>
[ColumnMapping("userAgent")]
public string UserAgent { get; set; }
/// <summary>
/// User agent type.
/// </summary>
[ColumnMapping("userAgentType")]
public HttpUserAgentType UserAgentType { get; set; }
/// <summary>
/// The ip address this refresh token belongs to.
/// </summary>
[ColumnMapping("ipAddress")]
public string IpAddress { get; set; }
/// <summary>
/// When the token was created.
/// </summary>
[ColumnMapping("createdAt")]
public DateTime CreatedAt { get; set; }
}

View file

@ -0,0 +1,78 @@
using Galaeth.Core.Attributes.EntityAnnotation;
using Galaeth.Core.Constants;
namespace Galaeth.Core.Entities;
/// <summary>
/// User entity.
/// </summary>
[TableMapping("users")]
public class User
{
/// <summary>
/// User id.
/// </summary>
[ColumnMapping("id")]
[PrimaryKey]
public Guid Id { get; set; }
/// <summary>
/// When the user was created.
/// </summary>
[ColumnMapping("createdAt")]
public DateTime CreatedAt { get; set; }
/// <summary>
/// When the user's entity was last updated.
/// </summary>
[ColumnMapping("updatedAt")]
public DateTime UpdatedAt { get; set; }
/// <summary>
/// The user's name.
/// </summary>
[ColumnMapping("username")]
public string Username { get; set; }
/// <summary>
/// The user's email address.
/// </summary>
[ColumnMapping("email")]
public string EmailAddress { get; set; }
/// <summary>
/// Authentication hash.
/// </summary>
[ColumnMapping("authHash")]
public string PasswordHash { get; set; }
/// <summary>
/// Authentication salt.
/// </summary>
[ColumnMapping("authSalt")]
public string PasswordSalt { get; set; }
/// <summary>
/// The ip address of the creator.
/// </summary>
[ColumnMapping("creatorIp")]
public string CreatorIp { get; set; }
/// <summary>
/// When the user last logged in.
/// </summary>
[ColumnMapping("lastLoggedIn")]
public DateTime LastLoggedIn { get; set; }
/// <summary>
/// The user account's role.
/// </summary>
[ColumnMapping("role")]
public UserRole Role { get; set; }
/// <summary>
/// State of the user's account.
/// </summary>
[ColumnMapping("state")]
public UserState State { get; set; }
}

View file

@ -0,0 +1,17 @@
namespace Galaeth.Core.Exceptions;
/// <summary>
/// Exception to be thrown if errors occur during migrations.
/// </summary>
public class MigrationException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MigrationException"/> class.
/// </summary>
/// <param name="file">The migration file causing the error.</param>
/// <param name="error">The error message.</param>
public MigrationException(string file, string error)
: base($"An exception occured whilst attempting to migrate the database with file {file}: {error}")
{
}
}

View file

@ -0,0 +1,22 @@
namespace Galaeth.Core.Exceptions;
/// <summary>
/// Thrown if an entity has a property that has a primary key attribute, but no column attribute.
/// </summary>
public class MissingColumnAttributeException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MissingColumnAttributeException"/> class.
/// </summary>
/// <param name="entityType">The entity causing the exception.</param>
public MissingColumnAttributeException(Type entityType)
: base($"Entity '{entityType}' primary key requires a ColumnMapping attribute")
{
EntityType = entityType;
}
/// <summary>
/// The entity type.
/// </summary>
public Type EntityType { get; set; }
}

View file

@ -0,0 +1,22 @@
namespace Galaeth.Core.Exceptions;
/// <summary>
/// Thrown if an entity has multiple primary keys defined.
/// </summary>
public class MultiplePrimaryKeysException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MultiplePrimaryKeysException"/> class.
/// </summary>
/// <param name="entityType">The entity causing the exception.</param>
public MultiplePrimaryKeysException(Type entityType)
: base($"Entity '{entityType}' contains multiple primary keys")
{
EntityType = entityType;
}
/// <summary>
/// The entity type.
/// </summary>
public Type EntityType { get; set; }
}

View file

@ -0,0 +1,24 @@
using Galaeth.Core.Attributes.EntityAnnotation;
namespace Galaeth.Core.Exceptions;
/// <summary>
/// Thrown if an entity is missing a property with <see cref="PrimaryKeyAttribute"/>.
/// </summary>
public class PrimaryKeyMissingException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="PrimaryKeyMissingException"/> class.
/// </summary>
/// <param name="entityType">The entity causing the exception.</param>
public PrimaryKeyMissingException(Type entityType)
: base($"Entity '{entityType}' is missing a property with a PrimaryKey.")
{
EntityType = entityType;
}
/// <summary>
/// The entity type.
/// </summary>
public Type EntityType { get; set; }
}

View file

@ -0,0 +1,50 @@
using System.Net;
namespace Galaeth.Core.Exceptions;
/// <summary>
/// Service exception to be fed back into the response.
/// </summary>
public class ServiceException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="ServiceException"/> class.
/// </summary>
/// <param name="errorCode">Error code to provide.</param>
/// <param name="errorMessage">Error message to provide.</param>
public ServiceException(string errorCode, string errorMessage)
: base(errorMessage)
{
ErrorCode = errorCode;
ErrorMessage = errorMessage;
}
/// <summary>
/// Initializes a new instance of the <see cref="ServiceException"/> class.
/// </summary>
/// <param name="errorCode">Error code to provide.</param>
/// <param name="errorMessage">Error message to provide.</param>
/// <param name="httpStatusCode">The http status code to provide.</param>
public ServiceException(string errorCode, string errorMessage, HttpStatusCode httpStatusCode)
: base(errorMessage)
{
ErrorCode = errorCode;
ErrorMessage = errorMessage;
HttpStatusCode = httpStatusCode;
}
/// <summary>
/// Standard error code.
/// </summary>
public string ErrorCode { get; }
/// <summary>
/// Error message.
/// </summary>
public string ErrorMessage { get; }
/// <summary>
/// Http Status Code.
/// </summary>
public HttpStatusCode HttpStatusCode { get; } = HttpStatusCode.BadRequest;
}

View file

@ -0,0 +1,30 @@
using Galaeth.Core.Configuration;
using TimeSpanParserUtil;
namespace Galaeth.Core.Extensions;
/// <summary>
/// Extensions for <see cref="JwtConfiguration"/>.
/// </summary>
public static class JwtConfigurationExtensions
{
/// <summary>
/// Return when (UTC) the access token expires.
/// </summary>
/// <param name="configuration">Instance of <see cref="JwtConfiguration"/>.</param>
/// <returns>A UTC DateTime of when the expiration occurs.</returns>
public static DateTime AccessTokenExpirationDateTime(this JwtConfiguration configuration)
{
return DateTime.UtcNow.Add(TimeSpanParser.Parse(configuration.AccessTokenExpiration));
}
/// <summary>
/// Return when (UTC) the refresh token expires.
/// </summary>
/// <param name="configuration">Instance of <see cref="JwtConfiguration"/>.</param>
/// <returns>A UTC DateTime of when the expiration occurs.</returns>
public static DateTime RefreshTokenExpirationDateTime(this JwtConfiguration configuration)
{
return DateTime.UtcNow.Add(TimeSpanParser.Parse(configuration.RefreshTokenExpiration));
}
}

View file

@ -0,0 +1,23 @@
namespace Galaeth.Core.Extensions;
/// <summary>
/// Extensions for lists.
/// </summary>
public static class ListExtensions
{
/// <summary>
/// Chunk a list into smaller chunks.
/// </summary>
/// <param name="source">Source list.</param>
/// <param name="chunkSize">Chunk size.</param>
/// <typeparam name="T">List type.</typeparam>
/// <returns>Collection of chunked lists.</returns>
public static List<List<T>> ChunkBy<T>(this List<T> source, int chunkSize)
{
return source
.Select((x, i) => new { Index = i, Value = x })
.GroupBy(x => x.Index / chunkSize)
.Select(x => x.Select(v => v.Value).ToList())
.ToList();
}
}

View file

@ -0,0 +1,32 @@
namespace Galaeth.Core.Extensions;
/// <summary>
/// Extension methods for strings.
/// </summary>
public static class StringExtensions
{
/// <summary>
/// Mask an email address for logging purposes.
/// </summary>
/// <param name="emailAddress">The email address to obfuscate.</param>
/// <returns>The obfuscated email address, or the original if there's an issue with the email address.</returns>
public static string MaskEmailAddress(this string emailAddress)
{
if (string.IsNullOrWhiteSpace(emailAddress))
{
return string.Empty;
}
if (!emailAddress.Contains('@'))
{
return emailAddress;
}
if (emailAddress.StartsWith('@'))
{
return emailAddress;
}
return $"{emailAddress[0]}****{emailAddress[(emailAddress.IndexOf('@') - 1) ..]}";
}
}

View file

@ -0,0 +1,31 @@
using Galaeth.Core.Constants;
namespace Galaeth.Core.Extensions;
/// <summary>
/// <see cref="UserRole"/> extensions.
/// </summary>
public static class UserRoleExtensions
{
/// <summary>
/// Return a collection of roles a role is entitled to.
/// </summary>
/// <param name="role">The role to query.</param>
/// <returns>A collection of roles the role is entitled to.</returns>
public static UserRole[] UserRoleEntitlement(this UserRole role)
{
switch (role)
{
default:
case UserRole.Normal:
return
[UserRole.Normal];
case UserRole.Moderator:
return
[UserRole.Normal, UserRole.Moderator];
case UserRole.Root:
return
[UserRole.Normal, UserRole.Moderator, UserRole.Root];
}
}
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<CodeAnalysisRuleSet>../Galaeth.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="FluentValidation" Version="11.10.0" />
<PackageReference Include="Injectio" Version="3.3.0" />
<PackageReference Include="MyCSharp.HttpUserAgentParser" Version="3.0.9" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,14 @@
using Injectio.Attributes;
namespace Galaeth.Core.Infrastructure;
/// <summary>
/// Perform any setup steps required for the data access layer.
/// </summary>
public interface IDataAccessLayerSetup
{
/// <summary>
/// Setup routine.
/// </summary>
Task Setup();
}

View file

@ -0,0 +1,13 @@
namespace Galaeth.Core.Infrastructure;
/// <summary>
/// Database migration process.
/// </summary>
public interface IDatabaseMigrator
{
/// <summary>
/// Run database migrations.
/// </summary>
/// <param name="migrationsPath">The directory where migrations are found.</param>
Task MigrateDatabaseAsync(string migrationsPath = "Migrations");
}

View file

@ -0,0 +1,21 @@
using System.Data;
namespace Galaeth.Core.Infrastructure;
/// <summary>
/// Scoped database connection provider.
/// </summary>
public interface IDbConnectionProvider : IDisposable
{
/// <summary>
/// Open new database connection for this scope.
/// </summary>
/// <returns>Instance of <see cref="IDbConnection"/>.</returns>
IDbConnection OpenConnection();
/// <summary>
/// Open new database connection for this scope.
/// </summary>
/// <returns>Instance of <see cref="IDbConnection"/>.</returns>
Task<IDbConnection> OpenConnectionAsync();
}

View file

@ -0,0 +1,24 @@
using System.Data;
using System.Data.Common;
namespace Galaeth.Core.Infrastructure;
/// <summary>
/// Database transaction provider.
/// </summary>
public interface ITransactionProvider
{
/// <summary>
/// Begin a new transaction.
/// </summary>
/// <param name="isolationLevel">One of <see cref="IsolationLevel"/>.</param>
/// <returns>Instance of <see cref="IDbTransaction"/>.</returns>
IDbTransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Unspecified);
/// <summary>
/// Begin a new transaction.
/// </summary>
/// <param name="isolationLevel">One of <see cref="IsolationLevel"/>.</param>
/// <returns>Instance of <see cref="IDbTransaction"/>.</returns>
Task<IDbTransaction> BeginTransactionAsync(IsolationLevel isolationLevel = IsolationLevel.Unspecified);
}

View file

@ -0,0 +1,15 @@
using Galaeth.Core.Entities;
namespace Galaeth.Core.RepositoryInterfaces;
/// <summary>
/// <see cref="EmailDomainBlacklist"/> Repository.
/// </summary>
public interface IEmailDomainBlacklistRepository : IGenericRepository<EmailDomainBlacklist, string>
{
/// <summary>
/// Count how many domains are in the blacklist.
/// </summary>
/// <returns>A count of entries.</returns>
Task<long> CountEntries();
}

View file

@ -0,0 +1,50 @@
namespace Galaeth.Core.RepositoryInterfaces;
/// <summary>
/// Generic repository with auto-generated CRUD operations.
/// </summary>
/// <typeparam name="TEntity">The entity this repository handles.</typeparam>
/// <typeparam name="TKeyType">The data type of the primary key.</typeparam>
public interface IGenericRepository<TEntity, TKeyType>
where TEntity : class
{
/// <summary>
/// Find an entity by its id.
/// </summary>
/// <param name="id">The entity's id.</param>
/// <returns>The entity, or null if not found.</returns>
Task<TEntity> FindByIdAsync(TKeyType id);
/// <summary>
/// Retrieve all entities.
/// </summary>
/// <returns>Collection of entities.</returns>
Task<IEnumerable<TEntity>> GetAllAsync();
/// <summary>
/// Add an entity to the table and return it's new id.
/// </summary>
/// <param name="entity">The entity to add.</param>
/// <returns>The id of the new entity.</returns>
Task<TKeyType> AddAsync(TEntity entity);
/// <summary>
/// Add multiple entities to the table.
/// </summary>
/// <param name="entities">The entities to add.</param>
/// <returns>The id of the new entity.</returns>
Task AddAsync(IEnumerable<TEntity> entities);
/// <summary>
/// Update an entity.
/// </summary>
/// <param name="entity">The entity (needs an id) to update.</param>
/// <returns>True if entity updated.</returns>
Task<bool> UpdateAsync(TEntity entity);
/// <summary>
/// Delete an entity by its id.
/// </summary>
/// <param name="id">The entity id.</param>
Task DeleteAsync(TKeyType id);
}

View file

@ -0,0 +1,15 @@
using Galaeth.Core.Entities;
namespace Galaeth.Core.RepositoryInterfaces;
/// <summary>
/// <see cref="RefreshToken"/> Repository.
/// </summary>
public interface IRefreshTokenRepository : IGenericRepository<RefreshToken, string>
{
/// <summary>
/// Revoke all tokens for the provided user id.
/// </summary>
/// <param name="userId">User id.</param>
Task RevokeAllForUser(Guid userId);
}

View file

@ -0,0 +1,29 @@
using Galaeth.Core.Entities;
namespace Galaeth.Core.RepositoryInterfaces;
/// <summary>
/// <see cref="User"/> Repository.
/// </summary>
public interface IUserRepository : IGenericRepository<User, Guid>
{
/// <summary>
/// Return a count of all users.
/// </summary>
/// <returns>A count of users in the table.</returns>
Task<long> CountUsersAsync();
/// <summary>
/// Find a user by their username.
/// </summary>
/// <param name="username">The username to search for.</param>
/// <returns>The user entity if found, or null.</returns>
Task<User> FindByUsername(string username);
/// <summary>
/// Find a user by their email address.
/// </summary>
/// <param name="emailAddress">The email address to search for.</param>
/// <returns>The user entity if found, or null.</returns>
Task<User> FindByEmailAddress(string emailAddress);
}

View file

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<CodeAnalysisRuleSet>../Galaeth.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Galaeth.Core\Galaeth.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Npgsql" Version="8.0.5" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Content Include="Migrations\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View file

@ -0,0 +1,49 @@
using Galaeth.Core.Attributes.EntityAnnotation;
using Galaeth.Core.Infrastructure;
using Injectio.Attributes;
using Microsoft.Extensions.Logging;
namespace Galaeth.DAL.Infrastructure;
/// <inheritdoc />
[RegisterScoped]
public class DataAccessLayerSetup : IDataAccessLayerSetup
{
private readonly ILogger<DataAccessLayerSetup> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DataAccessLayerSetup"/> class.
/// </summary>
/// <param name="logger">Instance of <see cref="ILogger"/>.</param>
public DataAccessLayerSetup(ILogger<DataAccessLayerSetup> logger)
{
_logger = logger;
}
/// <inheritdoc />
public Task Setup()
{
_logger.LogInformation("Setting up data access layer");
Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true;
SetupMappings();
return Task.CompletedTask;
}
/// <summary>
/// Setup custom mapping on all entities decorated with <see cref="TableMappingAttribute"/>.
/// </summary>
private static void SetupMappings()
{
// Find all entities annotated with Table attribute.
var entityTypes = typeof(IDataAccessLayerSetup).Assembly.GetTypes()
.Where(type => type.GetCustomAttributes(typeof(TableMappingAttribute), true).Any());
foreach (var entity in entityTypes)
{
Dapper.SqlMapper.SetTypeMap(entity, new EntityAttributeTypeMapper(entity));
}
}
}

View file

@ -0,0 +1,90 @@
using Dapper;
using Galaeth.Core.Exceptions;
using Galaeth.Core.Infrastructure;
using Injectio.Attributes;
using Microsoft.Extensions.Logging;
namespace Galaeth.DAL.Infrastructure;
/// <inheritdoc />
[RegisterScoped]
public class DatabaseMigrator : IDatabaseMigrator
{
private readonly IDbConnectionProvider _dbConnectionProvider;
private readonly ITransactionProvider _transactionProvider;
private readonly ILogger<DatabaseMigrator> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DatabaseMigrator"/> class.
/// </summary>
/// <param name="dbConnectionProvider">Instance of <see cref="IDbConnectionProvider"/>.</param>
/// <param name="transactionProvider">Instance of <see cref="ITransactionProvider"/>.</param>
/// <param name="logger">Instance of <see cref="ILogger"/>.</param>
public DatabaseMigrator(
IDbConnectionProvider dbConnectionProvider,
ITransactionProvider transactionProvider,
ILogger<DatabaseMigrator> logger)
{
_dbConnectionProvider = dbConnectionProvider;
_transactionProvider = transactionProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task MigrateDatabaseAsync(string migrationsPath)
{
var connection = await _dbConnectionProvider.OpenConnectionAsync();
_logger.LogInformation("Beginning database migrations");
using var transaction = await _transactionProvider.BeginTransactionAsync();
await connection.ExecuteAsync("""
CREATE TABLE IF NOT EXISTS migrations (
date_added TIMESTAMP NOT NULL,
filename VARCHAR(128) NOT NULL,
CONSTRAINT migrations_filename_pk PRIMARY KEY (filename)
);
""");
var appliedMigrationsEnumerable = await
connection.QueryAsync<string>("SELECT filename FROM migrations ORDER BY date_added;");
// Prevent multiple enumeration.
var appliedMigrations = appliedMigrationsEnumerable.ToList();
var files = Directory.GetFiles(migrationsPath, "*.sql").ToList();
files.Sort();
foreach (var filename in files)
{
var file = filename.Replace(migrationsPath, string.Empty).Trim(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (appliedMigrations.Contains(file))
{
_logger.LogInformation("Already applied: {file}", file);
continue;
}
_logger.LogInformation("Applying {file}...", file);
try
{
var sql = await File.ReadAllTextAsync(filename);
await connection.ExecuteAsync(sql);
await connection.ExecuteAsync(
"INSERT INTO migrations (date_added, filename) SELECT CURRENT_TIMESTAMP, @pFilename ;",
new
{
pFilename = file,
});
}
catch (Exception e)
{
throw new MigrationException(file, e.Message);
}
}
transaction.Commit();
_logger.LogInformation("Database migrations complete");
}
}

View file

@ -0,0 +1,95 @@
using System.Data;
using Galaeth.Core.Configuration;
using Galaeth.Core.Infrastructure;
using Injectio.Attributes;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
namespace Galaeth.DAL.Infrastructure;
/// <inheritdoc cref="Galaeth.Core.Infrastructure.IDbConnectionProvider" />
[RegisterScoped]
public class DbConnectionProvider : IDbConnectionProvider, IAsyncDisposable
{
private readonly IOptions<DatabaseConfiguration> _databaseConfiguration;
private readonly ILogger<DbConnectionProvider> _logger;
private NpgsqlConnection _dbConnection;
/// <summary>
/// Initializes a new instance of the <see cref="DbConnectionProvider"/> class.
/// </summary>
/// <param name="databaseConfiguration">Instance of <see cref="IOptions{DatabaseConfiguration}"/>.</param>
/// <param name="logger">Instance of <see cref="ILogger"/>.</param>
public DbConnectionProvider(
IOptions<DatabaseConfiguration> databaseConfiguration,
ILogger<DbConnectionProvider> logger)
{
_databaseConfiguration = databaseConfiguration;
_logger = logger;
}
/// <inheritdoc cref="Galaeth.Core.Infrastructure.IDbConnectionProvider" />
public IDbConnection OpenConnection()
{
if (_dbConnection is not null)
{
if (_dbConnection.State == ConnectionState.Closed)
{
_dbConnection.Open();
}
return _dbConnection;
}
_logger.LogDebug("Opening database connection");
_dbConnection = new NpgsqlConnection(_databaseConfiguration.Value.ConnectionString);
_dbConnection.Open();
return _dbConnection;
}
/// <inheritdoc />
public async Task<IDbConnection> OpenConnectionAsync()
{
if (_dbConnection is not null)
{
if (_dbConnection.State == ConnectionState.Closed)
{
await _dbConnection.OpenAsync();
}
return _dbConnection;
}
_logger.LogDebug("Opening database connection");
_dbConnection = new NpgsqlConnection(_databaseConfiguration.Value.ConnectionString);
await _dbConnection.OpenAsync();
return _dbConnection;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_dbConnection != null)
{
await _dbConnection.DisposeAsync();
}
GC.SuppressFinalize(this);
}
/// <inheritdoc />
public void Dispose()
{
if (_dbConnection is null)
{
return;
}
_logger.LogDebug("Disposing database connection");
_dbConnection.Close();
_dbConnection.Dispose();
_dbConnection = null;
GC.SuppressFinalize(this);
}
}

View file

@ -0,0 +1,31 @@
using System.Reflection;
using Dapper;
using Galaeth.Core.Attributes.EntityAnnotation;
namespace Galaeth.DAL.Infrastructure;
/// <summary>
/// Entity attribute type mapper.
/// </summary>
public class EntityAttributeTypeMapper : FallbackTypeMapper
{
/// <summary>
/// Initializes a new instance of the <see cref="EntityAttributeTypeMapper"/> class.
/// </summary>
/// <param name="entityType">The entity type.</param>
public EntityAttributeTypeMapper(Type entityType)
: base([
new CustomPropertyTypeMap(
entityType,
(type, columnName) =>
type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
.FirstOrDefault(
prop =>
prop.GetCustomAttributes(false)
.OfType<ColumnMappingAttribute>()
.Any(attr => attr.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)))),
new DefaultTypeMap(entityType)
])
{
}
}

View file

@ -0,0 +1,110 @@
using System.Reflection;
using Dapper;
namespace Galaeth.DAL.Infrastructure;
/// <summary>
/// Fallback type mapper.
/// </summary>
public class FallbackTypeMapper : SqlMapper.ITypeMap
{
private readonly IEnumerable<SqlMapper.ITypeMap> _mappers;
/// <summary>
/// Initializes a new instance of the <see cref="FallbackTypeMapper"/> class.
/// </summary>
/// <param name="mappers">Collection of <see cref="SqlMapper.ITypeMap"/>.</param>
public FallbackTypeMapper(IEnumerable<SqlMapper.ITypeMap> mappers)
{
_mappers = mappers;
}
/// <summary>
/// Find constructor.
/// </summary>
/// <param name="names">Collection of names.</param>
/// <param name="types">Collection of types.</param>
/// <returns>Instance of <see cref="ConstructorInfo"/>.</returns>
public ConstructorInfo FindConstructor(string[] names, Type[] types)
{
foreach (var mapper in _mappers)
{
try
{
var result = mapper.FindConstructor(names, types);
if (result != null)
{
return result;
}
}
catch
{
// Do nothing
}
}
return null;
}
/// <summary>
/// Find explicit constructor.
/// </summary>
/// <returns>Instance of <see cref="ConstructorInfo"/>.</returns>
public ConstructorInfo FindExplicitConstructor()
{
return null;
}
/// <summary>
/// Get constructor paramter map.
/// </summary>
/// <param name="constructor">Instance of <see cref="ConstructorInfo"/>.</param>
/// <param name="columnName">Column name.</param>
/// <returns>Instance of <see cref="SqlMapper.IMemberMap"/>.</returns>
public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, string columnName)
{
foreach (var mapper in _mappers)
{
try
{
var result = mapper.GetConstructorParameter(constructor, columnName);
if (result != null)
{
return result;
}
}
catch
{
// Do nothing.
}
}
return null;
}
/// <summary>
/// Get member.
/// </summary>
/// <param name="columnName">Column name.</param>
/// <returns>Instance of <see cref="SqlMapper.IMemberMap"/>.</returns>
public SqlMapper.IMemberMap GetMember(string columnName)
{
foreach (var mapper in _mappers)
{
try
{
var result = mapper.GetMember(columnName);
if (result != null)
{
return result;
}
}
catch
{
// Do nothing.
}
}
return null;
}
}

View file

@ -0,0 +1,35 @@
using System.Data;
using Galaeth.Core.Infrastructure;
using Injectio.Attributes;
namespace Galaeth.DAL.Infrastructure;
/// <inheritdoc />
[RegisterScoped]
public class TransactionProvider : ITransactionProvider
{
private readonly IDbConnectionProvider _dbConnectionProvider;
/// <summary>
/// Initializes a new instance of the <see cref="TransactionProvider"/> class.
/// </summary>
/// <param name="dbConnectionProvider">Instance of <see cref="IDbConnectionProvider"/>.</param>
public TransactionProvider(IDbConnectionProvider dbConnectionProvider)
{
_dbConnectionProvider = dbConnectionProvider;
}
/// <inheritdoc cref="Galaeth.Core.Infrastructure.ITransactionProvider"/>
public IDbTransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Unspecified)
{
var connection = _dbConnectionProvider.OpenConnection();
return connection.BeginTransaction(isolationLevel);
}
/// <inheritdoc cref="Galaeth.Core.Infrastructure.ITransactionProvider"/>
public async Task<IDbTransaction> BeginTransactionAsync(IsolationLevel isolationLevel = IsolationLevel.Unspecified)
{
var connection = await _dbConnectionProvider.OpenConnectionAsync();
return connection.BeginTransaction(isolationLevel);
}
}

View file

@ -0,0 +1,9 @@
CREATE OR REPLACE FUNCTION updated_at_timestamp() RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
BEGIN
NEW.updatedAt = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$;

View file

@ -0,0 +1,20 @@
CREATE TABLE users
(
id UUID UNIQUE PRIMARY KEY NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
authHash TEXT,
authSalt TEXT,
state SMALLINT,
role SMALLINT,
lastLoggedIn TIMESTAMP DEFAULT NULL,
creatorIp TEXT DEFAULT '0.0.0.0'
);
CREATE TRIGGER users_updated_at
BEFORE UPDATE
ON users
FOR EACH ROW
EXECUTE PROCEDURE updated_at_timestamp();

View file

@ -0,0 +1,5 @@
CREATE TABLE emailDomainBlacklist
(
domain TEXT UNIQUE PRIMARY KEY NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
CREATE TABLE refreshTokens
(
token TEXT UNIQUE PRIMARY KEY NOT NULL,
tokenContext SMALLINT NOT NULL,
userId UUID REFERENCES users(id) ON DELETE CASCADE,
userAgent TEXT NOT NULL,
userAgentType SMALLINT NOT NULL,
ipAddress TEXT NOT NULL,
expires TIMESTAMP NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

View file

@ -0,0 +1,251 @@
using System.Data;
using System.Reflection;
using System.Text;
using Dapper;
using Galaeth.Core.Attributes.EntityAnnotation;
using Galaeth.Core.Exceptions;
using Galaeth.Core.Infrastructure;
using Galaeth.Core.RepositoryInterfaces;
namespace Galaeth.DAL.Repositories;
/// <inheritdoc />
public class BaseRepository<TEntity, TKeyType> : IGenericRepository<TEntity, TKeyType>
where TEntity : class
{
/// <summary>
/// Instance of <see cref="IDbConnectionProvider"/>.
/// </summary>
private readonly IDbConnectionProvider _db;
/// <summary>
/// Initializes a new instance of the <see cref="BaseRepository{TEntity, TKeyType}"/> class.
/// </summary>
/// <param name="db">Instance of <see cref="IDbConnectionProvider"/>.</param>
protected BaseRepository(IDbConnectionProvider db)
{
_db = db;
SetupRepository();
}
/// <summary>
/// Table name.
/// </summary>
protected string Table { get; set; }
/// <summary>
/// Primary key column name.
/// </summary>
private string PrimaryKeyColumn { get; set; }
/// <summary>
/// Whether the primary key should be treated as auto-generated by the database.
/// </summary>
private bool AutoPrimaryKey { get; set; }
/// <summary>
/// Generated select all query string.
/// </summary>
private string SelectAllQuery { get; set; }
/// <summary>
/// Generated find by id query string.
/// </summary>
private string FindByIdQuery { get; set; }
/// <summary>
/// Generated insert query string.
/// </summary>
private string InsertQuery { get; set; }
/// <summary>
/// Generated update query string.
/// </summary>
private string UpdateQuery { get; set; }
/// <summary>
/// Generated delete query string.
/// </summary>
private string DeleteQuery { get; set; }
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IGenericRepository{TEntity,TKeyType}" />
public async Task<TEntity> FindByIdAsync(TKeyType id)
{
return await WithDatabaseAsync(async connection =>
{
var result = await connection.QueryFirstOrDefaultAsync<TEntity>(FindByIdQuery, new
{
pId = id,
});
return result;
});
}
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IGenericRepository{TEntity,TKeyType}" />
public async Task<IEnumerable<TEntity>> GetAllAsync()
{
return await WithDatabaseAsync(async connection =>
{
var result = await connection.QueryAsync<TEntity>(SelectAllQuery);
return result;
});
}
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IGenericRepository{TEntity,TKeyType}" />
public async Task<TKeyType> AddAsync(TEntity entity)
{
return await WithDatabaseAsync(async connection =>
{
return await connection.QuerySingleAsync<TKeyType>(
InsertQuery, entity);
});
}
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IGenericRepository{TEntity,TKeyType}" />
public async Task AddAsync(IEnumerable<TEntity> entities)
{
await WithDatabaseAsync(async connection =>
{
await connection.ExecuteAsync(
InsertQuery, entities);
});
}
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IGenericRepository{TEntity,TKeyType}" />
public async Task<bool> UpdateAsync(TEntity entity)
{
return await WithDatabaseAsync(async connection => await connection.ExecuteAsync(UpdateQuery, entity) > 0);
}
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IGenericRepository{TEntity,TKeyType}" />
public async Task DeleteAsync(TKeyType id)
{
await WithDatabaseAsync(async connection => await connection.ExecuteAsync(DeleteQuery, new
{
pId = id,
}));
}
/// <summary>
/// Establish a connection and perform the query function.
/// </summary>
/// <param name="query">Query method to execute.</param>
/// <typeparam name="T">The return type.</typeparam>
/// <returns>Result of query.</returns>
/// <exception cref="Exception">Thrown if an exception occurs.</exception>
protected async Task<T> WithDatabaseAsync<T>(Func<IDbConnection, Task<T>> query)
{
var connection = await _db.OpenConnectionAsync();
return await query(connection);
}
/// <summary>
/// Establish a connection and perform the query function.
/// </summary>
/// <param name="query">Query method to execute.</param>
protected async Task WithDatabaseAsync(Func<IDbConnection, Task> query)
{
var connection = await _db.OpenConnectionAsync();
await query(connection);
}
/// <summary>
/// Fetch a comma separated list of property names.
/// </summary>
/// <param name="excludeKey">True to exclude key property.</param>
/// <returns>Comma separated list of column names.</returns>
private static string GetPropertyNames(bool excludeKey = false)
{
var properties = typeof(TEntity).GetProperties()
.Where(p => !excludeKey || p.GetCustomAttribute<PrimaryKeyAttribute>() == null)
.Where(p => p.IsDefined(typeof(PrimaryKeyAttribute), true) ||
p.IsDefined(typeof(ColumnMappingAttribute), true));
var values = string.Join(", ", properties.Select(p => $"@{p.Name}"));
return values;
}
/// <summary>
/// Setup repository.
/// </summary>
private void SetupRepository()
{
var entityType = typeof(TEntity);
var properties = entityType.GetProperties();
var propertiesExcludingKey = properties
.Where(p => p.GetCustomAttribute<PrimaryKeyAttribute>() == null);
// Set up table name.
var tableAttribute = entityType.GetCustomAttribute<TableMappingAttribute>();
if (tableAttribute is null)
{
Table = typeof(TEntity).Name + "s";
}
else
{
Table = tableAttribute.Name;
}
// Set up primary key.
var primaryKeyProperties = properties.Where(p => p.IsDefined(typeof(PrimaryKeyAttribute), true)).ToList();
if (primaryKeyProperties is null || primaryKeyProperties.Count == 0)
{
throw new PrimaryKeyMissingException(typeof(TEntity));
}
if (primaryKeyProperties.Count > 1)
{
throw new MultiplePrimaryKeysException(typeof(TEntity));
}
var primaryKeyProperty = primaryKeyProperties.First();
var primaryKey = primaryKeyProperty.GetCustomAttribute<PrimaryKeyAttribute>();
var primaryKeyCol = primaryKeyProperty.GetCustomAttribute<ColumnMappingAttribute>();
if (primaryKeyCol is null)
{
throw new MissingColumnAttributeException(typeof(TEntity));
}
PrimaryKeyColumn = primaryKeyCol!.Name;
AutoPrimaryKey = primaryKey!.AutoGenerated;
SelectAllQuery = $"SELECT * FROM {Table};";
FindByIdQuery = $"SELECT * FROM {Table} WHERE {PrimaryKeyColumn} = @pId;";
// Add query.
var columns = string.Join(", ", properties
.Where(p => !AutoPrimaryKey || !p.IsDefined(typeof(PrimaryKeyAttribute)))
.Where(p => p.IsDefined(typeof(ColumnMappingAttribute)))
.Select(p => p.GetCustomAttribute<ColumnMappingAttribute>() !.Name));
var propertyNames = GetPropertyNames(AutoPrimaryKey);
var insertQuery = new StringBuilder();
insertQuery.Append(
$"INSERT INTO {Table} ({columns}) VALUES ({propertyNames}) RETURNING {PrimaryKeyColumn};");
InsertQuery = insertQuery.ToString();
// Update query.
var updateQuery = new StringBuilder();
updateQuery.Append($"UPDATE {Table} SET");
foreach (var property in propertiesExcludingKey.Where(p => p.IsDefined(typeof(ColumnMappingAttribute))))
{
var columnAttr = property.GetCustomAttribute<ColumnMappingAttribute>();
var propertyName = property.Name;
updateQuery.Append($" {columnAttr!.Name} = @{propertyName},");
}
updateQuery.Remove(updateQuery.Length - 1, 1);
updateQuery.Append($" WHERE {PrimaryKeyColumn} = @{primaryKeyProperty.Name};");
UpdateQuery = updateQuery.ToString();
// Delete query.
DeleteQuery = $"DELETE FROM {Table} WHERE {PrimaryKeyColumn} = @pId;";
}
}

View file

@ -0,0 +1,28 @@
using Dapper;
using Galaeth.Core.Entities;
using Galaeth.Core.Infrastructure;
using Galaeth.Core.RepositoryInterfaces;
using Injectio.Attributes;
namespace Galaeth.DAL.Repositories;
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IEmailDomainBlacklistRepository" />
[RegisterScoped]
public class EmailDomainBlacklistRepository : BaseRepository<EmailDomainBlacklist, string>, IEmailDomainBlacklistRepository
{
/// <summary>
/// Initializes a new instance of the <see cref="EmailDomainBlacklistRepository"/> class.
/// </summary>
/// <param name="db">Instance of <see cref="IDbConnectionProvider"/>.</param>
public EmailDomainBlacklistRepository(IDbConnectionProvider db)
: base(db)
{
}
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IEmailDomainBlacklistRepository" />
public async Task<long> CountEntries()
{
return await WithDatabaseAsync(async connection =>
await connection.QueryFirstAsync<long>($"SELECT COUNT(*) FROM {Table};"));
}
}

View file

@ -0,0 +1,31 @@
using Dapper;
using Galaeth.Core.Entities;
using Galaeth.Core.Infrastructure;
using Galaeth.Core.RepositoryInterfaces;
using Injectio.Attributes;
namespace Galaeth.DAL.Repositories;
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IRefreshTokenRepository" />
[RegisterScoped]
public class RefreshTokenRepository : BaseRepository<RefreshToken, string>, IRefreshTokenRepository
{
/// <summary>
/// Initializes a new instance of the <see cref="RefreshTokenRepository"/> class.
/// </summary>
/// <param name="db">Instance of <see cref="IDbConnectionProvider"/>.</param>
public RefreshTokenRepository(IDbConnectionProvider db)
: base(db)
{
}
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IRefreshTokenRepository" />
public async Task RevokeAllForUser(Guid userId)
{
await WithDatabaseAsync(async connection => await connection.ExecuteAsync(
$"DELETE FROM {Table} WHERE userId = @pUserId", new
{
pUserId = userId,
}));
}
}

View file

@ -0,0 +1,48 @@
using Dapper;
using Galaeth.Core.Entities;
using Galaeth.Core.Infrastructure;
using Galaeth.Core.RepositoryInterfaces;
using Injectio.Attributes;
namespace Galaeth.DAL.Repositories;
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IUserRepository" />
[RegisterScoped]
public class UserRepository : BaseRepository<User, Guid>, IUserRepository
{
/// <summary>
/// Initializes a new instance of the <see cref="UserRepository"/> class.
/// </summary>
/// <param name="db">Instance of <see cref="IDbConnectionProvider"/>.</param>
public UserRepository(IDbConnectionProvider db)
: base(db)
{
}
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IUserRepository" />
public async Task<long> CountUsersAsync()
{
return await WithDatabaseAsync(async connection =>
await connection.QueryFirstAsync<long>($"SELECT COUNT(*) FROM {Table};"));
}
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IUserRepository" />
public async Task<User> FindByUsername(string username)
{
return await WithDatabaseAsync(async connection =>
await connection.QueryFirstOrDefaultAsync<User>($"SELECT * FROM {Table} WHERE username = @pUsername", new
{
pUsername = username,
}));
}
/// <inheritdoc cref="Galaeth.Core.RepositoryInterfaces.IUserRepository" />
public async Task<User> FindByEmailAddress(string emailAddress)
{
return await WithDatabaseAsync(async connection =>
await connection.QueryFirstOrDefaultAsync<User>($"SELECT * FROM {Table} WHERE email = @pEmailAddress", new
{
pEmailAddress = emailAddress,
}));
}
}

View file

@ -0,0 +1,17 @@
namespace Galaeth.Services.Configuration;
/// <summary>
/// Configuration for the email domain blacklist service.
/// </summary>
public class EmailDomainBlacklistConfiguration
{
/// <summary>
/// Whether the email domain blacklist should be used.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Url of a master list for email address domains to blacklist.
/// </summary>
public string MasterList { get; set; }
}

View file

@ -0,0 +1,22 @@
namespace Galaeth.Services.Configuration;
/// <summary>
/// Configuration for the first user created if none other exist.
/// </summary>
public class InitialUserConfiguration
{
/// <summary>
/// Username.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Email address.
/// </summary>
public string Email { get; set; }
/// <summary>
/// Password.
/// </summary>
public string Password { get; set; }
}

View file

@ -0,0 +1,33 @@
namespace Galaeth.Services.Configuration;
/// <summary>
/// How the password should be hashed.
/// Refer to https://argon2-cffi.readthedocs.io/en/stable/argon2.html.
/// </summary>
public class PwdHashConfiguration
{
/// <summary>
/// Number of threads to use. (Higher the better).
/// </summary>
public int DegreeOfParallelism { get; set; }
/// <summary>
/// Number of iterations over the memory.
/// </summary>
public int NumberOfIterations { get; set; }
/// <summary>
/// Memory used by the algorithm.
/// </summary>
public int MemoryToUseKb { get; set; }
/// <summary>
/// Salt size in bytes.
/// </summary>
public int SaltSize { get; set; }
/// <summary>
/// Hash size in bytes.
/// </summary>
public int HashSize { get; set; }
}

View file

@ -0,0 +1,12 @@
namespace Galaeth.Services.Configuration;
/// <summary>
/// User registration configuration.
/// </summary>
public class RegistrationConfiguration
{
/// <summary>
/// Whether email verification should be required.
/// </summary>
public bool RequireEmailActivation { get; set; }
}

View file

@ -0,0 +1,17 @@
namespace Galaeth.Services.Constants;
/// <summary>
/// Collection of claim ids.
/// </summary>
public static class ClaimIds
{
/// <summary>
/// User Id claim.
/// </summary>
public const string UserId = "user-id";
/// <summary>
/// User Role claim.
/// </summary>
public const string Role = "role";
}

View file

@ -0,0 +1,37 @@
namespace Galaeth.Services.Constants;
/// <summary>
/// Collection of standard error codes.
/// </summary>
public static class ServiceErrorCodes
{
/// <summary>
/// Username is already taken.
/// </summary>
public const string UsernameTaken = "username-taken";
/// <summary>
/// Email address already registered to a user.
/// </summary>
public const string EmailAlreadyRegistered = "email-already-registered";
/// <summary>
/// Invalid login credentials were provided.
/// </summary>
public const string InvalidCredentials = "invalid-credentials";
/// <summary>
/// This user is suspended from logging in.
/// </summary>
public const string SuspendedUser = "user-suspended";
/// <summary>
/// This user is banned from logging in.
/// </summary>
public const string BannedUser = "user-banned";
/// <summary>
/// This user's account is not yet activated.
/// </summary>
public const string PendingActivation = "pending-activation";
}

View file

@ -0,0 +1,37 @@
namespace Galaeth.Services.Dtos;
/// <summary>
/// Dto containing an access and refresh token for authentication.
/// </summary>
public class AccessTokensDto
{
/// <summary>
/// Access token.
/// </summary>
public string AccessToken { get; set; }
/// <summary>
/// Refresh token.
/// </summary>
public string RefreshToken { get; set; }
/// <summary>
/// When the access token expires.
/// </summary>
public DateTime AccessTokenExpires { get; set; }
/// <summary>
/// When the refresh token expires.
/// </summary>
public DateTime RefreshTokenExpires { get; set; }
/// <summary>
/// When the access token expires (UTC ticks).
/// </summary>
public long AccessTokenExpiresTicks { get; set; }
/// <summary>
/// When the refresh token expires (UTC ticks).
/// </summary>
public long RefreshTokenExpiresTicks { get; set; }
}

View file

@ -0,0 +1,20 @@
using System.Net;
using MyCSharp.HttpUserAgentParser;
namespace Galaeth.Services.Dtos;
/// <summary>
/// A base for all authentication-related request dtos.
/// </summary>
public class AuthRequestBaseDto
{
/// <summary>
/// Agent's ip address.
/// </summary>
public IPAddress IpAddress { get; set; }
/// <summary>
/// Agent's user agent information.
/// </summary>
public HttpUserAgentInformation UserAgent { get; set; }
}

View file

@ -0,0 +1,20 @@
using System.Net;
using MyCSharp.HttpUserAgentParser;
namespace Galaeth.Services.Dtos;
/// <summary>
/// Authentication (login) request dto.
/// </summary>
public class AuthenticateUserDto : AuthRequestBaseDto
{
/// <summary>
/// Agent's username.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Agent's password.
/// </summary>
public string Password { get; set; }
}

View file

@ -0,0 +1,17 @@
namespace Galaeth.Services.Dtos;
/// <summary>
/// A change of password request.
/// </summary>
public class ChangePasswordDto : AuthRequestBaseDto
{
/// <summary>
/// The user's old password.
/// </summary>
public string OldPassword { get; set; }
/// <summary>
/// The user's new password.
/// </summary>
public string NewPassword { get; set; }
}

View file

@ -0,0 +1,34 @@
using System.Net;
namespace Galaeth.Services.Dtos;
/// <summary>
/// Perform the creation of a new user.
/// </summary>
public class CreateUserDto
{
/// <summary>
/// New user's username. Must be unique.
/// </summary>
public string Username { get; set; }
/// <summary>
/// New user's email address. Must be unique.
/// </summary>
public string Email { get; set; }
/// <summary>
/// New user's password. Must comply with password policy.
/// </summary>
public string Password { get; set; }
/// <summary>
/// New user's IP address upon registration.
/// </summary>
public IPAddress IpAddress { get; set; }
/// <summary>
/// If activation should be performed immediately.
/// </summary>
public bool ActivateImmediately { get; set; }
}

View file

@ -0,0 +1,15 @@
using System.Net;
using MyCSharp.HttpUserAgentParser;
namespace Galaeth.Services.Dtos;
/// <summary>
/// Request the authentication of a user with a refresh token.
/// </summary>
public class RefreshAuthenticationDto : AuthRequestBaseDto
{
/// <summary>
/// The refresh token being used to re-authenticate a session.
/// </summary>
public string RefreshToken { get; set; }
}

View file

@ -0,0 +1,22 @@
using System.Net;
using Galaeth.Core.Exceptions;
using Galaeth.Services.Constants;
namespace Galaeth.Services.Exceptions;
/// <summary>
/// Thrown when an attempt to create a user with an email address already registered to an account.
/// </summary>
public class EmailAlreadyRegisteredException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="EmailAlreadyRegisteredException"/> class.
/// </summary>
public EmailAlreadyRegisteredException()
: base(
ServiceErrorCodes.EmailAlreadyRegistered,
"This email address is already registered to an account",
HttpStatusCode.Conflict)
{
}
}

View file

@ -0,0 +1,19 @@
using System.Net;
using Galaeth.Core.Exceptions;
using Galaeth.Services.Constants;
namespace Galaeth.Services.Exceptions;
/// <summary>
/// Thrown if username/password is incorrect.
/// </summary>
public class InvalidCredentialsException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="InvalidCredentialsException"/> class.
/// </summary>
public InvalidCredentialsException()
: base(ServiceErrorCodes.InvalidCredentials, "The credentials provided were incorrect", HttpStatusCode.Unauthorized)
{
}
}

View file

@ -0,0 +1,19 @@
using System.Net;
using Galaeth.Core.Constants;
using Galaeth.Core.Exceptions;
namespace Galaeth.Services.Exceptions;
/// <summary>
/// Thrown when an attempt to perform an unauthorised action occurs.
/// </summary>
public class UnauthorizedException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UnauthorizedException"/> class.
/// </summary>
public UnauthorizedException()
: base(CoreErrorCodes.Unauthorized, $"You are not authorized to do that", HttpStatusCode.Unauthorized)
{
}
}

View file

@ -0,0 +1,18 @@
using Galaeth.Core.Exceptions;
using Galaeth.Services.Constants;
namespace Galaeth.Services.Exceptions;
/// <summary>
/// Thrown when the logging in user is suspended.
/// </summary>
public class UserBannedException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UserBannedException"/> class.
/// </summary>
public UserBannedException()
: base(ServiceErrorCodes.BannedUser, "You are banned from logging in", System.Net.HttpStatusCode.Unauthorized)
{
}
}

View file

@ -0,0 +1,18 @@
using Galaeth.Core.Exceptions;
using Galaeth.Services.Constants;
namespace Galaeth.Services.Exceptions;
/// <summary>
/// Thrown when the logging in user is suspended.
/// </summary>
public class UserNotActivatedException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UserNotActivatedException"/> class.
/// </summary>
public UserNotActivatedException()
: base(ServiceErrorCodes.SuspendedUser, "Your account is pending activation. Please check your emails", System.Net.HttpStatusCode.Unauthorized)
{
}
}

View file

@ -0,0 +1,18 @@
using Galaeth.Core.Exceptions;
using Galaeth.Services.Constants;
namespace Galaeth.Services.Exceptions;
/// <summary>
/// Thrown when the logging in user is suspended.
/// </summary>
public class UserSuspendedException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UserSuspendedException"/> class.
/// </summary>
public UserSuspendedException()
: base(ServiceErrorCodes.SuspendedUser, "You are suspended from logging in", System.Net.HttpStatusCode.Unauthorized)
{
}
}

View file

@ -0,0 +1,20 @@
using System.Net;
using Galaeth.Core.Exceptions;
using Galaeth.Services.Constants;
namespace Galaeth.Services.Exceptions;
/// <summary>
/// Thrown when there is an attempt to create a user with an already taken username.
/// </summary>
public class UsernameTakenException : ServiceException
{
/// <summary>
/// Initializes a new instance of the <see cref="UsernameTakenException"/> class.
/// </summary>
/// <param name="username">The username attempted.</param>
public UsernameTakenException(string username)
: base(ServiceErrorCodes.UsernameTaken, $"Username {username} has already been taken", HttpStatusCode.Conflict)
{
}
}

View file

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<CodeAnalysisRuleSet>../Galaeth.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Galaeth.Core\Galaeth.Core.csproj" />
<ProjectReference Include="..\Galaeth.DAL\Galaeth.DAL.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,30 @@
using Galaeth.Services.Dtos;
namespace Galaeth.Services.Interfaces;
/// <summary>
/// Provide methods to authenticate new sessions.
/// </summary>
public interface IAuthenticationService
{
/// <summary>
/// Authenticate a user.
/// </summary>
/// <param name="request">Instance of <see cref="AuthenticateUserDto"/>.</param>
/// <returns>Instance of <see cref="AccessTokensDto"/>.</returns>
Task<AccessTokensDto> AuthenticateUser(AuthenticateUserDto request);
/// <summary>
/// Authenticate a user with a refresh token.
/// </summary>
/// <param name="request">Instance of <see cref="RefreshAuthenticationDto"/>.</param>
/// <returns>Instance of <see cref="AccessTokensDto"/>.</returns>
Task<AccessTokensDto> AuthenticateUser(RefreshAuthenticationDto request);
/// <summary>
/// Change user (requesting)'s password. Old password must be provided.
/// This action will remove all refresh tokens and generate a new token pair.
/// </summary>
/// <param name="request">Instance of <see cref="ChangePasswordDto"/>.</param>
Task<AccessTokensDto> ChangeUserPassword(ChangePasswordDto request);
}

View file

@ -0,0 +1,47 @@
namespace Galaeth.Services.Interfaces;
/// <summary>
/// Cryptographic operations.
/// </summary>
public interface ICryptographyService
{
/// <summary>
/// Generate a random salt.
/// </summary>
/// <param name="size">Size of the salt to generate - if omitted, will use PwdHashSettings:HashSize config.</param>
/// <returns>Byte array containing the generated salt.</returns>
byte[] GenerateSalt(int? size = null);
/// <summary>
/// Hash a password string with the accompanying salt.
/// </summary>
/// <param name="password">The password to hash.</param>
/// <param name="salt">The salt to use.</param>
/// <returns>A byte array of PwdHashSettings:HashSize config size of the password.</returns>
byte[] HashPassword(string password, byte[] salt);
/// <summary>
/// Verify that the given password matches the password hash and salt.
/// </summary>
/// <param name="password">The password to test.</param>
/// <param name="salt">The password's accompanying salt.</param>
/// <param name="passwordHash">The password hash to test again.</param>
/// <returns>True if verified, false otherwise.</returns>
bool VerifyPassword(string password, byte[] salt, byte[] passwordHash);
/// <summary>
/// Verify that the given password matches the password hash and salt.
/// </summary>
/// <param name="password">The password to test.</param>
/// <param name="salt">The password's base64 encoded accompanying salt.</param>
/// <param name="passwordHash">The password base64 hash to test again.</param>
/// <returns>True if verified, false otherwise.</returns>
bool VerifyPassword(string password, string salt, string passwordHash);
/// <summary>
/// Generate a cryptographically random string.
/// </summary>
/// <param name="length">Length of string to create.</param>
/// <returns>A random string.</returns>
string GenerateRandomString(int length);
}

View file

@ -0,0 +1,19 @@
namespace Galaeth.Services.Interfaces;
/// <summary>
/// A service providing a blacklist of email domains to prevent abuse.
/// </summary>
public interface IEmailDomainBlacklistService
{
/// <summary>
/// Update the blacklist.
/// </summary>
Task UpdateBlacklist();
/// <summary>
/// Check the email against the blacklist.
/// </summary>
/// <param name="emailAddress">The email address to test.</param>
/// <returns>True if the email address' domain is in the blacklist.</returns>
Task<bool> CheckBlacklist(string emailAddress);
}

View file

@ -0,0 +1,13 @@
namespace Galaeth.Services.Interfaces;
/// <summary>
/// Identify who the requesting user is.
/// </summary>
public interface IIdentityProvider
{
/// <summary>
/// Get the user id of the requester.
/// </summary>
/// <returns>String representing the user id, or null if none.</returns>
string GetRequestingUserId();
}

View file

@ -0,0 +1,12 @@
namespace Galaeth.Services.Interfaces;
/// <summary>
/// Service responsible for creating the initial user if no other users exist.
/// </summary>
public interface IInitialUserService
{
/// <summary>
/// If an initial user is required, then create it.
/// </summary>
Task CreateFirstUserIfRequiredAsync();
}

View file

@ -0,0 +1,24 @@
using Galaeth.Core.Dtos;
using Galaeth.Services.Dtos;
namespace Galaeth.Services.Interfaces;
/// <summary>
/// User Service.
/// </summary>
public interface IUserService
{
/// <summary>
/// Create a new user and return its user dto.
/// </summary>
/// <param name="createUser">Instance of <see cref="CreateUserDto"/>.</param>
/// <returns>Instance of <see cref="UserDto"/>.</returns>
Task<UserDto> CreateNewUser(CreateUserDto createUser);
/// <summary>
/// Fetch a user based on their user id.
/// </summary>
/// <param name="userId">User id.</param>
/// <returns>Instance of <see cref="UserDto"/>, or null if not found.</returns>
Task<UserDto> FindById(string userId);
}

Some files were not shown because too many files have changed in this diff Show more