diff --git a/Notesnook.API/Controllers/UsersController.cs b/Notesnook.API/Controllers/UsersController.cs index fbf1f88..a15c856 100644 --- a/Notesnook.API/Controllers/UsersController.cs +++ b/Notesnook.API/Controllers/UsersController.cs @@ -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 Signup() + public async Task Signup([FromForm] SignupForm form) { try { - await UserService.CreateUserAsync(); - return Ok(); + return Ok(await UserService.CreateUserAsync(form)); } catch (Exception ex) { diff --git a/Notesnook.API/Interfaces/IUserService.cs b/Notesnook.API/Interfaces/IUserService.cs index 1eae89e..795cfb2 100644 --- a/Notesnook.API/Interfaces/IUserService.cs +++ b/Notesnook.API/Interfaces/IUserService.cs @@ -20,12 +20,13 @@ along with this program. If not, see . 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 CreateUserAsync(SignupForm form); Task DeleteUserAsync(string userId); Task DeleteUserAsync(string userId, string? jti, string password); Task ResetUserAsync(string userId, bool removeAttachments); diff --git a/Notesnook.API/Models/Responses/SignupResponse.cs b/Notesnook.API/Models/Responses/SignupResponse.cs deleted file mode 100644 index b5d6922..0000000 --- a/Notesnook.API/Models/Responses/SignupResponse.cs +++ /dev/null @@ -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; } - } -} diff --git a/Notesnook.API/Services/UserService.cs b/Notesnook.API/Services/UserService.cs index c1ffaad..da4b9b7 100644 --- a/Notesnook.API/Services/UserService.cs +++ b/Notesnook.API/Services/UserService.cs @@ -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 CreateUserAsync(SignupForm form) { - SignupResponse response = await httpClient.ForwardAsync(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 GetUserAsync(string userId) diff --git a/Streetwriters.Common/Interfaces/IUserAccountService.cs b/Streetwriters.Common/Interfaces/IUserAccountService.cs index 307fc8e..da98b9b 100644 --- a/Streetwriters.Common/Interfaces/IUserAccountService.cs +++ b/Streetwriters.Common/Interfaces/IUserAccountService.cs @@ -16,5 +16,7 @@ namespace Streetwriters.Common.Interfaces Task ResetPasswordAsync(string userId, string newPassword); [WampProcedure("co.streetwriters.identity.users.clear_sessions")] Task ClearSessionsAsync(string userId, string clientId, bool all, string jti, string? refreshToken); + [WampProcedure("co.streetwriters.identity.users.create_user")] + Task CreateUserAsync(string clientId, string email, string password, string? userAgent = null); } } \ No newline at end of file diff --git a/Streetwriters.Identity/Models/SignupForm.cs b/Streetwriters.Common/Models/SignupForm.cs similarity index 94% rename from Streetwriters.Identity/Models/SignupForm.cs rename to Streetwriters.Common/Models/SignupForm.cs index dbf1033..d90dde9 100644 --- a/Streetwriters.Identity/Models/SignupForm.cs +++ b/Streetwriters.Common/Models/SignupForm.cs @@ -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 { diff --git a/Streetwriters.Common/Models/SignupResponse.cs b/Streetwriters.Common/Models/SignupResponse.cs new file mode 100644 index 0000000..f797727 --- /dev/null +++ b/Streetwriters.Common/Models/SignupResponse.cs @@ -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 errors) + { + return new SignupResponse + { + Errors = [.. errors] + }; + } + } +} diff --git a/Streetwriters.Identity/Controllers/AccountController.cs b/Streetwriters.Identity/Controllers/AccountController.cs index f759809..79e5df1 100644 --- a/Streetwriters.Identity/Controllers/AccountController.cs +++ b/Streetwriters.Identity/Controllers/AccountController.cs @@ -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 diff --git a/Streetwriters.Identity/Controllers/SignupController.cs b/Streetwriters.Identity/Controllers/SignupController.cs deleted file mode 100644 index ca27091..0000000 --- a/Streetwriters.Identity/Controllers/SignupController.cs +++ /dev/null @@ -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 . -*/ - -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 logger; - private readonly EmailAddressValidator emailValidator; - - public SignupController(UserManager _userManager, ITemplatedEmailSender _emailSender, - SignInManager _signInManager, RoleManager _roleManager, IMFAService _mfaService, - ILogger 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 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"; - } - } -} diff --git a/Streetwriters.Identity/Extensions/UrlExtensions.cs b/Streetwriters.Identity/Extensions/UrlExtensions.cs index 41f3689..13b7147 100644 --- a/Streetwriters.Identity/Extensions/UrlExtensions.cs +++ b/Streetwriters.Identity/Extensions/UrlExtensions.cs @@ -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(); } } } \ No newline at end of file diff --git a/Streetwriters.Identity/Interfaces/ITokenGenerationService.cs b/Streetwriters.Identity/Interfaces/ITokenGenerationService.cs index 53b61bf..46cf3ef 100644 --- a/Streetwriters.Identity/Interfaces/ITokenGenerationService.cs +++ b/Streetwriters.Identity/Interfaces/ITokenGenerationService.cs @@ -19,6 +19,7 @@ along with this program. If not, see . 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 CreateAccessTokenAsync(User user, string clientId); - Task CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 60); - Task TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60); + Task CreateAccessTokenAsync(User user, string clientId, int lifetime = 1800); + Task CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 1200); + Task TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 1200); + Task CreateUserTokensAsync(User user, string clientId, int lifetime = 1800); } } \ No newline at end of file diff --git a/Streetwriters.Identity/Services/TokenGenerationService.cs b/Streetwriters.Identity/Services/TokenGenerationService.cs index 5b9c290..06b5bf9 100644 --- a/Streetwriters.Identity/Services/TokenGenerationService.cs +++ b/Streetwriters.Identity/Services/TokenGenerationService.cs @@ -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 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 CreateAccessTokenAsync(User user, string clientId) + public async Task 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 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 TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60) { var principal = await PrincipalFactory.CreateAsync(user); diff --git a/Streetwriters.Identity/Services/UserAccountService.cs b/Streetwriters.Identity/Services/UserAccountService.cs index d0878fb..059cf3b 100644 --- a/Streetwriters.Identity/Services/UserAccountService.cs +++ b/Streetwriters.Identity/Services/UserAccountService.cs @@ -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 userManager, IMFAService mfaService, IPersistedGrantStore persistedGrantStore) : IUserAccountService + public class UserAccountService(UserManager userManager, IMFAService mfaService, IPersistedGrantStore persistedGrantStore, RoleManager roleManager, EmailAddressValidator emailValidator, ITemplatedEmailSender emailSender, ITokenGenerationService tokenGenerationService, ILogger logger) : IUserAccountService { public async Task GetUserAsync(string clientId, string userId) { @@ -109,6 +116,89 @@ namespace Streetwriters.Identity.Services return true; } + public async Task 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();