diff --git a/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs b/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs index 97eabc2..294b00b 100644 --- a/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs +++ b/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs @@ -44,6 +44,7 @@ namespace Notesnook.API.Accessors public Repository UsersSettings { get; } public Repository Monographs { get; } public Repository InboxApiKey { get; } + public SyncItemsRepository InboxItems { get; } public SyncItemsRepositoryAccessor(IDbContext dbContext, @@ -71,6 +72,8 @@ namespace Notesnook.API.Accessors IMongoCollection vaults, [FromKeyedServices(Collections.TagsKey)] IMongoCollection tags, + [FromKeyedServices(Collections.InboxItems)] + IMongoCollection inboxItems, Repository usersSettings, Repository monographs, Repository inboxApiKey) @@ -90,6 +93,7 @@ namespace Notesnook.API.Accessors Colors = new SyncItemsRepository(dbContext, colors); Vaults = new SyncItemsRepository(dbContext, vaults); Tags = new SyncItemsRepository(dbContext, tags); + InboxItems = new SyncItemsRepository(dbContext, inboxItems); } } } \ No newline at end of file diff --git a/Notesnook.API/Authorization/InboxApiKeyAuthenticationHandler.cs b/Notesnook.API/Authorization/InboxApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..66c3899 --- /dev/null +++ b/Notesnook.API/Authorization/InboxApiKeyAuthenticationHandler.cs @@ -0,0 +1,101 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Notesnook.API.Models; +using Streetwriters.Data.Repositories; + +namespace Notesnook.API.Authorization +{ + public static class InboxApiKeyAuthenticationDefaults + { + public const string AuthenticationScheme = "InboxApiKey"; + } + + public class InboxApiKeyAuthenticationSchemeOptions : AuthenticationSchemeOptions + { + } + + public class InboxApiKeyAuthenticationHandler : AuthenticationHandler + { + private readonly Repository _inboxApiKeyRepository; + + public InboxApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + Repository inboxApiKeyRepository) + : base(options, logger, encoder) + { + _inboxApiKeyRepository = inboxApiKeyRepository; + } + + protected override async Task HandleAuthenticateAsync() + { + if (!Request.Headers.ContainsKey("Authorization")) + { + return AuthenticateResult.Fail("Missing Authorization header"); + } + + var apiKey = Request.Headers["Authorization"].ToString().Trim(); + if (string.IsNullOrEmpty(apiKey)) + { + return AuthenticateResult.Fail("Missing API key"); + } + + try + { + var inboxApiKey = await _inboxApiKeyRepository.FindOneAsync(k => k.Key == apiKey); + if (inboxApiKey == null) + { + return AuthenticateResult.Fail("Invalid API key"); + } + + if (inboxApiKey.ExpiryDate > 0 && DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() > inboxApiKey.ExpiryDate) + { + return AuthenticateResult.Fail("API key has expired"); + } + + inboxApiKey.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + await _inboxApiKeyRepository.UpsertAsync(inboxApiKey, k => k.Key == apiKey); + + var claims = new[] + { + new Claim("sub", inboxApiKey.UserId), + }; + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return AuthenticateResult.Success(ticket); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error validating inbox API key"); + return AuthenticateResult.Fail("Error validating API key"); + } + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Constants.cs b/Notesnook.API/Constants.cs index e77818f..6e6e366 100644 --- a/Notesnook.API/Constants.cs +++ b/Notesnook.API/Constants.cs @@ -14,6 +14,7 @@ namespace Notesnook.API public const string TagsKey = "tags"; public const string ColorsKey = "colors"; public const string VaultsKey = "vaults"; + public const string InboxItems = "inbox_items"; public const string InboxApiKeysKey = "inbox_api_keys"; } } \ No newline at end of file diff --git a/Notesnook.API/Controllers/InboxController.cs b/Notesnook.API/Controllers/InboxController.cs index a65833d..d3595a0 100644 --- a/Notesnook.API/Controllers/InboxController.cs +++ b/Notesnook.API/Controllers/InboxController.cs @@ -22,25 +22,36 @@ using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using MongoDB.Bson; +using Notesnook.API.Authorization; +using Notesnook.API.Interfaces; using Notesnook.API.Models; +using Notesnook.API.Repositories; using Streetwriters.Common; using Streetwriters.Data.Repositories; namespace Notesnook.API.Controllers { [ApiController] - [Authorize] [Route("inbox")] public class InboxController : ControllerBase { private readonly Repository InboxApiKey; + private readonly Repository UserSetting; + private SyncItemsRepository InboxItems; - public InboxController(Repository inboxApiKeysRepository) + public InboxController( + Repository inboxApiKeysRepository, + Repository userSettingsRepository, + ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor) { InboxApiKey = inboxApiKeysRepository; + UserSetting = userSettingsRepository; + InboxItems = syncItemsRepositoryAccessor.InboxItems; } [HttpGet("api-keys")] + [Authorize(Policy = "Notesnook")] public async Task GetApiKeysAsync() { var userId = User.FindFirstValue("sub"); @@ -57,6 +68,7 @@ namespace Notesnook.API.Controllers } [HttpPost("api-keys")] + [Authorize(Policy = "Notesnook")] public async Task CreateApiKeyAsync([FromBody] InboxApiKey request) { var userId = User.FindFirstValue("sub"); @@ -96,6 +108,7 @@ namespace Notesnook.API.Controllers } [HttpDelete("api-keys/{apiKey}")] + [Authorize(Policy = "Notesnook")] public async Task DeleteApiKeyAsync(string apiKey) { var userId = User.FindFirstValue("sub"); @@ -115,5 +128,74 @@ namespace Notesnook.API.Controllers return BadRequest(new { error = ex.Message }); } } + + [HttpGet("public-encryption-key")] + [Authorize(Policy = InboxApiKeyAuthenticationDefaults.AuthenticationScheme)] + public async Task GetPublicKeyAsync() + { + var userId = User.FindFirstValue("sub"); + try + { + var userSetting = await UserSetting.FindOneAsync(u => u.UserId == userId); + if (string.IsNullOrWhiteSpace(userSetting?.InboxKeys?.Public)) + { + return BadRequest(new { error = "Inbox public key is not configured." }); + } + return Ok(new { key = userSetting.InboxKeys.Public }); + } + catch (Exception ex) + { + await Slogger.Error(nameof(GetPublicKeyAsync), "Couldn't get user's inbox's public key.", userId, ex.ToString()); + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPost("items")] + [Authorize(Policy = InboxApiKeyAuthenticationDefaults.AuthenticationScheme)] + public async Task CreateInboxItemAsync([FromBody] InboxSyncItem request) + { + var userId = User.FindFirstValue("sub"); + try + { + if (request.Key.Algorithm != Algorithms.XSAL_X25519_7) + { + return BadRequest(new { error = $"Only {Algorithms.XSAL_X25519_7} is supported for inbox item password." }); + } + if (string.IsNullOrWhiteSpace(request.Key.Cipher)) + { + return BadRequest(new { error = "Inbox item password cipher is required." }); + } + if (request.Key.Length <= 0) + { + return BadRequest(new { error = "Valid inbox item password length is required." }); + } + if (request.Algorithm != Algorithms.Default) + { + return BadRequest(new { error = $"Only {Algorithms.Default} is supported for inbox item." }); + } + if (request.Version <= 0) + { + return BadRequest(new { error = "Valid inbox item version is required." }); + } + if (string.IsNullOrWhiteSpace(request.Cipher) || string.IsNullOrWhiteSpace(request.IV)) + { + return BadRequest(new { error = "Inbox item cipher and iv is required." }); + } + if (request.Length <= 0) + { + return BadRequest(new { error = "Valid inbox item length is required." }); + } + + request.UserId = userId; + request.ItemId = ObjectId.GenerateNewId().ToString(); + await InboxItems.InsertAsync(request); + return Ok(); + } + catch (Exception ex) + { + await Slogger.Error(nameof(CreateInboxItemAsync), "Couldn't create inbox item.", userId, ex.ToString()); + return BadRequest(new { error = ex.Message }); + } + } } } diff --git a/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs b/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs index 6e7eaca..507f2c1 100644 --- a/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs +++ b/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs @@ -38,6 +38,7 @@ namespace Notesnook.API.Interfaces SyncItemsRepository Colors { get; } SyncItemsRepository Vaults { get; } SyncItemsRepository Tags { get; } + SyncItemsRepository InboxItems { get; } Repository UsersSettings { get; } Repository Monographs { get; } Repository InboxApiKey { get; } diff --git a/Notesnook.API/Models/Algorithms.cs b/Notesnook.API/Models/Algorithms.cs index cea365a..91d4e2a 100644 --- a/Notesnook.API/Models/Algorithms.cs +++ b/Notesnook.API/Models/Algorithms.cs @@ -22,5 +22,6 @@ namespace Notesnook.API.Models public class Algorithms { public static string Default => "xcha-argon2i13-7"; + public static string XSAL_X25519_7 => "xsal-x25519-7"; } } \ No newline at end of file diff --git a/Notesnook.API/Models/InboxSyncItem.cs b/Notesnook.API/Models/InboxSyncItem.cs new file mode 100644 index 0000000..1f782b2 --- /dev/null +++ b/Notesnook.API/Models/InboxSyncItem.cs @@ -0,0 +1,70 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Notesnook.API.Models +{ + + [MessagePack.MessagePackObject] + public class InboxSyncItem : SyncItem + { + [DataMember(Name = "key")] + [JsonPropertyName("key")] + [MessagePack.Key("key")] + [Required] + public EncryptedKey Key + { + get; set; + } + } + + [MessagePack.MessagePackObject] + public class EncryptedKey + { + [DataMember(Name = "alg")] + [JsonPropertyName("alg")] + [MessagePack.Key("alg")] + [Required] + public string Algorithm + { + get; set; + } + + [DataMember(Name = "cipher")] + [JsonPropertyName("cipher")] + [MessagePack.Key("cipher")] + [Required] + public string Cipher + { + get; set; + } + + [JsonPropertyName("length")] + [DataMember(Name = "length")] + [MessagePack.Key("length")] + [Required] + public long Length + { + get; set; + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Models/SyncItem.cs b/Notesnook.API/Models/SyncItem.cs index dbfc3aa..7d04537 100644 --- a/Notesnook.API/Models/SyncItem.cs +++ b/Notesnook.API/Models/SyncItem.cs @@ -111,7 +111,7 @@ namespace Notesnook.API.Models public string Algorithm { get; set; - } = Algorithms.Default; + } } public class SyncItemBsonSerializer : SerializerBase diff --git a/Notesnook.API/Repositories/SyncItemsRepository.cs b/Notesnook.API/Repositories/SyncItemsRepository.cs index 64746bb..3b413e0 100644 --- a/Notesnook.API/Repositories/SyncItemsRepository.cs +++ b/Notesnook.API/Repositories/SyncItemsRepository.cs @@ -46,7 +46,7 @@ namespace Notesnook.API.Repositories this.collectionName = collection.CollectionNamespace.CollectionName; } - private readonly List ALGORITHMS = [Algorithms.Default]; + private readonly List ALGORITHMS = [Algorithms.Default, Algorithms.XSAL_X25519_7]; private bool IsValidAlgorithm(string algorithm) { return ALGORITHMS.Contains(algorithm); diff --git a/Notesnook.API/Startup.cs b/Notesnook.API/Startup.cs index d91b6dc..f70dd03 100644 --- a/Notesnook.API/Startup.cs +++ b/Notesnook.API/Startup.cs @@ -119,6 +119,11 @@ namespace Notesnook.API policy.Requirements.Add(new SyncRequirement()); policy.Requirements.Add(new ProUserRequirement()); }); + options.AddPolicy(InboxApiKeyAuthenticationDefaults.AuthenticationScheme, policy => + { + policy.AuthenticationSchemes.Add(InboxApiKeyAuthenticationDefaults.AuthenticationScheme); + policy.RequireAuthenticatedUser(); + }); options.DefaultPolicy = options.GetPolicy("Notesnook"); }).AddSingleton(); ; @@ -152,7 +157,11 @@ namespace Notesnook.API options.SaveToken = true; options.EnableCaching = true; options.CacheDuration = TimeSpan.FromMinutes(30); - }); + }) + .AddScheme( + InboxApiKeyAuthenticationDefaults.AuthenticationScheme, + options => { } + ); // Serializer.RegisterSerializer(new SyncItemBsonSerializer()); if (!BsonClassMap.IsClassMapRegistered(typeof(UserSettings))) @@ -184,6 +193,7 @@ namespace Notesnook.API .AddMongoCollection(Collections.TagsKey) .AddMongoCollection(Collections.ColorsKey) .AddMongoCollection(Collections.VaultsKey) + .AddMongoCollection(Collections.InboxItems) .AddMongoCollection(Collections.InboxApiKeysKey); services.AddScoped();