Added backend functions

This commit is contained in:
2025-03-04 13:56:38 +01:00
parent a98709b0a1
commit 3a118a9b34
22 changed files with 578 additions and 5 deletions

View File

@@ -0,0 +1,60 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using WorkTime.Shared.Services;
namespace WorkTime.Api;
public class AuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IAuthService authService)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) {
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() {
if (! await authService.IsAuthenticated()) {
return AuthenticateResult.Fail("Invalid or missing Guid.");
}
var guid = await authService.GetCurrentUserId();
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, guid.ToString()) };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
public static Task ConfigureOpenApi(OpenApiDocument document, OpenApiDocumentTransformerContext transformerContext, CancellationToken token) {
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, OpenApiSecurityScheme>();
document.SecurityRequirements ??= new List<OpenApiSecurityRequirement>();
document.Components.SecuritySchemes.Add(nameof(AuthHandler), new OpenApiSecurityScheme {
Type = SecuritySchemeType.Http,
In = ParameterLocation.Header,
Name = IAuthService.HeaderName,
Scheme = "Bearer",
Description = "GUID Authorization header using a custom scheme.\nExample: \"Authorization: {GUID}\""
});
//TODO: only add security requirement to authorized endpoints
document.SecurityRequirements.Add(new() {
{
new() {
Reference = new OpenApiReference {
Type = ReferenceType.SecurityScheme,
Id = nameof(AuthHandler)
}
}, []
}
});
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Mvc;
namespace WorkTime.Api.Controllers;
[ApiController, Route("auth")]
public class AuthController : ControllerBase {
[HttpGet("register")]
public ActionResult<Guid> Register() {
return Ok(Guid.NewGuid().ToString());
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using WorkTime.Api.Logic;
using WorkTime.Shared.Models;
namespace WorkTime.Api.Controllers;
[ApiController, Route("entries"), Authorize]
public class EntryController(EntryLogic logic) : ControllerBase {
[HttpGet("{id:guid}")]
public async Task<IResult> GetAllEntries(Guid id) {
var result = await logic.GetAllEntries(id);
return result.MapResult();
}
[HttpGet("{id:guid}/{day:datetime}")]
public async Task<IResult> GetEntries(Guid id, DateTime day) {
var result = await logic.GetEntries(id, DateOnly.FromDateTime(day));
return result.MapResult();
}
[HttpPost]
public async Task<IResult> AddEntry(TimeEntry entry) {
var result = await logic.AddEntry(entry);
return result.MapResult();
}
[HttpDelete("{id:int}")]
public async Task<IResult> DeleteEntry(int id) {
var result = await logic.DeleteEntry(id);
return result.MapResult();
}
}

View File

@@ -0,0 +1,10 @@
using Microsoft.EntityFrameworkCore;
using WorkTime.Shared.Models;
namespace WorkTime.Api;
public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options) {
public DbSet<TimeEntry> Entries { get; set; }
}

View File

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

View File

@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Http.HttpResults;
using WorkTime.ServiceDefaults.Results;
using WorkTime.Shared.Models;
using WorkTime.Shared.Repositories;
using WorkTime.Shared.Services;
namespace WorkTime.Api.Logic;
public class EntryLogic(IEntryRepository entryRepository, IAuthService authService) {
public async Task<LogicResult<TimeEntry[]>> GetAllEntries(Guid owner) {
if (!await authService.IsAuthenticated())
return new(Results.Unauthorized());
if (await authService.GetCurrentUserId() != owner)
return new(Results.Unauthorized());
return await entryRepository.GetAllEntries(owner);
}
public async Task<LogicResult<TimeEntry[]>> GetEntries(Guid owner, DateOnly date) {
if (!await authService.IsAuthenticated())
return new(Results.Unauthorized());
if (await authService.GetCurrentUserId() != owner)
return new(Results.Unauthorized());
return await entryRepository.GetEntries(owner, date);
}
public async Task<LogicResult> AddEntry(TimeEntry entry) {
if (!await authService.IsAuthenticated())
return new(Results.Unauthorized());
if (await authService.GetCurrentUserId() != entry.Owner)
return new(Results.Unauthorized());
await entryRepository.AddEntry(entry);
return new();
}
public async Task<LogicResult> DeleteEntry(int id) {
if (!await authService.IsAuthenticated())
return new(Results.Unauthorized());
var entry = await entryRepository.GetEntry(id);
if (entry is null)
return new(Results.NotFound());
if (await authService.GetCurrentUserId() != entry.Owner)
return new(Results.Unauthorized());
await entryRepository.DeleteEntry(entry);
return new();
}
}

View File

@@ -0,0 +1,56 @@
using MartinCostello.OpenApi;
using Microsoft.AspNetCore.Authentication;
using WorkTime.Api;
using WorkTime.Api.Logic;
using WorkTime.Api.Repositories;
using WorkTime.Api.Services;
using WorkTime.Shared.Repositories;
using WorkTime.Shared.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi(options => {
options.AddDocumentTransformer(AuthHandler.ConfigureOpenApi);
});
builder.Services.AddOpenApiExtensions(options => {
options.AddServerUrls = true;
});
builder.AddServiceDefaults();
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHttpContextAccessor();
builder.Services.AddNpgsql<DatabaseContext>(builder.Configuration.GetConnectionString("data"));
builder.Services.AddScoped<IEntryRepository, EntryRepository>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<EntryLogic>();
builder.Services.AddAuthentication(nameof(AuthHandler))
.AddScheme<AuthenticationSchemeOptions, AuthHandler>(nameof(AuthHandler), _ => { });
builder.Services.AddAuthorization();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) {
app.MapOpenApi();
app.UseSwaggerUI(options => {
options.SwaggerEndpoint("/openapi/v1.json", "WorkTime.Api");
});
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
db.Database.EnsureCreated();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5212",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7091;http://localhost:5212",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using WorkTime.Shared.Models;
using WorkTime.Shared.Repositories;
namespace WorkTime.Api.Repositories;
internal class EntryRepository(DatabaseContext context) : IEntryRepository {
public async Task<TimeEntry[]> GetAllEntries(Guid owner) {
return await context.Entries
.Where(entry => entry.Owner == owner)
.OrderBy(entry => entry.Timestamp)
.ToArrayAsync();
}
public async Task<TimeEntry[]> GetEntries(Guid owner, DateOnly date) {
return await context.Entries
.Where(entry => entry.Owner == owner)
.Where(entry => DateOnly.FromDateTime(entry.Timestamp) == date)
.OrderBy(entry => entry.Timestamp)
.ToArrayAsync();
}
public async Task<TimeEntry?> GetEntry(int id) {
return await context.Entries
.FindAsync(id);
}
public async Task AddEntry(TimeEntry entry) {
await context.Entries.AddAsync(entry);
await context.SaveChangesAsync();
}
public async Task DeleteEntry(TimeEntry entry) {
context.Entries.Remove(entry);
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,29 @@
using WorkTime.Shared.Services;
namespace WorkTime.Api.Services;
internal class AuthService(IHttpContextAccessor accessor) : IAuthService {
public Task<bool> IsAuthenticated() {
var header = accessor.HttpContext?.Request.Headers[IAuthService.HeaderName];
if (header is not { Count: 1 })
return Task.FromResult(false);
var value = header.Value[0]!.Replace("Bearer ", "");
if (Guid.TryParse(value, out var guid))
return Task.FromResult(false);
return Task.FromResult(guid != Guid.Empty);
}
public async Task<Guid> GetCurrentUserId() {
if (!await IsAuthenticated())
return Guid.Empty;
var header = accessor.HttpContext?.Request.Headers[IAuthService.HeaderName]!;
var value = header.Value[0]!.Replace("Bearer ", "");
return Guid.Parse(value);
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.1.0" />
<PackageReference Include="MartinCostello.OpenApi.Extensions" Version="1.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="7.3.1" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WorkTime.ServiceDefaults\WorkTime.ServiceDefaults.csproj" />
<ProjectReference Include="..\WorkTime.Shared\WorkTime.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}