init with code from Foxnouns.NET
This commit is contained in:
		
						commit
						e658366473
					
				
					 30 changed files with 1387 additions and 0 deletions
				
			
		
							
								
								
									
										27
									
								
								Hydra.Backend/Config.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								Hydra.Backend/Config.cs
									
										
									
									
									
										Normal 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; }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								Hydra.Backend/Database/BaseModel.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								Hydra.Backend/Database/BaseModel.cs
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
namespace Hydra.Backend.Database;
 | 
			
		||||
 | 
			
		||||
public abstract class BaseModel
 | 
			
		||||
{
 | 
			
		||||
    public Ulid Id { get; init; } = Ulid.NewUlid();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										73
									
								
								Hydra.Backend/Database/HydraContext.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								Hydra.Backend/Database/HydraContext.cs
									
										
									
									
									
										Normal 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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								Hydra.Backend/Database/Models/Account.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								Hydra.Backend/Database/Models/Account.cs
									
										
									
									
									
										Normal 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; } = [];
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										39
									
								
								Hydra.Backend/Database/Models/Application.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								Hydra.Backend/Database/Models/Application.cs
									
										
									
									
									
										Normal 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
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								Hydra.Backend/Database/Models/Member.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Hydra.Backend/Database/Models/Member.cs
									
										
									
									
									
										Normal 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!;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										17
									
								
								Hydra.Backend/Database/Models/Token.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								Hydra.Backend/Database/Models/Token.cs
									
										
									
									
									
										Normal 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!;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								Hydra.Backend/Database/UlidConverter.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								Hydra.Backend/Database/UlidConverter.cs
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										23
									
								
								Hydra.Backend/Dockerfile
									
										
									
									
									
										Normal 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"]
 | 
			
		||||
							
								
								
									
										3
									
								
								Hydra.Backend/GlobalUsing.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								Hydra.Backend/GlobalUsing.cs
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
global using ILogger = Serilog.ILogger;
 | 
			
		||||
global using Log = Serilog.Log;
 | 
			
		||||
global using NUlid;
 | 
			
		||||
							
								
								
									
										36
									
								
								Hydra.Backend/Hydra.Backend.csproj
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								Hydra.Backend/Hydra.Backend.csproj
									
										
									
									
									
										Normal 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>
 | 
			
		||||
							
								
								
									
										29
									
								
								Hydra.Backend/HydraError.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								Hydra.Backend/HydraError.cs
									
										
									
									
									
										Normal 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);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										66
									
								
								Hydra.Backend/Middleware/AuthenticationMiddleware.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								Hydra.Backend/Middleware/AuthenticationMiddleware.cs
									
										
									
									
									
										Normal 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;
 | 
			
		||||
							
								
								
									
										34
									
								
								Hydra.Backend/Middleware/AuthorizationMiddleware.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								Hydra.Backend/Middleware/AuthorizationMiddleware.cs
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										46
									
								
								Hydra.Backend/Program.cs
									
										
									
									
									
										Normal 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();
 | 
			
		||||
							
								
								
									
										41
									
								
								Hydra.Backend/Properties/launchSettings.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								Hydra.Backend/Properties/launchSettings.json
									
										
									
									
									
										Normal 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"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										51
									
								
								Hydra.Backend/Utils/AuthUtils.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								Hydra.Backend/Utils/AuthUtils.cs
									
										
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										35
									
								
								Hydra.Backend/Utils/PatchRequest.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								Hydra.Backend/Utils/PatchRequest.cs
									
										
									
									
									
										Normal 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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										73
									
								
								Hydra.Backend/Utils/StartupExtensions.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								Hydra.Backend/Utils/StartupExtensions.cs
									
										
									
									
									
										Normal 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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue