identity: simplify user sign up

This commit is contained in:
Abdullah Atta authored and Abdullah Atta committed 2026-02-16 13:43:04 +05:00
1 parent d5790d8785
commit 9ae5db378d
13 files changed
+222 -209

No files matched your search

@@ -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
@@ -1,156 +0,0 @@
/*
This file is part of the Notesnook Sync Server project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the Affero GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Affero GNU General Public License for more details.
You should have received a copy of the Affero GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using AspNetCore.Identity.Mongo.Model;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using Streetwriters.Common;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Models;
using Streetwriters.Identity.Enums;
using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Models;
using Streetwriters.Identity.Services;
namespace Streetwriters.Identity.Controllers
{
[ApiController]
[Route("signup")]
public class SignupController : IdentityControllerBase
{
private readonly ILogger<SignupController> logger;
private readonly EmailAddressValidator emailValidator;
public SignupController(UserManager<User> _userManager, ITemplatedEmailSender _emailSender,
SignInManager<User> _signInManager, RoleManager<MongoRole> _roleManager, IMFAService _mfaService,
ILogger<SignupController> logger, EmailAddressValidator emailValidator) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService)
{
this.logger = logger;
this.emailValidator = emailValidator;
}
private async Task AddClientRoleAsync(string clientId)
{
if (await RoleManager.FindByNameAsync(clientId) == null)
await RoleManager.CreateAsync(new MongoRole(clientId));
}
[HttpPost]
[AllowAnonymous]
[EnableRateLimiting("strict")]
public async Task<IActionResult> Signup([FromForm] SignupForm form)
{
if (Constants.DISABLE_SIGNUPS)
return BadRequest(new string[] { "Creating new accounts is not allowed." });
try
{
var client = Clients.FindClientById(form.ClientId);
if (client == null) return BadRequest(new string[] { "Invalid client id." });
await AddClientRoleAsync(client.Id);
// email addresses must be case-insensitive
form.Email = form.Email.ToLowerInvariant();
form.Username = form.Username?.ToLowerInvariant();
if (!await emailValidator.IsEmailAddressValidAsync(form.Email)) return BadRequest(new string[] { "Invalid email address." });
var result = await UserManager.CreateAsync(new User
{
Email = form.Email,
EmailConfirmed = Constants.IS_SELF_HOSTED,
UserName = form.Username ?? form.Email,
}, form.Password);
if (result.Errors.Any((e) => e.Code == "DuplicateEmail"))
{
var user = await UserManager.FindByEmailAsync(form.Email);
if (user == null) return BadRequest(new string[] { "User not found." });
if (!await UserManager.IsInRoleAsync(user, client.Id))
{
if (!await UserManager.CheckPasswordAsync(user, form.Password))
{
// TODO
await UserManager.RemovePasswordAsync(user);
await UserManager.AddPasswordAsync(user, form.Password);
}
await MFAService.DisableMFAAsync(user);
await UserManager.AddToRoleAsync(user, client.Id);
}
else
{
return BadRequest(new string[] { "Invalid email address.." });
}
return Ok(new
{
userId = user.Id.ToString()
});
}
if (result.Succeeded)
{
var user = await UserManager.FindByEmailAsync(form.Email);
if (user == null) return BadRequest(new string[] { "User not found after creation." });
await UserManager.AddToRoleAsync(user, client.Id);
if (Constants.IS_SELF_HOSTED)
{
await UserManager.AddClaimAsync(user, new Claim(UserService.GetClaimKey(client.Id), "believer"));
}
else
{
await UserManager.AddClaimAsync(user, new Claim("platform", PlatformFromUserAgent(base.HttpContext.Request.Headers.UserAgent)));
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL);
if (!string.IsNullOrEmpty(user.Email) && callbackUrl != null)
{
await EmailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client);
}
}
return Ok(new
{
userId = user.Id.ToString()
});
}
return BadRequest(result.Errors.ToErrors());
}
catch (System.Exception ex)
{
logger.LogError(ex, "Failed to create user account for email: {Email}", form.Email);
return BadRequest("Failed to create an account.");
}
}
static string PlatformFromUserAgent(string? userAgent)
{
if (string.IsNullOrEmpty(userAgent)) return "unknown";
return userAgent.Contains("okhttp/") ? "android" : userAgent.Contains("Darwin/") || userAgent.Contains("CFNetwork/") ? "ios" : "web";
}
}
}
@@ -25,25 +25,24 @@ using Streetwriters.Common;
using Streetwriters.Identity.Controllers;
using Streetwriters.Identity.Enums;
namespace Microsoft.AspNetCore.Mvc
namespace Streetwriters.Identity.Extensions
{
public static class UrlHelperExtensions
public static class UrlExtensions
{
public static string? TokenLink(this IUrlHelper urlHelper, string userId, string code, string clientId, TokenType type)
public static string? TokenLink(string userId, string code, string clientId, TokenType type)
{
return urlHelper.ActionLink(
var url = new UriBuilder();
#if (DEBUG || STAGING)
host: $"{Servers.IdentityServer.Hostname}:{Servers.IdentityServer.Port}",
protocol: "http",
url.Host = $"{Servers.IdentityServer.Hostname}";
url.Port = Servers.IdentityServer.Port;
url.Scheme = "http";
#else
host: Servers.IdentityServer.PublicURL.Host,
protocol: Servers.IdentityServer.PublicURL.Scheme,
url.Host = Servers.IdentityServer.PublicURL.Host;
url.Scheme = Servers.IdentityServer.PublicURL.Scheme;
#endif
action: nameof(AccountController.ConfirmToken),
controller: "Account",
values: new { userId, code, clientId, type });
url.Path = "account/confirm";
url.Query = $"userId={Uri.EscapeDataString(userId)}&code={Uri.EscapeDataString(code)}&clientId={Uri.EscapeDataString(clientId)}&type={Uri.EscapeDataString(type.ToString())}";
return url.ToString();
}
}
}
@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System.Security.Claims;
using System.Threading.Tasks;
using IdentityServer4.ResponseHandling;
using IdentityServer4.Validation;
using Streetwriters.Common.Models;
@@ -26,8 +27,9 @@ namespace Streetwriters.Identity.Interfaces
{
public interface ITokenGenerationService
{
Task<string> CreateAccessTokenAsync(User user, string clientId);
Task<string> CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 60);
Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60);
Task<string> CreateAccessTokenAsync(User user, string clientId, int lifetime = 1800);
Task<string> CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 1200);
Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 1200);
Task<TokenResponse?> CreateUserTokensAsync(User user, string clientId, int lifetime = 1800);
}
}
@@ -1,57 +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.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using Microsoft.AspNetCore.Mvc;
namespace Streetwriters.Identity.Models
{
public class SignupForm
{
[Required]
[StringLength(120, ErrorMessage = "Password must be longer than or equal to 8 characters.", MinimumLength = 8)]
[BindProperty(Name = "password")]
public required string Password
{
get; set;
}
[Required]
[BindProperty(Name = "email")]
[EmailAddress]
public required string Email
{
get; set;
}
[BindProperty(Name = "username")]
public string? Username
{
get; set;
}
[Required]
[BindProperty(Name = "client_id")]
public required string ClientId
{
get; set;
}
}
}
@@ -24,6 +24,7 @@ using IdentityModel;
using IdentityServer4;
using IdentityServer4.Configuration;
using IdentityServer4.Models;
using IdentityServer4.ResponseHandling;
using IdentityServer4.Services;
using IdentityServer4.Stores;
using IdentityServer4.Validation;
@@ -41,12 +42,14 @@ namespace Streetwriters.Identity.Helpers
private IdentityServerOptions ISOptions { get; set; }
private IdentityServerTools Tools { get; set; }
private IResourceStore ResourceStore { get; set; }
private readonly IRefreshTokenService refreshTokenService;
public TokenGenerationService(ITokenService tokenService,
IUserClaimsPrincipalFactory<User> principalFactory,
IdentityServerOptions identityServerOptions,
IPersistedGrantStore persistedGrantStore,
IdentityServerTools tools,
IResourceStore resourceStore)
IResourceStore resourceStore,
IRefreshTokenService _refreshTokenService)
{
TokenService = tokenService;
PrincipalFactory = principalFactory;
@@ -54,16 +57,25 @@ namespace Streetwriters.Identity.Helpers
PersistedGrantStore = persistedGrantStore;
Tools = tools;
ResourceStore = resourceStore;
refreshTokenService = _refreshTokenService;
}
public async Task<string> CreateAccessTokenAsync(User user, string clientId)
public async Task<string> CreateAccessTokenAsync(User user, string clientId, int lifetime = 1800)
{
var client = Config.Clients.FirstOrDefault((c) => c.ClientId == clientId);
if (client == null)
{
throw new System.ArgumentException($"Client with ID '{clientId}' not found", nameof(clientId));
}
var IdentityPricipal = await PrincipalFactory.CreateAsync(user);
var IdentityUser = new IdentityServerUser(user.Id.ToString());
IdentityUser.AdditionalClaims = IdentityPricipal.Claims.ToArray();
IdentityUser.DisplayName = user.UserName;
IdentityUser.AuthenticationTime = System.DateTime.UtcNow;
IdentityUser.IdentityProvider = IdentityServerConstants.LocalIdentityProvider;
var IdentityUser = new IdentityServerUser(user.Id.ToString())
{
AdditionalClaims = [.. IdentityPricipal.Claims],
DisplayName = user.UserName,
AuthenticationTime = System.DateTime.UtcNow,
IdentityProvider = IdentityServerConstants.LocalIdentityProvider
};
var Request = new TokenCreationRequest
{
Subject = IdentityUser.CreatePrincipal(),
@@ -71,16 +83,61 @@ namespace Streetwriters.Identity.Helpers
ValidatedRequest = new ValidatedRequest()
};
Request.ValidatedRequest.Subject = Request.Subject;
Request.ValidatedRequest.SetClient(Config.Clients.FirstOrDefault((c) => c.ClientId == clientId));
Request.ValidatedRequest.SetClient(client);
Request.ValidatedRequest.AccessTokenType = AccessTokenType.Reference;
Request.ValidatedRequest.AccessTokenLifetime = 18000;
Request.ValidatedResources = new ResourceValidationResult(new Resources(Config.IdentityResources, Config.ApiResources, Config.ApiScopes));
Request.ValidatedRequest.AccessTokenLifetime = lifetime;
var requestedScopes = client.AllowedScopes.Select(s => new ParsedScopeValue(s));
Request.ValidatedResources = await ResourceStore.CreateResourceValidationResult(new ParsedScopesResult
{
ParsedScopes = [.. requestedScopes]
});
Request.ValidatedRequest.Options = ISOptions;
Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims;
var accessToken = await TokenService.CreateAccessTokenAsync(Request);
return await TokenService.CreateSecurityTokenAsync(accessToken);
}
public async Task<TokenResponse?> CreateUserTokensAsync(User user, string clientId, int lifetime = 1800)
{
var client = Config.Clients.FirstOrDefault((c) => c.ClientId == clientId);
var principal = await PrincipalFactory.CreateAsync(user);
if (client == null || principal == null) return null;
var IdentityUser = new IdentityServerUser(user.Id.ToString())
{
AdditionalClaims = [.. principal.Claims],
DisplayName = user.UserName,
AuthenticationTime = System.DateTime.UtcNow,
IdentityProvider = IdentityServerConstants.LocalIdentityProvider
};
var Request = new TokenCreationRequest
{
Subject = IdentityUser.CreatePrincipal(),
IncludeAllIdentityClaims = true,
ValidatedRequest = new ValidatedRequest()
};
Request.ValidatedRequest.Subject = Request.Subject;
Request.ValidatedRequest.SetClient(client);
Request.ValidatedRequest.AccessTokenType = AccessTokenType.Reference;
Request.ValidatedRequest.AccessTokenLifetime = lifetime;
var requestedScopes = client.AllowedScopes.Select(s => new ParsedScopeValue(s));
Request.ValidatedResources = await ResourceStore.CreateResourceValidationResult(new ParsedScopesResult
{
ParsedScopes = [.. requestedScopes]
});
Request.ValidatedRequest.Options = ISOptions;
Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims;
var accessToken = await TokenService.CreateAccessTokenAsync(Request);
var refreshToken = await refreshTokenService.CreateRefreshTokenAsync(principal, accessToken, client);
return new TokenResponse
{
AccessToken = await TokenService.CreateSecurityTokenAsync(accessToken),
AccessTokenLifetime = lifetime,
RefreshToken = refreshToken,
Scope = string.Join(" ", accessToken.Scopes)
};
}
public async Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60)
{
var principal = await PrincipalFactory.CreateAsync(user);
@@ -1,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();