Added authenication/authorization. Refactored api startup.
Some checks failed
ci / build-test (push) Has been cancelled
ci / publish-image (push) Has been cancelled

This commit is contained in:
2026-02-16 00:20:02 -05:00
parent a85989a337
commit 9f30ef446a
25 changed files with 685 additions and 154 deletions

View File

@@ -0,0 +1,30 @@
using JSMR.Domain.Entities;
using JSMR.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Api.Startup;
public static class DevSeeding
{
public static async Task SeedDevelopmentAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var username = "brister";
if (!await db.Users.AnyAsync(u => u.Username == username))
{
db.Users.Add(new User
{
Username = username,
PasswordHash = BCrypt.Net.BCrypt.HashPassword("password"),
Role = "Admin",
IsActive = true
});
await db.SaveChangesAsync();
Console.WriteLine("✅ Seeded development user: brister / password");
}
}
}

View File

@@ -0,0 +1,9 @@
using Serilog;
namespace JSMR.Api.Startup;
public static class HostBuilderExtensions
{
public static IHostBuilder UseAppSerilog(this IHostBuilder host)
=> host.UseSerilog();
}

View File

@@ -0,0 +1,118 @@
using JSMR.Application.DI;
using JSMR.Infrastructure.Data;
using JSMR.Infrastructure.DI;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.EntityFrameworkCore;
using Serilog;
using Serilog.Events;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JSMR.Api.Startup;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection services, IHostApplicationBuilder builder)
{
services
.AddMemoryCache()
.AddApplication()
.AddInfrastructure();
string connectionString = builder.Configuration.GetConnectionString("AppDb")
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb");
services.AddDbContextFactory<AppDbContext>(opt =>
opt.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))
.EnableSensitiveDataLogging(false));
services.AddControllers();
return services;
}
public static IServiceCollection AddAppJson(this IServiceCollection services)
{
services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
});
return services;
}
public static IServiceCollection AddAppOpenApi(this IServiceCollection services)
{
services.AddOpenApi();
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{
services.AddAuthorization();
services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "vw_auth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = ctx =>
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
},
OnRedirectToAccessDenied = ctx =>
{
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
}
};
});
return services;
}
public static IServiceCollection AddAppCors(this IServiceCollection services, IHostApplicationBuilder builder)
{
// Prefer config-based origins so you stop editing code for ports.
// appsettings.Development.json:
// "Cors": { "AllowedOrigins": [ "https://localhost:7112", ... ] }
string[] origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
services.AddCors(options =>
{
options.AddPolicy("ui", policyBuilder =>
policyBuilder.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials());
});
return services;
}
public static IServiceCollection AddAppLogging(this IServiceCollection services, IHostApplicationBuilder builder)
{
var config = builder.Configuration;
var env = builder.Environment;
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(config)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.WithProperty("Service", "JSMR.Api")
.Enrich.WithProperty("Environment", env.EnvironmentName)
.CreateLogger();
return services;
}
}

View File

@@ -0,0 +1,145 @@
using JSMR.Application.Circles.Queries.Search;
using JSMR.Application.Creators.Queries.Search;
using JSMR.Application.Tags.Queries.Search;
using JSMR.Application.Users;
using JSMR.Application.VoiceWorks.Queries.Search;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Serilog;
using System.Diagnostics;
using System.Security.Claims;
namespace JSMR.Api.Startup;
public static class WebApplicationExtensions
{
public static WebApplication UseAppPipeline(this WebApplication app, IHostEnvironment env)
{
app.UseCors("ui");
if (env.IsDevelopment())
app.MapOpenApi();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.UseSerilogRequestLogging(opts =>
{
opts.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
});
app.Use(async (ctx, next) =>
{
var traceId = Activity.Current?.TraceId.ToString() ?? ctx.TraceIdentifier;
using (Serilog.Context.LogContext.PushProperty("TraceId", traceId))
{
ctx.Response.Headers["x-trace-id"] = traceId;
await next();
}
});
return app;
}
public static void MapAppEndpoints(this WebApplication app)
{
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapSearchEndpoints();
app.MapAuthenticationEndpoints();
}
private static void MapSearchEndpoints(this WebApplication app)
{
app.MapPost("/api/circles/search", async (
SearchCirclesRequest request,
SearchCirclesHandler handler,
CancellationToken ct) =>
{
try
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
}
});
app.MapPost("/api/voiceworks/search", async (
SearchVoiceWorksRequest request,
SearchVoiceWorksHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
app.MapPost("/api/tags/search", async (
SearchTagsRequest request,
SearchTagsHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
app.MapPost("/api/creators/search", async (
SearchCreatorsRequest request,
SearchCreatorsHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
}
private static void MapAuthenticationEndpoints(this WebApplication app)
{
app.MapPost("/auth/login", async (LoginRequest req, IUserRepository users, HttpContext http) =>
{
var user = await users.FindByUsernameAsync(req.Username);
if (user is null || !user.IsActive)
return Results.Unauthorized();
if (!users.VerifyPassword(user, req.Password))
return Results.Unauthorized();
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.Role, user.Role ?? "User")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return Results.Ok(new { ok = true });
});
app.MapPost("/auth/logout", async (HttpContext http) =>
{
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Ok(new { ok = true });
});
app.MapGet("/api/me", (ClaimsPrincipal user) =>
{
return Results.Ok(new
{
name = user.Identity?.Name,
id = user.FindFirstValue(ClaimTypes.NameIdentifier),
role = user.FindFirstValue(ClaimTypes.Role)
});
}).RequireAuthorization();
}
public record LoginRequest(string Username, string Password);
}