mirror of
https://github.com/streetwriters/notesnook-sync-server.git
synced 2026-02-27 02:13:28 +00:00
Compare commits
7 Commits
v1.0-beta.
...
v1.0-beta.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b1de7cc62 | ||
|
|
3f2ba697bc | ||
|
|
9ae5db378d | ||
|
|
d5790d8785 | ||
|
|
9424afed68 | ||
|
|
b9385ae112 | ||
|
|
014c4e3b32 |
@@ -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();
|
||||
|
||||
@@ -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." });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
24
Notesnook.API/Models/ChangePasswordForm.cs
Normal file
24
Notesnook.API/Models/ChangePasswordForm.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Notesnook.API/Models/ContentSanitizationLevel.cs
Normal file
38
Notesnook.API/Models/ContentSanitizationLevel.cs
Normal 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
|
||||
}
|
||||
}
|
||||
25
Notesnook.API/Models/DeleteBulkRequest.cs
Normal file
25
Notesnook.API/Models/DeleteBulkRequest.cs
Normal 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; }
|
||||
}
|
||||
@@ -83,5 +83,8 @@ namespace Notesnook.API.Models
|
||||
|
||||
[JsonPropertyName("viewCount")]
|
||||
public int ViewCount { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public ContentSanitizationLevel ContentSanitizationLevel { get; set; }
|
||||
}
|
||||
}
|
||||
19
Notesnook.API/Models/ResetPasswordForm.cs
Normal file
19
Notesnook.API/Models/ResetPasswordForm.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
30
Streetwriters.Common/Models/SignupResponse.cs
Normal file
30
Streetwriters.Common/Models/SignupResponse.cs
Normal 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]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user