diff --git a/Spond.API.Test/Program.cs b/Spond.API.Test/Program.cs index 4de9c52..186fff1 100644 --- a/Spond.API.Test/Program.cs +++ b/Spond.API.Test/Program.cs @@ -256,8 +256,14 @@ private static async Task PrintGroups() public static async Task Login(string emailPhone, string password) { - if (await SpondClient.LoginWithEmail(emailPhone, password)) return true; - if (await SpondClient.LoginWithPhoneNumber(emailPhone, password)) return true; + static Task OtpCallback(string maskedPhone) + { + Console.Write($"Enter the one-time password sent to {maskedPhone}: "); + return Task.FromResult(Console.ReadLine() ?? string.Empty); + } + + if (await SpondClient.LoginWithEmail(emailPhone, password, OtpCallback)) return true; + if (await SpondClient.LoginWithPhoneNumber(emailPhone, password, OtpCallback)) return true; return false; } } diff --git a/Spond.API/Models/CommonData.Core.V1.cs b/Spond.API/Models/CommonData.Core.V1.cs new file mode 100644 index 0000000..e345dea --- /dev/null +++ b/Spond.API/Models/CommonData.Core.V1.cs @@ -0,0 +1,89 @@ +using Spond.API.Extensions; +using Spond.API.Interfaces; + +namespace Spond.API.Models; + +/// +/// Implementation of ICommonData for the current Spond API (core/v1). +/// Provides API endpoints and URL construction for the active Spond API. +/// +internal class CommonData_Core_V1 : ICommonData +{ + /// + public string LoginTokenPropertyName => "loginToken"; + /// + public string BaseUrl => "https://api.spond.com/"; + /// + public string LoginUrl => "core/v1/login"; + /// + public string UserUrl => "core/v1/profile"; + /// + public string GroupsUrl => "core/v1/groups"; + + /// + /// Constructs the events URL with query parameters. + /// + /// List of query parameters. + /// The complete events URL with query string. + private static string GetEventsUrl(List parameters) + { + return $"core/v1/sponds{(parameters.Count > 0 ? "?" : string.Empty)}{string.Join('&', parameters)}"; + } + + /// + /// Builds the list of query parameters for event requests. + /// + /// The minimum end time for events. + /// The maximum end time for events. + /// Whether to include event comments. + /// Whether to include hidden events. + /// Whether to add profile information. + /// Whether to include scheduled events. + /// The sort order for events. + /// The maximum number of events to retrieve. + /// Optional group ID filter. + /// Optional subgroup ID filter. + /// A list of formatted query parameter strings. + private static List GetEventsParameters(DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max, string? groupId, string? subGroupId) + { + var parameters = new List + { + $"minEndTimestamp={minEndTime.ToIso8601(true)}", + $"maxEndTimestamp={maxEndTime.ToIso8601(true)}", + $"max={max ?? (int)Math.Max(1, Math.Round((maxEndTime - minEndTime).TotalDays * 5))}", + $"order={order switch + { + Enums.Order.Ascending => "asc", + Enums.Order.Descending => "desc", + _ => "asc" + }}" + }; + + if (includeComments is not null) parameters.Add($"includeComments={includeComments.ToString()?.ToLower()}"); + if (includeHidden is not null) parameters.Add($"includeHidden={includeHidden.ToString()?.ToLower()}"); + if (addProfileInfo is not null) parameters.Add($"addProfileInfo={addProfileInfo.ToString()?.ToLower()}"); + if (scheduled is not null) parameters.Add($"scheduled={scheduled.ToString()?.ToLower()}"); + if (groupId is not null) parameters.Add($"groupId={groupId}"); + if (subGroupId is not null) parameters.Add($"subGroupId={subGroupId}"); + + return parameters; + } + + /// + public string GetEventsUrl(DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max) + { + return GetEventsUrl(GetEventsParameters(minEndTime, maxEndTime, includeComments, includeHidden, addProfileInfo, scheduled, order, max, null, null)); + } + + /// + public string GetEventsUrl(string groupId, DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max) + { + return GetEventsUrl(GetEventsParameters(minEndTime, maxEndTime, includeComments, includeHidden, addProfileInfo, scheduled, order, max, groupId, null)); + } + + /// + public string GetEventsUrl(string groupId, string subGroupId, DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max) + { + return GetEventsUrl(GetEventsParameters(minEndTime, maxEndTime, includeComments, includeHidden, addProfileInfo, scheduled, order, max, groupId, subGroupId)); + } +} diff --git a/Spond.API/Services/SpondClient.cs b/Spond.API/Services/SpondClient.cs index 95f2447..d9d5146 100644 --- a/Spond.API/Services/SpondClient.cs +++ b/Spond.API/Services/SpondClient.cs @@ -3,6 +3,7 @@ using Spond.API.Models; using System.Net; using System.Net.Http.Json; +using System.Text.Json; using Newtonsoft.Json; using static Spond.API.Enums; using JsonDocument = System.Text.Json.JsonDocument; @@ -22,59 +23,148 @@ public class SpondClient /// /// Initializes a new instance of the class. /// - /// Optional common data configuration. If null, defaults to CommonData_2_1. + /// Optional common data configuration. If null, defaults to CommonData_Core_V1. /// Optional logger for logging client operations. public SpondClient(ICommonData? commonData = null, ILogger? logger = null) { - _commonData = commonData ?? new CommonData_2_1(); + _commonData = commonData ?? new CommonData_Core_V1(); _client = new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer() }) { BaseAddress = new Uri(_commonData.BaseUrl) }; _logger = logger; } /// /// Authenticates with the Spond API using an email address and password. + /// When the account requires two-factor authentication, the + /// is invoked with the masked phone number that received the one-time code. + /// The callback must return the OTP entered by the user. + /// If is null and 2FA is required, the login fails. /// /// The user's email address. /// The user's password. + /// + /// Optional async callback invoked when a one-time password is needed. + /// Receives the masked phone number (e.g. "****12") and must return the OTP code. + /// /// True if login was successful, false otherwise. - public async Task LoginWithEmail(string email, string password) + public async Task LoginWithEmail(string email, string password, Func>? otpCallback = null) { var loginPayload = new { email, password }; - return await Login(loginPayload); + return await Login(loginPayload, otpCallback); } /// /// Authenticates with the Spond API using a phone number and password. + /// When the account requires two-factor authentication, the + /// is invoked with the masked phone number that received the one-time code. + /// The callback must return the OTP entered by the user. + /// If is null and 2FA is required, the login fails. /// /// The user's phone number. /// The user's password. + /// + /// Optional async callback invoked when a one-time password is needed. + /// Receives the masked phone number (e.g. "****12") and must return the OTP code. + /// /// True if login was successful, false otherwise. - public async Task LoginWithPhoneNumber(string phoneNumber, string password) + public async Task LoginWithPhoneNumber(string phoneNumber, string password, Func>? otpCallback = null) { var loginPayload = new { phoneNumber, password }; - return await Login(loginPayload); + return await Login(loginPayload, otpCallback); } /// /// Internal method to handle the login process with different payload types. + /// Supports the two-factor authentication flow: if the API responds with a + /// temporary token and a masked phone number instead of a login token, the + /// is used to obtain the one-time password and + /// a second verification request is sent to complete authentication. /// /// The type of the login payload (email or phone number). /// The login credentials payload. + /// + /// Optional async callback invoked when 2FA is required. + /// Receives the masked phone number and must return the OTP code. + /// /// True if login was successful, false otherwise. - private async Task Login(T loginPayload) + private async Task Login(T loginPayload, Func>? otpCallback) { var loginResp = await _client.PostAsJsonAsync(_commonData.LoginUrl, loginPayload); if (!loginResp.IsSuccessStatusCode) { - _logger?.LogError($"Error logging in: {loginResp.StatusCode} - {loginResp.Content.ReadAsStringAsync()}"); + _logger?.LogError("Error logging in: {StatusCode}", loginResp.StatusCode); return false; } + var loginJson = await loginResp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(loginJson); - var loginToken = doc.RootElement.GetProperty(_commonData.LoginTokenPropertyName).GetString(); - _client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", loginToken); - return true; + var root = doc.RootElement; + + // Happy path: direct login token returned (no 2FA). + if (root.TryGetProperty(_commonData.LoginTokenPropertyName, out var tokenElement)) + { + var loginToken = tokenElement.GetString(); + if (string.IsNullOrEmpty(loginToken)) + { + _logger?.LogError("Login response contained an empty login token."); + return false; + } + _client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", loginToken); + return true; + } + + // 2FA path: API returned a temporary token and the masked destination phone number. + if (root.TryGetProperty("token", out var tempTokenElement) && + root.TryGetProperty("phoneNumber", out var phoneElement)) + { + var tempToken = tempTokenElement.GetString(); + var maskedPhone = phoneElement.GetString() ?? string.Empty; + + if (otpCallback is null) + { + _logger?.LogError( + "Login requires a one-time password sent to {Phone}. " + + "Provide an otpCallback to handle two-factor authentication.", + maskedPhone); + return false; + } + + var otpCode = await otpCallback(maskedPhone); + if (string.IsNullOrWhiteSpace(otpCode)) + { + _logger?.LogError("OTP callback returned an empty code."); + return false; + } + + var otpPayload = new { code = otpCode, token = tempToken }; + var otpResp = await _client.PostAsJsonAsync(_commonData.LoginUrl, otpPayload); + if (!otpResp.IsSuccessStatusCode) + { + _logger?.LogError("OTP verification failed: {StatusCode}", otpResp.StatusCode); + return false; + } + + var otpJson = await otpResp.Content.ReadAsStringAsync(); + using var otpDoc = JsonDocument.Parse(otpJson); + if (!otpDoc.RootElement.TryGetProperty(_commonData.LoginTokenPropertyName, out var finalTokenElement)) + { + _logger?.LogError("OTP verification response did not contain a login token."); + return false; + } + + var finalToken = finalTokenElement.GetString(); + if (string.IsNullOrEmpty(finalToken)) + { + _logger?.LogError("OTP verification response contained an empty login token."); + return false; + } + _client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", finalToken); + return true; + } + + _logger?.LogError("Unexpected login response: {Response}", loginJson); + return false; } ///