From a3eaac2dec846b4cca089a08d4d88ba65e5f7e63 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 02:59:04 +0700 Subject: [PATCH] feat(control): add testing password login authentication for web console --- .../PasswordHasher.cs | 88 +++++ .../PasswordLoginOptions.cs | 14 + .../PasswordSessionAuthenticationHandler.cs | 70 ++++ .../PasswordSessionService.cs | 104 +++++ src/ServerMonitorManager.Control/Program.cs | 148 ++++++- .../wwwroot/app.js | 193 +++++++++- .../wwwroot/index.html | 51 +++ .../wwwroot/style.css | 39 ++ .../PasswordLoginModels.cs | 7 + .../SmmJsonContext.cs | 3 + .../PasswordLoginTests.cs | 361 ++++++++++++++++++ 11 files changed, 1062 insertions(+), 16 deletions(-) create mode 100644 src/ServerMonitorManager.Control/PasswordHasher.cs create mode 100644 src/ServerMonitorManager.Control/PasswordLoginOptions.cs create mode 100644 src/ServerMonitorManager.Control/PasswordSessionAuthenticationHandler.cs create mode 100644 src/ServerMonitorManager.Control/PasswordSessionService.cs create mode 100644 src/ServerMonitorManager.Core/PasswordLoginModels.cs create mode 100644 tests/ServerMonitorManager.Control.Tests/PasswordLoginTests.cs diff --git a/src/ServerMonitorManager.Control/PasswordHasher.cs b/src/ServerMonitorManager.Control/PasswordHasher.cs new file mode 100644 index 0000000..58da9ff --- /dev/null +++ b/src/ServerMonitorManager.Control/PasswordHasher.cs @@ -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", "", ""] + 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); + } +} diff --git a/src/ServerMonitorManager.Control/PasswordLoginOptions.cs b/src/ServerMonitorManager.Control/PasswordLoginOptions.cs new file mode 100644 index 0000000..6f6b63c --- /dev/null +++ b/src/ServerMonitorManager.Control/PasswordLoginOptions.cs @@ -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; +} diff --git a/src/ServerMonitorManager.Control/PasswordSessionAuthenticationHandler.cs b/src/ServerMonitorManager.Control/PasswordSessionAuthenticationHandler.cs new file mode 100644 index 0000000..e69db53 --- /dev/null +++ b/src/ServerMonitorManager.Control/PasswordSessionAuthenticationHandler.cs @@ -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 options, + ILoggerFactory logger, + UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) +{ + private readonly PasswordSessionService _sessionService = sessionService; + + public const string SchemeName = "PasswordSession"; + public const string SessionCookieName = "smm_session"; + + protected override Task 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 + { + 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; + } +} diff --git a/src/ServerMonitorManager.Control/PasswordSessionService.cs b/src/ServerMonitorManager.Control/PasswordSessionService.cs new file mode 100644 index 0000000..dd9233c --- /dev/null +++ b/src/ServerMonitorManager.Control/PasswordSessionService.cs @@ -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 options, + TimeProvider? timeProvider = null) +{ + private readonly IOptionsMonitor _options = options; + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + private readonly ConcurrentDictionary _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 _); + } + } + } +} diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index 379bd37..2bb2afa 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -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() && options.ClientCertificateDays is >= 1 and <= 90, "Invalid Control paths, heartbeat, retention, maintenance, expiration, reconciliation, or backup settings.") .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(PasswordLoginOptions.SectionName)); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -62,10 +74,36 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); -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(); @@ -97,7 +141,9 @@ builder.Services.AddAuthentication(CertificateAuthenticationDefaults.Authenticat context.Success(); } }; - }); + }) + .AddScheme( + PasswordSessionAuthenticationHandler.SchemeName, _ => { }); builder.Services.AddOptions( CertificateAuthenticationDefaults.AuthenticationScheme) .Configure((options, authority) => @@ -125,6 +171,15 @@ app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); +var passwordOptions = builder.Configuration + .GetSection(PasswordLoginOptions.SectionName) + .Get() ?? 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(); var backupService = app.Services.GetRequiredService(); 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 options) => + Results.Ok(new PasswordLoginStatusResponse(options.CurrentValue.EnabledForTesting))); + +auth.MapPost("/login", async ( + PasswordLoginRequest request, + HttpContext context, + PasswordSessionService sessionService, + IOptionsMonitor 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")); diff --git a/src/ServerMonitorManager.Control/wwwroot/app.js b/src/ServerMonitorManager.Control/wwwroot/app.js index a5ea1d9..36c59f4 100644 --- a/src/ServerMonitorManager.Control/wwwroot/app.js +++ b/src/ServerMonitorManager.Control/wwwroot/app.js @@ -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) { diff --git a/src/ServerMonitorManager.Control/wwwroot/index.html b/src/ServerMonitorManager.Control/wwwroot/index.html index 7df7725..0800394 100644 --- a/src/ServerMonitorManager.Control/wwwroot/index.html +++ b/src/ServerMonitorManager.Control/wwwroot/index.html @@ -29,9 +29,18 @@ + Добавить узел + + +
@@ -118,6 +127,48 @@ Синхронизировано + + diff --git a/src/ServerMonitorManager.Control/wwwroot/style.css b/src/ServerMonitorManager.Control/wwwroot/style.css index 679e26b..83fd81c 100644 --- a/src/ServerMonitorManager.Control/wwwroot/style.css +++ b/src/ServerMonitorManager.Control/wwwroot/style.css @@ -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; + } } diff --git a/src/ServerMonitorManager.Core/PasswordLoginModels.cs b/src/ServerMonitorManager.Core/PasswordLoginModels.cs new file mode 100644 index 0000000..2658cfe --- /dev/null +++ b/src/ServerMonitorManager.Core/PasswordLoginModels.cs @@ -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); diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs index 3e2449e..7a9be22 100644 --- a/src/ServerMonitorManager.Core/SmmJsonContext.cs +++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs @@ -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; diff --git a/tests/ServerMonitorManager.Control.Tests/PasswordLoginTests.cs b/tests/ServerMonitorManager.Control.Tests/PasswordLoginTests.cs new file mode 100644 index 0000000..7d67b8b --- /dev/null +++ b/tests/ServerMonitorManager.Control.Tests/PasswordLoginTests.cs @@ -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( + 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( + 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( + 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( + 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(); + 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( + 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 + { + 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 + { + ["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(); + services.AddAuthentication() + .AddScheme( + "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 options, + ILoggerFactory logger, + UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) + { + protected override Task 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 + { + 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; + } + } +}