From 2b756d8e0cd80d1f01a829f5fc02ee9001229330 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sun, 19 Jul 2026 00:41:53 +0200 Subject: [PATCH] feat(server): adds ASP .NET Core identity framework authentication --- src/ReverseLlama.Server/AdminEndpoints.cs | 132 +++++++++++++++++- .../Data/ApplicationDbContext.cs | 13 ++ .../Models/ApplicationUser.cs | 7 + src/ReverseLlama.Server/Program.cs | 56 +++++++- .../ReverseLlama.Server.csproj | 2 + 5 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 src/ReverseLlama.Server/Data/ApplicationDbContext.cs create mode 100644 src/ReverseLlama.Server/Models/ApplicationUser.cs diff --git a/src/ReverseLlama.Server/AdminEndpoints.cs b/src/ReverseLlama.Server/AdminEndpoints.cs index c77fb57..c4acc2d 100644 --- a/src/ReverseLlama.Server/AdminEndpoints.cs +++ b/src/ReverseLlama.Server/AdminEndpoints.cs @@ -1,10 +1,16 @@ using System.Security.Claims; using System.Text; using System.Text.Json; +using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; +using Microsoft.EntityFrameworkCore; +using ReverseLlama.Server.Data; +using ReverseLlama.Server.Models; namespace ReverseLlama.Server; @@ -28,6 +34,99 @@ internal static class AdminEndpoints [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme])) .RequireAuthorization(); } + else + { + app.MapGet("/admin/login", async (HttpContext context, SignInManager signInManager, IAntiforgery antiforgery, string? returnUrl) => + { + if (context.User.Identity?.IsAuthenticated == true) + return Results.Redirect(NormalizeLocalReturnUrl(returnUrl)); + + if (await signInManager.UserManager.Users.AnyAsync()) + { + var tokens = antiforgery.GetAndStoreTokens(context); + return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), null, tokens.RequestToken!), "text/html"); + } + + return Results.Redirect("/admin/setup"); + }).AllowAnonymous(); + + app.MapPost("/admin/login", async (HttpContext context, SignInManager signInManager, IAntiforgery antiforgery, string? returnUrl, [FromForm] string? username, [FromForm] string? password) => + { + if (await signInManager.UserManager.Users.AnyAsync() == false) + return Results.Redirect("/admin/setup"); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + var tokens = antiforgery.GetAndStoreTokens(context); + return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), "Username and password are required.", tokens.RequestToken!), "text/html"); + } + + var result = await signInManager.PasswordSignInAsync(username, password, true, true); + if (result.Succeeded) + return Results.Redirect(NormalizeLocalReturnUrl(returnUrl)); + + if (result.IsLockedOut) + { + var tokens = antiforgery.GetAndStoreTokens(context); + return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), "Account is locked out.", tokens.RequestToken!), "text/html"); + } + + { + var tokens = antiforgery.GetAndStoreTokens(context); + return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), "Invalid username or password.", tokens.RequestToken!), "text/html"); + } + }).AllowAnonymous(); + + app.MapGet("/admin/setup", async (HttpContext context, SignInManager signInManager, IAntiforgery antiforgery) => + { + if (context.User.Identity?.IsAuthenticated == true) + return Results.Redirect("/admin"); + + if (await signInManager.UserManager.Users.AnyAsync()) + return Results.Redirect("/admin/login"); + + var tokens = antiforgery.GetAndStoreTokens(context); + return Results.Content(SetupPage(null, tokens.RequestToken!), "text/html"); + }).AllowAnonymous(); + + app.MapPost("/admin/setup", async (HttpContext context, SignInManager signInManager, IAntiforgery antiforgery, [FromForm] string? username, [FromForm] string? email, [FromForm] string? password, [FromForm] string? confirmPassword) => + { + if (await signInManager.UserManager.Users.AnyAsync()) + return Results.Redirect("/admin/login"); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + var tokens = antiforgery.GetAndStoreTokens(context); + return Results.Content(SetupPage("Username and password are required.", tokens.RequestToken!), "text/html"); + } + + if (password != confirmPassword) + { + var tokens = antiforgery.GetAndStoreTokens(context); + return Results.Content(SetupPage("Passwords do not match.", tokens.RequestToken!), "text/html"); + } + + var user = new ApplicationUser { UserName = username, Email = email }; + var result = await signInManager.UserManager.CreateAsync(user, password); + if (result.Succeeded) + { + await signInManager.SignInAsync(user, true); + return Results.Redirect("/admin"); + } + + var errors = string.Join(" ", result.Errors.Select(e => e.Description)); + { + var tokens = antiforgery.GetAndStoreTokens(context); + return Results.Content(SetupPage(errors, tokens.RequestToken!), "text/html"); + } + }).AllowAnonymous(); + + app.MapPost("/admin/logout", async (SignInManager signInManager) => + { + await signInManager.SignOutAsync(); + return Results.Redirect("/admin/login"); + }).RequireAuthorization(); + } app.MapGet("/admin/auth-error", () => Results.Text( @@ -43,11 +142,7 @@ internal static class AdminEndpoints } else { - api.AddEndpointFilter((context, next) => - new ValueTask( - Results.Json( - new { error = "No authentication configured - authentication required to use the admin API" }, - statusCode: StatusCodes.Status403Forbidden))); + api.RequireAuthorization(); } api.MapGet("/summary", (HttpContext context, TunnelHub hub, ManagementStore store) => @@ -458,8 +553,35 @@ internal static class AdminEndpoints adminHome.RequireAuthorization(); adminAssets.RequireAuthorization(); } + else + { + adminHome.RequireAuthorization(); + adminAssets.RequireAuthorization(); + } } + private static string LoginPage(string returnUrl, string? error, string? antiforgeryToken) + { + var errorHtml = string.IsNullOrEmpty(error) + ? "" + : "
" + HtmlEncode(error) + "
"; + var loginAction = "/admin/login" + (returnUrl != "/admin" ? "?returnUrl=" + Uri.EscapeDataString(returnUrl) : ""); + + return "\n\n\n\n\nReverse Llama - Login\n\n\n\n
\n

Reverse Llama Admin

\n" + errorHtml + "\n
\n\n\n\n\n\n\n\n
\n
\n\n"; + } + + private static string SetupPage(string? error, string? antiforgeryToken) + { + var errorHtml = string.IsNullOrEmpty(error) + ? "" + : "
" + HtmlEncode(error) + "
"; + + return "\n\n\n\n\nReverse Llama - Initial Setup\n\n\n\n
\n

Reverse Llama

\n

Initial Setup - Create Admin Account

\n" + errorHtml + "\n
\n\n\n\n\n\n\n\n\n\n\n
\n
\n\n\n"; + } + + private static string? HtmlEncode(string? value) => + string.IsNullOrEmpty(value) ? null : System.Net.WebUtility.HtmlEncode(value); + private static object BuildSummary( ClaimsPrincipal user, TunnelHub hub, diff --git a/src/ReverseLlama.Server/Data/ApplicationDbContext.cs b/src/ReverseLlama.Server/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..aea5c2c --- /dev/null +++ b/src/ReverseLlama.Server/Data/ApplicationDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using ReverseLlama.Server.Models; + +namespace ReverseLlama.Server.Data; + +internal sealed class ApplicationDbContext : IdentityDbContext +{ + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } +} diff --git a/src/ReverseLlama.Server/Models/ApplicationUser.cs b/src/ReverseLlama.Server/Models/ApplicationUser.cs new file mode 100644 index 0000000..ea76fb1 --- /dev/null +++ b/src/ReverseLlama.Server/Models/ApplicationUser.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity; + +namespace ReverseLlama.Server.Models; + +internal sealed class ApplicationUser : IdentityUser +{ +} diff --git a/src/ReverseLlama.Server/Program.cs b/src/ReverseLlama.Server/Program.cs index 7e330a0..8168509 100644 --- a/src/ReverseLlama.Server/Program.cs +++ b/src/ReverseLlama.Server/Program.cs @@ -4,9 +4,13 @@ using ElmahCore; using ElmahCore.Mvc; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using ReverseLlama.Protocol; using ReverseLlama.Server; +using ReverseLlama.Server.Data; +using ReverseLlama.Server.Models; var builder = WebApplication.CreateBuilder(args); var settings = ServerSettings.FromConfiguration(builder.Configuration); @@ -68,9 +72,47 @@ if (settings.Keycloak.IsConfigured) return Task.CompletedTask; } }; - }); + }); } +if (!settings.Keycloak.IsConfigured) +{ + var identityDbPath = Path.Combine(AppContext.BaseDirectory, "App_Data", "identity.sqlite"); + var identityConnectionString = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder + { + DataSource = identityDbPath, + Mode = Microsoft.Data.Sqlite.SqliteOpenMode.ReadWriteCreate + }.ToString(); + + builder.Services.AddDbContext(options => + options.UseSqlite(identityConnectionString)); + + builder.Services + .AddIdentity(options => + { + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredLength = 8; + options.User.RequireUniqueEmail = true; + options.SignIn.RequireConfirmedAccount = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + builder.Services.ConfigureApplicationCookie(options => + { + options.Cookie.Name = "ReverseLlama.Admin"; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.LoginPath = "/admin/login"; + options.LogoutPath = "/admin/logout"; + options.AccessDeniedPath = "/admin/login"; + }); +} + +builder.Services.AddAntiforgery(); builder.Services.AddAuthorization(); builder.Services.AddCors(options => @@ -123,6 +165,18 @@ if (settings.Keycloak.IsConfigured) app.UseAuthorization(); } +if (!settings.Keycloak.IsConfigured) +{ + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.EnsureCreated(); + + app.UseAuthentication(); + app.UseAuthorization(); +} + +app.UseAntiforgery(); + app.UseElmah(); app.UseCors(); diff --git a/src/ReverseLlama.Server/ReverseLlama.Server.csproj b/src/ReverseLlama.Server/ReverseLlama.Server.csproj index cac2e31..d10ecd0 100644 --- a/src/ReverseLlama.Server/ReverseLlama.Server.csproj +++ b/src/ReverseLlama.Server/ReverseLlama.Server.csproj @@ -4,7 +4,9 @@ + +