identity: simplify user sign up

This commit is contained in:
Abdullah Atta
2026-02-16 13:18:42 +05:00
committed by Abdullah Atta
parent d5790d8785
commit 9ae5db378d
13 changed files with 222 additions and 209 deletions

View File

@@ -33,6 +33,7 @@ using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
namespace Notesnook.API.Controllers
{
@@ -43,12 +44,11 @@ namespace Notesnook.API.Controllers
{
[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)
{

View File

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

View File

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

View File

@@ -51,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
@@ -84,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)

View File

@@ -16,5 +16,7 @@ namespace Streetwriters.Common.Interfaces
Task<bool> ResetPasswordAsync(string userId, string newPassword);
[WampProcedure("co.streetwriters.identity.users.clear_sessions")]
Task<bool> ClearSessionsAsync(string userId, string clientId, bool all, string jti, string? refreshToken);
[WampProcedure("co.streetwriters.identity.users.create_user")]
Task<SignupResponse> CreateUserAsync(string clientId, string email, string password, string? userAgent = null);
}
}

View File

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

View File

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

View File

@@ -38,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;
@@ -125,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
@@ -158,7 +159,7 @@ namespace Streetwriters.Identity.Controllers
if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
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)
return Ok(callbackUrl);
#else

View File

@@ -1,156 +0,0 @@
/*
This file is part of the Notesnook Sync Server project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the Affero GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Affero GNU General Public License for more details.
You should have received a copy of the Affero GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using AspNetCore.Identity.Mongo.Model;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using Streetwriters.Common;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Models;
using Streetwriters.Identity.Enums;
using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Models;
using Streetwriters.Identity.Services;
namespace Streetwriters.Identity.Controllers
{
[ApiController]
[Route("signup")]
public class SignupController : IdentityControllerBase
{
private readonly ILogger<SignupController> logger;
private readonly EmailAddressValidator emailValidator;
public SignupController(UserManager<User> _userManager, ITemplatedEmailSender _emailSender,
SignInManager<User> _signInManager, RoleManager<MongoRole> _roleManager, IMFAService _mfaService,
ILogger<SignupController> logger, EmailAddressValidator emailValidator) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService)
{
this.logger = logger;
this.emailValidator = emailValidator;
}
private async Task AddClientRoleAsync(string clientId)
{
if (await RoleManager.FindByNameAsync(clientId) == null)
await RoleManager.CreateAsync(new MongoRole(clientId));
}
[HttpPost]
[AllowAnonymous]
[EnableRateLimiting("strict")]
public async Task<IActionResult> Signup([FromForm] SignupForm form)
{
if (Constants.DISABLE_SIGNUPS)
return BadRequest(new string[] { "Creating new accounts is not allowed." });
try
{
var client = Clients.FindClientById(form.ClientId);
if (client == null) return BadRequest(new string[] { "Invalid client id." });
await AddClientRoleAsync(client.Id);
// email addresses must be case-insensitive
form.Email = form.Email.ToLowerInvariant();
form.Username = form.Username?.ToLowerInvariant();
if (!await emailValidator.IsEmailAddressValidAsync(form.Email)) return BadRequest(new string[] { "Invalid email address." });
var result = await UserManager.CreateAsync(new User
{
Email = form.Email,
EmailConfirmed = Constants.IS_SELF_HOSTED,
UserName = form.Username ?? form.Email,
}, form.Password);
if (result.Errors.Any((e) => e.Code == "DuplicateEmail"))
{
var user = await UserManager.FindByEmailAsync(form.Email);
if (user == null) return BadRequest(new string[] { "User not found." });
if (!await UserManager.IsInRoleAsync(user, client.Id))
{
if (!await UserManager.CheckPasswordAsync(user, form.Password))
{
// TODO
await UserManager.RemovePasswordAsync(user);
await UserManager.AddPasswordAsync(user, form.Password);
}
await MFAService.DisableMFAAsync(user);
await UserManager.AddToRoleAsync(user, client.Id);
}
else
{
return BadRequest(new string[] { "Invalid email address.." });
}
return Ok(new
{
userId = user.Id.ToString()
});
}
if (result.Succeeded)
{
var user = await UserManager.FindByEmailAsync(form.Email);
if (user == null) return BadRequest(new string[] { "User not found after creation." });
await UserManager.AddToRoleAsync(user, client.Id);
if (Constants.IS_SELF_HOSTED)
{
await UserManager.AddClaimAsync(user, new Claim(UserService.GetClaimKey(client.Id), "believer"));
}
else
{
await UserManager.AddClaimAsync(user, new Claim("platform", PlatformFromUserAgent(base.HttpContext.Request.Headers.UserAgent)));
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL);
if (!string.IsNullOrEmpty(user.Email) && callbackUrl != null)
{
await EmailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client);
}
}
return Ok(new
{
userId = user.Id.ToString()
});
}
return BadRequest(result.Errors.ToErrors());
}
catch (System.Exception ex)
{
logger.LogError(ex, "Failed to create user account for email: {Email}", form.Email);
return BadRequest("Failed to create an account.");
}
}
static string PlatformFromUserAgent(string? userAgent)
{
if (string.IsNullOrEmpty(userAgent)) return "unknown";
return userAgent.Contains("okhttp/") ? "android" : userAgent.Contains("Darwin/") || userAgent.Contains("CFNetwork/") ? "ios" : "web";
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,22 +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, IPersistedGrantStore persistedGrantStore) : 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)
{
@@ -109,6 +116,89 @@ namespace Streetwriters.Identity.Services
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();