All checks were successful
Build and Deploy / deploy (push) Successful in 41s
Replace random unrecoverable passwords with a confirmation-based flow: admin approval generates a secure token and sends a ticket reply with a confirmation link; the developer clicks the link, enters their SilverDESK password, and all services (Mattermost, Mailcow, Gitea) are provisioned with that password. Adds password sync endpoint for SilverDESK resets and updates the post-signup success panel to redirect to SilverDESK login with the username pre-populated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
495 lines
19 KiB
C#
495 lines
19 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace SilverLabs.Website.Services;
|
|
|
|
public record PendingDeployment(
|
|
string Token,
|
|
string Username,
|
|
string Email,
|
|
string FullName,
|
|
string TicketId,
|
|
DateTime CreatedAt,
|
|
DateTime ExpiresAt);
|
|
|
|
public class ProvisioningService
|
|
{
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly ILogger<ProvisioningService> _logger;
|
|
private readonly IConfiguration _configuration;
|
|
|
|
private static readonly ConcurrentDictionary<string, PendingDeployment> _pendingDeployments = new();
|
|
|
|
public ProvisioningService(
|
|
IHttpClientFactory httpClientFactory,
|
|
ILogger<ProvisioningService> logger,
|
|
IConfiguration configuration)
|
|
{
|
|
_httpClientFactory = httpClientFactory;
|
|
_logger = logger;
|
|
_configuration = configuration;
|
|
}
|
|
|
|
// --- Token management ---
|
|
|
|
public PendingDeployment CreatePendingDeployment(string username, string email, string fullName, string ticketId)
|
|
{
|
|
CleanupExpiredTokens();
|
|
|
|
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
|
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
|
|
|
var deployment = new PendingDeployment(
|
|
token, username, email, fullName, ticketId,
|
|
DateTime.UtcNow, DateTime.UtcNow.AddHours(48));
|
|
|
|
_pendingDeployments[token] = deployment;
|
|
_logger.LogInformation("Created pending deployment for {Username} (ticket {TicketId}), token expires {ExpiresAt}",
|
|
username, ticketId, deployment.ExpiresAt);
|
|
|
|
return deployment;
|
|
}
|
|
|
|
public PendingDeployment? GetPendingDeployment(string token)
|
|
{
|
|
CleanupExpiredTokens();
|
|
|
|
if (_pendingDeployments.TryGetValue(token, out var deployment))
|
|
{
|
|
if (deployment.ExpiresAt > DateTime.UtcNow)
|
|
return deployment;
|
|
|
|
_pendingDeployments.TryRemove(token, out _);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public void RemovePendingDeployment(string token)
|
|
{
|
|
_pendingDeployments.TryRemove(token, out _);
|
|
}
|
|
|
|
private void CleanupExpiredTokens()
|
|
{
|
|
var expired = _pendingDeployments
|
|
.Where(kvp => kvp.Value.ExpiresAt <= DateTime.UtcNow)
|
|
.Select(kvp => kvp.Key)
|
|
.ToList();
|
|
|
|
foreach (var key in expired)
|
|
_pendingDeployments.TryRemove(key, out _);
|
|
}
|
|
|
|
// --- Authentication ---
|
|
|
|
public async Task<bool> ValidateSilverDeskCredentialsAsync(string username, string password)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("SilverDesk");
|
|
var payload = new { username, password };
|
|
var response = await client.PostAsJsonAsync("/api/auth/login", payload);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogInformation("SilverDESK credential validation succeeded for {Username}", username);
|
|
return true;
|
|
}
|
|
|
|
_logger.LogWarning("SilverDESK credential validation failed for {Username}: {Status}", username, response.StatusCode);
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error validating SilverDESK credentials for {Username}", username);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// --- Full provisioning with password ---
|
|
|
|
public async Task<(bool Success, string Message)> ProvisionWithPasswordAsync(
|
|
string ticketId, string username, string email, string fullName, string password)
|
|
{
|
|
var results = new List<string>();
|
|
var allSuccess = true;
|
|
|
|
// 1. Create Mattermost user
|
|
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName, password);
|
|
results.Add($"Mattermost: {mmMsg}");
|
|
if (!mmOk) allSuccess = false;
|
|
|
|
// 2. Create Mailcow mailbox
|
|
var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName, password);
|
|
results.Add($"Mailcow: {mailMsg}");
|
|
if (!mailOk) allSuccess = false;
|
|
|
|
// 3. Create Gitea user
|
|
var (giteaOk, giteaMsg) = await CreateGiteaUserAsync(username, email, fullName, password);
|
|
results.Add($"Gitea: {giteaMsg}");
|
|
if (!giteaOk) allSuccess = false;
|
|
|
|
// 4. Update the DeveloperApplication record in SilverDESK
|
|
var (updateOk, updateMsg) = await UpdateApplicationStatusAsync(ticketId, mmOk, mailOk, giteaOk);
|
|
results.Add($"Application record: {updateMsg}");
|
|
if (!updateOk) allSuccess = false;
|
|
|
|
var summary = string.Join("; ", results);
|
|
_logger.LogInformation("Provisioning for {Username} (ticket {TicketId}): {Summary}", username, ticketId, summary);
|
|
|
|
return (allSuccess, summary);
|
|
}
|
|
|
|
// --- Ticket replies ---
|
|
|
|
public async Task<(bool Success, string Message)> SendTicketReplyAsync(string ticketId, string content, string action = "waitingcustomer")
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("SilverDesk");
|
|
var payload = new { content, action };
|
|
var response = await client.PostAsJsonAsync($"/api/tickets/{ticketId}/reply", payload);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogInformation("Sent ticket reply to {TicketId} with action {Action}", ticketId, action);
|
|
return (true, "Reply sent");
|
|
}
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Failed to send ticket reply to {TicketId}: {Status} {Body}", ticketId, response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error sending ticket reply to {TicketId}", ticketId);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// --- Password sync ---
|
|
|
|
public async Task<(bool Success, string Message)> SyncPasswordAsync(string username, string newPassword)
|
|
{
|
|
var results = new List<string>();
|
|
var allSuccess = true;
|
|
|
|
// 1. Mattermost - need to look up user ID first
|
|
var (mmOk, mmMsg) = await UpdateMattermostPasswordAsync(username, newPassword);
|
|
results.Add($"Mattermost: {mmMsg}");
|
|
if (!mmOk) allSuccess = false;
|
|
|
|
// 2. Mailcow
|
|
var (mailOk, mailMsg) = await UpdateMailcowPasswordAsync(username, newPassword);
|
|
results.Add($"Mailcow: {mailMsg}");
|
|
if (!mailOk) allSuccess = false;
|
|
|
|
// 3. Gitea
|
|
var (giteaOk, giteaMsg) = await UpdateGiteaPasswordAsync(username, newPassword);
|
|
results.Add($"Gitea: {giteaMsg}");
|
|
if (!giteaOk) allSuccess = false;
|
|
|
|
var summary = string.Join("; ", results);
|
|
_logger.LogInformation("Password sync for {Username}: {Summary}", username, summary);
|
|
|
|
return (allSuccess, summary);
|
|
}
|
|
|
|
// --- Application status update ---
|
|
|
|
private async Task<(bool Success, string Message)> UpdateApplicationStatusAsync(
|
|
string ticketId, bool mattermostProvisioned, bool mailcowProvisioned, bool giteaProvisioned = false)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("SilverDesk");
|
|
|
|
var lookupResponse = await client.GetAsync($"/api/developer-program/applications?ticketId={ticketId}");
|
|
if (!lookupResponse.IsSuccessStatusCode)
|
|
{
|
|
var body = await lookupResponse.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Failed to look up application by ticket {TicketId}: {Status} {Body}",
|
|
ticketId, lookupResponse.StatusCode, body);
|
|
return (false, $"Lookup failed ({lookupResponse.StatusCode})");
|
|
}
|
|
|
|
var apps = await lookupResponse.Content.ReadFromJsonAsync<JsonElement>();
|
|
if (apps.GetArrayLength() == 0)
|
|
{
|
|
_logger.LogWarning("No application found for ticket {TicketId}", ticketId);
|
|
return (false, "No application found for ticket");
|
|
}
|
|
|
|
var appId = apps[0].GetProperty("id").GetString();
|
|
|
|
var updatePayload = new
|
|
{
|
|
status = 1, // ApplicationStatus.Approved
|
|
mattermostProvisioned,
|
|
mailcowProvisioned,
|
|
giteaProvisioned
|
|
};
|
|
|
|
var updateResponse = await client.PutAsJsonAsync($"/api/developer-program/applications/{appId}", updatePayload);
|
|
if (updateResponse.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogInformation("Application {AppId} updated to Approved for ticket {TicketId}", appId, ticketId);
|
|
return (true, "Updated to Approved");
|
|
}
|
|
|
|
var updateBody = await updateResponse.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Failed to update application {AppId}: {Status} {Body}",
|
|
appId, updateResponse.StatusCode, updateBody);
|
|
return (false, $"Update failed ({updateResponse.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating application status for ticket {TicketId}", ticketId);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// --- Service account creation ---
|
|
|
|
public async Task<(bool Success, string Message)> CreateSilverDeskUserAsync(string username, string email, string fullName)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("SilverDesk");
|
|
var payload = new
|
|
{
|
|
username,
|
|
email,
|
|
fullName,
|
|
password = Guid.NewGuid().ToString("N")[..16] + "!A1"
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await client.PostAsync("/api/users", content);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "User created");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("SilverDESK user creation failed: {Status} {Body}", response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "SilverDESK user creation error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> CreateMattermostUserAsync(
|
|
string username, string email, string fullName, string password)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Mattermost");
|
|
var nameParts = fullName.Split(' ', 2);
|
|
var payload = new
|
|
{
|
|
email,
|
|
username,
|
|
first_name = nameParts[0],
|
|
last_name = nameParts.Length > 1 ? nameParts[1] : "",
|
|
password
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await client.PostAsync("/api/v4/users", content);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "User created");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Mattermost user creation failed: {Status} {Body}", response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Mattermost user creation error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> CreateMailcowMailboxAsync(
|
|
string username, string fullName, string password)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Mailcow");
|
|
var payload = new
|
|
{
|
|
local_part = username,
|
|
domain = "silverlabs.uk",
|
|
name = fullName,
|
|
password,
|
|
password2 = password,
|
|
quota = 1024, // 1GB
|
|
active = 1,
|
|
force_pw_update = 0
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await client.PostAsync("/api/v1/add/mailbox", content);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "Mailbox created");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Mailcow mailbox creation failed: {Status} {Body}", response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Mailcow mailbox creation error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> CreateGiteaUserAsync(
|
|
string username, string email, string fullName, string password)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Gitea");
|
|
var payload = new
|
|
{
|
|
email,
|
|
full_name = fullName,
|
|
login_name = username,
|
|
must_change_password = false,
|
|
password,
|
|
send_notify = false,
|
|
username,
|
|
visibility = "public"
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await client.PostAsync("/api/v1/admin/users", content);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "User created");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Gitea user creation failed: {Status} {Body}", response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Gitea user creation error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// --- Password update methods ---
|
|
|
|
private async Task<(bool Success, string Message)> UpdateMattermostPasswordAsync(string username, string newPassword)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Mattermost");
|
|
|
|
// Look up user ID by username
|
|
var userResponse = await client.GetAsync($"/api/v4/users/username/{username}");
|
|
if (!userResponse.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogWarning("Mattermost user lookup failed for {Username}: {Status}", username, userResponse.StatusCode);
|
|
return (false, $"User not found ({userResponse.StatusCode})");
|
|
}
|
|
|
|
var userData = await userResponse.Content.ReadFromJsonAsync<JsonElement>();
|
|
var userId = userData.GetProperty("id").GetString();
|
|
|
|
// Update password (admin reset — no old password needed with bot token)
|
|
var payload = new { new_password = newPassword };
|
|
var response = await client.PutAsJsonAsync($"/api/v4/users/{userId}/password", payload);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "Password updated");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Mattermost password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Mattermost password update error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> UpdateMailcowPasswordAsync(string username, string newPassword)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Mailcow");
|
|
var payload = new
|
|
{
|
|
items = new[] { $"{username}@silverlabs.uk" },
|
|
attr = new
|
|
{
|
|
password = newPassword,
|
|
password2 = newPassword
|
|
}
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await client.PostAsync("/api/v1/edit/mailbox", content);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "Password updated");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Mailcow password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Mailcow password update error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> UpdateGiteaPasswordAsync(string username, string newPassword)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Gitea");
|
|
var payload = new
|
|
{
|
|
login_name = username,
|
|
password = newPassword,
|
|
must_change_password = false,
|
|
source_id = 0
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/admin/users/{username}")
|
|
{
|
|
Content = content
|
|
};
|
|
|
|
var response = await client.SendAsync(request);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "Password updated");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Gitea password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Gitea password update error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
}
|