Compare commits

...
32 changed files with 623 additions and 308 deletions
+21 -3
View File
@@ -21,20 +21,17 @@ using System;
using System.Net.Http; using System.Net.Http;
using System.Security.Claims; using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.S3.Model;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MongoDB.Driver; using MongoDB.Driver;
using Notesnook.API.Accessors;
using Notesnook.API.Helpers; using Notesnook.API.Helpers;
using Notesnook.API.Interfaces; using Notesnook.API.Interfaces;
using Notesnook.API.Models; using Notesnook.API.Models;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors; using Streetwriters.Common.Accessors;
using Streetwriters.Common.Extensions; using Streetwriters.Common.Extensions;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
namespace Notesnook.API.Controllers namespace Notesnook.API.Controllers
@@ -212,5 +209,26 @@ namespace Notesnook.API.Controllers
return BadRequest(new { error = "Failed to delete attachment." }); 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." });
}
}
} }
} }
+46 -4
View File
@@ -18,7 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using System; using System;
using System.Net.Http;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Timeouts; using Microsoft.AspNetCore.Http.Timeouts;
@@ -28,22 +30,25 @@ using Notesnook.API.Interfaces;
using Notesnook.API.Models; using Notesnook.API.Models;
using Notesnook.API.Models.Responses; using Notesnook.API.Models.Responses;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
namespace Notesnook.API.Controllers namespace Notesnook.API.Controllers
{ {
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("users")] [Route("users")]
public class UsersController(IUserService UserService, ILogger<UsersController> logger) : ControllerBase public class UsersController(IUserService UserService, WampServiceAccessor serviceAccessor, ILogger<UsersController> logger) : ControllerBase
{ {
[HttpPost] [HttpPost]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> Signup() public async Task<IActionResult> Signup([FromForm] SignupForm form)
{ {
try try
{ {
await UserService.CreateUserAsync(); return Ok(await UserService.CreateUserAsync(form));
return Ok();
} }
catch (Exception ex) 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")] [HttpPost("reset")]
public async Task<IActionResult> Reset([FromForm] bool removeAttachments) public async Task<IActionResult> Reset([FromForm] bool removeAttachments)
{ {
-2
View File
@@ -27,8 +27,6 @@ FROM build AS publish
RUN dotnet publish -c Release -o /app/publish \ RUN dotnet publish -c Release -o /app/publish \
#--runtime alpine-x64 \ #--runtime alpine-x64 \
--self-contained true \ --self-contained true \
/p:TrimMode=partial \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true \ /p:PublishSingleFile=true \
/p:JsonSerializerIsReflectionEnabledByDefault=true \ /p:JsonSerializerIsReflectionEnabledByDefault=true \
-a $TARGETARCH -a $TARGETARCH
+1 -1
View File
@@ -247,7 +247,7 @@ namespace Notesnook.API.Hubs
var chunks = PrepareChunks( var chunks = PrepareChunks(
userId, userId,
ids, ids,
size: 1000, size: 100,
resetSync: device.IsSyncReset, resetSync: device.IsSyncReset,
maxBytes: 7 * 1024 * 1024 maxBytes: 7 * 1024 * 1024
); );
+1 -3
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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.S3.Model; using Amazon.S3.Model;
using Notesnook.API.Models; using Notesnook.API.Models;
using Notesnook.API.Models.Responses;
using Streetwriters.Common.Interfaces;
namespace Notesnook.API.Interfaces namespace Notesnook.API.Interfaces
{ {
public interface IS3Service public interface IS3Service
{ {
Task DeleteObjectAsync(string userId, string name); Task DeleteObjectAsync(string userId, string name);
Task DeleteObjectsAsync(string userId, string[] names);
Task DeleteDirectoryAsync(string userId); Task DeleteDirectoryAsync(string userId);
Task<long> GetObjectSizeAsync(string userId, string name); Task<long> GetObjectSizeAsync(string userId, string name);
Task<string?> GetUploadObjectUrlAsync(string userId, string name); Task<string?> GetUploadObjectUrlAsync(string userId, string name);
+2 -1
View File
@@ -20,12 +20,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System.Threading.Tasks; using System.Threading.Tasks;
using Notesnook.API.Models; using Notesnook.API.Models;
using Notesnook.API.Models.Responses; using Notesnook.API.Models.Responses;
using Streetwriters.Common.Models;
namespace Notesnook.API.Interfaces namespace Notesnook.API.Interfaces
{ {
public interface IUserService public interface IUserService
{ {
Task CreateUserAsync(); Task<SignupResponse> CreateUserAsync(SignupForm form);
Task DeleteUserAsync(string userId); Task DeleteUserAsync(string userId);
Task DeleteUserAsync(string userId, string? jti, string password); Task DeleteUserAsync(string userId, string? jti, string password);
Task<bool> ResetUserAsync(string userId, bool removeAttachments); Task<bool> ResetUserAsync(string userId, bool removeAttachments);
@@ -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;
}
}
}
+25
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; }
}
+19
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;
}
}
}
@@ -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")] [JsonPropertyName("monographPasswordsKey")]
public EncryptedData? MonographPasswordsKey { get; set; } public EncryptedData? MonographPasswordsKey { get; set; }
[JsonPropertyName("dataEncryptionKey")]
public EncryptedData? DataEncryptionKey { get; set; }
[JsonPropertyName("legacyDataEncryptionKey")]
public EncryptedData? LegacyDataEncryptionKey { get; set; }
[JsonPropertyName("inboxKeys")] [JsonPropertyName("inboxKeys")]
public InboxKeys? InboxKeys { get; set; } public InboxKeys? InboxKeys { get; set; }
+8
View File
@@ -98,6 +98,14 @@ namespace Notesnook.API.Models
get; set; get; set;
} }
[JsonPropertyName("keyVersion")]
[DataMember(Name = "keyVersion")]
[MessagePack.Key("keyVersion")]
public int? KeyVersion
{
get; set;
}
[JsonPropertyName("alg")] [JsonPropertyName("alg")]
[DataMember(Name = "alg")] [DataMember(Name = "alg")]
[MessagePack.Key("alg")] [MessagePack.Key("alg")]
+2
View File
@@ -24,6 +24,8 @@ namespace Notesnook.API.Models
public EncryptedData? AttachmentsKey { get; set; } public EncryptedData? AttachmentsKey { get; set; }
public EncryptedData? MonographPasswordsKey { get; set; } public EncryptedData? MonographPasswordsKey { get; set; }
public InboxKeys? InboxKeys { get; set; } public InboxKeys? InboxKeys { get; set; }
public EncryptedData? DataEncryptionKey { get; set; }
public EncryptedData? LegacyDataEncryptionKey { get; set; }
} }
public class InboxKeys public class InboxKeys
+2
View File
@@ -55,6 +55,8 @@ namespace Notesnook.API.Models
public EncryptedData? VaultKey { get; set; } public EncryptedData? VaultKey { get; set; }
public EncryptedData? AttachmentsKey { get; set; } public EncryptedData? AttachmentsKey { get; set; }
public EncryptedData? MonographPasswordsKey { get; set; } public EncryptedData? MonographPasswordsKey { get; set; }
public EncryptedData? DataEncryptionKey { get; set; }
public EncryptedData? LegacyDataEncryptionKey { get; set; }
public InboxKeys? InboxKeys { get; set; } public InboxKeys? InboxKeys { get; set; }
public Limit? StorageLimit { get; set; } public Limit? StorageLimit { get; set; }
@@ -91,11 +91,7 @@ namespace Notesnook.API.Repositories
public void DeleteByUserId(string userId) public void DeleteByUserId(string userId)
{ {
var filter = Builders<SyncItem>.Filter.Eq("UserId", userId); var filter = Builders<SyncItem>.Filter.Eq("UserId", userId);
var writes = new List<WriteModel<SyncItem>> dbContext.AddCommand((handle, ct) => Collection.DeleteManyAsync(handle, filter, null, ct));
{
new DeleteManyModel<SyncItem>(filter)
};
dbContext.AddCommand((handle, ct) => Collection.BulkWriteAsync(handle, writes, options: null, ct));
} }
public void Upsert(SyncItem item, string userId, long dateSynced) public void Upsert(SyncItem item, string userId, long dateSynced)
+64 -6
View File
@@ -24,21 +24,15 @@ using System.Net.Http;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon; using Amazon;
using Amazon.Runtime;
using Amazon.S3; using Amazon.S3;
using Amazon.S3.Model; using Amazon.S3.Model;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver; using MongoDB.Driver;
using Notesnook.API.Accessors;
using Notesnook.API.Helpers; using Notesnook.API.Helpers;
using Notesnook.API.Interfaces; using Notesnook.API.Interfaces;
using Notesnook.API.Models; using Notesnook.API.Models;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors; using Streetwriters.Common.Accessors;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Notesnook.API.Services namespace Notesnook.API.Services
{ {
@@ -110,6 +104,70 @@ namespace Notesnook.API.Services
throw new Exception("Could not delete object."); 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) public async Task DeleteDirectoryAsync(string userId)
{ {
var request = new ListObjectsV2Request var request = new ListObjectsV2Request
+2 -2
View File
@@ -73,7 +73,7 @@ namespace Notesnook.API.Services
return result; return result;
} }
const int MaxIdsPerChunk = 400_000; const int MaxIdsPerChunk = 25_000;
public async Task AppendIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids) public async Task AppendIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids)
{ {
var filter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Where(x => x.Ids.Length < MaxIdsPerChunk); var filter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Where(x => x.Ids.Length < MaxIdsPerChunk);
@@ -81,7 +81,7 @@ namespace Notesnook.API.Services
if (chunk != null) if (chunk != null)
{ {
var update = Builders<DeviceIdsChunk>.Update.PushEach(x => x.Ids, ids.Select(i => i.ToString())); var update = Builders<DeviceIdsChunk>.Update.AddToSetEach(x => x.Ids, ids.Select(i => i.ToString()));
await repositories.DeviceIdsChunks.Collection.UpdateOneAsync( await repositories.DeviceIdsChunks.Collection.UpdateOneAsync(
Builders<DeviceIdsChunk>.Filter.Eq(x => x.Id, chunk.Id), Builders<DeviceIdsChunk>.Filter.Eq(x => x.Id, chunk.Id),
update update
+17 -5
View File
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using System; using System;
using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
@@ -50,15 +51,16 @@ namespace Notesnook.API.Services
private IS3Service S3Service { get; set; } = s3Service; private IS3Service S3Service { get; set; } = s3Service;
private readonly IUnitOfWork unit = unitOfWork; 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); SignupResponse response = await serviceAccessor.UserAccountService.CreateUserAsync(form.ClientId, form.Email, form.Password, HttpContextAccessor.HttpContext?.Request.Headers["User-Agent"].ToString());
if (!response.Success || (response.Errors != null && response.Errors.Length > 0) || response.UserId == null)
if ((response.Errors != null && response.Errors.Length > 0) || response.UserId == null)
{ {
logger.LogError("Failed to sign up user: {Response}", JsonSerializer.Serialize(response)); logger.LogError("Failed to sign up user: {Response}", JsonSerializer.Serialize(response));
if (response.Errors != null && response.Errors.Length > 0) if (response.Errors != null && response.Errors.Length > 0)
throw new Exception(string.Join(" ", response.Errors)); 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 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) public async Task<UserResponse> GetUserAsync(string userId)
@@ -133,6 +135,8 @@ namespace Notesnook.API.Services
PhoneNumber = user.PhoneNumber, PhoneNumber = user.PhoneNumber,
AttachmentsKey = userSettings.AttachmentsKey, AttachmentsKey = userSettings.AttachmentsKey,
MonographPasswordsKey = userSettings.MonographPasswordsKey, MonographPasswordsKey = userSettings.MonographPasswordsKey,
DataEncryptionKey = userSettings.DataEncryptionKey,
LegacyDataEncryptionKey = userSettings.LegacyDataEncryptionKey,
InboxKeys = userSettings.InboxKeys, InboxKeys = userSettings.InboxKeys,
Salt = userSettings.Salt, Salt = userSettings.Salt,
Subscription = subscription, Subscription = subscription,
@@ -155,6 +159,11 @@ namespace Notesnook.API.Services
{ {
userSettings.MonographPasswordsKey = keys.MonographPasswordsKey; 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 != null)
{ {
if (keys.InboxKeys.Public == null || keys.InboxKeys.Private == null) if (keys.InboxKeys.Public == null || keys.InboxKeys.Private == null)
@@ -182,6 +191,7 @@ namespace Notesnook.API.Services
public async Task DeleteUserAsync(string userId) public async Task DeleteUserAsync(string userId)
{ {
logger.LogInformation("Deleting user {UserId}", userId);
var cc = new CancellationTokenSource(); var cc = new CancellationTokenSource();
Repositories.Notes.DeleteByUserId(userId); Repositories.Notes.DeleteByUserId(userId);
@@ -267,6 +277,8 @@ namespace Notesnook.API.Services
userSettings.AttachmentsKey = null; userSettings.AttachmentsKey = null;
userSettings.MonographPasswordsKey = null; userSettings.MonographPasswordsKey = null;
userSettings.DataEncryptionKey = null;
userSettings.LegacyDataEncryptionKey = null;
userSettings.VaultKey = null; userSettings.VaultKey = null;
userSettings.InboxKeys = null; userSettings.InboxKeys = null;
userSettings.LastSynced = 0; userSettings.LastSynced = 0;
@@ -10,7 +10,13 @@ namespace Streetwriters.Common.Interfaces
Task<UserModel?> GetUserAsync(string clientId, string userId); Task<UserModel?> GetUserAsync(string clientId, string userId);
[WampProcedure("co.streetwriters.identity.users.delete_user")] [WampProcedure("co.streetwriters.identity.users.delete_user")]
Task DeleteUserAsync(string clientId, string userId, string password); Task DeleteUserAsync(string clientId, string userId, string password);
// [WampProcedure("co.streetwriters.identity.users.create_user")] [WampProcedure("co.streetwriters.identity.users.change_password")]
// Task<UserModel> CreateUserAsync(); 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);
} }
} }
@@ -21,7 +21,7 @@ using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Streetwriters.Identity.Models namespace Streetwriters.Common.Models
{ {
public class SignupForm public class SignupForm
{ {
@@ -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]
};
}
}
}
@@ -33,9 +33,6 @@ namespace Streetwriters.Data.DbContexts
public static IMongoClient CreateMongoDbClient(IDbSettings dbSettings) public static IMongoClient CreateMongoDbClient(IDbSettings dbSettings)
{ {
var settings = MongoClientSettings.FromConnectionString(dbSettings.ConnectionString); var settings = MongoClientSettings.FromConnectionString(dbSettings.ConnectionString);
settings.MaxConnectionPoolSize = 500;
settings.MinConnectionPoolSize = 0;
settings.HeartbeatInterval = TimeSpan.FromSeconds(60);
return new MongoClient(settings); return new MongoClient(settings);
} }
@@ -25,6 +25,7 @@ using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using AspNetCore.Identity.Mongo.Model; using AspNetCore.Identity.Mongo.Model;
using IdentityServer4.Extensions;
using IdentityServer4.Stores; using IdentityServer4.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -37,6 +38,7 @@ using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages; using Streetwriters.Common.Messages;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
using Streetwriters.Identity.Enums; using Streetwriters.Identity.Enums;
using Streetwriters.Identity.Extensions;
using Streetwriters.Identity.Interfaces; using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Models; using Streetwriters.Identity.Models;
using Streetwriters.Identity.Services; using Streetwriters.Identity.Services;
@@ -124,7 +126,7 @@ namespace Streetwriters.Identity.Controllers
{ {
ArgumentNullException.ThrowIfNull(user.Email); ArgumentNullException.ThrowIfNull(user.Email);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); 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); await EmailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client);
} }
else else
@@ -149,6 +151,7 @@ namespace Streetwriters.Identity.Controllers
[EnableRateLimiting("strict")] [EnableRateLimiting("strict")]
public async Task<IActionResult> ResetUserPassword([FromForm] ResetPasswordForm form) public async Task<IActionResult> ResetUserPassword([FromForm] ResetPasswordForm form)
{ {
var client = Clients.FindClientById(form.ClientId); var client = Clients.FindClientById(form.ClientId);
if (client == null) return BadRequest("Invalid client_id."); if (client == null) return BadRequest("Invalid client_id.");
@@ -156,13 +159,13 @@ namespace Streetwriters.Identity.Controllers
if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok(); if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword"); var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD); var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
#if (DEBUG || STAGING) #if (DEBUG || STAGING)
return Ok(callbackUrl); return Ok(callbackUrl);
#else #else
logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl); logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl);
await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client); await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client);
return Ok(); return Ok();
#endif #endif
} }
@@ -248,34 +251,6 @@ namespace Streetwriters.Identity.Controllers
} }
return BadRequest(result.Errors.ToErrors()); return BadRequest(result.Errors.ToErrors());
} }
case "change_password":
{
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":
{
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": case "change_marketing_consent":
{ {
var claimType = $"{client.Id}:marketing_consent"; var claimType = $"{client.Id}:marketing_consent";
@@ -294,40 +269,14 @@ namespace Streetwriters.Identity.Controllers
[HttpPost("sessions/clear")] [HttpPost("sessions/clear")]
public async Task<IActionResult> ClearUserSessions([FromQuery] bool all, [FromForm] string? refresh_token) 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 jti = User.FindFirstValue("jti");
var userId = User.GetSubjectId();
var grants = await PersistedGrantStore.GetAllAsync(new PersistedGrantFilter var clientId = User.FindFirstValue("client_id");
{ if (await UserAccountService.ClearSessionsAsync(userId, clientId, all, refresh_token, jti))
ClientId = client.Id, await SendLogoutMessageAsync(userId, "Session revoked.");
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.");
return Ok(); return Ok();
} }
private static string GetHashedKey(string value, string grantType)
{
return (value + ":" + grantType).Sha256();
}
private async Task SendLogoutMessageAsync(string userId, string reason) private async Task SendLogoutMessageAsync(string userId, string reason)
{ {
await SendMessageAsync(userId, new Message 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";
}
}
}
-2
View File
@@ -27,8 +27,6 @@ FROM build AS publish
RUN dotnet publish -c Release -o /app/publish \ RUN dotnet publish -c Release -o /app/publish \
#--runtime alpine-x64 \ #--runtime alpine-x64 \
--self-contained true \ --self-contained true \
/p:TrimMode=partial \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true \ /p:PublishSingleFile=true \
/p:JsonSerializerIsReflectionEnabledByDefault=true \ /p:JsonSerializerIsReflectionEnabledByDefault=true \
-a $TARGETARCH -a $TARGETARCH
@@ -25,25 +25,24 @@ using Streetwriters.Common;
using Streetwriters.Identity.Controllers; using Streetwriters.Identity.Controllers;
using Streetwriters.Identity.Enums; 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)
{ {
var url = new UriBuilder();
return urlHelper.ActionLink(
#if (DEBUG || STAGING) #if (DEBUG || STAGING)
host: $"{Servers.IdentityServer.Hostname}:{Servers.IdentityServer.Port}", url.Host = $"{Servers.IdentityServer.Hostname}";
protocol: "http", url.Port = Servers.IdentityServer.Port;
url.Scheme = "http";
#else #else
host: Servers.IdentityServer.PublicURL.Host, url.Host = Servers.IdentityServer.PublicURL.Host;
protocol: Servers.IdentityServer.PublicURL.Scheme, url.Scheme = Servers.IdentityServer.PublicURL.Scheme;
#endif #endif
action: nameof(AccountController.ConfirmToken), url.Path = "account/confirm";
controller: "Account", url.Query = $"userId={Uri.EscapeDataString(userId)}&code={Uri.EscapeDataString(code)}&clientId={Uri.EscapeDataString(clientId)}&type={Uri.EscapeDataString(type.ToString())}";
values: new { userId, code, clientId, type }); 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.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityServer4.ResponseHandling;
using IdentityServer4.Validation; using IdentityServer4.Validation;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
@@ -26,8 +27,9 @@ namespace Streetwriters.Identity.Interfaces
{ {
public interface ITokenGenerationService public interface ITokenGenerationService
{ {
Task<string> CreateAccessTokenAsync(User user, string clientId); Task<string> CreateAccessTokenAsync(User user, string clientId, int lifetime = 1800);
Task<string> CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 60); 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 = 20 * 60); 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;
using IdentityServer4.Configuration; using IdentityServer4.Configuration;
using IdentityServer4.Models; using IdentityServer4.Models;
using IdentityServer4.ResponseHandling;
using IdentityServer4.Services; using IdentityServer4.Services;
using IdentityServer4.Stores; using IdentityServer4.Stores;
using IdentityServer4.Validation; using IdentityServer4.Validation;
@@ -41,12 +42,14 @@ namespace Streetwriters.Identity.Helpers
private IdentityServerOptions ISOptions { get; set; } private IdentityServerOptions ISOptions { get; set; }
private IdentityServerTools Tools { get; set; } private IdentityServerTools Tools { get; set; }
private IResourceStore ResourceStore { get; set; } private IResourceStore ResourceStore { get; set; }
private readonly IRefreshTokenService refreshTokenService;
public TokenGenerationService(ITokenService tokenService, public TokenGenerationService(ITokenService tokenService,
IUserClaimsPrincipalFactory<User> principalFactory, IUserClaimsPrincipalFactory<User> principalFactory,
IdentityServerOptions identityServerOptions, IdentityServerOptions identityServerOptions,
IPersistedGrantStore persistedGrantStore, IPersistedGrantStore persistedGrantStore,
IdentityServerTools tools, IdentityServerTools tools,
IResourceStore resourceStore) IResourceStore resourceStore,
IRefreshTokenService _refreshTokenService)
{ {
TokenService = tokenService; TokenService = tokenService;
PrincipalFactory = principalFactory; PrincipalFactory = principalFactory;
@@ -54,16 +57,25 @@ namespace Streetwriters.Identity.Helpers
PersistedGrantStore = persistedGrantStore; PersistedGrantStore = persistedGrantStore;
Tools = tools; Tools = tools;
ResourceStore = resourceStore; 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 IdentityPricipal = await PrincipalFactory.CreateAsync(user);
var IdentityUser = new IdentityServerUser(user.Id.ToString()); var IdentityUser = new IdentityServerUser(user.Id.ToString())
IdentityUser.AdditionalClaims = IdentityPricipal.Claims.ToArray(); {
IdentityUser.DisplayName = user.UserName; AdditionalClaims = [.. IdentityPricipal.Claims],
IdentityUser.AuthenticationTime = System.DateTime.UtcNow; DisplayName = user.UserName,
IdentityUser.IdentityProvider = IdentityServerConstants.LocalIdentityProvider; AuthenticationTime = System.DateTime.UtcNow,
IdentityProvider = IdentityServerConstants.LocalIdentityProvider
};
var Request = new TokenCreationRequest var Request = new TokenCreationRequest
{ {
Subject = IdentityUser.CreatePrincipal(), Subject = IdentityUser.CreatePrincipal(),
@@ -71,16 +83,61 @@ namespace Streetwriters.Identity.Helpers
ValidatedRequest = new ValidatedRequest() ValidatedRequest = new ValidatedRequest()
}; };
Request.ValidatedRequest.Subject = Request.Subject; 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.AccessTokenType = AccessTokenType.Reference;
Request.ValidatedRequest.AccessTokenLifetime = 18000; Request.ValidatedRequest.AccessTokenLifetime = lifetime;
Request.ValidatedResources = new ResourceValidationResult(new Resources(Config.IdentityResources, Config.ApiResources, Config.ApiScopes)); var requestedScopes = client.AllowedScopes.Select(s => new ParsedScopeValue(s));
Request.ValidatedResources = await ResourceStore.CreateResourceValidationResult(new ParsedScopesResult
{
ParsedScopes = [.. requestedScopes]
});
Request.ValidatedRequest.Options = ISOptions; Request.ValidatedRequest.Options = ISOptions;
Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims; Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims;
var accessToken = await TokenService.CreateAccessTokenAsync(Request); var accessToken = await TokenService.CreateAccessTokenAsync(Request);
return await TokenService.CreateSecurityTokenAsync(accessToken); 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) public async Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60)
{ {
var principal = await PrincipalFactory.CreateAsync(user); var principal = await PrincipalFactory.CreateAsync(user);
@@ -1,16 +1,29 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using AspNetCore.Identity.Mongo.Model;
using IdentityServer4;
using IdentityServer4.Stores;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Streetwriters.Common;
using Streetwriters.Common.Enums; using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces; using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
using Streetwriters.Common.Services;
using Streetwriters.Identity.Enums;
using Streetwriters.Identity.Extensions;
using Streetwriters.Identity.Interfaces; using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Models; using Streetwriters.Identity.Models;
namespace Streetwriters.Identity.Services 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) public async Task<UserModel?> GetUserAsync(string clientId, string userId)
{ {
@@ -54,5 +67,141 @@ namespace Streetwriters.Identity.Services
await userManager.DeleteAsync(user); 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();
}
} }
} }
+6 -5
View File
@@ -53,6 +53,7 @@ using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Jobs; using Streetwriters.Identity.Jobs;
using Streetwriters.Identity.Services; using Streetwriters.Identity.Services;
using Streetwriters.Identity.Validation; using Streetwriters.Identity.Validation;
using IdentityServer4.MongoDB.Configuration;
namespace Streetwriters.Identity namespace Streetwriters.Identity
{ {
@@ -107,11 +108,6 @@ namespace Streetwriters.Identity
options.UsersCollection = "users"; options.UsersCollection = "users";
// options.MigrationCollection = "migration"; // options.MigrationCollection = "migration";
options.ConnectionString = connectionString; 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(); }).AddDefaultTokenProviders();
services.AddIdentityServer( services.AddIdentityServer(
@@ -137,6 +133,11 @@ namespace Streetwriters.Identity
.AddKeyManagement() .AddKeyManagement()
.AddFileSystemPersistence(Path.Combine(WebHostEnvironment.ContentRootPath, @"keystore")); .AddFileSystemPersistence(Path.Combine(WebHostEnvironment.ContentRootPath, @"keystore"));
services.Configure<MongoDBConfiguration>(options =>
{
options.ConnectionString = connectionString;
});
services.Configure<DataProtectionTokenProviderOptions>(options => services.Configure<DataProtectionTokenProviderOptions>(options =>
{ {
options.TokenLifespan = TimeSpan.FromHours(2); options.TokenLifespan = TimeSpan.FromHours(2);
-2
View File
@@ -27,8 +27,6 @@ FROM build AS publish
RUN dotnet publish -c Release -o /app/publish \ RUN dotnet publish -c Release -o /app/publish \
#--runtime alpine-x64 \ #--runtime alpine-x64 \
--self-contained true \ --self-contained true \
/p:TrimMode=partial \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true \ /p:PublishSingleFile=true \
/p:JsonSerializerIsReflectionEnabledByDefault=true \ /p:JsonSerializerIsReflectionEnabledByDefault=true \
-a $TARGETARCH -a $TARGETARCH
+92 -1
View File
@@ -210,7 +210,21 @@ const server = Bun.serve({
}); });
} }
// Proxy the request // Check if it's a YouTube URL and redirect instead of proxying
if (isYouTubeEmbed(targetUrl)) {
// YouTube URL detected, redirect to youtube-nocookie.com
logRequest(req.method, targetUrl, 200);
return new Response(serveYouTubeEmbed(targetUrl), {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "frame-ancestors *",
"X-Frame-Options": "ALLOWALL",
},
});
}
// Proxy the request for non-YouTube URLs
const response = await proxyRequest(targetUrl); const response = await proxyRequest(targetUrl);
logRequest(req.method, targetUrl, response.status); logRequest(req.method, targetUrl, response.status);
return response; return response;
@@ -229,3 +243,80 @@ console.log(
); );
console.log(`📋 Health check: http://${server.hostname}:${server.port}/health`); console.log(`📋 Health check: http://${server.hostname}:${server.port}/health`);
console.log(`🌍 Environment: ${Bun.env.NODE_ENV || "development"}`); console.log(`🌍 Environment: ${Bun.env.NODE_ENV || "development"}`);
/**
* This is required to bypass YouTube's Referrer Policy restrictions when
* embedding videos on the mobile app. It basically "proxies" the Referrer and
* allows any YouTube video to be embedded anywhere without restrictions.
*/
function serveYouTubeEmbed(url: string) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="strict-origin-when-cross-origin">
<meta name="robots" content="noindex,nofollow">
<title>YouTube Video Embed</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing:border-box
}
body, html {
overflow: hidden;
background:#000
}
iframe {
border: 0;
width: 100vw;
height: 100vh;
display: block
}
</style>
</head>
<body>
<iframe src="${transformYouTubeUrl(
url
)}" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin" title="Video player"></iframe>
</body>
</html>`;
}
// Check if URL is a YouTube embed (including youtube-nocookie.com)
function isYouTubeEmbed(urlString: string) {
const url = new URL(urlString);
return (
(url.hostname === "www.youtube.com" ||
url.hostname === "youtube.com" ||
url.hostname === "m.youtube.com" ||
url.hostname === "www.youtube-nocookie.com" ||
url.hostname === "youtube-nocookie.com") &&
url.pathname.startsWith("/embed/")
);
}
// Transform YouTube URLs to use youtube-nocookie.com for enhanced privacy
function transformYouTubeUrl(urlString: string): string {
try {
const url = new URL(urlString);
// Check if it's a YouTube domain
if (
url.hostname === "www.youtube.com" ||
url.hostname === "youtube.com" ||
url.hostname === "m.youtube.com"
) {
// Replace with youtube-nocookie.com
url.hostname = "www.youtube-nocookie.com";
return url.toString();
}
return urlString;
} catch {
return urlString;
}
}