Move all CRUD create/edit forms from inline on list pages to dedicated form pages with back-button navigation and post-save redirect. Add Playwright Docker container (browser server on port 3000) with 25 passing E2E tests covering login, navigation, and site CRUD workflows. Add POST /auth/token endpoint for clean JWT retrieval.
140 lines
5.7 KiB
C#
140 lines
5.7 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ScadaLink.Security;
|
|
|
|
namespace ScadaLink.CentralUI.Auth;
|
|
|
|
/// <summary>
|
|
/// Minimal API endpoints for login/logout. These run outside Blazor Server (standard HTTP POST).
|
|
/// On success, signs in via ASP.NET Core cookie authentication and redirects to dashboard.
|
|
/// </summary>
|
|
public static class AuthEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
|
|
{
|
|
endpoints.MapPost("/auth/login", async (HttpContext context) =>
|
|
{
|
|
var form = await context.Request.ReadFormAsync();
|
|
var username = form["username"].ToString();
|
|
var password = form["password"].ToString();
|
|
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
|
{
|
|
context.Response.Redirect("/login?error=Username+and+password+are+required.");
|
|
return;
|
|
}
|
|
|
|
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
|
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
|
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
|
|
|
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
|
if (!authResult.Success)
|
|
{
|
|
var errorMsg = Uri.EscapeDataString(authResult.ErrorMessage ?? "Authentication failed.");
|
|
context.Response.Redirect($"/login?error={errorMsg}");
|
|
return;
|
|
}
|
|
|
|
// Map LDAP groups to roles
|
|
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
|
|
|
|
// Build claims from LDAP auth + role mapping
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.Name, authResult.Username ?? username),
|
|
new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username),
|
|
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username),
|
|
};
|
|
|
|
foreach (var role in roleMappingResult.Roles)
|
|
{
|
|
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
|
|
}
|
|
|
|
if (!roleMappingResult.IsSystemWideDeployment)
|
|
{
|
|
foreach (var siteId in roleMappingResult.PermittedSiteIds)
|
|
{
|
|
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
|
|
}
|
|
}
|
|
|
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
var principal = new ClaimsPrincipal(identity);
|
|
|
|
await context.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
principal,
|
|
new AuthenticationProperties
|
|
{
|
|
IsPersistent = true,
|
|
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(30)
|
|
});
|
|
|
|
context.Response.Redirect("/");
|
|
}).DisableAntiforgery();
|
|
|
|
endpoints.MapPost("/auth/token", async (HttpContext context) =>
|
|
{
|
|
var form = await context.Request.ReadFormAsync();
|
|
var username = form["username"].ToString();
|
|
var password = form["password"].ToString();
|
|
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
|
{
|
|
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
|
|
}
|
|
|
|
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
|
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
|
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
|
|
|
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
|
if (!authResult.Success)
|
|
{
|
|
return Results.Json(
|
|
new { error = authResult.ErrorMessage ?? "Authentication failed." },
|
|
statusCode: 401);
|
|
}
|
|
|
|
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
|
|
|
|
var token = jwtService.GenerateToken(
|
|
authResult.DisplayName ?? username,
|
|
authResult.Username ?? username,
|
|
roleMappingResult.Roles,
|
|
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds);
|
|
|
|
return Results.Json(new
|
|
{
|
|
access_token = token,
|
|
token_type = "Bearer",
|
|
username = authResult.Username ?? username,
|
|
display_name = authResult.DisplayName ?? username,
|
|
roles = roleMappingResult.Roles,
|
|
});
|
|
}).DisableAntiforgery();
|
|
|
|
endpoints.MapPost("/auth/logout", async (HttpContext context) =>
|
|
{
|
|
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
context.Response.Redirect("/login");
|
|
}).DisableAntiforgery();
|
|
|
|
// GET /logout — allows direct navigation to logout (redirects to login after sign-out)
|
|
endpoints.MapGet("/logout", async (HttpContext context) =>
|
|
{
|
|
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
return Results.Redirect("/login");
|
|
});
|
|
|
|
return endpoints;
|
|
}
|
|
}
|