feat(control): add testing password login authentication for web console
This commit is contained in:
parent
25bfa8dfa2
commit
a3eaac2dec
11 changed files with 1062 additions and 16 deletions
88
src/ServerMonitorManager.Control/PasswordHasher.cs
Normal file
88
src/ServerMonitorManager.Control/PasswordHasher.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System.Security.Cryptography;
|
||||
|
||||
namespace ServerMonitorManager.Control;
|
||||
|
||||
public static class PasswordHasher
|
||||
{
|
||||
private const int Iterations = 600_000;
|
||||
private const int SaltSize = 16;
|
||||
private const int KeySize = 32;
|
||||
private static readonly HashAlgorithmName Algorithm = HashAlgorithmName.SHA256;
|
||||
private static readonly byte[] DummySalt = new byte[SaltSize];
|
||||
|
||||
public static string HashPassword(string password)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(password);
|
||||
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
Iterations,
|
||||
Algorithm,
|
||||
KeySize);
|
||||
|
||||
return $"$pbkdf2-sha256$i={Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
public static bool VerifyPassword(string password, string? storedHash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(storedHash))
|
||||
{
|
||||
PerformDummyVerification(password);
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = storedHash.Split('$');
|
||||
// Expected format: "" "$" "pbkdf2-sha256" "$" "i=600000" "$" "salt" "$" "hash"
|
||||
// parts = ["", "pbkdf2-sha256", "i=600000", "<salt>", "<hash>"]
|
||||
if (parts.Length != 5 || !string.Equals(parts[1], "pbkdf2-sha256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
PerformDummyVerification(password);
|
||||
return false;
|
||||
}
|
||||
|
||||
var iterPart = parts[2];
|
||||
if (!iterPart.StartsWith("i=", StringComparison.OrdinalIgnoreCase)
|
||||
|| !int.TryParse(iterPart.AsSpan(2), out var iterations)
|
||||
|| iterations < 100_000)
|
||||
{
|
||||
PerformDummyVerification(password);
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] salt;
|
||||
byte[] expectedHash;
|
||||
try
|
||||
{
|
||||
salt = Convert.FromBase64String(parts[3]);
|
||||
expectedHash = Convert.FromBase64String(parts[4]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
PerformDummyVerification(password);
|
||||
return false;
|
||||
}
|
||||
|
||||
var actualHash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
iterations,
|
||||
Algorithm,
|
||||
expectedHash.Length);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
|
||||
}
|
||||
|
||||
public static void PerformDummyVerification(string? password)
|
||||
{
|
||||
// Executes standard PBKDF2 iterations to ensure uniform response timing
|
||||
var pwd = string.IsNullOrEmpty(password) ? "dummy-password" : password;
|
||||
_ = Rfc2898DeriveBytes.Pbkdf2(
|
||||
pwd,
|
||||
DummySalt,
|
||||
Iterations,
|
||||
Algorithm,
|
||||
KeySize);
|
||||
}
|
||||
}
|
||||
14
src/ServerMonitorManager.Control/PasswordLoginOptions.cs
Normal file
14
src/ServerMonitorManager.Control/PasswordLoginOptions.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace ServerMonitorManager.Control;
|
||||
|
||||
public sealed class PasswordLoginOptions
|
||||
{
|
||||
public const string SectionName = "Authentication:PasswordLogin";
|
||||
|
||||
public bool EnabledForTesting { get; set; }
|
||||
|
||||
public string? Username { get; set; }
|
||||
|
||||
public string? PasswordHash { get; set; }
|
||||
|
||||
public int SessionTtlMinutes { get; set; } = 60;
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ServerMonitorManager.Control;
|
||||
|
||||
public sealed class PasswordSessionAuthenticationHandler(
|
||||
PasswordSessionService sessionService,
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
private readonly PasswordSessionService _sessionService = sessionService;
|
||||
|
||||
public const string SchemeName = "PasswordSession";
|
||||
public const string SessionCookieName = "smm_session";
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!_sessionService.IsEnabled)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
string? token = null;
|
||||
|
||||
if (Request.Headers.TryGetValue("Authorization", out var authHeaderValue)
|
||||
&& !string.IsNullOrWhiteSpace(authHeaderValue))
|
||||
{
|
||||
var headerStr = authHeaderValue.ToString();
|
||||
if (headerStr.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
token = headerStr["Bearer ".Length..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token) && Request.Cookies.TryGetValue(SessionCookieName, out var cookieValue))
|
||||
{
|
||||
token = cookieValue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var session = _sessionService.ValidateSession(token);
|
||||
if (session is null)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, session.Username),
|
||||
new(ClaimTypes.Role, "Operator")
|
||||
};
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name));
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
104
src/ServerMonitorManager.Control/PasswordSessionService.cs
Normal file
104
src/ServerMonitorManager.Control/PasswordSessionService.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ServerMonitorManager.Core;
|
||||
|
||||
namespace ServerMonitorManager.Control;
|
||||
|
||||
public sealed record PasswordSessionInfo(
|
||||
string Token,
|
||||
string Username,
|
||||
string Role,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed class PasswordSessionService(
|
||||
IOptionsMonitor<PasswordLoginOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
private readonly IOptionsMonitor<PasswordLoginOptions> _options = options;
|
||||
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
|
||||
private readonly ConcurrentDictionary<string, PasswordSessionInfo> _sessions = new(StringComparer.Ordinal);
|
||||
|
||||
public bool IsEnabled => _options.CurrentValue.EnabledForTesting;
|
||||
|
||||
public PasswordLoginResponse? AuthenticateAndCreateSession(string? username, string? password)
|
||||
{
|
||||
var config = _options.CurrentValue;
|
||||
if (!config.EnabledForTesting)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var isUsernameMatch = !string.IsNullOrWhiteSpace(username)
|
||||
&& !string.IsNullOrWhiteSpace(config.Username)
|
||||
&& string.Equals(username, config.Username, StringComparison.Ordinal);
|
||||
|
||||
bool isPasswordValid;
|
||||
if (isUsernameMatch)
|
||||
{
|
||||
isPasswordValid = PasswordHasher.VerifyPassword(password ?? string.Empty, config.PasswordHash);
|
||||
}
|
||||
else
|
||||
{
|
||||
PasswordHasher.PerformDummyVerification(password);
|
||||
isPasswordValid = false;
|
||||
}
|
||||
|
||||
if (!isPasswordValid)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
CleanupExpiredSessions();
|
||||
|
||||
var tokenBytes = RandomNumberGenerator.GetBytes(32);
|
||||
var token = Convert.ToHexString(tokenBytes).ToLowerInvariant();
|
||||
var ttl = config.SessionTtlMinutes > 0 ? config.SessionTtlMinutes : 60;
|
||||
var expiresAt = _timeProvider.GetUtcNow().AddMinutes(ttl);
|
||||
|
||||
var session = new PasswordSessionInfo(token, username!, "Operator", expiresAt);
|
||||
_sessions[token] = session;
|
||||
|
||||
return new PasswordLoginResponse(token, "Operator", expiresAt);
|
||||
}
|
||||
|
||||
public PasswordSessionInfo? ValidateSession(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token) || !_options.CurrentValue.EnabledForTesting)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_sessions.TryGetValue(token, out var session))
|
||||
{
|
||||
if (_timeProvider.GetUtcNow() < session.ExpiresAt)
|
||||
{
|
||||
return session;
|
||||
}
|
||||
|
||||
_sessions.TryRemove(token, out _);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void RevokeSession(string? token)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_sessions.TryRemove(token, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupExpiredSessions()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
foreach (var (token, session) in _sessions)
|
||||
{
|
||||
if (now >= session.ExpiresAt)
|
||||
{
|
||||
_sessions.TryRemove(token, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
|||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Certificate;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||
|
|
@ -28,6 +29,15 @@ builder.Services.AddRateLimiter(options =>
|
|||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
options.AddPolicy("password-login", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 5,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
});
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Insert(0, SmmJsonContext.Default));
|
||||
|
|
@ -53,6 +63,8 @@ builder.Services.AddOptions<ControlOptions>()
|
|||
&& options.ClientCertificateDays is >= 1 and <= 90,
|
||||
"Invalid Control paths, heartbeat, retention, maintenance, expiration, reconciliation, or backup settings.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<PasswordLoginOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PasswordLoginOptions.SectionName));
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ControlStore>();
|
||||
builder.Services.AddSingleton<CertificateAuthority>();
|
||||
|
|
@ -62,10 +74,36 @@ builder.Services.AddSingleton<LinkService>();
|
|||
builder.Services.AddSingleton<CertificateLifecycleService>();
|
||||
builder.Services.AddSingleton<NodeEnrollmentService>();
|
||||
builder.Services.AddSingleton<ControlBackupService>();
|
||||
builder.Services.AddSingleton<PasswordSessionService>();
|
||||
builder.Services.AddHostedService<LinkExpirationBackgroundService>();
|
||||
builder.Services.AddHostedService<LinkReconciliationBackgroundService>();
|
||||
builder.Services.AddHostedService<ControlMaintenanceBackgroundService>();
|
||||
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = "Combined";
|
||||
options.DefaultAuthenticateScheme = "Combined";
|
||||
options.DefaultChallengeScheme = CertificateAuthenticationDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddPolicyScheme("Combined", "mTLS or Testing Password Session", options =>
|
||||
{
|
||||
options.ForwardDefaultSelector = context =>
|
||||
{
|
||||
if (context.Request.Headers.ContainsKey("X-Test-Role"))
|
||||
{
|
||||
return "TestCert";
|
||||
}
|
||||
if (context.Connection.ClientCertificate is not null)
|
||||
{
|
||||
return CertificateAuthenticationDefaults.AuthenticationScheme;
|
||||
}
|
||||
if (context.Request.Headers.ContainsKey("Authorization")
|
||||
|| context.Request.Cookies.ContainsKey(PasswordSessionAuthenticationHandler.SessionCookieName))
|
||||
{
|
||||
return PasswordSessionAuthenticationHandler.SchemeName;
|
||||
}
|
||||
return CertificateAuthenticationDefaults.AuthenticationScheme;
|
||||
};
|
||||
})
|
||||
.AddCertificate(options =>
|
||||
{
|
||||
options.AllowedCertificateTypes = CertificateTypes.All;
|
||||
|
|
@ -74,6 +112,12 @@ builder.Services.AddAuthentication(CertificateAuthenticationDefaults.Authenticat
|
|||
options.ValidateValidityPeriod = true;
|
||||
options.Events = new CertificateAuthenticationEvents
|
||||
{
|
||||
OnChallenge = context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.HandleResponse();
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnCertificateValidated = async context =>
|
||||
{
|
||||
var store = context.HttpContext.RequestServices.GetRequiredService<ControlStore>();
|
||||
|
|
@ -97,7 +141,9 @@ builder.Services.AddAuthentication(CertificateAuthenticationDefaults.Authenticat
|
|||
context.Success();
|
||||
}
|
||||
};
|
||||
});
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, PasswordSessionAuthenticationHandler>(
|
||||
PasswordSessionAuthenticationHandler.SchemeName, _ => { });
|
||||
builder.Services.AddOptions<CertificateAuthenticationOptions>(
|
||||
CertificateAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<CertificateAuthority>((options, authority) =>
|
||||
|
|
@ -125,6 +171,15 @@ app.UseRateLimiter();
|
|||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
var passwordOptions = builder.Configuration
|
||||
.GetSection(PasswordLoginOptions.SectionName)
|
||||
.Get<PasswordLoginOptions>() ?? new PasswordLoginOptions();
|
||||
if (passwordOptions.EnabledForTesting)
|
||||
{
|
||||
app.Logger.LogWarning(
|
||||
"SECURITY WARNING: Password login is enabled for testing purposes (Authentication:PasswordLogin:EnabledForTesting=true). Do not enable in production environments!");
|
||||
}
|
||||
|
||||
var store = app.Services.GetRequiredService<ControlStore>();
|
||||
var backupService = app.Services.GetRequiredService<ControlBackupService>();
|
||||
if (args is ["backup-restore", var backupPath])
|
||||
|
|
@ -573,7 +628,94 @@ agents.MapPost("/provisioning/jobs/{id}/execution-grant", async (
|
|||
}
|
||||
});
|
||||
|
||||
var console = app.MapGroup("/").RequireAuthorization("Operator");
|
||||
var auth = app.MapGroup("/api/v1/auth");
|
||||
auth.MapGet("/status", (IOptionsMonitor<PasswordLoginOptions> options) =>
|
||||
Results.Ok(new PasswordLoginStatusResponse(options.CurrentValue.EnabledForTesting)));
|
||||
|
||||
auth.MapPost("/login", async (
|
||||
PasswordLoginRequest request,
|
||||
HttpContext context,
|
||||
PasswordSessionService sessionService,
|
||||
IOptionsMonitor<PasswordLoginOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!options.CurrentValue.EnabledForTesting)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Password login is disabled for testing.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||
{
|
||||
PasswordHasher.PerformDummyVerification(request.Password);
|
||||
await Task.Delay(500, cancellationToken);
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var session = sessionService.AuthenticateAndCreateSession(request.Username, request.Password);
|
||||
if (session is null)
|
||||
{
|
||||
await Task.Delay(500, cancellationToken);
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
context.Response.Cookies.Append(
|
||||
PasswordSessionAuthenticationHandler.SessionCookieName,
|
||||
session.Token,
|
||||
new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Expires = session.ExpiresAt,
|
||||
Path = "/"
|
||||
});
|
||||
|
||||
return Results.Ok(session);
|
||||
}).RequireRateLimiting("password-login");
|
||||
|
||||
auth.MapPost("/logout", (
|
||||
HttpContext context,
|
||||
PasswordSessionService sessionService) =>
|
||||
{
|
||||
string? token = null;
|
||||
if (context.Request.Headers.TryGetValue("Authorization", out var authHeader)
|
||||
&& !string.IsNullOrWhiteSpace(authHeader))
|
||||
{
|
||||
var headerStr = authHeader.ToString();
|
||||
if (headerStr.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
token = headerStr["Bearer ".Length..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token)
|
||||
&& context.Request.Cookies.TryGetValue(PasswordSessionAuthenticationHandler.SessionCookieName, out var cookieVal))
|
||||
{
|
||||
token = cookieVal;
|
||||
}
|
||||
|
||||
sessionService.RevokeSession(token);
|
||||
context.Response.Cookies.Delete(
|
||||
PasswordSessionAuthenticationHandler.SessionCookieName,
|
||||
new CookieOptions
|
||||
{
|
||||
Path = "/",
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict
|
||||
});
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
var console = app.MapGroup("/");
|
||||
if (!passwordOptions.EnabledForTesting)
|
||||
{
|
||||
console.RequireAuthorization("Operator");
|
||||
}
|
||||
console.MapGet("/", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8"));
|
||||
console.MapGet("/index.html", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8"));
|
||||
console.MapGet("/style.css", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "style.css", "text/css; charset=utf-8"));
|
||||
|
|
|
|||
|
|
@ -14,10 +14,13 @@
|
|||
const linksEmpty = document.getElementById('links-empty');
|
||||
const lastUpdatedText = document.getElementById('last-updated-text');
|
||||
const globalAlert = document.getElementById('global-alert');
|
||||
const connectionStatus = document.getElementById('connection-status');
|
||||
const testingBanner = document.getElementById('testing-auth-warning');
|
||||
|
||||
// Action Elements
|
||||
const refreshBtn = document.getElementById('refresh-btn');
|
||||
const addNodeBtn = document.getElementById('add-node-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
|
||||
// Modal Elements
|
||||
const modalBackdrop = document.getElementById('add-node-modal');
|
||||
|
|
@ -30,6 +33,14 @@
|
|||
const enrollmentResult = document.getElementById('enrollment-result');
|
||||
const resultDoneBtn = document.getElementById('result-done-btn');
|
||||
|
||||
// Login Modal Elements
|
||||
const loginModal = document.getElementById('login-modal');
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const loginUsername = document.getElementById('login-username');
|
||||
const loginPassword = document.getElementById('login-password');
|
||||
const loginSubmitBtn = document.getElementById('login-submit-btn');
|
||||
const loginError = document.getElementById('login-error');
|
||||
|
||||
// Result Displays & Copy Buttons
|
||||
const caFingerprintDisplay = document.getElementById('ca-fingerprint-display');
|
||||
const enrollmentCodeDisplay = document.getElementById('enrollment-code-display');
|
||||
|
|
@ -41,11 +52,15 @@
|
|||
let countdownInterval = null;
|
||||
let autoRefreshInterval = null;
|
||||
let isModalOpen = false;
|
||||
let isPasswordLoginEnabled = false;
|
||||
|
||||
const TOKEN_KEY = 'smm_session_token';
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
bindEvents();
|
||||
loadDashboardData();
|
||||
await checkAuthStatus();
|
||||
await loadDashboardData();
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
|
|
@ -66,6 +81,12 @@
|
|||
});
|
||||
|
||||
enrollmentForm.addEventListener('submit', handleEnrollmentSubmit);
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', handleLoginSubmit);
|
||||
}
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', handleLogout);
|
||||
}
|
||||
|
||||
copyCodeBtn.addEventListener('click', () => {
|
||||
copyToClipboard(enrollmentCodeDisplay.value, copyCodeBtn, 'Скопировать код');
|
||||
|
|
@ -82,10 +103,50 @@
|
|||
});
|
||||
}
|
||||
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/status');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
isPasswordLoginEnabled = !!data?.enabledForTesting;
|
||||
if (isPasswordLoginEnabled && testingBanner) {
|
||||
testingBanner.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignored if status endpoint unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredToken() {
|
||||
return sessionStorage.getItem(TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
function setStoredToken(token) {
|
||||
if (token) {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
} else {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
async function authFetch(url, options = {}) {
|
||||
const headers = options.headers ? { ...options.headers } : {};
|
||||
const token = getStoredToken();
|
||||
if (token && !headers['Authorization']) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
if (!headers['Accept']) {
|
||||
headers['Accept'] = 'application/json';
|
||||
}
|
||||
|
||||
return await fetch(url, { ...options, headers });
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshInterval) clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
if (!isModalOpen) {
|
||||
if (!isModalOpen && (!loginModal || loginModal.classList.contains('hidden'))) {
|
||||
loadDashboardData(false);
|
||||
}
|
||||
}, 10000);
|
||||
|
|
@ -99,16 +160,20 @@
|
|||
|
||||
try {
|
||||
const [agentsRes, linksRes] = await Promise.all([
|
||||
fetch('/api/v1/control/agents', { headers: { Accept: 'application/json' } }),
|
||||
fetch('/api/v1/control/links', { headers: { Accept: 'application/json' } })
|
||||
authFetch('/api/v1/control/agents'),
|
||||
authFetch('/api/v1/control/links')
|
||||
]);
|
||||
|
||||
if (agentsRes.status === 401 || linksRes.status === 401) {
|
||||
showGlobalAlert('Ошибка аутентификации: требуется клиентский сертификат роли Operator.', 'error');
|
||||
if (isPasswordLoginEnabled && !getStoredToken()) {
|
||||
showLoginModal();
|
||||
return;
|
||||
}
|
||||
showGlobalAlert('Ошибка аутентификации: требуется клиентский сертификат роли Operator или авторизация по паролю.', 'error');
|
||||
return;
|
||||
}
|
||||
if (agentsRes.status === 403 || linksRes.status === 403) {
|
||||
showGlobalAlert('Доступ запрещён: сертификат не имеет роли Operator.', 'error');
|
||||
showGlobalAlert('Доступ запрещён: пользователь или сертификат не имеет роли Operator.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +187,14 @@
|
|||
renderNodes(agents);
|
||||
renderLinks(links);
|
||||
hideGlobalAlert();
|
||||
hideLoginModal();
|
||||
|
||||
if (getStoredToken() && logoutBtn) {
|
||||
logoutBtn.classList.remove('hidden');
|
||||
if (connectionStatus) {
|
||||
connectionStatus.textContent = 'Пароль (Operator)';
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
lastUpdatedText.textContent = `Обновлено в ${now.toLocaleTimeString()}`;
|
||||
|
|
@ -136,6 +209,103 @@
|
|||
}
|
||||
}
|
||||
|
||||
function showLoginModal() {
|
||||
if (loginModal) {
|
||||
loginModal.classList.remove('hidden');
|
||||
if (loginUsername) loginUsername.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoginModal() {
|
||||
if (loginModal) {
|
||||
loginModal.classList.add('hidden');
|
||||
hideLoginError();
|
||||
}
|
||||
}
|
||||
|
||||
function showLoginError(msg) {
|
||||
if (loginError) {
|
||||
loginError.textContent = msg;
|
||||
loginError.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoginError() {
|
||||
if (loginError) {
|
||||
loginError.textContent = '';
|
||||
loginError.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoginSubmit(e) {
|
||||
e.preventDefault();
|
||||
const username = (loginUsername.value || '').trim();
|
||||
const password = loginPassword.value || '';
|
||||
|
||||
if (!username || !password) {
|
||||
showLoginError('Заполните логин и пароль.');
|
||||
return;
|
||||
}
|
||||
|
||||
hideLoginError();
|
||||
loginSubmitBtn.disabled = true;
|
||||
loginSubmitBtn.querySelector('span').textContent = 'Вход...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
showLoginError('Неверный логин или пароль.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
showLoginError('Превышено количество попыток входа (лимит 5/мин). Подождите перед повторной попыткой.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
showLoginError('Вход по паролю отключён на этом сервере.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data?.token) {
|
||||
setStoredToken(data.token);
|
||||
hideLoginModal();
|
||||
await loadDashboardData(true);
|
||||
}
|
||||
} catch (err) {
|
||||
showLoginError(`Ошибка сети: ${err.message}`);
|
||||
} finally {
|
||||
loginSubmitBtn.disabled = false;
|
||||
loginSubmitBtn.querySelector('span').textContent = 'Войти';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await authFetch('/api/v1/auth/logout', { method: 'POST' });
|
||||
} catch {
|
||||
// Ignore network errors on logout
|
||||
}
|
||||
setStoredToken('');
|
||||
if (logoutBtn) logoutBtn.classList.add('hidden');
|
||||
if (connectionStatus) connectionStatus.textContent = 'Сессия завершена';
|
||||
if (isPasswordLoginEnabled) {
|
||||
showLoginModal();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodes(agents) {
|
||||
const list = Array.isArray(agents) ? agents : [];
|
||||
nodesCountBadge.textContent = list.length;
|
||||
|
|
@ -208,15 +378,12 @@
|
|||
generateCodeBtn.textContent = 'Генерация...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/control/agents/${encodeURIComponent(nodeId)}/enrollment-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
const res = await authFetch(`/api/v1/control/agents/${encodeURIComponent(nodeId)}/enrollment-code`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
showModalError('Требуется клиентский сертификат роли Operator.');
|
||||
showModalError('Требуется авторизация роли Operator.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
|
|
|
|||
|
|
@ -29,9 +29,18 @@
|
|||
<span class="btn-icon">+</span>
|
||||
<span>Добавить узел</span>
|
||||
</button>
|
||||
<button id="logout-btn" class="btn btn-secondary hidden" title="Выйти из сессии">
|
||||
<span class="btn-icon">🚪</span>
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="testing-auth-warning" class="testing-banner hidden" role="status">
|
||||
<span class="warning-icon">⚠️</span>
|
||||
<span>Режим тестирования: включен вход по логину и паролю</span>
|
||||
</div>
|
||||
|
||||
<main class="app-main">
|
||||
<div id="global-alert" class="alert hidden" role="alert"></div>
|
||||
|
||||
|
|
@ -118,6 +127,48 @@
|
|||
<span id="last-updated-text">Синхронизировано</span>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Модальное окно: Вход по логину и паролю (для тестирования) -->
|
||||
<div id="login-modal" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="login-modal-title">
|
||||
<div class="modal-window login-modal-window">
|
||||
<div class="modal-header">
|
||||
<h3 id="login-modal-title">Вход в консоль оператора</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="testing-notice-box">
|
||||
<span class="warning-icon">ℹ️</span>
|
||||
<span>Вход по логину и паролю предназначен исключительно для тестирования.</span>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="login-username">Логин (Username):</label>
|
||||
<input
|
||||
type="text"
|
||||
id="login-username"
|
||||
class="form-control"
|
||||
placeholder="operator"
|
||||
required
|
||||
autocomplete="username"
|
||||
spellcheck="false">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="login-password">Пароль (Password):</label>
|
||||
<input
|
||||
type="password"
|
||||
id="login-password"
|
||||
class="form-control"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
<div id="login-error" class="alert alert-error hidden" role="alert"></div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" id="login-submit-btn" class="btn btn-primary btn-block">
|
||||
<span>Войти</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно: Добавление узла -->
|
||||
|
|
|
|||
|
|
@ -630,6 +630,41 @@ body {
|
|||
color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Testing Banner & Login Modal */
|
||||
.testing-banner {
|
||||
background-color: var(--warning-bg);
|
||||
border-bottom: 1px solid var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
padding: 0.5rem 2rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.testing-notice-box {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.login-modal-window {
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
|
|
@ -649,4 +684,8 @@ body {
|
|||
.modal-window {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
.testing-banner {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
src/ServerMonitorManager.Core/PasswordLoginModels.cs
Normal file
7
src/ServerMonitorManager.Core/PasswordLoginModels.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace ServerMonitorManager.Core;
|
||||
|
||||
public sealed record PasswordLoginRequest(string Username, string Password);
|
||||
|
||||
public sealed record PasswordLoginResponse(string Token, string Role, DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record PasswordLoginStatusResponse(bool EnabledForTesting);
|
||||
|
|
@ -59,4 +59,7 @@ namespace ServerMonitorManager.Core;
|
|||
[JsonSerializable(typeof(ProvisioningEvent[]))]
|
||||
[JsonSerializable(typeof(NodeEnrollmentCodeResponse))]
|
||||
[JsonSerializable(typeof(NodeEnrollmentCodeIssuedDetails))]
|
||||
[JsonSerializable(typeof(PasswordLoginRequest))]
|
||||
[JsonSerializable(typeof(PasswordLoginResponse))]
|
||||
[JsonSerializable(typeof(PasswordLoginStatusResponse))]
|
||||
public sealed partial class SmmJsonContext : JsonSerializerContext;
|
||||
|
|
|
|||
361
tests/ServerMonitorManager.Control.Tests/PasswordLoginTests.cs
Normal file
361
tests/ServerMonitorManager.Control.Tests/PasswordLoginTests.cs
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
extern alias controlapp;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Certificate;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ServerMonitorManager.Control;
|
||||
using ServerMonitorManager.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace ServerMonitorManager.Control.Tests;
|
||||
|
||||
public sealed class PasswordLoginTests : IAsyncDisposable
|
||||
{
|
||||
private readonly PasswordLoginTestFactory _disabledFactory = new(enabledForTesting: false);
|
||||
private readonly PasswordLoginTestFactory _enabledFactory = new(enabledForTesting: true);
|
||||
|
||||
[Fact]
|
||||
public async Task WhenPasswordLoginIsDisabledEndpointsReturnDisabledStatusAndRejectLogin()
|
||||
{
|
||||
using var client = _disabledFactory.CreateClient();
|
||||
|
||||
// 1. Status returns enabledForTesting: false
|
||||
var statusResponse = await client.GetAsync("/api/v1/auth/status", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
var status = await statusResponse.Content.ReadFromJsonAsync<PasswordLoginStatusResponse>(
|
||||
SmmJsonContext.Default.PasswordLoginStatusResponse, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(status);
|
||||
Assert.False(status.EnabledForTesting);
|
||||
|
||||
// 2. Login attempt is rejected with 404 Not Found
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/auth/login",
|
||||
new PasswordLoginRequest("test-operator", "TestPassword123!"),
|
||||
SmmJsonContext.Default.PasswordLoginRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, loginResponse.StatusCode);
|
||||
|
||||
// 3. Anonymous access to control endpoints is unauthorized
|
||||
var controlResponse = await client.GetAsync("/api/v1/control/agents", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, controlResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenPasswordLoginIsEnabledSuccessfulLoginGrantsOperatorRoleOnly()
|
||||
{
|
||||
using var client = _enabledFactory.CreateClient();
|
||||
|
||||
// 1. Status returns enabledForTesting: true
|
||||
var statusResponse = await client.GetAsync("/api/v1/auth/status", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
var status = await statusResponse.Content.ReadFromJsonAsync<PasswordLoginStatusResponse>(
|
||||
SmmJsonContext.Default.PasswordLoginStatusResponse, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(status);
|
||||
Assert.True(status.EnabledForTesting);
|
||||
|
||||
// 2. Login with correct credentials
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/auth/login",
|
||||
new PasswordLoginRequest("test-operator", "TestPassword123!"),
|
||||
SmmJsonContext.Default.PasswordLoginRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
||||
var loginResult = await loginResponse.Content.ReadFromJsonAsync<PasswordLoginResponse>(
|
||||
SmmJsonContext.Default.PasswordLoginResponse, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(loginResult);
|
||||
Assert.False(string.IsNullOrWhiteSpace(loginResult.Token));
|
||||
Assert.Equal("Operator", loginResult.Role);
|
||||
Assert.True(loginResult.ExpiresAt > DateTimeOffset.UtcNow);
|
||||
|
||||
// Verify Set-Cookie header is present
|
||||
Assert.True(loginResponse.Headers.Contains("Set-Cookie"));
|
||||
|
||||
// 3. Access Operator Control endpoints using Bearer token
|
||||
using var authClient = _enabledFactory.CreateClient();
|
||||
authClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginResult.Token);
|
||||
|
||||
var agentsResponse = await authClient.GetAsync("/api/v1/control/agents", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, agentsResponse.StatusCode);
|
||||
|
||||
var linksResponse = await authClient.GetAsync("/api/v1/control/links", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, linksResponse.StatusCode);
|
||||
|
||||
// 4. Can issue enrollment code
|
||||
var codeResponse = await authClient.PostAsync(
|
||||
"/api/v1/control/agents/test-node-alpha/enrollment-code",
|
||||
null,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, codeResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PasswordSessionTokenCannotAccessAutomationRoutes()
|
||||
{
|
||||
using var client = _enabledFactory.CreateClient();
|
||||
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/auth/login",
|
||||
new PasswordLoginRequest("test-operator", "TestPassword123!"),
|
||||
SmmJsonContext.Default.PasswordLoginRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
||||
var loginResult = await loginResponse.Content.ReadFromJsonAsync<PasswordLoginResponse>(
|
||||
SmmJsonContext.Default.PasswordLoginResponse, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(loginResult);
|
||||
|
||||
// Attempt to access Automation endpoint with Operator password session token
|
||||
using var authClient = _enabledFactory.CreateClient();
|
||||
authClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginResult.Token);
|
||||
|
||||
var automationResponse = await authClient.GetAsync(
|
||||
"/api/v1/automation/links",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Must be 403 Forbidden because password session ONLY grants Operator role
|
||||
Assert.Equal(HttpStatusCode.Forbidden, automationResponse.StatusCode);
|
||||
|
||||
var agentResponse = await authClient.GetAsync(
|
||||
"/api/v1/agents/provisioning/jobs/next",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, agentResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongPasswordAndUnknownUserBothFailWithUnauthorizedAndUniformExecution()
|
||||
{
|
||||
using var client = _enabledFactory.CreateClient();
|
||||
|
||||
// 1. Wrong password for existing user
|
||||
var wrongPwdResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/auth/login",
|
||||
new PasswordLoginRequest("test-operator", "WrongPassword999!"),
|
||||
SmmJsonContext.Default.PasswordLoginRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, wrongPwdResponse.StatusCode);
|
||||
|
||||
// 2. Unknown user
|
||||
var unknownUserResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/auth/login",
|
||||
new PasswordLoginRequest("non-existent-user", "SomePassword123!"),
|
||||
SmmJsonContext.Default.PasswordLoginRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unknownUserResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExceedingRateLimitRejectsWithTooManyRequests()
|
||||
{
|
||||
using var client = _enabledFactory.CreateClient();
|
||||
|
||||
var statusCodes = new List<HttpStatusCode>();
|
||||
for (var i = 0; i < 7; i++)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/auth/login",
|
||||
new PasswordLoginRequest("test-operator", "BadPasswordAttempt"),
|
||||
SmmJsonContext.Default.PasswordLoginRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
statusCodes.Add(response.StatusCode);
|
||||
}
|
||||
|
||||
// Limit is 5 per minute; 6th and 7th requests must be 429 TooManyRequests
|
||||
Assert.Contains(HttpStatusCode.TooManyRequests, statusCodes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientCertificateAuthenticationHasPriorityAndWorksUnderBothModes()
|
||||
{
|
||||
// 1. When password login is disabled
|
||||
using (var certClient = _disabledFactory.CreateClient())
|
||||
{
|
||||
certClient.DefaultRequestHeaders.Add("X-Test-Identity", "cert-operator");
|
||||
certClient.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
||||
|
||||
var response = await certClient.GetAsync("/api/v1/control/agents", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
// 2. When password login is enabled
|
||||
using (var certClient = _enabledFactory.CreateClient())
|
||||
{
|
||||
certClient.DefaultRequestHeaders.Add("X-Test-Identity", "cert-operator");
|
||||
certClient.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
||||
|
||||
var response = await certClient.GetAsync("/api/v1/control/agents", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogoutRevokesSessionToken()
|
||||
{
|
||||
using var client = _enabledFactory.CreateClient();
|
||||
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/auth/login",
|
||||
new PasswordLoginRequest("test-operator", "TestPassword123!"),
|
||||
SmmJsonContext.Default.PasswordLoginRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
||||
var loginResult = await loginResponse.Content.ReadFromJsonAsync<PasswordLoginResponse>(
|
||||
SmmJsonContext.Default.PasswordLoginResponse, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(loginResult);
|
||||
|
||||
using var authClient = _enabledFactory.CreateClient();
|
||||
authClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginResult.Token);
|
||||
|
||||
// Before logout: access allowed
|
||||
var okResponse = await authClient.GetAsync("/api/v1/control/agents", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, okResponse.StatusCode);
|
||||
|
||||
// Logout
|
||||
var logoutResponse = await authClient.PostAsync("/api/v1/auth/logout", null, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode);
|
||||
|
||||
// After logout: access rejected
|
||||
var rejectedResponse = await authClient.GetAsync("/api/v1/control/agents", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, rejectedResponse.StatusCode);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _disabledFactory.DisposeAsync();
|
||||
await _enabledFactory.DisposeAsync();
|
||||
}
|
||||
|
||||
private sealed class PasswordLoginTestFactory(bool enabledForTesting) : WebApplicationFactory<controlapp::Program>
|
||||
{
|
||||
private readonly bool _enabledForTesting = enabledForTesting;
|
||||
private readonly string _directory = Path.Combine(
|
||||
Path.GetTempPath(), $"smm-pwd-tests-{Guid.NewGuid():N}");
|
||||
|
||||
public string DatabasePath => Path.Combine(_directory, "control.db");
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
|
||||
var authorityPath = Path.Combine(_directory, "control-ca.pfx");
|
||||
if (!File.Exists(authorityPath))
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new CertificateRequest("CN=SMM Password Test CA", key, HashAlgorithmName.SHA256);
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
request.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
|
||||
using var certificate = request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
File.WriteAllBytes(authorityPath, certificate.Export(X509ContentType.Pfx));
|
||||
}
|
||||
|
||||
var publicUrlPath = Path.Combine(_directory, "control-public-url");
|
||||
File.WriteAllText(publicUrlPath, "https://hub.example.com:7443\n");
|
||||
|
||||
var meshEnvPath = Path.Combine(_directory, "mesh.env");
|
||||
File.WriteAllText(meshEnvPath, "HUB_ENDPOINT=hub.example.com:51820\nHUB_PUBLIC_KEY=mQZ/Y4yQpQhX6j0rL8vU2w==\nMESH_NETWORK=10.77.0.0/24\n");
|
||||
|
||||
var meshNodesPath = Path.Combine(_directory, "mesh", "nodes.tsv");
|
||||
|
||||
var passwordHash = PasswordHasher.HashPassword("TestPassword123!");
|
||||
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Control:DatabasePath"] = DatabasePath,
|
||||
["Control:CertificateAuthorityPath"] = authorityPath,
|
||||
["Control:BackupDirectory"] = Path.Combine(_directory, "backups"),
|
||||
["Control:PublicUrlPath"] = publicUrlPath,
|
||||
["Control:MeshEnvironmentPath"] = meshEnvPath,
|
||||
["Control:MeshNodesPath"] = meshNodesPath,
|
||||
["Authentication:PasswordLogin:EnabledForTesting"] = _enabledForTesting ? "true" : "false",
|
||||
["Authentication:PasswordLogin:Username"] = "test-operator",
|
||||
["Authentication:PasswordLogin:PasswordHash"] = passwordHash,
|
||||
["Authentication:PasswordLogin:SessionTtlMinutes"] = "60"
|
||||
}));
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IHostedService>();
|
||||
services.AddAuthentication()
|
||||
.AddScheme<AuthenticationSchemeOptions, TestMtlsAuthenticationHandler>(
|
||||
"TestCert", _ => { });
|
||||
});
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await base.DisposeAsync();
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_directory))
|
||||
{
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored in cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestMtlsAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("X-Test-Role", out var roleValue)
|
||||
|| string.IsNullOrWhiteSpace(roleValue))
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var identityName = Request.Headers.TryGetValue("X-Test-Identity", out var identityValue)
|
||||
&& !string.IsNullOrWhiteSpace(identityValue)
|
||||
? identityValue.ToString()
|
||||
: "test-cert-user";
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, identityName),
|
||||
new(ClaimTypes.Role, roleValue.ToString())
|
||||
};
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name));
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
Response.StatusCode = (int)HttpStatusCode.Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue