diff --git a/Agent/Services/AgentHubConnection.cs b/Agent/Services/AgentHubConnection.cs index 93189df0..97aa999f 100644 --- a/Agent/Services/AgentHubConnection.cs +++ b/Agent/Services/AgentHubConnection.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Remotely.Agent.Extensions; using Remotely.Agent.Interfaces; +using Remotely.Shared; using Remotely.Shared.Enums; using Remotely.Shared.Models; using Remotely.Shared.Services; @@ -449,7 +450,7 @@ public class AgentHubConnection : IAgentHubConnection, IDisposable } }); - _hubConnection.On("TransferFileFromBrowserToAgent", async (string transferID, List fileIDs, string requesterID, string authToken) => + _hubConnection.On("TransferFileFromBrowserToAgent", async (string transferID, List fileIDs, string requesterID, string expiringToken) => { try { @@ -467,7 +468,7 @@ public class AgentHubConnection : IAgentHubConnection, IDisposable { var url = $"{_connectionInfo.Host}/API/FileSharing/{fileID}"; using var client = _httpFactory.CreateClient(); - client.DefaultRequestHeaders.Add("Authorization", authToken); + client.DefaultRequestHeaders.Add(AppConstants.ExpiringTokenHeaderName, expiringToken); using var response = await client.GetAsync(url); var filename = response.Content.Headers.ContentDisposition.FileName; diff --git a/Agent/Services/ScriptExecutor.cs b/Agent/Services/ScriptExecutor.cs index a4c91e48..71ca180d 100644 --- a/Agent/Services/ScriptExecutor.cs +++ b/Agent/Services/ScriptExecutor.cs @@ -93,7 +93,7 @@ public class ScriptExecutor : IScriptExecutor int scriptRunId, string initiator, ScriptInputType scriptInputType, - string authToken) + string expiringToken) { try { @@ -106,7 +106,7 @@ public class ScriptExecutor : IScriptExecutor var connectionInfo = _configService.GetConnectionInfo(); var url = $"{connectionInfo.Host}/API/SavedScripts/{savedScriptId}"; using var hc = new HttpClient(); - hc.DefaultRequestHeaders.Add("Authorization", authToken); + hc.DefaultRequestHeaders.Add(AppConstants.ExpiringTokenHeaderName, expiringToken); var response = await hc.GetAsync(url); if (!response.IsSuccessStatusCode) { @@ -142,7 +142,7 @@ public class ScriptExecutor : IScriptExecutor result.InputType = scriptInputType; result.SavedScriptId = savedScriptId; - var responseResult = await SendResultsToApi(result, authToken); + var responseResult = await SendResultsToApi(result, expiringToken); } catch (Exception ex) { @@ -195,12 +195,12 @@ public class ScriptExecutor : IScriptExecutor } return null; } - private async Task SendResultsToApi(object result, string authToken) + private async Task SendResultsToApi(object result, string expiringToken) { var targetURL = _configService.GetConnectionInfo().Host + $"/API/ScriptResults"; using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("Authorization", authToken); + httpClient.DefaultRequestHeaders.Add(AppConstants.ExpiringTokenHeaderName, expiringToken); using var response = await httpClient.PostAsJsonAsync(targetURL, result); diff --git a/README.md b/README.md index 7d8c77af..4a9b1447 100644 --- a/README.md +++ b/README.md @@ -207,13 +207,13 @@ Remotely has a basic API, which can be browsed at https://remotely.lucency.co/sw When accessing the API from the browser on another website, you'll need to set up CORS in appsettings by adding the website origin URL to the TrustedCorsOrigins array. If you're not familiar with how CORS works, I recommend reading up on it before proceeding. For example, if I wanted to create a login form on https://lucency.co that logged into the Remotely API, I'd need to add "https://lucency.co" to the TrustedCorsOrigins. -The API key and secret must first be combined [ApiKey]:[ApiSecret] and then encoded with Base64 as [EncodedAuhorization]. After that you can add the encoded string to the request's Authorization header in the form "Basic [EncodedAuhorization]" +Each request to the API must have a header named "X-Api-Key". The value should be the API key's ID and secret, separated by a colon (i.e. [ApiKey]:[ApiSecret]). Below is an example API request: POST https://localhost:5001/API/Scripting/ExecuteCommand/PSCore/f2b0a595-5ea8-471b-975f-12e70e0f3497 HTTP/1.1 Content-Type: application/json - Authorization: 31fb288d-af97-4ce1-ae7b-ceebb98281ac:HLkrKaZGExYvozSPvcACZw9awKkhHnNK + X-Api-Key: 31fb288d-af97-4ce1-ae7b-ceebb98281ac:HLkrKaZGExYvozSPvcACZw9awKkhHnNK User-Agent: PostmanRuntime/7.22.0 Accept: */* Cache-Control: no-cache @@ -284,7 +284,7 @@ Register-ScheduledJob -ScriptBlock { $FreeSpace = $OsDrive.Free / ($OsDrive.Used + $OsDrive.Free) if ($FreeSpace -lt .1) { Invoke-WebRequest -Uri "https://localhost:5001/api/Alerts/Create/" -Method Post -Headers @{ - Authorization="3e9d8273-1dc1-4303-bd50-7a133e36b9b7:S+82XKZdvg278pSFHWtUklqHENuO5IhH" + X-Api-Key="3e9d8273-1dc1-4303-bd50-7a133e36b9b7:S+82XKZdvg278pSFHWtUklqHENuO5IhH" } -Body @" { "AlertDeviceID": "f2b0a595-5ea8-471b-975f-12e70e0f3497", diff --git a/Server/API/AlertsController.cs b/Server/API/AlertsController.cs index 5a02b9e1..680b98b0 100644 --- a/Server/API/AlertsController.cs +++ b/Server/API/AlertsController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using Immense.RemoteControl.Shared.Extensions; +using Microsoft.AspNetCore.Mvc; using Microsoft.Build.Framework; using Microsoft.Extensions.Logging; using Remotely.Server.Auth; @@ -43,7 +44,7 @@ public class AlertsController : ControllerBase { if (!Request.Headers.TryGetOrganizationId(out var orgId)) { - return BadRequest("OrganizationID is required."); + return Unauthorized(); } _logger.LogInformation("Alert created. Alert Options: {options}", JsonSerializer.Serialize(alertOptions)); @@ -111,19 +112,23 @@ public class AlertsController : ControllerBase { if (!Request.Headers.TryGetOrganizationId(out var orgId)) { - return BadRequest("OrganizationID is required."); + return Unauthorized(); } - var alert = await _dataService.GetAlert(alertID); - - if (alert?.OrganizationID == orgId) + var alertResult = await _dataService.GetAlert(alertID); + _logger.LogResult(alertResult); + if (!alertResult.IsSuccess) { - await _dataService.DeleteAlert(alert); - - return Ok(); + return BadRequest(alertResult.Reason); } - return Unauthorized(); + if (alertResult.Value.OrganizationID != orgId) + { + return Unauthorized(); + } + + await _dataService.DeleteAlert(alertResult.Value); + return Ok(); } [HttpDelete("DeleteAll")] @@ -131,7 +136,7 @@ public class AlertsController : ControllerBase { if (!Request.Headers.TryGetOrganizationId(out var orgId)) { - return BadRequest("OrganizationID is required."); + return Unauthorized(); } if (User.Identity?.IsAuthenticated == true) diff --git a/Server/API/ClientDownloadsController.cs b/Server/API/ClientDownloadsController.cs index 5a11809f..8d7f580b 100644 --- a/Server/API/ClientDownloadsController.cs +++ b/Server/API/ClientDownloadsController.cs @@ -120,7 +120,7 @@ public class ClientDownloadsController : ControllerBase { if (!Request.Headers.TryGetOrganizationId(out var orgId)) { - return BadRequest("OrganizationID is required."); + return Unauthorized(); } return await GetInstallFile(orgId, platformID); } diff --git a/Server/API/DevicesController.cs b/Server/API/DevicesController.cs index dc79cf8e..f963bb06 100644 --- a/Server/API/DevicesController.cs +++ b/Server/API/DevicesController.cs @@ -49,7 +49,7 @@ public class DevicesController : ControllerBase { if (!Request.Headers.TryGetOrganizationId(out var orgId)) { - return BadRequest("OrganizationID is required."); + return Unauthorized(); } var device = DataService.GetDevice(orgId, id); diff --git a/Server/API/OrganizationManagementController.cs b/Server/API/OrganizationManagementController.cs index e122eb4a..84d7562e 100644 --- a/Server/API/OrganizationManagementController.cs +++ b/Server/API/OrganizationManagementController.cs @@ -1,13 +1,19 @@ -using MailKit; +using Immense.RemoteControl.Desktop.Native.Windows; +using Immense.RemoteControl.Shared; +using Immense.RemoteControl.Shared.Extensions; +using MailKit; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Build.Framework; +using Microsoft.Extensions.Logging; using Org.BouncyCastle.Crypto.Agreement; using Remotely.Server.Auth; using Remotely.Server.Extensions; using Remotely.Server.Services; using Remotely.Shared.Models; using Remotely.Shared.ViewModels; +using System; using System.Text; using System.Text.Encodings.Web; using System.Threading.Tasks; @@ -20,90 +26,91 @@ namespace Remotely.Server.API; [ApiController] public class OrganizationManagementController : ControllerBase { - public OrganizationManagementController(IDataService dataService, + private readonly IDataService _dataService; + private readonly IEmailSenderEx _emailSender; + private readonly ILogger _logger; + private readonly UserManager _userManager; + + public OrganizationManagementController( UserManager userManager, - IEmailSenderEx emailSender) + IDataService dataService, + IEmailSenderEx emailSender, + ILogger logger) { - DataService = dataService; - UserManager = userManager; - EmailSender = emailSender; + _dataService = dataService; + _userManager = userManager; + _emailSender = emailSender; + _logger = logger; } - private IDataService DataService { get; } - private IEmailSenderEx EmailSender { get; } - private UserManager UserManager { get; } - - [HttpPost("ChangeIsAdmin/{userID}")] [ServiceFilter(typeof(ApiAuthorizationFilter))] - public IActionResult ChangeIsAdmin(string userID, [FromBody] bool isAdmin) + public async Task ChangeIsAdmin(string userId, [FromBody] bool isAdmin) { - if (User.Identity is null || - User.Identity.IsAuthenticated == false || - string.IsNullOrEmpty(User.Identity.Name)) - { - return Unauthorized(); - } - - if (User.Identity.IsAuthenticated == true && - !DataService.GetUserByNameWithOrg(User.Identity.Name).IsAdministrator) - { - return Unauthorized(); - } - - if (User.Identity.IsAuthenticated && - DataService.GetUserByNameWithOrg(User.Identity.Name).Id == userID) - { - return BadRequest("You can't remove administrator rights from yourself."); - } - if (!Request.Headers.TryGetOrganizationId(out var orgId)) { - return BadRequest("OrganizationID is required."); + return Unauthorized(); } - DataService.ChangeUserIsAdmin(orgId, userID, isAdmin); + if (User.Identity?.IsAuthenticated == true) + { + var userResult = await _dataService.GetUserByNameWithOrg($"{User.Identity.Name}"); + if (userResult.IsSuccess && userResult.Value.Id == userId) + { + return BadRequest("You can't remove administrator rights from yourself."); + } + } + + _dataService.ChangeUserIsAdmin(orgId, userId, isAdmin); return NoContent(); } [HttpDelete("DeleteInvite/{inviteID}")] [ServiceFilter(typeof(ApiAuthorizationFilter))] - public IActionResult DeleteInvite(string inviteID) + public async Task DeleteInvite(string inviteID) { - if (User.Identity is null || - User.Identity.IsAuthenticated == false || - string.IsNullOrEmpty(User.Identity.Name)) + if (!Request.Headers.TryGetOrganizationId(out var orgId)) { return Unauthorized(); } - if (!Request.Headers.TryGetOrganizationId(out var orgId)) + var result = await _dataService.DeleteInvite(orgId, inviteID); + _logger.LogResult(result); + + if (!result.IsSuccess) { - return BadRequest("OrganizationID is required."); + return BadRequest(result.Reason); } - DataService.DeleteInvite(orgId, inviteID); return NoContent(); } [HttpDelete("DeleteUser/{userID}")] [ServiceFilter(typeof(ApiAuthorizationFilter))] - public async Task DeleteUser(string userID) + public async Task DeleteUser(string userId) { - if (User.Identity.IsAuthenticated && - !DataService.GetUserByNameWithOrg(User.Identity.Name).IsAdministrator) + if (!Request.Headers.TryGetOrganizationId(out var orgId)) { return Unauthorized(); } - if (User.Identity.IsAuthenticated && - DataService.GetUserByNameWithOrg(User.Identity.Name).Id == userID) + if (User.Identity?.IsAuthenticated == true) { - return BadRequest("You can't delete yourself here. You must go to the Personal Data page to delete your own account."); + var userResult = await _dataService.GetUserByNameWithOrg($"{User.Identity.Name}"); + if (userResult.IsSuccess && userResult.Value.Id == userId) + { + return BadRequest("You can't delete yourself here. You must go to the Personal Data page to delete your own account."); + } + } + + + var result = await _dataService.DeleteUser(orgId, userId); + _logger.LogResult(result); + if (!result.IsSuccess) + { + return BadRequest(result.Reason); } - Request.Headers.TryGetValue("OrganizationID", out var orgID); - await DataService.DeleteUser(orgID, userID); return NoContent(); } @@ -111,28 +118,29 @@ public class OrganizationManagementController : ControllerBase [ServiceFilter(typeof(ApiAuthorizationFilter))] public IActionResult DeviceGroup() { - if (User.Identity.IsAuthenticated && - DataService.GetUserByNameWithOrg(User.Identity.Name).IsAdministrator) - { - return Ok(DataService.GetDeviceGroups(User.Identity.Name)); - } - if (Request.Headers.TryGetValue("OrganizationID", out var orgID)) - return Ok(DataService.GetDeviceGroupsForOrganization(orgID)); - return NotFound("Unable to find User or organizationID for device group"); - } - - [HttpDelete("DeviceGroup")] - [ServiceFilter(typeof(ApiAuthorizationFilter))] - public IActionResult DeviceGroup([FromBody] string deviceGroupID) - { - if (User.Identity.IsAuthenticated && - !DataService.GetUserByNameWithOrg(User.Identity.Name).IsAdministrator) + if (!Request.Headers.TryGetOrganizationId(out var orgId)) { return Unauthorized(); } - Request.Headers.TryGetValue("OrganizationID", out var orgID); - DataService.DeleteDeviceGroup(orgID, deviceGroupID.Trim()); + return Ok(_dataService.GetDeviceGroupsForOrganization(orgId)); + } + + [HttpDelete("DeviceGroup")] + [ServiceFilter(typeof(ApiAuthorizationFilter))] + public async Task DeviceGroup([FromBody] string deviceGroupId) + { + if (!Request.Headers.TryGetOrganizationId(out var orgId)) + { + return Unauthorized(); + } + + var result = await _dataService.DeleteDeviceGroup(orgId, deviceGroupId.Trim()); + _logger.LogResult(result); + if (!result.IsSuccess) + { + return BadRequest(result.Reason); + } return NoContent(); } @@ -140,8 +148,7 @@ public class OrganizationManagementController : ControllerBase [ServiceFilter(typeof(ApiAuthorizationFilter))] public async Task DeviceGroup([FromBody] DeviceGroup deviceGroup) { - if (User.Identity.IsAuthenticated && - !DataService.GetUserByNameWithOrg(User.Identity.Name).IsAdministrator) + if (!Request.Headers.TryGetOrganizationId(out var orgId)) { return Unauthorized(); } @@ -151,9 +158,7 @@ public class OrganizationManagementController : ControllerBase return BadRequest(); } - Request.Headers.TryGetValue("OrganizationID", out var orgId); - - var result = await DataService.AddDeviceGroup($"{orgId}", deviceGroup); + var result = await _dataService.AddDeviceGroup(orgId, deviceGroup); if (!result.IsSuccess) { return BadRequest(result.Reason); @@ -165,8 +170,7 @@ public class OrganizationManagementController : ControllerBase [ServiceFilter(typeof(ApiAuthorizationFilter))] public async Task DeviceGroupRemoveUser([FromBody] string userID, string groupID) { - if (User.Identity.IsAuthenticated && - !DataService.GetUserByNameWithOrg(User.Identity.Name).IsAdministrator) + if (!Request.Headers.TryGetOrganizationId(out var orgId)) { return Unauthorized(); } @@ -176,8 +180,7 @@ public class OrganizationManagementController : ControllerBase return BadRequest(); } - Request.Headers.TryGetValue("OrganizationID", out var orgID); - if (!await DataService.RemoveUserFromDeviceGroup(orgID, groupID, userID)) + if (!await _dataService.RemoveUserFromDeviceGroup(orgId, groupID, userID)) { return BadRequest("Failed to remove user from group."); } @@ -188,8 +191,7 @@ public class OrganizationManagementController : ControllerBase [ServiceFilter(typeof(ApiAuthorizationFilter))] public IActionResult DeviceGroupAddUser([FromBody] string userID, string groupID) { - if (User.Identity.IsAuthenticated && - !DataService.GetUserByNameWithOrg(User.Identity.Name).IsAdministrator) + if (!Request.Headers.TryGetOrganizationId(out var orgId)) { return Unauthorized(); } @@ -199,8 +201,7 @@ public class OrganizationManagementController : ControllerBase return BadRequest(); } - Request.Headers.TryGetValue("OrganizationID", out var orgID); - var result = DataService.AddUserToDeviceGroup(orgID, groupID, userID, out var resultMessage); + var result = _dataService.AddUserToDeviceGroup(orgId, groupID, userID, out var resultMessage); if (!result) { return BadRequest(resultMessage); @@ -211,24 +212,26 @@ public class OrganizationManagementController : ControllerBase [HttpGet("GenerateResetUrl/{userID}")] [ServiceFilter(typeof(ApiAuthorizationFilter))] - public async Task GenerateResetUrl(string userID) + public async Task GenerateResetUrl(string userId) { - if (User.Identity.IsAuthenticated && - !DataService.GetUserByNameWithOrg(User.Identity.Name).IsAdministrator) + if (!Request.Headers.TryGetOrganizationId(out var orgId)) { return Unauthorized(); } - Request.Headers.TryGetValue("OrganizationID", out var orgID); + var user = await _userManager.FindByIdAsync(userId); - var user = await UserManager.FindByIdAsync(userID); + if (user is null) + { + return NotFound(); + } - if (user.OrganizationID != orgID) + if (user.OrganizationID != orgId) { return Unauthorized(); } - var code = await UserManager.GeneratePasswordResetTokenAsync(user); + var code = await _userManager.GeneratePasswordResetTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); var callbackUrl = Url.Page( "/Account/ResetPassword", @@ -242,35 +245,37 @@ public class OrganizationManagementController : ControllerBase [HttpPut("Name")] [ServiceFilter(typeof(ApiAuthorizationFilter))] - public IActionResult Name([FromBody] string organizationName) + public async Task Name([FromBody] string organizationName) { - if (User.Identity.IsAuthenticated && - !DataService.GetUserByNameWithOrg(User.Identity.Name).IsAdministrator) + if (!Request.Headers.TryGetOrganizationId(out var orgId)) { return Unauthorized(); } + if (organizationName.Length > 25) { return BadRequest(); } - Request.Headers.TryGetValue("OrganizationID", out var orgID); - DataService.UpdateOrganizationName(orgID, organizationName.Trim()); + var result = await _dataService.UpdateOrganizationName(orgId, organizationName.Trim()); + _logger.LogResult(result); + if (!result.IsSuccess) + { + return BadRequest(result.Reason); + } return NoContent(); } [HttpPut("SetDefault")] [ServiceFilter(typeof(ApiAuthorizationFilter))] - public IActionResult SetDefault([FromBody] bool isDefault) + public async Task SetDefault([FromBody] bool isDefault) { - if (User.Identity.IsAuthenticated && - !DataService.GetUserByNameWithOrg(User.Identity.Name).IsServerAdmin) + if (!Request.Headers.TryGetOrganizationId(out var orgId)) { return Unauthorized(); } - Request.Headers.TryGetValue("OrganizationID", out var orgID); - DataService.SetIsDefaultOrganization(orgID, isDefault); + await _dataService.SetIsDefaultOrganization(orgId, isDefault); return NoContent(); } @@ -278,44 +283,39 @@ public class OrganizationManagementController : ControllerBase [ServiceFilter(typeof(ApiAuthorizationFilter))] public async Task SendInvite([FromBody] InviteViewModel invite) { - if (User.Identity.IsAuthenticated && - !DataService.GetUserByNameWithOrg(User.Identity.Name).IsAdministrator) + if (!Request.Headers.TryGetOrganizationId(out var orgId)) { return Unauthorized(); } + if (!ModelState.IsValid) { return BadRequest(); } - if (!Request.Headers.TryGetOrganizationId(out var orgId)) - { - return BadRequest("Organization ID is required."); - } - - if (!DataService.DoesUserExist(invite.InvitedUser)) + if (!_dataService.DoesUserExist(invite.InvitedUser)) { - var result = await DataService.CreateUser(invite.InvitedUser, invite.IsAdmin, orgId); + var result = await _dataService.CreateUser(invite.InvitedUser, invite.IsAdmin, orgId); if (!result.IsSuccess) { return BadRequest("There was an issue creating the new account."); } - var user = await UserManager.FindByEmailAsync(invite.InvitedUser); + var user = await _userManager.FindByEmailAsync(invite.InvitedUser); if (user is null) { return BadRequest("User not found."); } - await UserManager.ConfirmEmailAsync(user, await UserManager.GenerateEmailConfirmationTokenAsync(user)); + await _userManager.ConfirmEmailAsync(user, await _userManager.GenerateEmailConfirmationTokenAsync(user)); return Ok(); } else { - var newInvite = await DataService.AddInvite(orgId, invite); + var newInvite = await _dataService.AddInvite(orgId, invite); if (!newInvite.IsSuccess) { @@ -323,7 +323,7 @@ public class OrganizationManagementController : ControllerBase } var inviteURL = $"{Request.Scheme}://{Request.Host}/Invite?id={newInvite.Value.ID}"; - var emailResult = await EmailSender.SendEmailAsync(invite.InvitedUser, "Invitation to Organization in Remotely", + var emailResult = await _emailSender.SendEmailAsync(invite.InvitedUser, "Invitation to Organization in Remotely", $@"

Hello! diff --git a/Server/API/RemoteControlController.cs b/Server/API/RemoteControlController.cs index 96f70988..34205609 100644 --- a/Server/API/RemoteControlController.cs +++ b/Server/API/RemoteControlController.cs @@ -17,6 +17,7 @@ using Immense.RemoteControl.Server.Abstractions; using Immense.RemoteControl.Shared.Helpers; using Microsoft.Build.Framework; using Microsoft.Extensions.Logging; +using Remotely.Server.Extensions; // For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860 @@ -62,8 +63,12 @@ public class RemoteControlController : ControllerBase [ServiceFilter(typeof(ApiAuthorizationFilter))] public async Task Get(string deviceID) { - Request.Headers.TryGetValue("OrganizationID", out var orgID); - return await InitiateRemoteControl(deviceID, orgID); + if (!Request.Headers.TryGetOrganizationId(out var orgId)) + { + return Unauthorized(); + } + + return await InitiateRemoteControl(deviceID, orgId); } [HttpPost] @@ -74,11 +79,17 @@ public class RemoteControlController : ControllerBase return NotFound(); } - var orgId = _dataService.GetUserByNameWithOrg(rcRequest.Email)?.OrganizationID; + var userResult = await _dataService.GetUserByNameWithOrg(rcRequest.Email); + if (!userResult.IsSuccess) + { + return NotFound(); + } + + var orgId = userResult.Value.OrganizationID; var result = await _signInManager.PasswordSignInAsync(rcRequest.Email, rcRequest.Password, false, true); if (result.Succeeded && - _dataService.DoesUserHaveAccessToDevice(rcRequest.DeviceID, _dataService.GetUserByNameWithOrg(rcRequest.Email))) + _dataService.DoesUserHaveAccessToDevice(rcRequest.DeviceID, userResult.Value)) { _logger.LogInformation("API login successful for {rcRequestEmail}.", rcRequest.Email); return await InitiateRemoteControl(rcRequest.DeviceID, orgId); @@ -110,12 +121,20 @@ public class RemoteControlController : ControllerBase return Unauthorized(); } - if (User.Identity.IsAuthenticated && - !_dataService.DoesUserHaveAccessToDevice(targetDevice.ID, _dataService.GetUserByNameWithOrg(User.Identity.Name))) + if (User.Identity?.IsAuthenticated == true) { - return Unauthorized(); - } + var userResult = await _dataService.GetUserByNameWithOrg($"{User.Identity.Name}"); + if (!userResult.IsSuccess) + { + return Unauthorized(); + } + + if (!_dataService.DoesUserHaveAccessToDevice(targetDevice.ID, userResult.Value)) + { + return Unauthorized(); + } + } var sessionCount = _remoteControlSessionCache.Sessions .OfType() diff --git a/Server/API/ScriptResultsController.cs b/Server/API/ScriptResultsController.cs index 44233daf..917f7813 100644 --- a/Server/API/ScriptResultsController.cs +++ b/Server/API/ScriptResultsController.cs @@ -1,7 +1,10 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Remotely.Server.Auth; +using Remotely.Server.Extensions; using Remotely.Server.Services; using Remotely.Shared.Models; +using System; using System.Text; using System.Threading.Tasks; @@ -28,20 +31,26 @@ public class ScriptResultsController : ControllerBase [ServiceFilter(typeof(ApiAuthorizationFilter))] public ActionResult DownloadAll() { - Request.Headers.TryGetValue("OrganizationID", out var orgID); + if (!Request.Headers.TryGetOrganizationId(out var orgId)) + { + return Unauthorized(); + } - var commandResults = _dataService.GetAllCommandResults(orgID); + var commandResults = _dataService.GetAllCommandResults(orgId); var content = System.Text.Json.JsonSerializer.Serialize(commandResults); return File(Encoding.UTF8.GetBytes(content), "application/octet-stream", "ScriptHistory.json"); } [HttpGet("{scriptId}")] [ServiceFilter(typeof(ApiAuthorizationFilter))] - public FileResult DownloadResults(string scriptId) + public ActionResult DownloadResults(string scriptId) { - Request.Headers.TryGetValue("OrganizationID", out var orgID); + if (!Request.Headers.TryGetOrganizationId(out var orgId)) + { + return Unauthorized(); + } - var commandResult = _dataService.GetScriptResult(scriptId, orgID); + var commandResult = _dataService.GetScriptResult(scriptId, orgId); var content = System.Text.Json.JsonSerializer.Serialize(commandResult); return File(Encoding.UTF8.GetBytes(content), "application/octet-stream", "ScriptResults.json"); } @@ -49,40 +58,55 @@ public class ScriptResultsController : ControllerBase [HttpPost] [ServiceFilter(typeof(ExpiringTokenFilter))] - public async Task Post([FromBody] ScriptResult result) + public async Task> Post([FromBody] ScriptResult result) { _dataService.AddOrUpdateScriptResult(result); + var errorOut = result.ErrorOutput ?? Array.Empty(); + if (result.HadErrors && result.SavedScriptId.HasValue) { - var savedScript = await _dataService.GetSavedScript(result.SavedScriptId.Value); + var savedScriptResult = await _dataService.GetSavedScript(result.SavedScriptId.Value); + if (!savedScriptResult.IsSuccess) + { + return NotFound(); + } + + var savedScript = savedScriptResult.Value; if (savedScript.GenerateAlertOnError) { await _dataService.AddAlert(result.DeviceID, result.OrganizationID, $"Alert triggered while running script {savedScript.Name}.", - string.Join("\n", result.ErrorOutput)); + string.Join("\n", errorOut)); } if (savedScript.SendEmailOnError) { - var device = _dataService.GetDevice(result.DeviceID); + var deviceResult = await _dataService.GetDevice( + result.DeviceID, + query => query.Include(x => x.DeviceGroup)); - if (!string.IsNullOrWhiteSpace(device.DeviceGroupID)) + if (!deviceResult.IsSuccess) { - device.DeviceGroup = await _dataService.GetDeviceGroup(device.DeviceGroupID); + return NotFound(); } - await _emailSender.SendEmailAsync(savedScript.SendErrorEmailTo, - "Script Run Alert", - $"An alert was triggered while running script {savedScript.Name} on device {device.DeviceName}.

" + - $"Device ID: {device.ID}
" + - $"Device Name: {device.DeviceName}
" + - $"Device Alias: {device.Alias}
" + - $"Device Group (if any): {device.DeviceGroup?.Name}
" + + var device = deviceResult.Value; - $"Error Output:

" + - $"{string.Join("

", result.ErrorOutput)}"); + if (!string.IsNullOrWhiteSpace(savedScript.SendErrorEmailTo)) + { + await _emailSender.SendEmailAsync(savedScript.SendErrorEmailTo, + "Script Run Alert", + $"An alert was triggered while running script {savedScript.Name} on device {device.DeviceName}.

" + + $"Device ID: {device.ID}
" + + $"Device Name: {device.DeviceName}
" + + $"Device Alias: {device.Alias}
" + + $"Device Group (if any): {device.DeviceGroup?.Name}
" + + + $"Error Output:

" + + $"{string.Join("

", errorOut)}"); + } } } diff --git a/Server/Auth/ApiAuthorizationFilter.cs b/Server/Auth/ApiAuthorizationFilter.cs index 9a3e63e7..8e5916de 100644 --- a/Server/Auth/ApiAuthorizationFilter.cs +++ b/Server/Auth/ApiAuthorizationFilter.cs @@ -1,72 +1,90 @@ -using Microsoft.AspNetCore.Mvc; +using Immense.RemoteControl.Shared.Extensions; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Build.Framework; +using Microsoft.Extensions.Logging; using Remotely.Server.Services; +using Remotely.Shared; using System; using System.Net; using System.Text; +using System.Threading.Tasks; namespace Remotely.Server.Auth; -public class ApiAuthorizationFilter : IAuthorizationFilter +public class ApiAuthorizationFilter : IAsyncAuthorizationFilter { private readonly IDataService _dataService; + private readonly ILogger _logger; - public ApiAuthorizationFilter(IDataService dataService) + public ApiAuthorizationFilter( + IDataService dataService, + ILogger logger) { _dataService = dataService; + _logger = logger; } - public void OnAuthorization(AuthorizationFilterContext context) + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { - - if (context.HttpContext.User.Identity.IsAuthenticated) + try { - var orgID = _dataService.GetUserByNameWithOrg(context.HttpContext.User.Identity.Name)?.OrganizationID; - context.HttpContext.Request.Headers["OrganizationID"] = orgID; - return; + await Authorize(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while authorizing API key."); + } + } + + private async Task Authorize(AuthorizationFilterContext context) + { + var http = context.HttpContext; + http.Request.Headers["OrganizationID"] = string.Empty; + + if (http.User.Identity?.IsAuthenticated == true) + { + var userResult = await _dataService.GetUserByNameWithOrg($"{http.User.Identity.Name}"); + if (userResult.IsSuccess && userResult.Value.IsAdministrator) + { + http.Request.Headers["OrganizationID"] = userResult.Value.OrganizationID; + return; + } } - if (context.HttpContext.Request.Headers.TryGetValue("Authorization", out var result)) + if (http.Request.Headers.TryGetValue(AppConstants.ApiKeyHeaderName, out var apiHeaderValue)) { - - var headerComponents = result.ToString().Split(" "); + var headerComponents = apiHeaderValue.ToString().Split(":"); if (headerComponents.Length < 2) { - context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + http.Response.StatusCode = (int)HttpStatusCode.Forbidden; context.Result = new UnauthorizedResult(); return; }; - var tokenType = headerComponents[0].Trim(); - var encodedToken = headerComponents[1].Trim(); + var keyId = headerComponents[0].Trim(); + var secret = headerComponents[1].Trim(); - switch (tokenType) + var isValid = await _dataService.ValidateApiKey( + keyId, + secret, + http.Request.Path, + $"{http.Connection.RemoteIpAddress}"); + + if (isValid) { - case "Basic": - byte[] data = Convert.FromBase64String(encodedToken); - string decodedString = Encoding.UTF8.GetString(data); + var keyResult = await _dataService.GetApiKey(keyId); - var authComponents = decodedString.ToString().Split(":"); - if (authComponents.Length < 2) - { - context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; - context.Result = new UnauthorizedResult(); - return; - }; - - var keyId = authComponents[0]?.Trim(); - var apiSecret = authComponents[1]?.Trim(); - if (_dataService.ValidateApiKey(keyId, apiSecret, context.HttpContext.Request.Path, context.HttpContext.Connection.RemoteIpAddress.ToString())) - { - var orgID = _dataService.GetApiKey(keyId)?.OrganizationID; - context.HttpContext.Request.Headers["OrganizationID"] = orgID; - return; - } - break; + if (keyResult.IsSuccess) + { + http.Request.Headers["OrganizationID"] = keyResult.Value.OrganizationID; + return; + } } - } + http.Response.StatusCode = (int)HttpStatusCode.Forbidden; context.Result = new UnauthorizedResult(); } } diff --git a/Server/Auth/ExpiringTokenFilter.cs b/Server/Auth/ExpiringTokenFilter.cs index 3664ca78..14005538 100644 --- a/Server/Auth/ExpiringTokenFilter.cs +++ b/Server/Auth/ExpiringTokenFilter.cs @@ -1,24 +1,23 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Remotely.Server.Models; using Remotely.Server.Services; +using Remotely.Shared; using Remotely.Shared.Utilities; using System; -using System.Collections.Generic; -using System.Linq; +using System.Net; using System.Threading.Tasks; namespace Remotely.Server.Auth; -public class ExpiringTokenFilter : ActionFilterAttribute, IAuthorizationFilter +public class ExpiringTokenFilter : ActionFilterAttribute, IAsyncAuthorizationFilter { private readonly IDataService _dataService; private readonly IExpiringTokenService _expiringTokenService; private readonly ILogger _logger; - public ExpiringTokenFilter(IExpiringTokenService expiringTokenService, + public ExpiringTokenFilter( + IExpiringTokenService expiringTokenService, IDataService dataService, ILogger logger) { @@ -27,41 +26,70 @@ public class ExpiringTokenFilter : ActionFilterAttribute, IAuthorizationFilter _logger = logger; } - public void OnAuthorization(AuthorizationFilterContext context) + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { - if (context.HttpContext.User.Identity.IsAuthenticated) + try + { + await Authorize(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while authorizing expiring token."); + } + } + + private async Task Authorize(AuthorizationFilterContext context) + { + var http = context.HttpContext; + + if (http.User.Identity?.IsAuthenticated == true) { return; } - if (!context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authorization)) + if (http.Request.Headers.TryGetValue(AppConstants.ApiKeyHeaderName, out var apiHeaderValue)) { - context.Result = new UnauthorizedResult(); - return; - } - - if (authorization.ToString().Contains(":")) - { - var keyId = authorization.ToString().Split(":")[0]?.Trim(); - var apiSecret = authorization.ToString().Split(":")[1]?.Trim(); - - if (_dataService.ValidateApiKey(keyId, apiSecret, context.HttpContext.Request.Path, context.HttpContext.Connection.RemoteIpAddress.ToString())) + var headerComponents = apiHeaderValue.ToString().Split(":"); + if (headerComponents.Length < 2) { - var orgID = _dataService.GetApiKey(keyId)?.OrganizationID; - context.HttpContext.Request.Headers["OrganizationID"] = orgID; + http.Response.StatusCode = (int)HttpStatusCode.Forbidden; + context.Result = new UnauthorizedResult(); + return; + }; + + var keyId = headerComponents[0].Trim(); + var secret = headerComponents[1].Trim(); + + var isValid = await _dataService.ValidateApiKey( + keyId, + secret, + http.Request.Path, + $"{http.Connection.RemoteIpAddress}"); + + if (isValid) + { + var keyResult = await _dataService.GetApiKey(keyId); + + if (keyResult.IsSuccess) + { + _logger.LogDebug("Expiring token authorized via API key. Key ID: {keyId}.", keyId); + http.Request.Headers["OrganizationID"] = keyResult.Value.OrganizationID; + return; + } + } + } + + if (http.Request.Headers.TryGetValue(AppConstants.ExpiringTokenHeaderName, out var expiringToken)) + { + if (_expiringTokenService.TryGetExpiration(expiringToken.ToString(), out var expiration) && + expiration > Time.Now) + { + _logger.LogDebug("Expiring token authorized. Token: {token}. Expiration: {expiration}", expiringToken, expiration); return; } } - - if (_expiringTokenService.TryGetExpiration(authorization.ToString(), out var expiration) && - expiration > DateTimeOffset.Now) - { - _logger.LogDebug("Expiring token authorized. Token: {token}. Expiration: {expiration}", authorization, expiration); - return; - } - - _logger.LogDebug("Expiring token not authorized. Token: {token}.", authorization); + _logger.LogDebug("Expiring token not authorization failed."); context.Result = new UnauthorizedResult(); } } diff --git a/Server/Extensions/IHeaderDictionaryExtensions.cs b/Server/Extensions/IHeaderDictionaryExtensions.cs index 82184f35..e1900627 100644 --- a/Server/Extensions/IHeaderDictionaryExtensions.cs +++ b/Server/Extensions/IHeaderDictionaryExtensions.cs @@ -1,10 +1,18 @@ using Microsoft.AspNetCore.Http; using System.Diagnostics.CodeAnalysis; +using Remotely.Server.Auth; namespace Remotely.Server.Extensions; public static class IHeaderDictionaryExtensions { + /// + /// If true, ensures that the user was authorized via , + /// and is either an Administrator or is using a valid API access token. + /// + /// + /// + /// public static bool TryGetOrganizationId( this IHeaderDictionary headers, [NotNullWhen(true)] out string? organizationId) diff --git a/Server/Pages/ApiKeys.razor b/Server/Pages/ApiKeys.razor index ddb40a5c..2938ebba 100644 --- a/Server/Pages/ApiKeys.razor +++ b/Server/Pages/ApiKeys.razor @@ -93,7 +93,12 @@ else var secret = RandomGenerator.GenerateString(36); var secretHash = new PasswordHasher().HashPassword(string.Empty, secret); - await DataService.CreateApiToken(Username, _createKeyName, secretHash); + var result = await DataService.CreateApiToken(Username, _createKeyName, secretHash); + if (!result.IsSuccess) + { + ToastService.ShowToast2(result.Reason, ToastType.Error); + return; + } RefreshData(); _alertMessage = "Key created."; _newKeySecret = secret; @@ -121,9 +126,9 @@ else { _apiTokens.Clear(); _apiTokens.AddRange(DataService.GetAllApiTokens(User.Id)); - _createKeyName = null; - _alertMessage = null; - _newKeySecret = null; + _createKeyName = string.Empty; + _alertMessage = string.Empty; + _newKeySecret = string.Empty; } @@ -142,8 +147,8 @@ else { ModalService.ShowModal("Using API Keys", new[] { - "API keys should be added to the request header when making API calls. The key should be \"Authorization\", and value should be \"{key-id}:{key-secret}\". Note the colon in between.", - "Example: Authorization=e5da1c09-e851-4bd4-a8c1-532144b3f894:7uY6h5zBYm4+90pZVek4lD6ewbQ83nKcDpghBfG00hhZu6Ew" + "API keys should be added to the request header when making API calls. The key should be \"X-Api-Key\", and value should be \"{key-id}:{key-secret}\". Note the colon in between.", + "Example: X-Api-Key=e5da1c09-e851-4bd4-a8c1-532144b3f894:7uY6h5zBYm4+90pZVek4lD6ewbQ83nKcDpghBfG00hhZu6Ew" }); } } diff --git a/Server/Services/AuthService.cs b/Server/Services/AuthService.cs index 2cb38d51..a6171749 100644 --- a/Server/Services/AuthService.cs +++ b/Server/Services/AuthService.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Components.Authorization; +using Immense.RemoteControl.Shared; +using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Identity; using Remotely.Shared.Models; using System; @@ -13,7 +14,7 @@ namespace Remotely.Server.Services; public interface IAuthService { Task IsAuthenticated(); - Task GetUser(); + Task> GetUser(); } public class AuthService : IAuthService @@ -35,15 +36,15 @@ public class AuthService : IAuthService return principal?.User?.Identity?.IsAuthenticated ?? false; } - public async Task GetUser() + public async Task> GetUser() { var principal = await _authProvider.GetAuthenticationStateAsync(); if (principal?.User?.Identity?.IsAuthenticated == true) { - return await _dataService.GetUserAsync(principal.User.Identity.Name); + return await _dataService.GetUserAsync($"{principal.User.Identity.Name}"); } - return null; + return Result.Fail("Not authenticated."); } } diff --git a/Server/Services/DataService.cs b/Server/Services/DataService.cs index 237530de..1efea121 100644 --- a/Server/Services/DataService.cs +++ b/Server/Services/DataService.cs @@ -116,7 +116,9 @@ public interface IDataService Task> GetDefaultOrganization(); - Task> GetDevice(string deviceId); + Task> GetDevice( + string deviceId, + Action>? includesBuilder = null); Task> GetDevice(string orgId, string deviceId); @@ -1268,11 +1270,18 @@ public class DataService : IDataService return Result.Ok(device); } - public async Task> GetDevice(string deviceId) + public async Task> GetDevice( + string deviceId, + Action>? includesBuilder = null) { using var dbContext = _appDbFactory.GetContext(); - - var device = await dbContext.Devices.FirstOrDefaultAsync(x => x.ID == deviceId); + + var query = dbContext.Devices.AsQueryable(); + if (includesBuilder is not null) + { + includesBuilder(query); + } + var device = await query.FirstOrDefaultAsync(x => x.ID == deviceId); if (device is null) { @@ -1710,7 +1719,7 @@ public class DataService : IDataService public async Task> GetUserByNameWithOrg(string userName) { - if (userName == null) + if (string.IsNullOrWhiteSpace(userName)) { return Result.Fail("Username cannot be empty."); } diff --git a/Server/Shared/NavMenu.razor b/Server/Shared/NavMenu.razor index e56e0b4b..2f4f1e27 100644 --- a/Server/Shared/NavMenu.razor +++ b/Server/Shared/NavMenu.razor @@ -131,29 +131,44 @@ -@code { +@code { private bool collapseNavMenu = true; - private RemotelyUser _user; - private Organization _organization; + private RemotelyUser? _user; + private Organization? _organization; protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - _user = await AuthService.GetUser(); + var userResult = await AuthService.GetUser(); + + if (!userResult.IsSuccess) + { + return; + } + + _user = userResult.Value; if (!string.IsNullOrWhiteSpace(_user?.OrganizationID)) { - _organization = DataService.GetOrganizationById(_user.OrganizationID); + var orgResult = await DataService.GetOrganizationById(_user.OrganizationID); + if (orgResult.IsSuccess) + { + _organization = orgResult.Value; + } } else { - _organization = await DataService.GetDefaultOrganization(); + var orgResult = await DataService.GetDefaultOrganization(); + if (orgResult.IsSuccess) + { + _organization = orgResult.Value; + } } } - private string NavMenuCssClass => collapseNavMenu ? "collapse" : null; + private string NavMenuCssClass => collapseNavMenu ? "collapse" : ""; private void ToggleNavMenu() diff --git a/Shared/AppConstants.cs b/Shared/AppConstants.cs index c2179fb3..d2ab8a69 100644 --- a/Shared/AppConstants.cs +++ b/Shared/AppConstants.cs @@ -13,6 +13,8 @@ public class AppConstants public const int EmbeddedDataBlockLength = 256; public const long MaxUploadFileSize = 100_000_000; public const double ScriptRunExpirationMinutes = 30; + public const string ApiKeyHeaderName = "X-Api-Key"; + public const string ExpiringTokenHeaderName = "X-Expiring-Token"; #pragma warning disable IDE0230 // Use UTF-8 string literal public static byte[] EmbeddedImmySignature { get; } = new byte[] { 73, 109, 109, 121, 66, 111, 116, 32, 114, 111, 99, 107, 115, 32, 116, 104, 101, 32, 115, 111, 99, 107, 115, 32, 117, 110, 116, 105, 108, 32, 116, 104, 101, 32, 101, 113, 117, 105, 110, 111, 120, 33 }; diff --git a/submodules/Immense.RemoteControl b/submodules/Immense.RemoteControl index d82d3572..4de148bc 160000 --- a/submodules/Immense.RemoteControl +++ b/submodules/Immense.RemoteControl @@ -1 +1 @@ -Subproject commit d82d3572b7ae9d1b5091a0b83b0af9ffef663119 +Subproject commit 4de148bc4f37bf625186155373dd176e6c492207