7 Commits

Author SHA1 Message Date
Abdullah Atta
5b1de7cc62 monograph: fix typo 2026-02-25 15:42:58 +05:00
Abdullah Atta
3f2ba697bc monograph: fix monograph content sanitization 2026-02-25 15:39:25 +05:00
Abdullah Atta
9ae5db378d identity: simplify user sign up 2026-02-16 13:43:04 +05:00
Abdullah Atta
d5790d8785 api: minor refactor 2026-02-16 13:43:04 +05:00
Abdullah Atta
9424afed68 api: move to atomic password reset 2026-02-16 13:43:04 +05:00
01zulfi
b9385ae112 s3: add bulk delete api (#82) 2026-02-13 11:29:15 +05:00
Abdullah Atta
014c4e3b32 data: configure mongodb using connection string 2026-02-02 22:33:32 +05:00
30 changed files with 648 additions and 327 deletions

View File

@@ -35,6 +35,8 @@ using Notesnook.API.Authorization;
using Notesnook.API.Models;
using Notesnook.API.Services;
using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Helpers;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages;
@@ -46,7 +48,7 @@ namespace Notesnook.API.Controllers
[ApiController]
[Route("monographs")]
[Authorize("Sync")]
public class MonographsController(Repository<Monograph> monographs, IURLAnalyzer analyzer, SyncDeviceService syncDeviceService, ILogger<MonographsController> logger) : ControllerBase
public class MonographsController(Repository<Monograph> monographs, IURLAnalyzer analyzer, SyncDeviceService syncDeviceService, WampServiceAccessor serviceAccessor, ILogger<MonographsController> logger) : ControllerBase
{
const string SVG_PIXEL = "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'><circle r='9'/></svg>";
private const int MAX_DOC_SIZE = 15 * 1024 * 1024;
@@ -107,7 +109,11 @@ namespace Notesnook.API.Controllers
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
if (monograph.EncryptedContent == null)
monograph.CompressedContent = (await CleanupContentAsync(User, monograph.Content)).CompressBrotli();
{
var sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
monograph.ContentSanitizationLevel = sanitizationLevel;
}
monograph.UserId = userId;
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -158,8 +164,12 @@ namespace Notesnook.API.Controllers
if (monograph.EncryptedContent?.Cipher.Length > MAX_DOC_SIZE || monograph.CompressedContent?.Length > MAX_DOC_SIZE)
return base.BadRequest("Monograph is too big. Max allowed size is 15mb.");
var sanitizationLevel = ContentSanitizationLevel.Unknown;
if (monograph.EncryptedContent == null)
monograph.CompressedContent = (await CleanupContentAsync(User, monograph.Content)).CompressBrotli();
{
sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
}
else
monograph.Content = null;
@@ -173,6 +183,7 @@ namespace Notesnook.API.Controllers
.Set(m => m.SelfDestruct, monograph.SelfDestruct)
.Set(m => m.Title, monograph.Title)
.Set(m => m.Password, monograph.Password)
.Set(m => m.ContentSanitizationLevel, sanitizationLevel)
);
if (!result.IsAcknowledged) return BadRequest();
@@ -223,7 +234,22 @@ namespace Notesnook.API.Controllers
}
if (monograph.EncryptedContent == null)
{
var isContentUnsanitized = monograph.ContentSanitizationLevel == ContentSanitizationLevel.Partial || monograph.ContentSanitizationLevel == ContentSanitizationLevel.Unknown;
if (!Constants.IS_SELF_HOSTED && isContentUnsanitized && serviceAccessor.UserSubscriptionService != null && !await serviceAccessor.UserSubscriptionService.IsUserSubscribedAsync(Clients.Notesnook.Id, monograph.UserId!))
{
var cleaned = await SanitizeContentAsync(monograph.CompressedContent?.DecompressBrotli(), ContentSanitizationLevel.Full);
monograph.CompressedContent = cleaned.CompressBrotli();
await monographs.Collection.UpdateOneAsync(
CreateMonographFilter(monograph.UserId!, monograph),
Builders<Monograph>.Update
.Set(m => m.CompressedContent, monograph.CompressedContent)
.Set(m => m.ContentSanitizationLevel, ContentSanitizationLevel.Full)
);
}
monograph.Content = monograph.CompressedContent?.DecompressBrotli();
}
monograph.ItemId ??= monograph.Id;
return Ok(monograph);
}
@@ -241,7 +267,7 @@ namespace Notesnook.API.Controllers
if (monograph.SelfDestruct)
{
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(monograph.UserId, monograph),
CreateMonographFilter(monograph.UserId!, monograph),
new Monograph
{
ItemId = id,
@@ -251,12 +277,12 @@ namespace Notesnook.API.Controllers
ViewCount = 0
}
);
await MarkMonographForSyncAsync(monograph.UserId, id);
await MarkMonographForSyncAsync(monograph.UserId!, id);
}
else if (!hasVisitedBefore)
{
await monographs.Collection.UpdateOneAsync(
CreateMonographFilter(monograph.UserId, monograph),
CreateMonographFilter(monograph.UserId!, monograph),
Builders<Monograph>.Update.Inc(m => m.ViewCount, 1)
);
@@ -329,7 +355,20 @@ namespace Notesnook.API.Controllers
await syncDeviceService.AddIdsToAllDevicesAsync(userId, [new(monographId, "monograph")]);
}
private async Task<string> CleanupContentAsync(ClaimsPrincipal user, string? content)
// (selector, url-bearing attribute) pairs to inspect
private static readonly (string Selector, string Attribute)[] urlElements =
[
("a", "href"),
("img", "src"),
("iframe", "src"),
("embed", "src"),
("object", "data"),
("source", "src"),
("video", "src"),
("audio", "src"),
];
private async Task<string> SanitizeContentAsync(string? content, ContentSanitizationLevel level)
{
if (string.IsNullOrEmpty(content)) return string.Empty;
if (Constants.IS_SELF_HOSTED) return content;
@@ -338,31 +377,36 @@ namespace Notesnook.API.Controllers
var json = JsonSerializer.Deserialize<MonographContent>(content) ?? throw new Exception("Invalid monograph content.");
var html = json.Data;
if (user.IsUserSubscribed())
if (level == ContentSanitizationLevel.Partial)
{
var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(r => r.Content(html));
foreach (var element in document.QuerySelectorAll("a"))
foreach (var (selector, attribute) in urlElements)
{
var href = element.GetAttribute("href");
if (string.IsNullOrEmpty(href)) continue;
if (!await analyzer.IsURLSafeAsync(href))
foreach (var element in document.QuerySelectorAll(selector))
{
logger.LogInformation("Malicious URL detected: {Url}", href);
element.RemoveAttribute("href");
var url = element.GetAttribute(attribute);
if (string.IsNullOrEmpty(url)) continue;
if (!await analyzer.IsURLSafeAsync(url))
{
logger.LogInformation("Malicious URL detected in <{Selector} {Attribute}>: {Url}", selector, attribute, url);
element.RemoveAttribute(attribute);
}
}
}
html = document.ToHtml();
}
else
else if (level == ContentSanitizationLevel.Full)
{
var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(r => r.Content(html));
foreach (var element in document.QuerySelectorAll("a,iframe,img,object,svg,button,link"))
{
foreach (var attr in element.Attributes)
foreach (var attr in element.Attributes.ToList())
element.RemoveAttribute(attr.Name);
}
html = document.ToHtml();

View File

@@ -21,20 +21,17 @@ using System;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using Amazon.S3.Model;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using Notesnook.API.Accessors;
using Notesnook.API.Helpers;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Notesnook.API.Controllers
@@ -212,5 +209,26 @@ namespace Notesnook.API.Controllers
return BadRequest(new { error = "Failed to delete attachment." });
}
}
[HttpPost("bulk-delete")]
public async Task<IActionResult> DeleteBulkAsync([FromBody] DeleteBulkObjectsRequest request)
{
try
{
if (request.Names == null || request.Names.Length == 0)
{
return BadRequest(new { error = "No files specified for deletion." });
}
var userId = this.User.GetUserId();
await s3Service.DeleteObjectsAsync(userId, request.Names);
return Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "Error deleting objects for user.");
return BadRequest(new { error = "Failed to delete attachments." });
}
}
}
}

View File

@@ -18,7 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.Net.Http;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Timeouts;
@@ -28,22 +30,25 @@ using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Notesnook.API.Models.Responses;
using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
namespace Notesnook.API.Controllers
{
[ApiController]
[Authorize]
[Route("users")]
public class UsersController(IUserService UserService, ILogger<UsersController> logger) : ControllerBase
public class UsersController(IUserService UserService, WampServiceAccessor serviceAccessor, ILogger<UsersController> logger) : ControllerBase
{
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Signup()
public async Task<IActionResult> Signup([FromForm] SignupForm form)
{
try
{
await UserService.CreateUserAsync();
return Ok();
return Ok(await UserService.CreateUserAsync(form));
}
catch (Exception ex)
{
@@ -85,6 +90,43 @@ namespace Notesnook.API.Controllers
}
}
[HttpPatch("password/{type}")]
public async Task<IActionResult> ChangePassword([FromRoute] string type, [FromBody] ChangePasswordForm form)
{
var userId = User.GetUserId();
var clientId = User.FindFirstValue("client_id");
var jti = User.FindFirstValue("jti");
var isPasswordReset = type == "reset";
try
{
var result = isPasswordReset ? await serviceAccessor.UserAccountService.ResetPasswordAsync(userId, form.NewPassword) : await serviceAccessor.UserAccountService.ChangePasswordAsync(userId, form.OldPassword, form.NewPassword);
if (!result)
return BadRequest("Failed to change password.");
await UserService.SetUserKeysAsync(userId, form.UserKeys);
await serviceAccessor.UserAccountService.ClearSessionsAsync(userId, clientId, all: false, jti, null);
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
{
UserId = userId,
OriginTokenId = jti,
Message = new Message
{
Type = "logout",
Data = JsonSerializer.Serialize(new { reason = "Password changed." })
}
});
return Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to change password");
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("reset")]
public async Task<IActionResult> Reset([FromForm] bool removeAttachments)
{

View File

@@ -17,18 +17,16 @@ You should have received a copy of the Affero GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System.Threading;
using System.Threading.Tasks;
using Amazon.S3.Model;
using Notesnook.API.Models;
using Notesnook.API.Models.Responses;
using Streetwriters.Common.Interfaces;
namespace Notesnook.API.Interfaces
{
public interface IS3Service
{
Task DeleteObjectAsync(string userId, string name);
Task DeleteObjectsAsync(string userId, string[] names);
Task DeleteDirectoryAsync(string userId);
Task<long> GetObjectSizeAsync(string userId, string name);
Task<string?> GetUploadObjectUrlAsync(string userId, string name);

View File

@@ -20,12 +20,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System.Threading.Tasks;
using Notesnook.API.Models;
using Notesnook.API.Models.Responses;
using Streetwriters.Common.Models;
namespace Notesnook.API.Interfaces
{
public interface IUserService
{
Task CreateUserAsync();
Task<SignupResponse> CreateUserAsync(SignupForm form);
Task DeleteUserAsync(string userId);
Task DeleteUserAsync(string userId, string? jti, string password);
Task<bool> ResetUserAsync(string userId, bool removeAttachments);

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
namespace Notesnook.API.Models
{
public class ChangePasswordForm
{
public string? OldPassword
{
get; set;
}
[Required]
public required string NewPassword
{
get; set;
}
[Required]
public required UserKeys UserKeys
{
get; set;
}
}
}

View File

@@ -0,0 +1,38 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
namespace Notesnook.API.Models
{
public enum ContentSanitizationLevel
{
Unknown = 0,
/// <summary>
/// Full sanitization applied: links, iframes, images, and other embeds are stripped.
/// Applied to monographs published by free-tier users.
/// </summary>
Full = 1,
/// <summary>
/// Partial sanitization: only unsafe/malicious URLs are removed; rich content is preserved.
/// Applied to monographs published by subscribed users. Requires re-sanitization if the
/// publisher's subscription lapses.
/// </summary>
Partial = 2
}
}

View File

@@ -0,0 +1,25 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
namespace Notesnook.API.Models;
public class DeleteBulkObjectsRequest
{
public required string[] Names { get; set; }
}

View File

@@ -83,5 +83,8 @@ namespace Notesnook.API.Models
[JsonPropertyName("viewCount")]
public int ViewCount { get; set; }
[JsonIgnore]
public ContentSanitizationLevel ContentSanitizationLevel { get; set; }
}
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace Notesnook.API.Models
{
public class ResetPasswordForm
{
[Required]
public required string NewPassword
{
get; set;
}
[Required]
public required UserKeys UserKeys
{
get; set;
}
}
}

View File

@@ -1,14 +0,0 @@
using System.Text.Json.Serialization;
using Streetwriters.Common.Models;
namespace Notesnook.API.Models.Responses
{
public class SignupResponse : Response
{
[JsonPropertyName("userId")]
public string? UserId { get; set; }
[JsonPropertyName("errors")]
public string[]? Errors { get; set; }
}
}

View File

@@ -15,6 +15,11 @@ namespace Notesnook.API.Models.Responses
[JsonPropertyName("monographPasswordsKey")]
public EncryptedData? MonographPasswordsKey { get; set; }
[JsonPropertyName("dataEncryptionKey")]
public EncryptedData? DataEncryptionKey { get; set; }
[JsonPropertyName("legacyDataEncryptionKey")]
public EncryptedData? LegacyDataEncryptionKey { get; set; }
[JsonPropertyName("inboxKeys")]
public InboxKeys? InboxKeys { get; set; }

View File

@@ -98,6 +98,14 @@ namespace Notesnook.API.Models
get; set;
}
[JsonPropertyName("keyVersion")]
[DataMember(Name = "keyVersion")]
[MessagePack.Key("keyVersion")]
public int? KeyVersion
{
get; set;
}
[JsonPropertyName("alg")]
[DataMember(Name = "alg")]
[MessagePack.Key("alg")]

View File

@@ -24,6 +24,8 @@ namespace Notesnook.API.Models
public EncryptedData? AttachmentsKey { get; set; }
public EncryptedData? MonographPasswordsKey { get; set; }
public InboxKeys? InboxKeys { get; set; }
public EncryptedData? DataEncryptionKey { get; set; }
public EncryptedData? LegacyDataEncryptionKey { get; set; }
}
public class InboxKeys

View File

@@ -55,6 +55,8 @@ namespace Notesnook.API.Models
public EncryptedData? VaultKey { get; set; }
public EncryptedData? AttachmentsKey { get; set; }
public EncryptedData? MonographPasswordsKey { get; set; }
public EncryptedData? DataEncryptionKey { get; set; }
public EncryptedData? LegacyDataEncryptionKey { get; set; }
public InboxKeys? InboxKeys { get; set; }
public Limit? StorageLimit { get; set; }

View File

@@ -24,21 +24,15 @@ using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using Notesnook.API.Accessors;
using Notesnook.API.Helpers;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Notesnook.API.Services
{
@@ -110,6 +104,70 @@ namespace Notesnook.API.Services
throw new Exception("Could not delete object.");
}
public async Task DeleteObjectsAsync(string userId, string[] names)
{
var objectsToDelete = new List<KeyVersion>();
foreach (var name in names)
{
var objectName = GetFullObjectName(userId, name);
if (objectName == null) continue;
objectsToDelete.Add(new KeyVersion { Key = objectName });
}
if (objectsToDelete.Count == 0)
{
return;
}
// S3 DeleteObjectsRequest supports max 1000 keys per request
var batchSize = 1000;
var deleteErrors = new List<DeleteError>();
var failedBatches = 0;
for (int i = 0; i < objectsToDelete.Count; i += batchSize)
{
var batch = objectsToDelete.Skip(i).Take(batchSize).ToList();
var deleteObjectsResponse = await S3InternalClient.ExecuteWithFailoverAsync(
(client) => client.DeleteObjectsAsync(new DeleteObjectsRequest
{
BucketName = INTERNAL_BUCKET_NAME,
Objects = batch,
}),
operationName: "DeleteObjects",
isWriteOperation: true
);
if (!IsSuccessStatusCode((int)deleteObjectsResponse.HttpStatusCode))
{
failedBatches++;
}
if (deleteObjectsResponse.DeleteErrors.Count > 0)
{
deleteErrors.AddRange(deleteObjectsResponse.DeleteErrors);
}
}
if (failedBatches > 0 || deleteErrors.Count > 0)
{
var errorParts = new List<string>();
if (failedBatches > 0)
{
errorParts.Add($"{failedBatches} batch(es) failed with unsuccessful status code");
}
if (deleteErrors.Count > 0)
{
errorParts.Add(string.Join(", ", deleteErrors.Select(e => $"{e.Key}: {e.Message}")));
}
throw new Exception(string.Join("; ", errorParts));
}
}
public async Task DeleteDirectoryAsync(string userId)
{
var request = new ListObjectsV2Request

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
@@ -50,15 +51,16 @@ namespace Notesnook.API.Services
private IS3Service S3Service { get; set; } = s3Service;
private readonly IUnitOfWork unit = unitOfWork;
public async Task CreateUserAsync()
public async Task<SignupResponse> CreateUserAsync(SignupForm form)
{
SignupResponse response = await httpClient.ForwardAsync<SignupResponse>(this.HttpContextAccessor, $"{Servers.IdentityServer}/signup", HttpMethod.Post);
if (!response.Success || (response.Errors != null && response.Errors.Length > 0) || response.UserId == null)
SignupResponse response = await serviceAccessor.UserAccountService.CreateUserAsync(form.ClientId, form.Email, form.Password, HttpContextAccessor.HttpContext?.Request.Headers["User-Agent"].ToString());
if ((response.Errors != null && response.Errors.Length > 0) || response.UserId == null)
{
logger.LogError("Failed to sign up user: {Response}", JsonSerializer.Serialize(response));
if (response.Errors != null && response.Errors.Length > 0)
throw new Exception(string.Join(" ", response.Errors));
else throw new Exception("Could not create a new account. Error code: " + response.StatusCode);
else throw new Exception("Could not create a new account.");
}
await Repositories.UsersSettings.InsertAsync(new UserSettings
@@ -83,7 +85,7 @@ namespace Notesnook.API.Services
});
}
logger.LogInformation("New user created: {Response}", JsonSerializer.Serialize(response));
return response;
}
public async Task<UserResponse> GetUserAsync(string userId)
@@ -133,6 +135,8 @@ namespace Notesnook.API.Services
PhoneNumber = user.PhoneNumber,
AttachmentsKey = userSettings.AttachmentsKey,
MonographPasswordsKey = userSettings.MonographPasswordsKey,
DataEncryptionKey = userSettings.DataEncryptionKey,
LegacyDataEncryptionKey = userSettings.LegacyDataEncryptionKey,
InboxKeys = userSettings.InboxKeys,
Salt = userSettings.Salt,
Subscription = subscription,
@@ -155,6 +159,11 @@ namespace Notesnook.API.Services
{
userSettings.MonographPasswordsKey = keys.MonographPasswordsKey;
}
if (keys.DataEncryptionKey != null)
userSettings.DataEncryptionKey = keys.DataEncryptionKey;
if (keys.LegacyDataEncryptionKey != null)
userSettings.LegacyDataEncryptionKey = keys.LegacyDataEncryptionKey;
if (keys.InboxKeys != null)
{
if (keys.InboxKeys.Public == null || keys.InboxKeys.Private == null)
@@ -268,6 +277,8 @@ namespace Notesnook.API.Services
userSettings.AttachmentsKey = null;
userSettings.MonographPasswordsKey = null;
userSettings.DataEncryptionKey = null;
userSettings.LegacyDataEncryptionKey = null;
userSettings.VaultKey = null;
userSettings.InboxKeys = null;
userSettings.LastSynced = 0;

View File

@@ -10,7 +10,13 @@ namespace Streetwriters.Common.Interfaces
Task<UserModel?> GetUserAsync(string clientId, string userId);
[WampProcedure("co.streetwriters.identity.users.delete_user")]
Task DeleteUserAsync(string clientId, string userId, string password);
// [WampProcedure("co.streetwriters.identity.users.create_user")]
// Task<UserModel> CreateUserAsync();
[WampProcedure("co.streetwriters.identity.users.change_password")]
Task<bool> ChangePasswordAsync(string userId, string oldPassword, string newPassword);
[WampProcedure("co.streetwriters.identity.users.reset_password")]
Task<bool> ResetPasswordAsync(string userId, string newPassword);
[WampProcedure("co.streetwriters.identity.users.clear_sessions")]
Task<bool> ClearSessionsAsync(string userId, string clientId, bool all, string jti, string? refreshToken);
[WampProcedure("co.streetwriters.identity.users.create_user")]
Task<SignupResponse> CreateUserAsync(string clientId, string email, string password, string? userAgent = null);
}
}

View File

@@ -9,6 +9,8 @@ namespace Streetwriters.Common.Interfaces
{
[WampProcedure("co.streetwriters.subscriptions.subscriptions.get_user_subscription")]
Task<Subscription?> GetUserSubscriptionAsync(string clientId, string userId);
[WampProcedure("co.streetwriters.subscriptions.subscriptions.is_user_subscribed")]
Task<bool> IsUserSubscribedAsync(string clientId, string userId);
Subscription TransformUserSubscription(Subscription subscription);
}
}

View File

@@ -21,7 +21,7 @@ using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using Microsoft.AspNetCore.Mvc;
namespace Streetwriters.Identity.Models
namespace Streetwriters.Common.Models
{
public class SignupForm
{

View File

@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Streetwriters.Common.Models;
namespace Streetwriters.Common.Models
{
public class SignupResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; }
[JsonPropertyName("expires_in")]
public int AccessTokenLifetime { get; set; }
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; }
[JsonPropertyName("scope")]
public string Scope { get; set; }
[JsonPropertyName("user_id")]
public string UserId { get; set; }
public string[]? Errors { get; set; }
public static SignupResponse Error(IEnumerable<string> errors)
{
return new SignupResponse
{
Errors = [.. errors]
};
}
}
}

View File

@@ -1,6 +1,9 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Streetwriters.Common.Interfaces;
@@ -22,7 +25,8 @@ namespace Streetwriters.Common.Services
public async Task<bool> IsURLSafeAsync(string uri)
{
if (string.IsNullOrEmpty(Constants.WEBRISK_API_URI)) return true;
var response = await httpClient.PostAsJsonAsync(Constants.WEBRISK_API_URI, new { uri });
var body = new StringContent(JsonSerializer.Serialize(new { uri }), Encoding.UTF8, new MediaTypeHeaderValue("application/json"));
var response = await httpClient.PostAsync(Constants.WEBRISK_API_URI, body);
if (!response.IsSuccessStatusCode) return true;
var json = await response.Content.ReadFromJsonAsync<WebRiskAPIResponse>();
return json.Threat.ThreatTypes == null || json.Threat.ThreatTypes.Length == 0;

View File

@@ -33,9 +33,6 @@ namespace Streetwriters.Data.DbContexts
public static IMongoClient CreateMongoDbClient(IDbSettings dbSettings)
{
var settings = MongoClientSettings.FromConnectionString(dbSettings.ConnectionString);
settings.MaxConnectionPoolSize = 500;
settings.MinConnectionPoolSize = 0;
settings.HeartbeatInterval = TimeSpan.FromSeconds(60);
return new MongoClient(settings);
}

View File

@@ -25,6 +25,7 @@ using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using AspNetCore.Identity.Mongo.Model;
using IdentityServer4.Extensions;
using IdentityServer4.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@@ -37,6 +38,7 @@ using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
using Streetwriters.Identity.Enums;
using Streetwriters.Identity.Extensions;
using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Models;
using Streetwriters.Identity.Services;
@@ -97,12 +99,12 @@ namespace Streetwriters.Identity.Controllers
}
case TokenType.RESET_PASSWORD:
{
// if (!await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", code))
return BadRequest("Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience.");
if (!await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", code))
return BadRequest("Invalid token.");
// var authorizationCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "PasswordResetAuthorizationCode");
// var redirectUrl = $"{client.AccountRecoveryRedirectURL}?userId={userId}&code={authorizationCode}";
// return RedirectPermanent(redirectUrl);
var authorizationCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "PasswordResetAuthorizationCode");
var redirectUrl = $"{client.AccountRecoveryRedirectURL}?userId={userId}&code={authorizationCode}";
return RedirectPermanent(redirectUrl);
}
default:
return BadRequest("Invalid type.");
@@ -124,7 +126,7 @@ namespace Streetwriters.Identity.Controllers
{
ArgumentNullException.ThrowIfNull(user.Email);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL);
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL);
await EmailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client);
}
else
@@ -149,22 +151,22 @@ namespace Streetwriters.Identity.Controllers
[EnableRateLimiting("strict")]
public async Task<IActionResult> ResetUserPassword([FromForm] ResetPasswordForm form)
{
return BadRequest(new { error = "Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." });
// var client = Clients.FindClientById(form.ClientId);
// if (client == null) return BadRequest("Invalid client_id.");
// var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found.");
// if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
var client = Clients.FindClientById(form.ClientId);
if (client == null) return BadRequest("Invalid client_id.");
// var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
// var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
// #if (DEBUG || STAGING)
// return Ok(callbackUrl);
// #else
// logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl);
// await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client);
// return Ok();
// #endif
var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found.");
if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
#if (DEBUG || STAGING)
return Ok(callbackUrl);
#else
logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl);
await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client);
return Ok();
#endif
}
[HttpPost("logout")]
@@ -249,36 +251,6 @@ namespace Streetwriters.Identity.Controllers
}
return BadRequest(result.Errors.ToErrors());
}
case "change_password":
{
return BadRequest(new { error = "Password change is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." });
// ArgumentNullException.ThrowIfNull(form.OldPassword);
// ArgumentNullException.ThrowIfNull(form.NewPassword);
// var result = await UserManager.ChangePasswordAsync(user, form.OldPassword, form.NewPassword);
// if (result.Succeeded)
// {
// await SendLogoutMessageAsync(user.Id.ToString(), "Password changed.");
// return Ok();
// }
// return BadRequest(result.Errors.ToErrors());
}
case "reset_password":
{
return BadRequest(new { error = "Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." });
// ArgumentNullException.ThrowIfNull(form.NewPassword);
// var result = await UserManager.RemovePasswordAsync(user);
// if (result.Succeeded)
// {
// await MFAService.ResetMFAAsync(user);
// result = await UserManager.AddPasswordAsync(user, form.NewPassword);
// if (result.Succeeded)
// {
// await SendLogoutMessageAsync(user.Id.ToString(), "Password reset.");
// return Ok();
// }
// }
// return BadRequest(result.Errors.ToErrors());
}
case "change_marketing_consent":
{
var claimType = $"{client.Id}:marketing_consent";
@@ -297,40 +269,14 @@ namespace Streetwriters.Identity.Controllers
[HttpPost("sessions/clear")]
public async Task<IActionResult> ClearUserSessions([FromQuery] bool all, [FromForm] string? refresh_token)
{
var client = Clients.FindClientById(User.FindFirstValue("client_id"));
if (client == null) return BadRequest("Invalid client_id.");
var user = await UserManager.GetUserAsync(User) ?? throw new Exception("User not found.");
if (!await UserService.IsUserValidAsync(UserManager, user, client.Id)) return BadRequest($"Unable to find user with ID '{user.Id}'.");
var jti = User.FindFirstValue("jti");
var grants = await PersistedGrantStore.GetAllAsync(new PersistedGrantFilter
{
ClientId = client.Id,
SubjectId = user.Id.ToString()
});
string? refreshTokenKey = refresh_token != null ? GetHashedKey(refresh_token, PersistedGrantTypes.RefreshToken) : null;
var removedKeys = new List<string>();
foreach (var grant in grants)
{
if (!all && (grant.Data.Contains(jti) || grant.Key == refreshTokenKey)) continue;
await PersistedGrantStore.RemoveAsync(grant.Key);
removedKeys.Add(grant.Key);
}
await WampServers.NotesnookServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
await WampServers.MessengerServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
await WampServers.SubscriptionServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
await SendLogoutMessageAsync(user.Id.ToString(), "Session revoked.");
var userId = User.GetSubjectId();
var clientId = User.FindFirstValue("client_id");
if (await UserAccountService.ClearSessionsAsync(userId, clientId, all, refresh_token, jti))
await SendLogoutMessageAsync(userId, "Session revoked.");
return Ok();
}
private static string GetHashedKey(string value, string grantType)
{
return (value + ":" + grantType).Sha256();
}
private async Task SendLogoutMessageAsync(string userId, string reason)
{
await SendMessageAsync(userId, new Message

View File

@@ -1,156 +0,0 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using AspNetCore.Identity.Mongo.Model;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using Streetwriters.Common;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Models;
using Streetwriters.Identity.Enums;
using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Models;
using Streetwriters.Identity.Services;
namespace Streetwriters.Identity.Controllers
{
[ApiController]
[Route("signup")]
public class SignupController : IdentityControllerBase
{
private readonly ILogger<SignupController> logger;
private readonly EmailAddressValidator emailValidator;
public SignupController(UserManager<User> _userManager, ITemplatedEmailSender _emailSender,
SignInManager<User> _signInManager, RoleManager<MongoRole> _roleManager, IMFAService _mfaService,
ILogger<SignupController> logger, EmailAddressValidator emailValidator) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService)
{
this.logger = logger;
this.emailValidator = emailValidator;
}
private async Task AddClientRoleAsync(string clientId)
{
if (await RoleManager.FindByNameAsync(clientId) == null)
await RoleManager.CreateAsync(new MongoRole(clientId));
}
[HttpPost]
[AllowAnonymous]
[EnableRateLimiting("strict")]
public async Task<IActionResult> Signup([FromForm] SignupForm form)
{
if (Constants.DISABLE_SIGNUPS)
return BadRequest(new string[] { "Creating new accounts is not allowed." });
try
{
var client = Clients.FindClientById(form.ClientId);
if (client == null) return BadRequest(new string[] { "Invalid client id." });
await AddClientRoleAsync(client.Id);
// email addresses must be case-insensitive
form.Email = form.Email.ToLowerInvariant();
form.Username = form.Username?.ToLowerInvariant();
if (!await emailValidator.IsEmailAddressValidAsync(form.Email)) return BadRequest(new string[] { "Invalid email address." });
var result = await UserManager.CreateAsync(new User
{
Email = form.Email,
EmailConfirmed = Constants.IS_SELF_HOSTED,
UserName = form.Username ?? form.Email,
}, form.Password);
if (result.Errors.Any((e) => e.Code == "DuplicateEmail"))
{
var user = await UserManager.FindByEmailAsync(form.Email);
if (user == null) return BadRequest(new string[] { "User not found." });
if (!await UserManager.IsInRoleAsync(user, client.Id))
{
if (!await UserManager.CheckPasswordAsync(user, form.Password))
{
// TODO
await UserManager.RemovePasswordAsync(user);
await UserManager.AddPasswordAsync(user, form.Password);
}
await MFAService.DisableMFAAsync(user);
await UserManager.AddToRoleAsync(user, client.Id);
}
else
{
return BadRequest(new string[] { "Invalid email address.." });
}
return Ok(new
{
userId = user.Id.ToString()
});
}
if (result.Succeeded)
{
var user = await UserManager.FindByEmailAsync(form.Email);
if (user == null) return BadRequest(new string[] { "User not found after creation." });
await UserManager.AddToRoleAsync(user, client.Id);
if (Constants.IS_SELF_HOSTED)
{
await UserManager.AddClaimAsync(user, new Claim(UserService.GetClaimKey(client.Id), "believer"));
}
else
{
await UserManager.AddClaimAsync(user, new Claim("platform", PlatformFromUserAgent(base.HttpContext.Request.Headers.UserAgent)));
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL);
if (!string.IsNullOrEmpty(user.Email) && callbackUrl != null)
{
await EmailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client);
}
}
return Ok(new
{
userId = user.Id.ToString()
});
}
return BadRequest(result.Errors.ToErrors());
}
catch (System.Exception ex)
{
logger.LogError(ex, "Failed to create user account for email: {Email}", form.Email);
return BadRequest("Failed to create an account.");
}
}
static string PlatformFromUserAgent(string? userAgent)
{
if (string.IsNullOrEmpty(userAgent)) return "unknown";
return userAgent.Contains("okhttp/") ? "android" : userAgent.Contains("Darwin/") || userAgent.Contains("CFNetwork/") ? "ios" : "web";
}
}
}

View File

@@ -25,25 +25,24 @@ using Streetwriters.Common;
using Streetwriters.Identity.Controllers;
using Streetwriters.Identity.Enums;
namespace Microsoft.AspNetCore.Mvc
namespace Streetwriters.Identity.Extensions
{
public static class UrlHelperExtensions
public static class UrlExtensions
{
public static string? TokenLink(this IUrlHelper urlHelper, string userId, string code, string clientId, TokenType type)
public static string? TokenLink(string userId, string code, string clientId, TokenType type)
{
return urlHelper.ActionLink(
var url = new UriBuilder();
#if (DEBUG || STAGING)
host: $"{Servers.IdentityServer.Hostname}:{Servers.IdentityServer.Port}",
protocol: "http",
url.Host = $"{Servers.IdentityServer.Hostname}";
url.Port = Servers.IdentityServer.Port;
url.Scheme = "http";
#else
host: Servers.IdentityServer.PublicURL.Host,
protocol: Servers.IdentityServer.PublicURL.Scheme,
url.Host = Servers.IdentityServer.PublicURL.Host;
url.Scheme = Servers.IdentityServer.PublicURL.Scheme;
#endif
action: nameof(AccountController.ConfirmToken),
controller: "Account",
values: new { userId, code, clientId, type });
url.Path = "account/confirm";
url.Query = $"userId={Uri.EscapeDataString(userId)}&code={Uri.EscapeDataString(code)}&clientId={Uri.EscapeDataString(clientId)}&type={Uri.EscapeDataString(type.ToString())}";
return url.ToString();
}
}
}

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System.Security.Claims;
using System.Threading.Tasks;
using IdentityServer4.ResponseHandling;
using IdentityServer4.Validation;
using Streetwriters.Common.Models;
@@ -26,8 +27,9 @@ namespace Streetwriters.Identity.Interfaces
{
public interface ITokenGenerationService
{
Task<string> CreateAccessTokenAsync(User user, string clientId);
Task<string> CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 60);
Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60);
Task<string> CreateAccessTokenAsync(User user, string clientId, int lifetime = 1800);
Task<string> CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 1200);
Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 1200);
Task<TokenResponse?> CreateUserTokensAsync(User user, string clientId, int lifetime = 1800);
}
}

View File

@@ -24,6 +24,7 @@ using IdentityModel;
using IdentityServer4;
using IdentityServer4.Configuration;
using IdentityServer4.Models;
using IdentityServer4.ResponseHandling;
using IdentityServer4.Services;
using IdentityServer4.Stores;
using IdentityServer4.Validation;
@@ -41,12 +42,14 @@ namespace Streetwriters.Identity.Helpers
private IdentityServerOptions ISOptions { get; set; }
private IdentityServerTools Tools { get; set; }
private IResourceStore ResourceStore { get; set; }
private readonly IRefreshTokenService refreshTokenService;
public TokenGenerationService(ITokenService tokenService,
IUserClaimsPrincipalFactory<User> principalFactory,
IdentityServerOptions identityServerOptions,
IPersistedGrantStore persistedGrantStore,
IdentityServerTools tools,
IResourceStore resourceStore)
IResourceStore resourceStore,
IRefreshTokenService _refreshTokenService)
{
TokenService = tokenService;
PrincipalFactory = principalFactory;
@@ -54,16 +57,25 @@ namespace Streetwriters.Identity.Helpers
PersistedGrantStore = persistedGrantStore;
Tools = tools;
ResourceStore = resourceStore;
refreshTokenService = _refreshTokenService;
}
public async Task<string> CreateAccessTokenAsync(User user, string clientId)
public async Task<string> CreateAccessTokenAsync(User user, string clientId, int lifetime = 1800)
{
var client = Config.Clients.FirstOrDefault((c) => c.ClientId == clientId);
if (client == null)
{
throw new System.ArgumentException($"Client with ID '{clientId}' not found", nameof(clientId));
}
var IdentityPricipal = await PrincipalFactory.CreateAsync(user);
var IdentityUser = new IdentityServerUser(user.Id.ToString());
IdentityUser.AdditionalClaims = IdentityPricipal.Claims.ToArray();
IdentityUser.DisplayName = user.UserName;
IdentityUser.AuthenticationTime = System.DateTime.UtcNow;
IdentityUser.IdentityProvider = IdentityServerConstants.LocalIdentityProvider;
var IdentityUser = new IdentityServerUser(user.Id.ToString())
{
AdditionalClaims = [.. IdentityPricipal.Claims],
DisplayName = user.UserName,
AuthenticationTime = System.DateTime.UtcNow,
IdentityProvider = IdentityServerConstants.LocalIdentityProvider
};
var Request = new TokenCreationRequest
{
Subject = IdentityUser.CreatePrincipal(),
@@ -71,16 +83,61 @@ namespace Streetwriters.Identity.Helpers
ValidatedRequest = new ValidatedRequest()
};
Request.ValidatedRequest.Subject = Request.Subject;
Request.ValidatedRequest.SetClient(Config.Clients.FirstOrDefault((c) => c.ClientId == clientId));
Request.ValidatedRequest.SetClient(client);
Request.ValidatedRequest.AccessTokenType = AccessTokenType.Reference;
Request.ValidatedRequest.AccessTokenLifetime = 18000;
Request.ValidatedResources = new ResourceValidationResult(new Resources(Config.IdentityResources, Config.ApiResources, Config.ApiScopes));
Request.ValidatedRequest.AccessTokenLifetime = lifetime;
var requestedScopes = client.AllowedScopes.Select(s => new ParsedScopeValue(s));
Request.ValidatedResources = await ResourceStore.CreateResourceValidationResult(new ParsedScopesResult
{
ParsedScopes = [.. requestedScopes]
});
Request.ValidatedRequest.Options = ISOptions;
Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims;
var accessToken = await TokenService.CreateAccessTokenAsync(Request);
return await TokenService.CreateSecurityTokenAsync(accessToken);
}
public async Task<TokenResponse?> CreateUserTokensAsync(User user, string clientId, int lifetime = 1800)
{
var client = Config.Clients.FirstOrDefault((c) => c.ClientId == clientId);
var principal = await PrincipalFactory.CreateAsync(user);
if (client == null || principal == null) return null;
var IdentityUser = new IdentityServerUser(user.Id.ToString())
{
AdditionalClaims = [.. principal.Claims],
DisplayName = user.UserName,
AuthenticationTime = System.DateTime.UtcNow,
IdentityProvider = IdentityServerConstants.LocalIdentityProvider
};
var Request = new TokenCreationRequest
{
Subject = IdentityUser.CreatePrincipal(),
IncludeAllIdentityClaims = true,
ValidatedRequest = new ValidatedRequest()
};
Request.ValidatedRequest.Subject = Request.Subject;
Request.ValidatedRequest.SetClient(client);
Request.ValidatedRequest.AccessTokenType = AccessTokenType.Reference;
Request.ValidatedRequest.AccessTokenLifetime = lifetime;
var requestedScopes = client.AllowedScopes.Select(s => new ParsedScopeValue(s));
Request.ValidatedResources = await ResourceStore.CreateResourceValidationResult(new ParsedScopesResult
{
ParsedScopes = [.. requestedScopes]
});
Request.ValidatedRequest.Options = ISOptions;
Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims;
var accessToken = await TokenService.CreateAccessTokenAsync(Request);
var refreshToken = await refreshTokenService.CreateRefreshTokenAsync(principal, accessToken, client);
return new TokenResponse
{
AccessToken = await TokenService.CreateSecurityTokenAsync(accessToken),
AccessTokenLifetime = lifetime,
RefreshToken = refreshToken,
Scope = string.Join(" ", accessToken.Scopes)
};
}
public async Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60)
{
var principal = await PrincipalFactory.CreateAsync(user);

View File

@@ -1,16 +1,29 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using AspNetCore.Identity.Mongo.Model;
using IdentityServer4;
using IdentityServer4.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Streetwriters.Common;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
using Streetwriters.Common.Services;
using Streetwriters.Identity.Enums;
using Streetwriters.Identity.Extensions;
using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Models;
namespace Streetwriters.Identity.Services
{
public class UserAccountService(UserManager<User> userManager, IMFAService mfaService) : IUserAccountService
public class UserAccountService(UserManager<User> userManager, IMFAService mfaService, IPersistedGrantStore persistedGrantStore, RoleManager<MongoRole> roleManager, EmailAddressValidator emailValidator, ITemplatedEmailSender emailSender, ITokenGenerationService tokenGenerationService, ILogger<UserAccountService> logger) : IUserAccountService
{
public async Task<UserModel?> GetUserAsync(string clientId, string userId)
{
@@ -54,5 +67,141 @@ namespace Streetwriters.Identity.Services
await userManager.DeleteAsync(user);
}
public async Task<bool> ChangePasswordAsync(string userId, string oldPassword, string newPassword)
{
var user = await userManager.FindByIdAsync(userId) ?? throw new Exception("User not found.");
var result = await userManager.ChangePasswordAsync(user, oldPassword, newPassword);
return result.Succeeded;
}
public async Task<bool> ResetPasswordAsync(string userId, string newPassword)
{
var user = await userManager.FindByIdAsync(userId) ?? throw new Exception("User not found.");
var result = await userManager.RemovePasswordAsync(user);
if (!result.Succeeded) return false;
await mfaService.ResetMFAAsync(user);
result = await userManager.AddPasswordAsync(user, newPassword);
return result.Succeeded;
}
public async Task<bool> ClearSessionsAsync(string userId, string clientId, bool all, string jti, string? refreshToken)
{
var client = Clients.FindClientById(clientId) ?? throw new Exception("Invalid client_id.");
var user = await userManager.FindByIdAsync(userId) ?? throw new Exception("User not found.");
if (!await UserService.IsUserValidAsync(userManager, user, client.Id)) throw new Exception($"Unable to find user with ID '{user.Id}'.");
var grants = await persistedGrantStore.GetAllAsync(new PersistedGrantFilter
{
ClientId = client.Id,
SubjectId = user.Id.ToString()
});
string? refreshTokenKey = refreshToken != null ? GetHashedKey(refreshToken, IdentityServerConstants.PersistedGrantTypes.RefreshToken) : null;
List<string> removedKeys = [];
foreach (var grant in grants)
{
if (!all && (grant.Data.Contains(jti) || grant.Key == refreshTokenKey)) continue;
await persistedGrantStore.RemoveAsync(grant.Key);
removedKeys.Add(grant.Key);
}
await WampServers.NotesnookServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
await WampServers.MessengerServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
await WampServers.SubscriptionServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
// await SendLogoutMessageAsync(user.Id.ToString(), "Session revoked.");
return true;
}
public async Task<SignupResponse> CreateUserAsync(string clientId, string email, string password, string? userAgent = null)
{
if (Constants.DISABLE_SIGNUPS)
return new SignupResponse
{
Errors = ["Creating new accounts is not allowed."]
};
try
{
var client = Clients.FindClientById(clientId);
if (client == null) return new SignupResponse
{
Errors = ["Invalid client id."]
};
if (await roleManager.FindByNameAsync(clientId) == null)
await roleManager.CreateAsync(new MongoRole(clientId));
// email addresses must be case-insensitive
email = email.ToLowerInvariant();
if (!await emailValidator.IsEmailAddressValidAsync(email))
return new SignupResponse
{
Errors = ["Invalid email address."]
};
var result = await userManager.CreateAsync(new User
{
Email = email,
EmailConfirmed = Constants.IS_SELF_HOSTED,
UserName = email,
}, password);
if (result.Succeeded)
{
var user = await userManager.FindByEmailAsync(email);
if (user == null) return SignupResponse.Error(["User not found after creation."]);
await userManager.AddToRoleAsync(user, client.Id);
if (Constants.IS_SELF_HOSTED)
{
await userManager.AddClaimAsync(user, new Claim(UserService.GetClaimKey(client.Id), "believer"));
}
else
{
if (userAgent != null) await userManager.AddClaimAsync(user, new Claim("platform", PlatformFromUserAgent(userAgent)));
var code = await userManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL);
if (!string.IsNullOrEmpty(user.Email) && callbackUrl != null)
{
await emailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client);
}
}
var response = await tokenGenerationService.CreateUserTokensAsync(user, client.Id, 3600);
if (response == null) return SignupResponse.Error(["Failed to generate access token."]);
return new SignupResponse
{
AccessToken = response.AccessToken,
AccessTokenLifetime = response.AccessTokenLifetime,
RefreshToken = response.RefreshToken,
Scope = response.Scope,
UserId = user.Id.ToString()
};
}
return SignupResponse.Error(result.Errors.ToErrors());
}
catch (System.Exception ex)
{
logger.LogError(ex, "Failed to create user account for email: {Email}", email);
return SignupResponse.Error(["Failed to create an account."]);
}
}
private static string PlatformFromUserAgent(string? userAgent)
{
if (string.IsNullOrEmpty(userAgent)) return "unknown";
return userAgent.Contains("okhttp/") ? "android" : userAgent.Contains("Darwin/") || userAgent.Contains("CFNetwork/") ? "ios" : "web";
}
private static string GetHashedKey(string value, string grantType)
{
return (value + ":" + grantType).Sha256();
}
}
}

View File

@@ -53,6 +53,7 @@ using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Jobs;
using Streetwriters.Identity.Services;
using Streetwriters.Identity.Validation;
using IdentityServer4.MongoDB.Configuration;
namespace Streetwriters.Identity
{
@@ -107,11 +108,6 @@ namespace Streetwriters.Identity
options.UsersCollection = "users";
// options.MigrationCollection = "migration";
options.ConnectionString = connectionString;
options.ClusterConfigurator = builder =>
{
builder.ConfigureConnectionPool((c) => c.With(maxConnections: 500, minConnections: 0));
builder.ConfigureServer(s => s.With(heartbeatInterval: TimeSpan.FromSeconds(60)));
};
}).AddDefaultTokenProviders();
services.AddIdentityServer(
@@ -137,6 +133,11 @@ namespace Streetwriters.Identity
.AddKeyManagement()
.AddFileSystemPersistence(Path.Combine(WebHostEnvironment.ContentRootPath, @"keystore"));
services.Configure<MongoDBConfiguration>(options =>
{
options.ConnectionString = connectionString;
});
services.Configure<DataProtectionTokenProviderOptions>(options =>
{
options.TokenLifespan = TimeSpan.FromHours(2);