mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-07-26 16:08:13 +00:00
feat(auth): implement ticket and API token authentication with session model
Add PveSession, PveAuthenticator, PveVersion, and PveAuthMode. Support ticket-based auth (username/password with 2hr expiry) and API token auth (USER@REALM!TOKENID=UUID format). Version detection on connect via GET /api2/json/version. Custom exception types for API errors, expired sessions, missing connections, version mismatches, and task failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
namespace PSProxmoxVE.Core.Authentication
|
||||
{
|
||||
/// <summary>Authentication mode for Proxmox VE API</summary>
|
||||
public enum PveAuthMode
|
||||
{
|
||||
Ticket,
|
||||
ApiToken
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PSProxmoxVE.Core.Client;
|
||||
|
||||
namespace PSProxmoxVE.Core.Authentication
|
||||
{
|
||||
/// <summary>Provides methods to authenticate against a Proxmox VE server</summary>
|
||||
public static class PveAuthenticator
|
||||
{
|
||||
// Pattern: USER@REALM!TOKENID=UUID
|
||||
private static readonly Regex ApiTokenRegex = new Regex(
|
||||
@"^[^@]+@[^!]+![^=]+=.+$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates using username and password, obtaining a ticket and CSRF token.
|
||||
/// The username must be in the form user@realm (e.g. root@pam).
|
||||
/// </summary>
|
||||
public static PveSession AuthenticateWithCredentials(
|
||||
string hostname,
|
||||
int port,
|
||||
bool skipCertificateCheck,
|
||||
string username,
|
||||
string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hostname))
|
||||
throw new ArgumentException("Hostname cannot be null or empty.", nameof(hostname));
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
throw new ArgumentException("Username cannot be null or empty.", nameof(username));
|
||||
if (!username.Contains("@"))
|
||||
throw new ArgumentException("Username must include a realm (e.g. 'root@pam').", nameof(username));
|
||||
if (password == null)
|
||||
throw new ArgumentNullException(nameof(password));
|
||||
|
||||
var formData = new Dictionary<string, string>
|
||||
{
|
||||
{ "username", username },
|
||||
{ "password", password }
|
||||
};
|
||||
|
||||
string responseBody;
|
||||
using (var httpClient = new PveHttpClient(hostname, port, skipCertificateCheck))
|
||||
{
|
||||
responseBody = httpClient.Post("/api2/json/access/ticket", formData);
|
||||
}
|
||||
|
||||
var json = JObject.Parse(responseBody);
|
||||
var data = json["data"] ?? throw new InvalidOperationException("Response did not contain a 'data' field.");
|
||||
|
||||
var ticket = data["ticket"]?.Value<string>()
|
||||
?? throw new InvalidOperationException("Response did not contain a ticket.");
|
||||
var csrfToken = data["CSRFPreventionToken"]?.Value<string>()
|
||||
?? throw new InvalidOperationException("Response did not contain a CSRFPreventionToken.");
|
||||
|
||||
var ticketExpiry = DateTime.UtcNow.AddHours(2);
|
||||
|
||||
var session = new PveSession(hostname, port, skipCertificateCheck, ticket, csrfToken, ticketExpiry);
|
||||
|
||||
session.ServerVersion = GetVersion(session);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates using a Proxmox VE API token.
|
||||
/// The token must be in the format USER@REALM!TOKENID=UUID (e.g. root@pam!mytoken=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
|
||||
/// </summary>
|
||||
public static PveSession AuthenticateWithApiToken(
|
||||
string hostname,
|
||||
int port,
|
||||
bool skipCertificateCheck,
|
||||
string apiToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hostname))
|
||||
throw new ArgumentException("Hostname cannot be null or empty.", nameof(hostname));
|
||||
if (string.IsNullOrWhiteSpace(apiToken))
|
||||
throw new ArgumentException("API token cannot be null or empty.", nameof(apiToken));
|
||||
if (!ApiTokenRegex.IsMatch(apiToken))
|
||||
throw new ArgumentException(
|
||||
"API token must be in the format 'USER@REALM!TOKENID=UUID' (e.g. root@pam!mytoken=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).",
|
||||
nameof(apiToken));
|
||||
|
||||
var session = new PveSession(hostname, port, skipCertificateCheck, apiToken);
|
||||
|
||||
session.ServerVersion = GetVersion(session);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>Retrieves and parses the server version from the PVE API</summary>
|
||||
private static PveVersion GetVersion(PveSession session)
|
||||
{
|
||||
string responseBody;
|
||||
using (var httpClient = new PveHttpClient(session))
|
||||
{
|
||||
responseBody = httpClient.Get("/api2/json/version");
|
||||
}
|
||||
|
||||
var json = JObject.Parse(responseBody);
|
||||
var data = json["data"] ?? throw new InvalidOperationException("Version response did not contain a 'data' field.");
|
||||
|
||||
var versionString = data["version"]?.Value<string>()
|
||||
?? throw new InvalidOperationException("Version response did not contain a 'version' field.");
|
||||
|
||||
return PveVersion.Parse(versionString);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using PSProxmoxVE.Core.Models;
|
||||
|
||||
namespace PSProxmoxVE.Core.Authentication
|
||||
{
|
||||
/// <summary>Represents an authenticated session to a Proxmox VE server</summary>
|
||||
public class PveSession
|
||||
{
|
||||
public string Hostname { get; }
|
||||
public int Port { get; }
|
||||
public bool SkipCertificateCheck { get; }
|
||||
public PveAuthMode AuthMode { get; }
|
||||
public string? ApiToken { get; }
|
||||
public string? Ticket { get; }
|
||||
public string? CsrfToken { get; }
|
||||
public DateTime TicketExpiry { get; }
|
||||
public PveVersion ServerVersion { get; internal set; }
|
||||
|
||||
/// <summary>Returns true if the ticket has expired (only relevant for Ticket auth mode)</summary>
|
||||
public bool IsExpired
|
||||
{
|
||||
get
|
||||
{
|
||||
if (AuthMode == PveAuthMode.ApiToken)
|
||||
return false;
|
||||
return DateTime.UtcNow >= TicketExpiry;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Base URL for the Proxmox VE API</summary>
|
||||
public string BaseUrl => $"https://{Hostname}:{Port}/api2/json";
|
||||
|
||||
/// <summary>Creates a session using ticket-based authentication</summary>
|
||||
internal PveSession(
|
||||
string hostname,
|
||||
int port,
|
||||
bool skipCertificateCheck,
|
||||
string ticket,
|
||||
string csrfToken,
|
||||
DateTime ticketExpiry)
|
||||
{
|
||||
Hostname = hostname;
|
||||
Port = port;
|
||||
SkipCertificateCheck = skipCertificateCheck;
|
||||
AuthMode = PveAuthMode.Ticket;
|
||||
Ticket = ticket;
|
||||
CsrfToken = csrfToken;
|
||||
TicketExpiry = ticketExpiry;
|
||||
}
|
||||
|
||||
/// <summary>Creates a session using API token authentication</summary>
|
||||
internal PveSession(
|
||||
string hostname,
|
||||
int port,
|
||||
bool skipCertificateCheck,
|
||||
string apiToken)
|
||||
{
|
||||
Hostname = hostname;
|
||||
Port = port;
|
||||
SkipCertificateCheck = skipCertificateCheck;
|
||||
AuthMode = PveAuthMode.ApiToken;
|
||||
ApiToken = apiToken;
|
||||
TicketExpiry = DateTime.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
|
||||
namespace PSProxmoxVE.Core.Authentication
|
||||
{
|
||||
/// <summary>Represents a Proxmox VE server version (major.minor only)</summary>
|
||||
public class PveVersion
|
||||
{
|
||||
public int Major { get; }
|
||||
public int Minor { get; }
|
||||
|
||||
public PveVersion(int major, int minor)
|
||||
{
|
||||
Major = major;
|
||||
Minor = minor;
|
||||
}
|
||||
|
||||
/// <summary>Parse version from PVE API format "major.minor-patchlevel" e.g. "9.1-1"</summary>
|
||||
public static PveVersion Parse(string versionString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(versionString))
|
||||
throw new ArgumentException("Version string cannot be null or empty.", nameof(versionString));
|
||||
|
||||
// Strip patchlevel: "9.1-1" -> "9.1"
|
||||
var dashIndex = versionString.IndexOf('-');
|
||||
var majorMinor = dashIndex >= 0 ? versionString.Substring(0, dashIndex) : versionString;
|
||||
|
||||
var parts = majorMinor.Split('.');
|
||||
if (parts.Length < 2)
|
||||
throw new FormatException($"Invalid PVE version format: '{versionString}'. Expected 'major.minor' or 'major.minor-patchlevel'.");
|
||||
|
||||
if (!int.TryParse(parts[0], out var major) || !int.TryParse(parts[1], out var minor))
|
||||
throw new FormatException($"Invalid PVE version format: '{versionString}'. Major and minor must be integers.");
|
||||
|
||||
return new PveVersion(major, minor);
|
||||
}
|
||||
|
||||
public bool IsAtLeast(int major, int minor)
|
||||
{
|
||||
return Major > major || (Major == major && Minor >= minor);
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Major}.{Minor}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace PSProxmoxVE.Core.Exceptions
|
||||
{
|
||||
/// <summary>Exception thrown when the Proxmox VE API returns an error response</summary>
|
||||
public class PveApiException : Exception
|
||||
{
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
public string Resource { get; }
|
||||
public string HttpMethod { get; }
|
||||
|
||||
public PveApiException(HttpStatusCode statusCode, string message, string resource, string httpMethod)
|
||||
: base($"PVE API error ({(int)statusCode} {statusCode}) on {httpMethod} {resource}: {message}")
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
Resource = resource;
|
||||
HttpMethod = httpMethod;
|
||||
}
|
||||
|
||||
public PveApiException(HttpStatusCode statusCode, string message, string resource, string httpMethod, Exception innerException)
|
||||
: base($"PVE API error ({(int)statusCode} {statusCode}) on {httpMethod} {resource}: {message}", innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
Resource = resource;
|
||||
HttpMethod = httpMethod;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace PSProxmoxVE.Core.Exceptions
|
||||
{
|
||||
/// <summary>Exception thrown when authentication to a Proxmox VE server fails</summary>
|
||||
public class PveAuthenticationException : Exception
|
||||
{
|
||||
public PveAuthenticationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public PveAuthenticationException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace PSProxmoxVE.Core.Exceptions
|
||||
{
|
||||
/// <summary>Exception thrown when an operation is attempted without an active Proxmox VE session</summary>
|
||||
public class PveNotConnectedException : Exception
|
||||
{
|
||||
public PveNotConnectedException()
|
||||
: base("No active Proxmox VE session. Run Connect-PveServer first.")
|
||||
{
|
||||
}
|
||||
|
||||
public PveNotConnectedException(Exception innerException)
|
||||
: base("No active Proxmox VE session. Run Connect-PveServer first.", innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace PSProxmoxVE.Core.Exceptions
|
||||
{
|
||||
/// <summary>Exception thrown when the Proxmox VE session ticket has expired</summary>
|
||||
public class PveSessionExpiredException : Exception
|
||||
{
|
||||
public PveSessionExpiredException()
|
||||
: base("Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.")
|
||||
{
|
||||
}
|
||||
|
||||
public PveSessionExpiredException(Exception innerException)
|
||||
: base("Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.", innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
|
||||
namespace PSProxmoxVE.Core.Exceptions
|
||||
{
|
||||
/// <summary>Exception thrown when a Proxmox VE task completes with a failed exit status</summary>
|
||||
public class PveTaskFailedException : Exception
|
||||
{
|
||||
public string Upid { get; }
|
||||
public string ExitStatus { get; }
|
||||
|
||||
public PveTaskFailedException(string upid, string exitStatus)
|
||||
: base($"Task {upid} failed with exit status: {exitStatus}")
|
||||
{
|
||||
Upid = upid;
|
||||
ExitStatus = exitStatus;
|
||||
}
|
||||
|
||||
public PveTaskFailedException(string upid, string exitStatus, Exception innerException)
|
||||
: base($"Task {upid} failed with exit status: {exitStatus}", innerException)
|
||||
{
|
||||
Upid = upid;
|
||||
ExitStatus = exitStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
|
||||
namespace PSProxmoxVE.Core.Exceptions
|
||||
{
|
||||
/// <summary>Exception thrown when a Proxmox VE task does not complete within the allowed timeout period</summary>
|
||||
public class PveTaskTimeoutException : Exception
|
||||
{
|
||||
public string Upid { get; }
|
||||
public TimeSpan Timeout { get; }
|
||||
|
||||
public PveTaskTimeoutException(string upid, TimeSpan timeout)
|
||||
: base($"Task {upid} did not complete within {timeout.TotalSeconds} seconds.")
|
||||
{
|
||||
Upid = upid;
|
||||
Timeout = timeout;
|
||||
}
|
||||
|
||||
public PveTaskTimeoutException(string upid, TimeSpan timeout, Exception innerException)
|
||||
: base($"Task {upid} did not complete within {timeout.TotalSeconds} seconds.", innerException)
|
||||
{
|
||||
Upid = upid;
|
||||
Timeout = timeout;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using PSProxmoxVE.Core.Authentication;
|
||||
|
||||
namespace PSProxmoxVE.Core.Exceptions
|
||||
{
|
||||
/// <summary>Exception thrown when the connected Proxmox VE server does not meet the minimum version requirement for an operation</summary>
|
||||
public class PveVersionException : Exception
|
||||
{
|
||||
public int RequiredMajor { get; }
|
||||
public int RequiredMinor { get; }
|
||||
public PveVersion ActualVersion { get; }
|
||||
|
||||
public PveVersionException(int requiredMajor, int requiredMinor, PveVersion actualVersion)
|
||||
: base($"This operation requires Proxmox VE {requiredMajor}.{requiredMinor} or later. Connected server is version {actualVersion}.")
|
||||
{
|
||||
RequiredMajor = requiredMajor;
|
||||
RequiredMinor = requiredMinor;
|
||||
ActualVersion = actualVersion;
|
||||
}
|
||||
|
||||
public PveVersionException(int requiredMajor, int requiredMinor, PveVersion actualVersion, Exception innerException)
|
||||
: base($"This operation requires Proxmox VE {requiredMajor}.{requiredMinor} or later. Connected server is version {actualVersion}.", innerException)
|
||||
{
|
||||
RequiredMajor = requiredMajor;
|
||||
RequiredMinor = requiredMinor;
|
||||
ActualVersion = actualVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user