init with code from Foxnouns.NET

This commit is contained in:
sam 2024-08-04 15:57:10 +02:00
commit e658366473
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
30 changed files with 1387 additions and 0 deletions

27
Hydra.Backend/Config.cs Normal file
View file

@ -0,0 +1,27 @@
using Serilog.Events;
namespace Hydra.Backend;
public class Config
{
public string Host { get; init; } = "localhost";
public int Port { get; init; } = 3000;
public string Address => $"http://{Host}:{Port}";
public LoggingConfig Logging { get; init; } = new();
public DatabaseConfig Database { get; init; } = new();
public class LoggingConfig
{
public LogEventLevel LogEventLevel { get; init; } = LogEventLevel.Debug;
public string? SeqLogUrl { get; init; }
public bool LogQueries { get; init; } = false;
}
public class DatabaseConfig
{
public string Url { get; init; } = string.Empty;
public int? Timeout { get; init; }
public int? MaxPoolSize { get; init; }
}
}

View file

@ -0,0 +1,6 @@
namespace Hydra.Backend.Database;
public abstract class BaseModel
{
public Ulid Id { get; init; } = Ulid.NewUlid();
}

View file

@ -0,0 +1,73 @@
using System.Diagnostics.CodeAnalysis;
using EntityFramework.Exceptions.PostgreSQL;
using Hydra.Backend.Database.Models;
using Hydra.Backend.Utils;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Npgsql;
namespace Hydra.Backend.Database;
public class HydraContext : DbContext
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILoggerFactory? _loggerFactory;
public DbSet<Account> Accounts { get; set; }
public DbSet<Member> Members { get; set; }
public DbSet<Token> Tokens { get; set; }
public DbSet<Application> Applications { get; set; }
public HydraContext(Config config, ILoggerFactory? loggerFactory)
{
var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
{
Timeout = config.Database.Timeout ?? 5,
MaxPoolSize = config.Database.MaxPoolSize ?? 50,
}.ConnectionString;
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString);
dataSourceBuilder.UseNodaTime();
_dataSource = dataSourceBuilder.Build();
_loggerFactory = loggerFactory;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.ConfigureWarnings(c =>
c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)
.Ignore(CoreEventId.SaveChangesFailed))
.UseNpgsql(_dataSource, o => o.UseNodaTime())
.UseSnakeCaseNamingConvention()
.UseLoggerFactory(_loggerFactory)
.UseExceptionProcessor();
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
// ULIDs are stored as UUIDs in the database
configurationBuilder.Properties<Ulid>().HaveConversion<UlidConverter>();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Account>().HasIndex(u => u.Name).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => new { m.AccountId, m.Name }).IsUnique();
}
}
[SuppressMessage("ReSharper", "UnusedType.Global", Justification = "Used when generating migrations")]
public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory<HydraContext>
{
public HydraContext CreateDbContext(string[] args)
{
// Read the configuration file
var config = new ConfigurationBuilder()
.AddConfiguration(args)
.Build()
// Get the configuration as our config class
.Get<Config>() ?? new();
return new HydraContext(config, null);
}
}

View file

@ -0,0 +1,10 @@
namespace Hydra.Backend.Database.Models;
public class Account : BaseModel
{
public required string Name { get; set; }
public required string EmailAddress { get; set; }
public required string Password { get; set; }
public List<Member> Members { get; init; } = [];
}

View file

@ -0,0 +1,39 @@
using System.Security.Cryptography;
using Hydra.Backend.Utils;
namespace Hydra.Backend.Database.Models;
public class Application : BaseModel
{
public required string ClientId { get; init; }
public required string ClientSecret { get; init; }
public required string Name { get; init; }
public required string[] Scopes { get; init; }
public required string[] RedirectUris { get; set; }
public static Application Create(string name, string[] scopes,
string[] redirectUrls)
{
var clientId = RandomNumberGenerator.GetHexString(32, true);
var clientSecret = AuthUtils.RandomToken();
if (scopes.Except(AuthUtils.Scopes).Any())
{
throw new ArgumentException("Invalid scopes passed to Application.Create", nameof(scopes));
}
if (redirectUrls.Any(s => !AuthUtils.ValidateRedirectUri(s)))
{
throw new ArgumentException("Invalid redirect URLs passed to Application.Create", nameof(redirectUrls));
}
return new Application
{
ClientId = clientId,
ClientSecret = clientSecret,
Name = name,
Scopes = scopes,
RedirectUris = redirectUrls
};
}
}

View file

@ -0,0 +1,9 @@
namespace Hydra.Backend.Database.Models;
public class Member : BaseModel
{
public required string Name { get; set; }
public Ulid AccountId { get; init; }
public Account Account { get; init; } = null!;
}

View file

@ -0,0 +1,17 @@
using NodaTime;
namespace Hydra.Backend.Database.Models;
public class Token : BaseModel
{
public required byte[] Hash { get; init; }
public required Instant ExpiresAt { get; init; }
public required string[] Scopes { get; init; }
public bool ManuallyExpired { get; set; }
public Ulid UserId { get; init; }
public Account Account { get; init; } = null!;
public Ulid ApplicationId { get; set; }
public Application Application { get; set; } = null!;
}

View file

@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Hydra.Backend.Database;
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global",
Justification = "Referenced in HydraContext.ConfigureConventions")]
public class UlidConverter() : ValueConverter<Ulid, Guid>(
convertToProviderExpression: x => x.ToGuid(),
convertFromProviderExpression: x => new Ulid(x)
);

23
Hydra.Backend/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 ["Hydra.Backend/Hydra.Backend.csproj", "Hydra.Backend/"]
RUN dotnet restore "Hydra.Backend/Hydra.Backend.csproj"
COPY . .
WORKDIR "/src/Hydra.Backend"
RUN dotnet build "Hydra.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "Hydra.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Hydra.Backend.dll"]

View file

@ -0,0 +1,3 @@
global using ILogger = Serilog.ILogger;
global using Log = Serilog.Log;
global using NUlid;

View file

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.6"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4" />
<PackageReference Include="NUlid" Version="1.7.2" />
<PackageReference Include="Serilog" Version="4.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View file

@ -0,0 +1,29 @@
using System.Net;
namespace Hydra.Backend;
public class HydraError(string message, Exception? inner = null) : Exception(message)
{
public Exception? Inner => inner;
public class DatabaseError(string message, Exception? inner = null) : HydraError(message, inner);
public class UnknownEntityError(Type entityType, Exception? inner = null)
: DatabaseError($"Entity of type {entityType.Name} not found", inner);
}
public class ApiError(string message, HttpStatusCode? statusCode = null)
: HydraError(message)
{
public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError;
public class Unauthorized(string message) : ApiError(message, statusCode: HttpStatusCode.Unauthorized);
public class Forbidden(string message, IEnumerable<string>? scopes = null)
: ApiError(message, statusCode: HttpStatusCode.Forbidden)
{
public readonly string[] Scopes = scopes?.ToArray() ?? [];
}
public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest);
}

View file

@ -0,0 +1,66 @@
using System.Security.Cryptography;
using Hydra.Backend.Database;
using Hydra.Backend.Database.Models;
using Hydra.Backend.Utils;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Hydra.Backend.Middleware;
public class AuthenticationMiddleware(HydraContext db, IClock clock) : IMiddleware
{
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
var endpoint = ctx.GetEndpoint();
var metadata = endpoint?.Metadata.GetMetadata<AuthenticateAttribute>();
if (metadata == null)
{
await next(ctx);
return;
}
var header = ctx.Request.Headers.Authorization.ToString();
if (!AuthUtils.TryFromBase64String(header, out var rawToken))
{
await next(ctx);
return;
}
var hash = SHA512.HashData(rawToken);
var oauthToken = await db.Tokens
.Include(t => t.Application)
.Include(t => t.Account)
.FirstOrDefaultAsync(t => t.Hash == hash && t.ExpiresAt > clock.GetCurrentInstant() && !t.ManuallyExpired);
if (oauthToken == null)
{
await next(ctx);
return;
}
ctx.SetToken(oauthToken);
await next(ctx);
}
}
public static class HttpContextExtensions
{
private const string Key = "token";
public static void SetToken(this HttpContext ctx, Token token) => ctx.Items.Add(Key, token);
public static Account? GetAccount(this HttpContext ctx) => ctx.GetToken()?.Account;
public static Account GetAccountOrThrow(this HttpContext ctx) =>
ctx.GetAccount() ?? throw new ApiError.AuthenticationError("No account in HttpContext");
public static Token? GetToken(this HttpContext ctx)
{
if (ctx.Items.TryGetValue(Key, out var token))
return token as Token;
return null;
}
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthenticateAttribute : Attribute;

View file

@ -0,0 +1,34 @@
namespace Hydra.Backend.Middleware;
public class AuthorizationMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
var endpoint = ctx.GetEndpoint();
var attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
if (attribute == null)
{
await next(ctx);
return;
}
var token = ctx.GetToken();
if (token == null)
throw new ApiError.Unauthorized("This endpoint requires an authenticated user.");
if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes).Any())
throw new ApiError.Forbidden("This endpoint requires ungranted scopes.",
attribute.Scopes.Except(token.Scopes));
await next(ctx);
}
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute(params string[] scopes) : Attribute
{
public readonly bool RequireAdmin = scopes.Contains(":admin");
public readonly bool RequireModerator = scopes.Contains(":moderator");
public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).ToArray();
}

46
Hydra.Backend/Program.cs Normal file
View file

@ -0,0 +1,46 @@
using Hydra.Backend.Database;
using Hydra.Backend.Utils;
using Newtonsoft.Json.Serialization;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
var config = builder.AddConfiguration(args);
builder.AddSerilog();
builder.WebHost.ConfigureKestrel(opts =>
{
// Requests are limited to a maximum of 2 MB.
// No valid request body will ever come close to this limit,
// but the limit is slightly higher to prevent valid requests from being rejected.
opts.Limits.MaxRequestBodySize = 2 * 1024 * 1024;
});
builder.Services
.AddDbContext<HydraContext>()
.AddCustomMiddleware()
.AddEndpointsApiExplorer()
.AddSwaggerGen()
.AddControllers()
.AddNewtonsoftJson(options =>
{
options.SerializerSettings.ContractResolver = new PatchRequestContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy()
};
});
var app = builder.Build();
app.UseSerilogRequestLogging();
app.UseRouting();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors();
app.UseCustomMiddleware();
app.MapControllers();
app.Urls.Clear();
app.Urls.Add(config.Address);
app.Run();
Log.CloseAndFlush();

View file

@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:32246",
"sslPort": 44353
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5209",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7285;http://localhost:5209",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,51 @@
using System.Security.Cryptography;
using Hydra.Backend.Database.Models;
namespace Hydra.Backend.Utils;
public static class AuthUtils
{
public const string ClientCredentials = "client_credentials";
public const string AuthorizationCode = "authorization_code";
private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"];
// TODO: add actual scopes
public static readonly string[] Scopes = ["*"];
public static bool ValidateScopes(Application application, string[] scopes)
{
return !scopes.Except(application.Scopes).Any();
}
public static bool ValidateRedirectUri(string uri)
{
try
{
var scheme = new Uri(uri).Scheme;
return !ForbiddenSchemes.Contains(scheme);
}
catch
{
return false;
}
}
public static bool TryFromBase64String(string b64, out byte[] bytes)
{
try
{
bytes = Convert.FromBase64String(b64);
return true;
}
catch
{
bytes = [];
return false;
}
}
public static string RandomToken(int bytes = 48) =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('=');
public const int MaxAuthMethodsPerType = 3; // Maximum of 3 Discord accounts, 3 emails, etc
}

View file

@ -0,0 +1,35 @@
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Hydra.Backend.Utils;
/// <summary>
/// A base class used for PATCH requests which stores information on whether a key is explicitly set to null or not passed at all.
/// </summary>
public abstract class PatchRequest
{
private readonly HashSet<string> _properties = [];
public bool HasProperty(string propertyName) => _properties.Contains(propertyName);
public void SetHasProperty(string propertyName) => _properties.Add(propertyName);
}
/// <summary>
/// A custom contract resolver to reduce the boilerplate needed to use <see cref="PatchRequest" />.
/// Based on this StackOverflow answer: https://stackoverflow.com/a/58748036
/// </summary>
public class PatchRequestContractResolver : DefaultContractResolver
{
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
var prop = base.CreateProperty(member, memberSerialization);
prop.SetIsSpecified += (o, _) =>
{
if (o is not PatchRequest patchRequest) return;
patchRequest.SetHasProperty(prop.UnderlyingName!);
};
return prop;
}
}

View file

@ -0,0 +1,73 @@
using Hydra.Backend.Middleware;
using Serilog;
using Serilog.Events;
namespace Hydra.Backend.Utils;
public static class StartupExtensions
{
/// <summary>
/// Adds Serilog to this service collection. This method also initializes Serilog, so it should be called as early as possible, before any log calls.
/// </summary>
public static WebApplicationBuilder AddSerilog(this WebApplicationBuilder builder)
{
var config = builder.Configuration.Get<Config>() ?? new();
var logCfg = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.WithEnvironmentName()
.Enrich.WithMachineName()
.Enrich.WithProcessId()
.Enrich.WithProcessName()
.MinimumLevel.Is(config.Logging.LogEventLevel)
// ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead.
// Serilog doesn't disable the built-in logs, so we do it here.
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command",
config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal)
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
.MinimumLevel.Override("Hangfire", LogEventLevel.Information)
.WriteTo.Console();
if (config.Logging.SeqLogUrl != null)
{
logCfg.WriteTo.Seq(config.Logging.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose);
}
// AddSerilog doesn't seem to add an ILogger to the service collection, so add that manually.
builder.Services.AddSerilog().AddSingleton(Log.Logger = logCfg.CreateLogger());
return builder;
}
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
.AddScoped<AuthenticationMiddleware>()
.AddScoped<AuthorizationMiddleware>();
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => app
.UseMiddleware<AuthenticationMiddleware>()
.UseMiddleware<AuthorizationMiddleware>();
public static Config AddConfiguration(this WebApplicationBuilder builder, string[] args)
{
builder.Configuration.Sources.Clear();
builder.Configuration.AddConfiguration(args);
var config = builder.Configuration.Get<Config>() ?? new();
builder.Services.AddSingleton(config);
return config;
}
public static IConfigurationBuilder AddConfiguration(this IConfigurationBuilder builder, string[] args)
{
var file = Environment.GetEnvironmentVariable("HDYRA_CONFIG_FILE") ?? "config.ini";
return builder
.SetBasePath(Directory.GetCurrentDirectory())
.AddIniFile(file, optional: false, reloadOnChange: true)
.AddEnvironmentVariables()
.AddCommandLine(args);
}
}