From 4e9f82fe48d6849260d2554f82c3589a2afc4006 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Wed, 28 Dec 2022 17:24:47 +0500 Subject: [PATCH] open source identity server --- .gitignore | 3 +- Notesnook.sln | 6 + README.md | 8 +- Streetwriters.Identity/Config.cs | 86 ++ .../Controllers/AccountController.cs | 340 ++++++++ .../Controllers/IdentityControllerBase.cs | 67 ++ .../Controllers/MFAController.cs | 138 +++ .../Controllers/SignupController.cs | 115 +++ Streetwriters.Identity/Enums/TokenTypes.cs | 28 + .../Extensions/HttpContextExtensions.cs | 56 ++ .../Extensions/IEnumerableExtensions.cs | 42 + .../Extensions/IntExtensions.cs | 34 + .../Extensions/MongoDBTicketStore.cs | 73 ++ .../Extensions/UrlExtensions.cs | 48 + .../Handlers/ClientHandlers.cs | 39 + .../Handlers/NotesnookHandler.cs | 58 ++ .../Handlers/TokenResponseHandler.cs | 43 + .../Helpers/PasswordHelper.cs | 38 + .../Interfaces/IAppHandler.cs | 31 + .../Interfaces/IEmailSender.cs | 34 + .../Interfaces/IEmailTemplate.cs | 31 + .../Interfaces/IMFAService.cs | 40 + .../Interfaces/ISMSSender.cs | 30 + .../Interfaces/ITokenGenerationService.cs | 33 + .../MessageHandlers/CreateSubscription.cs | 56 ++ .../MessageHandlers/DeleteSubscription.cs | 53 ++ .../Models/AuthenticatorDetails.cs | 34 + .../Models/ChangeEmailForm.cs | 39 + .../Models/DeleteAccountForm.cs | 33 + .../Models/EmailTemplate.cs | 33 + .../Models/GetAccessTokenForm.cs | 49 ++ .../Models/MFAPasswordRequiredResponse.cs | 29 + .../Models/MFARequiredResponse.cs | 35 + .../Models/MessageBirdOptions.cs | 30 + .../Models/MultiFactorEnableForm.cs | 43 + .../Models/MultiFactorSetupForm.cs | 37 + .../Models/ResetPasswordForm.cs | 42 + Streetwriters.Identity/Models/SignupForm.cs | 57 ++ Streetwriters.Identity/Models/SmtpOptions.cs | 33 + .../Models/TwoFactorLoginForm.cs | 41 + .../Models/UpdateUserForm.cs | 53 ++ Streetwriters.Identity/Program.cs | 52 ++ .../Properties/launchSettings.json | 30 + .../CustomIntrospectionResponseGenerator.cs | 67 ++ .../Services/CustomRefreshTokenService.cs | 43 + .../Services/EmailSender.cs | 275 ++++++ Streetwriters.Identity/Services/MFAService.cs | 241 +++++ .../Services/PasswordHasher.cs | 47 + .../Services/ProfileService.cs | 61 ++ Streetwriters.Identity/Services/SMSSender.cs | 59 ++ .../Services/TokenGenerationService.cs | 116 +++ .../Services/UserService.cs | 84 ++ Streetwriters.Identity/Startup.cs | 219 +++++ .../Streetwriters.Identity.csproj | 46 + .../Templates/ConfirmEmail.html | 823 ++++++++++++++++++ .../Templates/ConfirmEmail.txt | 17 + .../Templates/Email2FACode.html | 435 +++++++++ .../Templates/Email2FACode.txt | 17 + .../Templates/EmailChangeConfirmation.html | 807 +++++++++++++++++ .../Templates/EmailChangeConfirmation.txt | 15 + .../Templates/FailedLoginAlert.html | 505 +++++++++++ .../Templates/FailedLoginAlert.txt | 15 + .../Templates/ResetAccountPassword.html | 658 ++++++++++++++ .../Templates/ResetAccountPassword.txt | 17 + .../Validation/BearerTokenValidator.cs | 62 ++ .../CustomResourceOwnerValidator.cs | 152 ++++ .../Validation/EmailGrantValidator.cs | 106 +++ .../Validation/LockedOutValidationResult.cs | 36 + .../Validation/MFAGrantValidator.cs | 142 +++ .../Validation/MFAPasswordGrantValidator.cs | 106 +++ .../appsettings.Development.json | 13 + 71 files changed, 7382 insertions(+), 2 deletions(-) create mode 100644 Streetwriters.Identity/Config.cs create mode 100644 Streetwriters.Identity/Controllers/AccountController.cs create mode 100644 Streetwriters.Identity/Controllers/IdentityControllerBase.cs create mode 100644 Streetwriters.Identity/Controllers/MFAController.cs create mode 100644 Streetwriters.Identity/Controllers/SignupController.cs create mode 100644 Streetwriters.Identity/Enums/TokenTypes.cs create mode 100644 Streetwriters.Identity/Extensions/HttpContextExtensions.cs create mode 100644 Streetwriters.Identity/Extensions/IEnumerableExtensions.cs create mode 100644 Streetwriters.Identity/Extensions/IntExtensions.cs create mode 100644 Streetwriters.Identity/Extensions/MongoDBTicketStore.cs create mode 100644 Streetwriters.Identity/Extensions/UrlExtensions.cs create mode 100644 Streetwriters.Identity/Handlers/ClientHandlers.cs create mode 100644 Streetwriters.Identity/Handlers/NotesnookHandler.cs create mode 100644 Streetwriters.Identity/Handlers/TokenResponseHandler.cs create mode 100644 Streetwriters.Identity/Helpers/PasswordHelper.cs create mode 100644 Streetwriters.Identity/Interfaces/IAppHandler.cs create mode 100644 Streetwriters.Identity/Interfaces/IEmailSender.cs create mode 100644 Streetwriters.Identity/Interfaces/IEmailTemplate.cs create mode 100644 Streetwriters.Identity/Interfaces/IMFAService.cs create mode 100644 Streetwriters.Identity/Interfaces/ISMSSender.cs create mode 100644 Streetwriters.Identity/Interfaces/ITokenGenerationService.cs create mode 100644 Streetwriters.Identity/MessageHandlers/CreateSubscription.cs create mode 100644 Streetwriters.Identity/MessageHandlers/DeleteSubscription.cs create mode 100644 Streetwriters.Identity/Models/AuthenticatorDetails.cs create mode 100644 Streetwriters.Identity/Models/ChangeEmailForm.cs create mode 100644 Streetwriters.Identity/Models/DeleteAccountForm.cs create mode 100644 Streetwriters.Identity/Models/EmailTemplate.cs create mode 100644 Streetwriters.Identity/Models/GetAccessTokenForm.cs create mode 100644 Streetwriters.Identity/Models/MFAPasswordRequiredResponse.cs create mode 100644 Streetwriters.Identity/Models/MFARequiredResponse.cs create mode 100644 Streetwriters.Identity/Models/MessageBirdOptions.cs create mode 100644 Streetwriters.Identity/Models/MultiFactorEnableForm.cs create mode 100644 Streetwriters.Identity/Models/MultiFactorSetupForm.cs create mode 100644 Streetwriters.Identity/Models/ResetPasswordForm.cs create mode 100644 Streetwriters.Identity/Models/SignupForm.cs create mode 100644 Streetwriters.Identity/Models/SmtpOptions.cs create mode 100644 Streetwriters.Identity/Models/TwoFactorLoginForm.cs create mode 100644 Streetwriters.Identity/Models/UpdateUserForm.cs create mode 100644 Streetwriters.Identity/Program.cs create mode 100644 Streetwriters.Identity/Properties/launchSettings.json create mode 100644 Streetwriters.Identity/Services/CustomIntrospectionResponseGenerator.cs create mode 100644 Streetwriters.Identity/Services/CustomRefreshTokenService.cs create mode 100644 Streetwriters.Identity/Services/EmailSender.cs create mode 100644 Streetwriters.Identity/Services/MFAService.cs create mode 100644 Streetwriters.Identity/Services/PasswordHasher.cs create mode 100644 Streetwriters.Identity/Services/ProfileService.cs create mode 100644 Streetwriters.Identity/Services/SMSSender.cs create mode 100644 Streetwriters.Identity/Services/TokenGenerationService.cs create mode 100644 Streetwriters.Identity/Services/UserService.cs create mode 100644 Streetwriters.Identity/Startup.cs create mode 100644 Streetwriters.Identity/Streetwriters.Identity.csproj create mode 100644 Streetwriters.Identity/Templates/ConfirmEmail.html create mode 100644 Streetwriters.Identity/Templates/ConfirmEmail.txt create mode 100644 Streetwriters.Identity/Templates/Email2FACode.html create mode 100644 Streetwriters.Identity/Templates/Email2FACode.txt create mode 100644 Streetwriters.Identity/Templates/EmailChangeConfirmation.html create mode 100644 Streetwriters.Identity/Templates/EmailChangeConfirmation.txt create mode 100644 Streetwriters.Identity/Templates/FailedLoginAlert.html create mode 100644 Streetwriters.Identity/Templates/FailedLoginAlert.txt create mode 100644 Streetwriters.Identity/Templates/ResetAccountPassword.html create mode 100644 Streetwriters.Identity/Templates/ResetAccountPassword.txt create mode 100644 Streetwriters.Identity/Validation/BearerTokenValidator.cs create mode 100644 Streetwriters.Identity/Validation/CustomResourceOwnerValidator.cs create mode 100644 Streetwriters.Identity/Validation/EmailGrantValidator.cs create mode 100644 Streetwriters.Identity/Validation/LockedOutValidationResult.cs create mode 100644 Streetwriters.Identity/Validation/MFAGrantValidator.cs create mode 100644 Streetwriters.Identity/Validation/MFAPasswordGrantValidator.cs create mode 100644 Streetwriters.Identity/appsettings.Development.json diff --git a/.gitignore b/.gitignore index 6c50423..3042a41 100644 --- a/.gitignore +++ b/.gitignore @@ -262,4 +262,5 @@ __pycache__/ keys/ dist/ -appsettings.json \ No newline at end of file +appsettings.json +keystore/ \ No newline at end of file diff --git a/Notesnook.sln b/Notesnook.sln index c104961..75c9443 100644 --- a/Notesnook.sln +++ b/Notesnook.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetwriters.Data", "Stree EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetwriters.Messenger", "Streetwriters.Messenger\Streetwriters.Messenger.csproj", "{BDA80415-6C8D-4481-AC31-E5B4D73E9629}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetwriters.Identity", "Streetwriters.Identity\Streetwriters.Identity.csproj", "{6800DEE0-768C-4BEB-B78C-08829EC5A106}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +38,9 @@ Global {BDA80415-6C8D-4481-AC31-E5B4D73E9629}.Debug|Any CPU.Build.0 = Debug|Any CPU {BDA80415-6C8D-4481-AC31-E5B4D73E9629}.Release|Any CPU.ActiveCfg = Release|Any CPU {BDA80415-6C8D-4481-AC31-E5B4D73E9629}.Release|Any CPU.Build.0 = Release|Any CPU + {6800DEE0-768C-4BEB-B78C-08829EC5A106}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6800DEE0-768C-4BEB-B78C-08829EC5A106}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6800DEE0-768C-4BEB-B78C-08829EC5A106}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6800DEE0-768C-4BEB-B78C-08829EC5A106}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/README.md b/README.md index b17a35d..dec29ac 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,18 @@ To run the `Streetwriters.Messenger` project: dotnet run --project Streetwriters.Messenger/Streetwriters.Messenger.csproj ``` +To run the `Streetwriters.Identity` project: + +```bash +dotnet run --project Streetwriters.Identity/Streetwriters.Identity.csproj +``` + ## TODO Self-hosting **Note: Self-hosting the Notesnook Sync Server is not yet possible. We are working to enable full on-premise self hosting so stay tuned!** - [x] Open source the Sync server -- [ ] Open source the Identity server +- [x] Open source the Identity server - [x] Open source the SSE Messaging infrastructure - [ ] Fully Dockerize all services - [ ] Publish on DockerHub diff --git a/Streetwriters.Identity/Config.cs b/Streetwriters.Identity/Config.cs new file mode 100644 index 0000000..2af8c2d --- /dev/null +++ b/Streetwriters.Identity/Config.cs @@ -0,0 +1,86 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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 IdentityServer4; +using IdentityServer4.Models; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Streetwriters.Identity +{ + public static class Config + { + public const string EMAIL_GRANT_TYPE = "email"; + public const string MFA_GRANT_TYPE = "mfa"; + public const string MFA_PASSWORD_GRANT_TYPE = "mfa_password"; + + public const string MFA_GRANT_TYPE_SCOPE = "auth:grant_types:mfa"; + public const string MFA_PASSWORD_GRANT_TYPE_SCOPE = "auth:grant_types:mfa_password"; + + public static IEnumerable IdentityResources => + new List { + new IdentityResources.OpenId(), + }; + + public static IEnumerable ApiResources => + new List + { + new ApiResource("notesnook", "Notesnook API", new string[] { "verified" }) + { + ApiSecrets = { new Secret(Environment.GetEnvironmentVariable("NOTESNOOK_API_SECRET")?.Sha256()) }, + Scopes = { "notesnook.sync" } + }, + // local API + new ApiResource(IdentityServerConstants.LocalApi.ScopeName) + }; + + public static IEnumerable ApiScopes => + new List + { + new ApiScope("notesnook.sync", "Notesnook Syncing Access"), + new ApiScope(IdentityServerConstants.LocalApi.ScopeName), + new ApiScope(MFA_GRANT_TYPE_SCOPE, "Multi-factor authentication access"), + new ApiScope(MFA_PASSWORD_GRANT_TYPE_SCOPE, "Multi-factor authentication password step access") + }; + + public static IEnumerable Clients => + new List + { + new Client + { + ClientName = "Notesnook", + ClientId = "notesnook", + AllowedGrantTypes = { GrantType.ResourceOwnerPassword, MFA_GRANT_TYPE, MFA_PASSWORD_GRANT_TYPE, EMAIL_GRANT_TYPE, }, + RequirePkce = false, + RequireClientSecret = false, + RequireConsent = false, + AccessTokenType = AccessTokenType.Reference, + AllowOfflineAccess = true, + UpdateAccessTokenClaimsOnRefresh = true, + RefreshTokenUsage = TokenUsage.OneTimeOnly, + RefreshTokenExpiration = TokenExpiration.Absolute, + AccessTokenLifetime = 3600, + + // scopes that client has access to + AllowedScopes = { "notesnook.sync", "offline_access", "openid", IdentityServerConstants.LocalApi.ScopeName, "mfa" }, + } + }; + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Controllers/AccountController.cs b/Streetwriters.Identity/Controllers/AccountController.cs new file mode 100644 index 0000000..039d981 --- /dev/null +++ b/Streetwriters.Identity/Controllers/AccountController.cs @@ -0,0 +1,340 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using AspNetCore.Identity.Mongo.Model; +using IdentityServer4.Configuration; +using IdentityServer4.Stores; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using MongoDB.Bson; +using Streetwriters.Common; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Messages; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Enums; +using Streetwriters.Identity.Handlers; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; +using static IdentityServer4.IdentityServerConstants; + +namespace Streetwriters.Identity.Controllers +{ + [ApiController] + [DisplayName("Account")] + [Route("account")] + [Authorize(LocalApi.PolicyName)] + public class AccountController : IdentityControllerBase + { + private IPersistedGrantStore PersistedGrantStore { get; set; } + private ITokenGenerationService TokenGenerationService { get; set; } + private IUserClaimsPrincipalFactory PrincipalFactory { get; set; } + private IdentityServerOptions ISOptions { get; set; } + public AccountController(UserManager _userManager, IEmailSender _emailSender, + SignInManager _signInManager, RoleManager _roleManager, IPersistedGrantStore store, + ITokenGenerationService tokenGenerationService, IMFAService _mfaService) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService) + { + PersistedGrantStore = store; + TokenGenerationService = tokenGenerationService; + } + + [HttpGet("confirm")] + [AllowAnonymous] + [ResponseCache(NoStore = true)] + public async Task ConfirmToken(string userId, string code, string clientId, TokenType type) + { + var client = Clients.FindClientById(clientId); + if (client == null) return BadRequest("Invalid client_id."); + + var user = await UserManager.FindByIdAsync(userId); + if (!await IsUserValidAsync(user, clientId)) return BadRequest($"Unable to find user with ID '{userId}'."); + + switch (type) + { + case TokenType.CONFRIM_EMAIL: + { + if (await UserManager.IsEmailConfirmedAsync(user)) return Ok("Email already verified."); + + var result = await UserManager.ConfirmEmailAsync(user, code); + if (!result.Succeeded) return BadRequest(result.Errors.ToErrors()); + + foreach (var handler in ClientHandlers.Handlers) + { + if (await UserManager.IsInRoleAsync(user, client.Id)) + { + await handler.Value.OnEmailConfirmed(userId); + // if (client.WelcomeEmailTemplateId != null) + // await EmailSender.SendWelcomeEmailAsync(user.Email, client); + } + } + var redirectUrl = $"{ClientHandlers.GetClientHandler(client.Type)?.EmailConfirmedRedirectURL}?userId={userId}"; + return RedirectPermanent(redirectUrl); + } + // case TokenType.CHANGE_EMAIL: + // { + // var newEmail = user.Claims.Find((c) => c.ClaimType == "new_email"); + // if (newEmail == null) return BadRequest("Email change was not requested."); + + // var result = await UserManager.ChangeEmailAsync(user, newEmail.ClaimValue.ToString(), code); + // if (result.Succeeded) + // { + // await UserManager.RemoveClaimAsync(user, newEmail.ToClaim()); + // return Ok("Email changed."); + // } + // return BadRequest("Could not change email."); + // } + case TokenType.RESET_PASSWORD: + { + if (!await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", code)) + return BadRequest("Invalid token."); + + var authorizationCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "PasswordResetAuthorizationCode"); + var redirectUrl = $"{ClientHandlers.GetClientHandler(client.Type)?.AccountRecoveryRedirectURL}?userId={userId}&code={authorizationCode}"; + return RedirectPermanent(redirectUrl); + } + default: + return BadRequest("Invalid type."); + } + + } + + [HttpPost("verify")] + public async Task SendVerificationEmail() + { + var client = Clients.FindClientById(User.FindFirstValue("client_id")); + if (client == null) return BadRequest("Invalid client_id."); + + var user = await UserManager.GetUserAsync(User); + if (!await IsUserValidAsync(user, client.Id)) return BadRequest($"Unable to find user with ID '{UserManager.GetUserId(User)}'."); + + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL, Request.Scheme); + await EmailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client); + return Ok(); + } + + [HttpPost("unregister")] + public async Task UnregisterAccountAync([FromForm] DeleteAccountForm form) + { + var client = Clients.FindClientById(User.FindFirstValue("client_id")); + if (client == null) return BadRequest("Invalid client_id."); + + var user = await UserManager.GetUserAsync(User); + if (!await IsUserValidAsync(user, client.Id)) return BadRequest($"Unable to find user with ID '{UserManager.GetUserId(User)}'."); + + if (!await UserManager.CheckPasswordAsync(user, form.Password)) + { + return Unauthorized(); + } + + await UserManager.RemoveFromRoleAsync(user, client.Id); + return Ok(); + } + + [HttpGet] + public async Task GetUserAccount() + { + var client = Clients.FindClientById(User.FindFirstValue("client_id")); + if (client == null) return BadRequest("Invalid client_id."); + + var user = await UserManager.GetUserAsync(User); + if (!await IsUserValidAsync(user, client.Id)) + return BadRequest($"Unable to find user with ID '{UserManager.GetUserId(User)}'."); + + return Ok(new UserModel + { + UserId = user.Id.ToString(), + Email = user.Email, + IsEmailConfirmed = user.EmailConfirmed, + // PhoneNumber = user.PhoneNumberConfirmed ? user.PhoneNumber : null, + MFA = new MFAConfig + { + IsEnabled = user.TwoFactorEnabled, + PrimaryMethod = MFAService.GetPrimaryMethod(user), + SecondaryMethod = MFAService.GetSecondaryMethod(user), + RemainingValidCodes = await MFAService.GetRemainingValidCodesAsync(user) + } + }); + } + + [HttpPost("recover")] + [AllowAnonymous] + public async Task ResetUserPassword([FromForm] ResetPasswordForm form) + { + var client = Clients.FindClientById(form.ClientId); + if (client == null) return BadRequest("Invalid client_id."); + + var user = await UserManager.FindByEmailAsync(form.Email); + if (!await IsUserValidAsync(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, Request.Scheme); +#if DEBUG + return Ok(callbackUrl); +#else + await Slogger.Info("ResetUserPassword", user.Email, callbackUrl); + await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client); + return Ok(); +#endif + } + + [HttpPost("logout")] + public async Task Logout() + { + var client = Clients.FindClientById(User.FindFirstValue("client_id")); + if (client == null) return BadRequest("Invalid client_id."); + + var user = await UserManager.GetUserAsync(User); + if (!await IsUserValidAsync(user, client.Id)) return BadRequest($"Unable to find user with ID '{UserManager.GetUserId(User)}'."); + + var subjectId = User.FindFirstValue("sub"); + var jti = User.FindFirstValue("jti"); + + var grants = await PersistedGrantStore.GetAllAsync(new PersistedGrantFilter + { + ClientId = client.Id, + SubjectId = subjectId + }); + grants = grants.Where((grant) => grant.Data.Contains(jti)); + if (grants.Any()) + { + foreach (var grant in grants) + { + await PersistedGrantStore.RemoveAsync(grant.Key); + } + } + return Ok(); + } + + [HttpPost("token")] + [AllowAnonymous] + public async Task GetAccessTokenFromCode([FromForm] GetAccessTokenForm form) + { + if (!Clients.IsValidClient(form.ClientId)) return BadRequest("Invalid clientId."); + var user = await UserManager.FindByIdAsync(form.UserId); + if (!await IsUserValidAsync(user, form.ClientId)) + return BadRequest($"Unable to find user with ID '{form.UserId}'."); + + if (!await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "PasswordResetAuthorizationCode", form.Code)) + return BadRequest("Invalid authorization_code."); + var token = await TokenGenerationService.CreateAccessTokenAsync(user, form.ClientId); + return Ok(new + { + access_token = token, + expires_in = 18000 + }); + } + + [HttpPatch] + public async Task UpdateAccount([FromForm] UpdateUserForm form) + { + var client = Clients.FindClientById(User.FindFirstValue("client_id")); + if (client == null) return BadRequest("Invalid client_id."); + + var user = await UserManager.GetUserAsync(User); + if (!await IsUserValidAsync(user, client.Id)) + return BadRequest($"Unable to find user with ID '{UserManager.GetUserId(User)}'."); + + switch (form.Type) + { + case "change_email": + { + var code = await UserManager.GenerateChangeEmailTokenAsync(user, form.NewEmail); + // var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CHANGE_EMAIL, Request.Scheme); + await EmailSender.SendChangeEmailConfirmationAsync(user.Email, code, client); + await UserManager.AddClaimAsync(user, new Claim("new_email", form.NewEmail)); + return Ok(); + } + case "change_password": + { + var result = await UserManager.ChangePasswordAsync(user, form.OldPassword, form.NewPassword); + if (result.Succeeded) + { + await SendPasswordChangedMessageAsync(user.Id.ToString()); + return Ok(); + } + return BadRequest(result.Errors.ToErrors()); + } + case "reset_password": + { + var result = await UserManager.RemovePasswordAsync(user); + if (result.Succeeded) + { + result = await UserManager.AddPasswordAsync(user, form.NewPassword); + if (result.Succeeded) + { + await SendPasswordChangedMessageAsync(user.Id.ToString()); + return Ok(); + } + } + return BadRequest(result.Errors.ToErrors()); + } + } + return BadRequest("Invalid type."); + } + + [HttpPost("sessions/clear")] + public async Task 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); + if (!await IsUserValidAsync(user, client.Id)) return BadRequest($"Unable to find user with ID '{user.Id.ToString()}'."); + + var jti = User.FindFirstValue("jti"); + + var grants = await PersistedGrantStore.GetAllAsync(new PersistedGrantFilter + { + ClientId = client.Id, + SubjectId = user.Id.ToString() + }); + foreach (var grant in grants) + { + if (!all && (grant.Data.Contains(jti) || grant.Data.Contains(refresh_token))) continue; + await PersistedGrantStore.RemoveAsync(grant.Key); + } + return Ok(); + } + + private async Task SendPasswordChangedMessageAsync(string userId) + { + await WampServers.MessengerServer.PublishMessageAsync(WampServers.MessengerServer.Topics.SendSSETopic, new SendSSEMessage + { + UserId = userId, + OriginTokenId = User.FindFirstValue("jti"), + Message = new Message + { + Type = "userPasswordChanged" + } + }); + } + + public async Task IsUserValidAsync(User user, string clientId) + { + return user != null && await UserManager.IsInRoleAsync(user, clientId); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Controllers/IdentityControllerBase.cs b/Streetwriters.Identity/Controllers/IdentityControllerBase.cs new file mode 100644 index 0000000..7918b91 --- /dev/null +++ b/Streetwriters.Identity/Controllers/IdentityControllerBase.cs @@ -0,0 +1,67 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Text.Encodings.Web; +using System.Threading.Tasks; +using AspNetCore.Identity.Mongo.Model; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; + +namespace Streetwriters.Identity.Controllers +{ + public abstract class IdentityControllerBase : ControllerBase + { + protected UserManager UserManager { get; set; } + protected SignInManager SignInManager { get; set; } + protected RoleManager RoleManager { get; set; } + protected IEmailSender EmailSender { get; set; } + protected UrlEncoder UrlEncoder { get; set; } + protected IMFAService MFAService { get; set; } + public IdentityControllerBase( + UserManager _userManager, + IEmailSender _emailSender, + SignInManager _signInManager, + RoleManager _roleManager, + IMFAService _mfaService + ) + { + UserManager = _userManager; + SignInManager = _signInManager; + RoleManager = _roleManager; + EmailSender = _emailSender; + MFAService = _mfaService; + UrlEncoder = UrlEncoder.Default; + } + + public override BadRequestObjectResult BadRequest(object error) + { + if (error is IEnumerable errors) + { + return base.BadRequest(new { errors }); + } + return base.BadRequest(new { error }); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Controllers/MFAController.cs b/Streetwriters.Identity/Controllers/MFAController.cs new file mode 100644 index 0000000..95859e2 --- /dev/null +++ b/Streetwriters.Identity/Controllers/MFAController.cs @@ -0,0 +1,138 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using AspNetCore.Identity.Mongo.Model; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Streetwriters.Common; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; +using Streetwriters.Identity.Services; +using static IdentityServer4.IdentityServerConstants; + +namespace Streetwriters.Identity.Controllers +{ + [ApiController] + [Route("mfa")] + [Authorize(LocalApi.PolicyName)] + public class MFAController : IdentityControllerBase + { + public MFAController(UserManager _userManager, IEmailSender _emailSender, + SignInManager _signInManager, RoleManager _roleManager, IMFAService _mfaService) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService) { } + + [HttpPost] + public async Task SetupAuthenticator([FromForm] MultiFactorSetupForm form) + { + var client = Clients.FindClientById(User.FindFirstValue("client_id")); + if (client == null) return BadRequest("Invalid client_id."); + + var user = await UserManager.GetUserAsync(User); + + try + { + switch (form.Type) + { + case "app": + var authenticatorDetails = await MFAService.GetAuthenticatorDetailsAsync(user, client); + return Ok(authenticatorDetails); + case "sms": + case "email": + await MFAService.SendOTPAsync(user, client, form, true); + return Ok(); + default: + return BadRequest("Invalid authenticator type."); + } + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete] + public async Task Disable2FA() + { + var user = await UserManager.GetUserAsync(User); + + if (!await UserManager.GetTwoFactorEnabledAsync(user)) + { + return BadRequest("Cannot disable 2FA as it's not currently enabled"); + } + + if (await MFAService.DisableMFAAsync(user)) + { + return Ok(); + } + + return BadRequest("Failed to disable 2FA."); + } + + [HttpGet("codes")] + public async Task GetRecoveryCodes() + { + var user = await UserManager.GetUserAsync(User); + if (!await UserManager.GetTwoFactorEnabledAsync(user)) return BadRequest("Please enable 2FA."); + return Ok(await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 16)); + } + + [HttpPost("send")] + [Authorize("mfa")] + [Authorize(LocalApi.PolicyName)] + public async Task RequestCode([FromForm] string type) + { + var client = Clients.FindClientById(User.FindFirstValue("client_id")); + if (client == null) return BadRequest("Invalid client_id."); + + var user = await UserManager.FindByIdAsync(User.FindFirstValue("sub")); + if (user == null) return Ok(); // We cannot expose that the user doesn't exist. + + await MFAService.SendOTPAsync(user, client, new MultiFactorSetupForm + { + Type = type, + PhoneNumber = user.PhoneNumber + }); + return Ok(); + } + + [HttpPatch] + public async Task EnableAuthenticator([FromForm] MultiFactorEnableForm form) + { + var user = await UserManager.GetUserAsync(User); + + if (!await MFAService.VerifyOTPAsync(user, form.VerificationCode, form.Type)) + return BadRequest("Invalid verification code."); + + if (form.IsFallback) + await MFAService.SetSecondaryMethodAsync(user, form.Type); + else + await MFAService.EnableMFAAsync(user, form.Type); + + return Ok(); + } + + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Controllers/SignupController.cs b/Streetwriters.Identity/Controllers/SignupController.cs new file mode 100644 index 0000000..f6957a3 --- /dev/null +++ b/Streetwriters.Identity/Controllers/SignupController.cs @@ -0,0 +1,115 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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 Streetwriters.Common; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Enums; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; + +namespace Streetwriters.Identity.Controllers +{ + [ApiController] + [Route("signup")] + public class SignupController : IdentityControllerBase + { + public SignupController(UserManager _userManager, IEmailSender _emailSender, + SignInManager _signInManager, RoleManager _roleManager, IMFAService _mfaService) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService) + { } + + private async Task AddClientRoleAsync(string clientId) + { + if (await RoleManager.FindByNameAsync(clientId) == null) + await RoleManager.CreateAsync(new MongoRole(clientId)); + } + + [HttpPost] + [AllowAnonymous] + public async Task Signup([FromForm] SignupForm form) + { + var client = Clients.FindClientById(form.ClientId); + if (client == null) return BadRequest("Invalid client_id."); + + await AddClientRoleAsync(client.Id); + + // email addresses must be case-insensitive + form.Email = form.Email.ToLowerInvariant(); + form.Username = form.Username?.ToLowerInvariant(); + + var result = await UserManager.CreateAsync(new User + { + Email = form.Email, + EmailConfirmed = false, + UserName = form.Username ?? form.Email, + }, form.Password); + + if (result.Errors.Any((e) => e.Code == "DuplicateEmail")) + { + var user = await UserManager.FindByEmailAsync(form.Email); + + 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 UserManager.AddToRoleAsync(user, client.Id); + } + else + { + return BadRequest(new string[] { "Email is invalid or already taken." }); + } + + return Ok(new + { + userId = user.Id.ToString() + }); + } + + if (result.Succeeded) + { + var user = await UserManager.FindByEmailAsync(form.Email); + + await UserManager.AddToRoleAsync(user, client.Id); + // await UserManager.AddClaimAsync(user, new Claim("verified", "false")); + + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL, Request.Scheme); + await EmailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client); + + return Ok(new + { + userId = user.Id.ToString() + }); + } + + return BadRequest(result.Errors.ToErrors()); + } + } +} diff --git a/Streetwriters.Identity/Enums/TokenTypes.cs b/Streetwriters.Identity/Enums/TokenTypes.cs new file mode 100644 index 0000000..2cbb05c --- /dev/null +++ b/Streetwriters.Identity/Enums/TokenTypes.cs @@ -0,0 +1,28 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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 . +*/ + +namespace Streetwriters.Identity.Enums +{ + public enum TokenType + { + CONFRIM_EMAIL = 0, + RESET_PASSWORD = 1, + CHANGE_EMAIL = 2, + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Extensions/HttpContextExtensions.cs b/Streetwriters.Identity/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000..a35bafc --- /dev/null +++ b/Streetwriters.Identity/Extensions/HttpContextExtensions.cs @@ -0,0 +1,56 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Text; +using Ng.Services; + +namespace Microsoft.AspNetCore.Http +{ + public static class HttpContextExtensions + { + static UserAgentService userAgentService = new UserAgentService(); + public static string GetClientInfo(this HttpContext httpContext) + { + var clientIp = httpContext.Connection.RemoteIpAddress; + var country = httpContext.Request.Headers["CF-IPCountry"]; + var userAgent = httpContext.Request.Headers["User-Agent"]; + var builder = new StringBuilder(); + + builder.AppendLine($"Date: {DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")}"); + + if (clientIp != null) + builder.AppendLine($"IP: {clientIp.ToString()}"); + + if (!string.IsNullOrEmpty(country)) + builder.AppendLine($"Country: {country.ToString()}"); + + if (!string.IsNullOrEmpty(userAgent)) + { + var ua = userAgentService.Parse(userAgent); + if (!string.IsNullOrEmpty(ua.Browser)) + builder.AppendLine($"Browser: {ua.Browser} {ua.BrowserVersion}"); + if (!string.IsNullOrEmpty(ua.Platform)) + builder.AppendLine($"Platform: {ua.Platform}"); + } + + return builder.ToString(); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Extensions/IEnumerableExtensions.cs b/Streetwriters.Identity/Extensions/IEnumerableExtensions.cs new file mode 100644 index 0000000..d5561bb --- /dev/null +++ b/Streetwriters.Identity/Extensions/IEnumerableExtensions.cs @@ -0,0 +1,42 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Streetwriters.Identity.Controllers; + +namespace System.Collections.Generic +{ + public static class IEnumberableExtensions + { + public static IEnumerable ToErrors(this IEnumerable collection) + { + return collection.Select((e) => e.Description); + } + + public static string GetClaimValue(this IEnumerable claims, string type) + { + return claims.FirstOrDefault((c) => c.Type == type)?.Value; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Extensions/IntExtensions.cs b/Streetwriters.Identity/Extensions/IntExtensions.cs new file mode 100644 index 0000000..92ca5af --- /dev/null +++ b/Streetwriters.Identity/Extensions/IntExtensions.cs @@ -0,0 +1,34 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Text; +using Ng.Services; + +namespace System +{ + public static class IntExtensions + { + public static string Pluralize(this int value, string singular, string plural) + { + // if (value == null) return $"0 {plural}"; + return value == 1 ? $"{value} {singular}" : $"{value} {plural}"; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Extensions/MongoDBTicketStore.cs b/Streetwriters.Identity/Extensions/MongoDBTicketStore.cs new file mode 100644 index 0000000..9c90eee --- /dev/null +++ b/Streetwriters.Identity/Extensions/MongoDBTicketStore.cs @@ -0,0 +1,73 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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 Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Caching.Memory; +using System; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Authentication +{ + public class MemoryCacheTicketStore : ITicketStore + { + private const string KeyPrefix = "AuthSessionStore"; + private IMemoryCache _cache; + + public MemoryCacheTicketStore() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + } + + Task ITicketStore.RemoveAsync(string key) + { + _cache.Remove(key); + return Task.FromResult(true); + } + + Task ITicketStore.RenewAsync(string key, AuthenticationTicket ticket) + { + var options = new MemoryCacheEntryOptions(); + var expiresUtc = ticket.Properties.ExpiresUtc; + if (expiresUtc.HasValue) + { + options.SetAbsoluteExpiration(expiresUtc.Value); + } + options.SetSlidingExpiration(TimeSpan.FromHours(1)); + + _cache.Set(key, ticket, options); + + return Task.FromResult(true); + } + + Task ITicketStore.RetrieveAsync(string key) + { + AuthenticationTicket ticket; + _cache.TryGetValue(key, out ticket); + return Task.FromResult(ticket); + } + + async Task ITicketStore.StoreAsync(AuthenticationTicket ticket) + { + var id = Guid.NewGuid(); + var key = KeyPrefix + id; + await ((ITicketStore)this).RenewAsync(key, ticket); + return key; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Extensions/UrlExtensions.cs b/Streetwriters.Identity/Extensions/UrlExtensions.cs new file mode 100644 index 0000000..28ed67d --- /dev/null +++ b/Streetwriters.Identity/Extensions/UrlExtensions.cs @@ -0,0 +1,48 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Streetwriters.Common; +using Streetwriters.Identity.Controllers; +using Streetwriters.Identity.Enums; + +namespace Microsoft.AspNetCore.Mvc +{ + public static class UrlHelperExtensions + { + public static string TokenLink(this IUrlHelper urlHelper, string userId, string code, string clientId, TokenType type, string scheme) + { + + return urlHelper.ActionLink( +#if DEBUG + host: $"{Servers.IdentityServer.Hostname}:{Servers.IdentityServer.Port}", +#else + host: Servers.IdentityServer.Domain, +#endif + action: nameof(AccountController.ConfirmToken), + controller: "Account", + values: new { userId, code, clientId, type }, + protocol: scheme); + + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Handlers/ClientHandlers.cs b/Streetwriters.Identity/Handlers/ClientHandlers.cs new file mode 100644 index 0000000..b9828d8 --- /dev/null +++ b/Streetwriters.Identity/Handlers/ClientHandlers.cs @@ -0,0 +1,39 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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 Streetwriters.Common; +using Streetwriters.Common.Enums; +using Streetwriters.Identity.Interfaces; + +namespace Streetwriters.Identity.Handlers +{ + public class ClientHandlers + { + public static Dictionary Handlers { get; set; } = new Dictionary + { + { ApplicationType.NOTESNOOK, new NotesnookHandler() } + }; + + public static IAppHandler GetClientHandler(ApplicationType type) + { + return Handlers[type]; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Handlers/NotesnookHandler.cs b/Streetwriters.Identity/Handlers/NotesnookHandler.cs new file mode 100644 index 0000000..b440865 --- /dev/null +++ b/Streetwriters.Identity/Handlers/NotesnookHandler.cs @@ -0,0 +1,58 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Threading.Tasks; +using Streetwriters.Common; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Messages; +using Streetwriters.Identity.Interfaces; + +namespace Streetwriters.Identity.Handlers +{ + public class NotesnookHandler : IAppHandler + { + public string Host { get; } + public string EmailConfirmedRedirectURL { get; } + public string AccountRecoveryRedirectURL { get; } + + public NotesnookHandler() + { +#if DEBUG + Host = "http://localhost:3000"; +#else + Host = "https://app.notesnook.com"; +#endif + EmailConfirmedRedirectURL = $"{this.Host}/account/verified"; + AccountRecoveryRedirectURL = $"{this.Host}/account/recovery"; + } + public async Task OnEmailConfirmed(string userId) + { + await WampServers.MessengerServer.PublishMessageAsync(WampServers.MessengerServer.Topics.SendSSETopic, new SendSSEMessage + { + UserId = userId, + Message = new Message + { + Type = "emailConfirmed", + Data = null + } + }); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Handlers/TokenResponseHandler.cs b/Streetwriters.Identity/Handlers/TokenResponseHandler.cs new file mode 100644 index 0000000..e8c3e07 --- /dev/null +++ b/Streetwriters.Identity/Handlers/TokenResponseHandler.cs @@ -0,0 +1,43 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using IdentityServer4.Services; +using IdentityServer4.Stores; +using IdentityServer4.Validation; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; + +namespace IdentityServer4.ResponseHandling +{ + /// + /// The default token response generator + /// + /// + public class TokenResponseHandler : TokenResponseGenerator, ITokenResponseGenerator + { + /// + /// Initializes a new instance of the class. + /// + /// The clock. + /// The token service. + /// The refresh token service. + /// The scope parser. + /// The resources. + /// The clients. + /// The logger. + public TokenResponseHandler(ISystemClock clock, ITokenService tokenService, IRefreshTokenService refreshTokenService, IScopeParser scopeParser, IResourceStore resources, IClientStore clients, ILogger logger) + : base(clock, tokenService, refreshTokenService, scopeParser, resources, clients, logger) + { + + } + + protected override async Task ProcessRefreshTokenRequestAsync(TokenRequestValidationResult request) + { + var response = await base.ProcessRefreshTokenRequestAsync(request); + // Fixes: https://github.com/IdentityServer/IdentityServer3/issues/3621 + response.IdentityToken = null; + return response; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Helpers/PasswordHelper.cs b/Streetwriters.Identity/Helpers/PasswordHelper.cs new file mode 100644 index 0000000..df039e7 --- /dev/null +++ b/Streetwriters.Identity/Helpers/PasswordHelper.cs @@ -0,0 +1,38 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Text; +using Sodium; + +namespace Streetwriters.Identity.Helpers +{ + internal class PasswordHelper + { + public static bool VerifyPassword(string password, string hash) + { + return PasswordHash.ArgonHashStringVerify(hash, password); + } + + public static string CreatePasswordHash(string password) + { + return PasswordHash.ArgonHashString(password, 3, 65536); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Interfaces/IAppHandler.cs b/Streetwriters.Identity/Interfaces/IAppHandler.cs new file mode 100644 index 0000000..ca8b3cb --- /dev/null +++ b/Streetwriters.Identity/Interfaces/IAppHandler.cs @@ -0,0 +1,31 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Threading.Tasks; + +namespace Streetwriters.Identity.Interfaces +{ + public interface IAppHandler + { + string Host { get; } + string EmailConfirmedRedirectURL { get; } + string AccountRecoveryRedirectURL { get; } + Task OnEmailConfirmed(string userId); + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Interfaces/IEmailSender.cs b/Streetwriters.Identity/Interfaces/IEmailSender.cs new file mode 100644 index 0000000..03e8ef7 --- /dev/null +++ b/Streetwriters.Identity/Interfaces/IEmailSender.cs @@ -0,0 +1,34 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Threading.Tasks; +using Streetwriters.Common.Interfaces; + +namespace Streetwriters.Identity.Interfaces +{ + public interface IEmailSender + { + Task SendWelcomeEmailAsync(string email, IClient client); + Task SendConfirmationEmailAsync(string email, string callbackUrl, IClient client); + Task SendChangeEmailConfirmationAsync(string email, string code, IClient client); + Task SendPasswordResetEmailAsync(string email, string callbackUrl, IClient client); + Task Send2FACodeEmailAsync(string email, string code, IClient client); + Task SendFailedLoginAlertAsync(string email, string deviceInfo, IClient client); + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Interfaces/IEmailTemplate.cs b/Streetwriters.Identity/Interfaces/IEmailTemplate.cs new file mode 100644 index 0000000..25cc23a --- /dev/null +++ b/Streetwriters.Identity/Interfaces/IEmailTemplate.cs @@ -0,0 +1,31 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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 . +*/ + +namespace Streetwriters.Identity.Interfaces +{ + public interface IEmailTemplate + { + string Subject { get; set; } + string Html { get; set; } + string Text { get; set; } + int? Id { get; set; } + object Data { get; set; } + long? SendAt { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Interfaces/IMFAService.cs b/Streetwriters.Identity/Interfaces/IMFAService.cs new file mode 100644 index 0000000..1025234 --- /dev/null +++ b/Streetwriters.Identity/Interfaces/IMFAService.cs @@ -0,0 +1,40 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Threading.Tasks; +using Streetwriters.Common.Interfaces; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Models; + +namespace Streetwriters.Identity.Interfaces +{ + public interface IMFAService + { + Task EnableMFAAsync(User user, string primaryMethod); + Task DisableMFAAsync(User user); + Task SetSecondaryMethodAsync(User user, string secondaryMethod); + string GetPrimaryMethod(User user); + string GetSecondaryMethod(User user); + Task GetRemainingValidCodesAsync(User user); + bool IsValidMFAMethod(string method); + Task GetAuthenticatorDetailsAsync(User user, IClient client); + Task SendOTPAsync(User user, IClient client, MultiFactorSetupForm form, bool isSetup = false); + Task VerifyOTPAsync(User user, string code, string method); + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Interfaces/ISMSSender.cs b/Streetwriters.Identity/Interfaces/ISMSSender.cs new file mode 100644 index 0000000..7f765d6 --- /dev/null +++ b/Streetwriters.Identity/Interfaces/ISMSSender.cs @@ -0,0 +1,30 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Threading.Tasks; +using Streetwriters.Common.Interfaces; + +namespace Streetwriters.Identity.Interfaces +{ + public interface ISMSSender + { + string SendOTP(string number, IClient client); + bool VerifyOTP(string id, string code); + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Interfaces/ITokenGenerationService.cs b/Streetwriters.Identity/Interfaces/ITokenGenerationService.cs new file mode 100644 index 0000000..1343e69 --- /dev/null +++ b/Streetwriters.Identity/Interfaces/ITokenGenerationService.cs @@ -0,0 +1,33 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Security.Claims; +using System.Threading.Tasks; +using IdentityServer4.Validation; +using Streetwriters.Common.Models; + +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); + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/MessageHandlers/CreateSubscription.cs b/Streetwriters.Identity/MessageHandlers/CreateSubscription.cs new file mode 100644 index 0000000..d1b5ec3 --- /dev/null +++ b/Streetwriters.Identity/MessageHandlers/CreateSubscription.cs @@ -0,0 +1,56 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Threading.Tasks; +using Streetwriters.Common.Messages; +using Streetwriters.Common.Models; +using Streetwriters.Common; +using System.Text.Json; +using Streetwriters.Data.Repositories; +using Streetwriters.Data.Interfaces; +using Streetwriters.Common.Interfaces; +using System; +using Microsoft.AspNetCore.Identity; +using Streetwriters.Common.Enums; +using System.Security.Claims; +using System.Linq; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Services; + +namespace Streetwriters.Identity.MessageHandlers +{ + public class CreateSubscription + { + public static async Task Process(CreateSubscriptionMessage message, UserManager userManager) + { + var user = await userManager.FindByIdAsync(message.UserId); + var client = Clients.FindClientByAppId(message.AppId); + if (client == null || user == null) return; + + IdentityUserClaim statusClaim = user.Claims.FirstOrDefault((c) => c.ClaimType == $"{client.Id}:status"); + Claim subscriptionClaim = UserService.SubscriptionTypeToClaim(client.Id, message.Type); + if (statusClaim?.ClaimValue == subscriptionClaim.Value) return; + if (statusClaim != null) + await userManager.ReplaceClaimAsync(user, statusClaim.ToClaim(), subscriptionClaim); + else + await userManager.AddClaimAsync(user, subscriptionClaim); + } + + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/MessageHandlers/DeleteSubscription.cs b/Streetwriters.Identity/MessageHandlers/DeleteSubscription.cs new file mode 100644 index 0000000..a413918 --- /dev/null +++ b/Streetwriters.Identity/MessageHandlers/DeleteSubscription.cs @@ -0,0 +1,53 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Threading.Tasks; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Messages; +using Streetwriters.Common.Interfaces; +using Streetwriters.Common.Models; +using Streetwriters.Common; +using System.Text.Json; +using System.IO; +using Streetwriters.Data.Repositories; +using Streetwriters.Data.Interfaces; +using Microsoft.AspNetCore.Identity; +using System.Linq; +using IdentityServer4.Stores; +using Streetwriters.Identity.Interfaces; + +namespace Streetwriters.Identity.MessageHandlers +{ + public class DeleteSubscription + { + public static async Task Process(DeleteSubscriptionMessage message, UserManager userManager) + { + var user = await userManager.FindByIdAsync(message.UserId); + var client = Clients.FindClientByAppId(message.AppId); + if (client != null || user != null) return; + + IdentityUserClaim statusClaim = user.Claims.FirstOrDefault((c) => c.ClaimType == $"{client.Id}:status"); + if (statusClaim != null) + { + await userManager.RemoveClaimAsync(user, statusClaim.ToClaim()); + } + } + } +} diff --git a/Streetwriters.Identity/Models/AuthenticatorDetails.cs b/Streetwriters.Identity/Models/AuthenticatorDetails.cs new file mode 100644 index 0000000..a149420 --- /dev/null +++ b/Streetwriters.Identity/Models/AuthenticatorDetails.cs @@ -0,0 +1,34 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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 . +*/ + +namespace Streetwriters.Identity.Models +{ + public class AuthenticatorDetails + { + public string SharedKey + { + get; set; + } + + public string AuthenticatorUri + { + get; set; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/ChangeEmailForm.cs b/Streetwriters.Identity/Models/ChangeEmailForm.cs new file mode 100644 index 0000000..2c69ffb --- /dev/null +++ b/Streetwriters.Identity/Models/ChangeEmailForm.cs @@ -0,0 +1,39 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace Streetwriters.Identity.Models +{ + public class ChangeEmailForm + { + [Required] + [BindProperty(Name = "email")] + [EmailAddress] + public string NewEmail + { + get; set; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/DeleteAccountForm.cs b/Streetwriters.Identity/Models/DeleteAccountForm.cs new file mode 100644 index 0000000..9a8b999 --- /dev/null +++ b/Streetwriters.Identity/Models/DeleteAccountForm.cs @@ -0,0 +1,33 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; + +namespace Streetwriters.Identity.Models +{ + public class DeleteAccountForm + { + [Required] + public string Password + { + get; set; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/EmailTemplate.cs b/Streetwriters.Identity/Models/EmailTemplate.cs new file mode 100644 index 0000000..660fb23 --- /dev/null +++ b/Streetwriters.Identity/Models/EmailTemplate.cs @@ -0,0 +1,33 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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 Streetwriters.Identity.Interfaces; + +namespace Streetwriters.Identity.Models +{ + public class EmailTemplate : IEmailTemplate + { + public int? Id { get; set; } + public object Data { get; set; } + public long? SendAt { get; set; } + public string Subject { get; set; } + public string Html { get; set; } + public string Text { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/GetAccessTokenForm.cs b/Streetwriters.Identity/Models/GetAccessTokenForm.cs new file mode 100644 index 0000000..c2a53c9 --- /dev/null +++ b/Streetwriters.Identity/Models/GetAccessTokenForm.cs @@ -0,0 +1,49 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace Streetwriters.Identity.Models +{ + public class GetAccessTokenForm + { + [Required] + [BindProperty(Name = "authorization_code")] + public string Code + { + get; set; + } + + [Required] + [BindProperty(Name = "user_id")] + public string UserId + { + get; set; + } + + [Required] + [BindProperty(Name = "client_id")] + public string ClientId + { + get; set; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/MFAPasswordRequiredResponse.cs b/Streetwriters.Identity/Models/MFAPasswordRequiredResponse.cs new file mode 100644 index 0000000..35e7c8c --- /dev/null +++ b/Streetwriters.Identity/Models/MFAPasswordRequiredResponse.cs @@ -0,0 +1,29 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Text.Json.Serialization; + +namespace Streetwriters.Identity.Models +{ + public class MFAPasswordRequiredResponse + { + [JsonPropertyName("token")] + public string Token { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/MFARequiredResponse.cs b/Streetwriters.Identity/Models/MFARequiredResponse.cs new file mode 100644 index 0000000..c374770 --- /dev/null +++ b/Streetwriters.Identity/Models/MFARequiredResponse.cs @@ -0,0 +1,35 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Text.Json.Serialization; + +namespace Streetwriters.Identity.Models +{ + public class MFARequiredResponse + { + [JsonPropertyName("primaryMethod")] + public string PrimaryMethod { get; set; } + [JsonPropertyName("secondaryMethod")] + public string SecondaryMethod { get; set; } + [JsonPropertyName("token")] + public string Token { get; set; } + [JsonPropertyName("phoneNumber")] + public string PhoneNumber { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/MessageBirdOptions.cs b/Streetwriters.Identity/Models/MessageBirdOptions.cs new file mode 100644 index 0000000..1dd3569 --- /dev/null +++ b/Streetwriters.Identity/Models/MessageBirdOptions.cs @@ -0,0 +1,30 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace Streetwriters.Identity.Models +{ + public class MessageBirdOptions + { + public string AccessKey { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/MultiFactorEnableForm.cs b/Streetwriters.Identity/Models/MultiFactorEnableForm.cs new file mode 100644 index 0000000..333e572 --- /dev/null +++ b/Streetwriters.Identity/Models/MultiFactorEnableForm.cs @@ -0,0 +1,43 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; + +namespace Streetwriters.Identity.Models +{ + public class MultiFactorEnableForm + { + [Required] + [DataType(DataType.Text)] + [Display(Name = "Authenticator type")] + [BindProperty(Name = "type")] + public string Type { get; set; } + + [Required] + [StringLength(6, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Verification Code")] + [BindProperty(Name = "code")] + public string VerificationCode { get; set; } + + [BindProperty(Name = "isFallback")] + public bool IsFallback { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/MultiFactorSetupForm.cs b/Streetwriters.Identity/Models/MultiFactorSetupForm.cs new file mode 100644 index 0000000..37c5768 --- /dev/null +++ b/Streetwriters.Identity/Models/MultiFactorSetupForm.cs @@ -0,0 +1,37 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace Streetwriters.Identity.Models +{ + public class MultiFactorSetupForm + { + [Required] + [Display(Name = "Authenticator type")] + [BindProperty(Name = "type")] + public string Type { get; set; } + + [Display(Name = "Phone number")] + [BindProperty(Name = "phoneNumber")] + public string PhoneNumber { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/ResetPasswordForm.cs b/Streetwriters.Identity/Models/ResetPasswordForm.cs new file mode 100644 index 0000000..fa95424 --- /dev/null +++ b/Streetwriters.Identity/Models/ResetPasswordForm.cs @@ -0,0 +1,42 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace Streetwriters.Identity.Models +{ + public class ResetPasswordForm + { + [Required] + [BindProperty(Name = "email")] + public string Email + { + get; set; + } + + [Required] + [BindProperty(Name = "client_id")] + public string ClientId + { + get; set; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/SignupForm.cs b/Streetwriters.Identity/Models/SignupForm.cs new file mode 100644 index 0000000..4e248ea --- /dev/null +++ b/Streetwriters.Identity/Models/SignupForm.cs @@ -0,0 +1,57 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.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 string Password + { + get; set; + } + + [Required] + [BindProperty(Name = "email")] + [EmailAddress] + public string Email + { + get; set; + } + + [BindProperty(Name = "username")] + public string Username + { + get; set; + } + + [Required] + [BindProperty(Name = "client_id")] + public string ClientId + { + get; set; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/SmtpOptions.cs b/Streetwriters.Identity/Models/SmtpOptions.cs new file mode 100644 index 0000000..c5df27c --- /dev/null +++ b/Streetwriters.Identity/Models/SmtpOptions.cs @@ -0,0 +1,33 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace Streetwriters.Identity.Models +{ + public class SmtpOptions + { + public string Username { get; set; } + public string Password { get; set; } + public string Host { get; set; } + public int Port { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/TwoFactorLoginForm.cs b/Streetwriters.Identity/Models/TwoFactorLoginForm.cs new file mode 100644 index 0000000..5bd075e --- /dev/null +++ b/Streetwriters.Identity/Models/TwoFactorLoginForm.cs @@ -0,0 +1,41 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace Streetwriters.Identity.Models +{ + public class TwoFactorLoginForm + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Authenticator code")] + [BindProperty(Name = "code")] + public string Code { get; set; } + + [BindProperty(Name = "rememberMachine")] + public bool RememberMachine { get; set; } + + [BindProperty(Name = "isRecoveryCode")] + public bool IsRecoveryCode { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Models/UpdateUserForm.cs b/Streetwriters.Identity/Models/UpdateUserForm.cs new file mode 100644 index 0000000..0822601 --- /dev/null +++ b/Streetwriters.Identity/Models/UpdateUserForm.cs @@ -0,0 +1,53 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace Streetwriters.Identity.Models +{ + public class UpdateUserForm + { + [Required] + [BindProperty(Name = "type")] + public string Type + { + get; set; + } + + [BindProperty(Name = "old_password")] + public string OldPassword + { + get; set; + } + + [BindProperty(Name = "new_password")] + public string NewPassword + { + get; set; + } + + [BindProperty(Name = "new_email")] + public string NewEmail + { + get; set; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Program.cs b/Streetwriters.Identity/Program.cs new file mode 100644 index 0000000..5b3a0d0 --- /dev/null +++ b/Streetwriters.Identity/Program.cs @@ -0,0 +1,52 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Streetwriters.Common; + +namespace Streetwriters.Identity +{ + public class Program + { + public static async Task Main(string[] args) + { + IHost host = CreateHostBuilder(args).Build(); + await host.RunAsync(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureLogging((options) => + { + options.AddConsole(); + options.AddSimpleConsole(); + }) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup().UseUrls(Servers.IdentityServer.ToString()); + }); + } +} diff --git a/Streetwriters.Identity/Properties/launchSettings.json b/Streetwriters.Identity/Properties/launchSettings.json new file mode 100644 index 0000000..34d594e --- /dev/null +++ b/Streetwriters.Identity/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:10770", + "sslPort": 44374 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Streetwriters.Identity": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Streetwriters.Identity/Services/CustomIntrospectionResponseGenerator.cs b/Streetwriters.Identity/Services/CustomIntrospectionResponseGenerator.cs new file mode 100644 index 0000000..994d029 --- /dev/null +++ b/Streetwriters.Identity/Services/CustomIntrospectionResponseGenerator.cs @@ -0,0 +1,67 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Collections.Specialized; +using System.Threading.Tasks; +using IdentityServer4.Endpoints.Results; +using IdentityServer4.Models; +using IdentityServer4.ResponseHandling; +using IdentityServer4.Services; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Streetwriters.Common.Models; + +namespace Streetwriters.Identity.Services +{ + public class CustomIntrospectionResponseGenerator : IntrospectionResponseGenerator + { + private UserManager UserManager { get; } + public CustomIntrospectionResponseGenerator(IEventService events, ILogger logger, UserManager userManager) : base(events, logger) + { + UserManager = userManager; + } + + public override async Task> ProcessAsync(IntrospectionRequestValidationResult validationResult) + { + var result = await base.ProcessAsync(validationResult); + + if (result.TryGetValue("sub", out object userId)) + { + var user = await UserManager.FindByIdAsync(userId.ToString()); + + var verifiedClaim = user.Claims.Find((c) => c.ClaimType == "verified"); + if (verifiedClaim != null) + await UserManager.RemoveClaimAsync(user, verifiedClaim.ToClaim()); + var hcliClaim = user.Claims.Find((c) => c.ClaimType == "hcli"); + if (hcliClaim != null) + await UserManager.RemoveClaimAsync(user, hcliClaim.ToClaim()); + + user.Claims.ForEach((claim) => + { + if (claim.ClaimType == "verified" || claim.ClaimType == "hcli") return; + result.TryAdd(claim.ClaimType, claim.ClaimValue); + }); + result.TryAdd("verified", user.EmailConfirmed.ToString().ToLowerInvariant()); + } + return result; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/CustomRefreshTokenService.cs b/Streetwriters.Identity/Services/CustomRefreshTokenService.cs new file mode 100644 index 0000000..dbec9d2 --- /dev/null +++ b/Streetwriters.Identity/Services/CustomRefreshTokenService.cs @@ -0,0 +1,43 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Threading.Tasks; +using IdentityServer4.Models; +using IdentityServer4.Services; +using IdentityServer4.Stores; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; + +namespace Streetwriters.Identity.Services +{ + public class CustomRefreshTokenService : DefaultRefreshTokenService + { + public CustomRefreshTokenService(IRefreshTokenStore refreshTokenStore, IProfileService profile, ISystemClock clock, ILogger logger) : base(refreshTokenStore, profile, clock, logger) + { + } + + protected override Task AcceptConsumedTokenAsync(RefreshToken refreshToken) + { + // Allow refresh token replay for 1 day. + // if (refreshToken.ConsumedTime?.ToUniversalTime().AddDays(1) < DateTime.UtcNow) return Task.FromResult(false); + return Task.FromResult(true); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/EmailSender.cs b/Streetwriters.Identity/Services/EmailSender.cs new file mode 100644 index 0000000..effea5b --- /dev/null +++ b/Streetwriters.Identity/Services/EmailSender.cs @@ -0,0 +1,275 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Threading.Tasks; +using Streetwriters.Identity.Interfaces; +using SendGrid; +using SendGrid.Helpers.Mail; +using Streetwriters.Common; +using Streetwriters.Common.Interfaces; +using Streetwriters.Identity.Models; +using System; +using System.Net.Http; +using System.Text.Json; +using System.Net.Http.Headers; +using System.Text.Json.Serialization; +using MailKit.Net.Smtp; +using MailKit; +using MimeKit; +using System.IO; +using Scriban; +using WebMarkupMin.Core; +using WebMarkupMin.Core.Loggers; +using MimeKit.Cryptography; +using Org.BouncyCastle.Bcpg.OpenPgp; +using System.Linq; +using System.Threading; +using Org.BouncyCastle.Bcpg; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Streetwriters.Identity.Services +{ + public class EmailSender : IEmailSender, IAsyncDisposable + { + IOptions SmtpOptions { get; set; } + NNGnuPGContext NNGnuPGContext { get; set; } + public EmailSender(IConfiguration configuration, IOptions smtpOptions) + { + SmtpOptions = smtpOptions; + NNGnuPGContext = new NNGnuPGContext(configuration.GetSection("PgpKeySettings")); + } + + EmailTemplate Email2FATemplate = new EmailTemplate + { + Html = ReadMinifiedHtmlFile("Templates/Email2FACode.html"), + Text = File.ReadAllText("Templates/Email2FACode.txt"), + Subject = "Your {{app_name}} account 2FA code", + }; + + EmailTemplate ConfirmEmailTemplate = new EmailTemplate + { + Html = ReadMinifiedHtmlFile("Templates/ConfirmEmail.html"), + Text = File.ReadAllText("Templates/ConfirmEmail.txt"), + Subject = "Confirm your {{app_name}} account", + }; + + EmailTemplate ConfirmChangeEmailTemplate = new EmailTemplate + { + Html = ReadMinifiedHtmlFile("Templates/EmailChangeConfirmation.html"), + Text = File.ReadAllText("Templates/EmailChangeConfirmation.txt"), + Subject = "Change {{app_name}} account email address", + }; + + EmailTemplate PasswordResetEmailTemplate = new EmailTemplate + { + Html = ReadMinifiedHtmlFile("Templates/ResetAccountPassword.html"), + Text = File.ReadAllText("Templates/ResetAccountPassword.txt"), + Subject = "Reset {{app_name}} account password", + }; + + EmailTemplate FailedLoginAlertTemplate = new EmailTemplate + { + Html = ReadMinifiedHtmlFile("Templates/FailedLoginAlert.html"), + Text = File.ReadAllText("Templates/FailedLoginAlert.txt"), + Subject = "Failed login attempt on your {{app_name}} account", + }; + + SmtpClient mailClient; + public EmailSender() + { + mailClient = new SmtpClient(); + } + + public async Task Send2FACodeEmailAsync(string email, string code, IClient client) + { + var template = new EmailTemplate + { + Html = Email2FATemplate.Html, + Text = Email2FATemplate.Text, + Subject = Email2FATemplate.Subject, + Data = new + { + app_name = client.Name, + code = code + } + }; + await SendEmailAsync(email, template, client); + } + + + public Task SendWelcomeEmailAsync(string email, IClient client) + { + // EmailTemplate template = new EmailTemplate + // { + // Id = client.WelcomeEmailTemplateId, + // Data = new { }, + // SendAt = DateTimeOffset.UtcNow.AddHours(2).ToUnixTimeSeconds() + // }; + // return SendEmailAsync(email, template, client); + return Task.CompletedTask; + } + + public async Task SendConfirmationEmailAsync(string email, string callbackUrl, IClient client) + { + var template = new EmailTemplate + { + Html = ConfirmEmailTemplate.Html, + Text = ConfirmEmailTemplate.Text, + Subject = ConfirmEmailTemplate.Subject, + Data = new + { + app_name = client.Name, + confirm_link = callbackUrl + } + }; + await SendEmailAsync(email, template, client); + } + + public async Task SendChangeEmailConfirmationAsync(string email, string callbackUrl, IClient client) + { + var template = new EmailTemplate + { + Html = ConfirmChangeEmailTemplate.Html, + Text = ConfirmChangeEmailTemplate.Text, + Subject = ConfirmChangeEmailTemplate.Subject, + Data = new + { + app_name = client.Name, + confirm_link = callbackUrl + } + }; + await SendEmailAsync(email, template, client); + } + + public async Task SendPasswordResetEmailAsync(string email, string callbackUrl, IClient client) + { + var template = new EmailTemplate + { + Html = PasswordResetEmailTemplate.Html, + Text = PasswordResetEmailTemplate.Text, + Subject = PasswordResetEmailTemplate.Subject, + Data = new + { + app_name = client.Name, + reset_link = callbackUrl + } + }; + await SendEmailAsync(email, template, client); + } + + + public async Task SendFailedLoginAlertAsync(string email, string deviceInfo, IClient client) + { + var template = new EmailTemplate + { + Html = FailedLoginAlertTemplate.Html, + Text = FailedLoginAlertTemplate.Text, + Subject = FailedLoginAlertTemplate.Subject, + Data = new + { + app_name = client.Name, + device_info = deviceInfo.Replace("\n", "
") + } + }; + await SendEmailAsync(email, template, client); + } + + private async Task SendEmailAsync(string email, IEmailTemplate template, IClient client) + { + try + { + if (!mailClient.IsConnected) + await mailClient.ConnectAsync(SmtpOptions.Value.Host, SmtpOptions.Value.Port, MailKit.Security.SecureSocketOptions.StartTls); + + if (!mailClient.IsAuthenticated) + await mailClient.AuthenticateAsync(SmtpOptions.Value.Username, SmtpOptions.Value.Password); + + var message = new MimeMessage(); + var sender = new MailboxAddress(client.SenderName, client.SenderEmail); + message.From.Add(sender); + message.To.Add(new MailboxAddress("", email)); + message.ReplyTo.Add(new MailboxAddress("Streetwriters", "support@streetwriters.co")); + message.Subject = await Template.Parse(template.Subject).RenderAsync(template.Data); + + var builder = new BodyBuilder(); + + builder.TextBody = await Template.Parse(template.Text).RenderAsync(template.Data); + builder.HtmlBody = await Template.Parse(template.Html).RenderAsync(template.Data); + + var key = NNGnuPGContext.GetSigningKey(sender); + if (key != null) + { + using (MemoryStream outputStream = new MemoryStream()) + { + using (Stream armoredStream = new ArmoredOutputStream(outputStream)) + { + key.PublicKey.Encode(armoredStream); + } + outputStream.Seek(0, SeekOrigin.Begin); + builder.Attachments.Add($"{client.Id}_pub.asc", Encoding.ASCII.GetBytes(Encoding.ASCII.GetString(outputStream.ToArray()))); + } + } + + message.Body = MultipartSigned.Create(NNGnuPGContext, sender, DigestAlgorithm.Sha256, builder.ToMessageBody()); + await mailClient.SendAsync(message); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + } + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + await mailClient.DisconnectAsync(true); + mailClient.Dispose(); + } + + + static string ReadMinifiedHtmlFile(string path) + { + var settings = new HtmlMinificationSettings() + { + WhitespaceMinificationMode = WhitespaceMinificationMode.Medium + }; + var cssMinifier = new KristensenCssMinifier(); + var jsMinifier = new CrockfordJsMinifier(); + + var minifier = new HtmlMinifier(settings, cssMinifier, jsMinifier, new NullLogger()); + + return minifier.Minify(File.ReadAllText(path), false).MinifiedContent; + } + } + + class NNGnuPGContext : GnuPGContext + { + IConfiguration PgpKeySettings { get; set; } + public NNGnuPGContext(IConfiguration pgpKeySettings) + { + PgpKeySettings = pgpKeySettings; + } + + protected override string GetPasswordForKey(PgpSecretKey key) + { + return PgpKeySettings[key.KeyId.ToString("X")]; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/MFAService.cs b/Streetwriters.Identity/Services/MFAService.cs new file mode 100644 index 0000000..ff37cee --- /dev/null +++ b/Streetwriters.Identity/Services/MFAService.cs @@ -0,0 +1,241 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Interfaces; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; + +namespace Streetwriters.Identity.Services +{ + internal class MFAService : IMFAService + { + const string PRIMARY_METHOD_CLAIM = "mfa:primary"; + const string SECONDARY_METHOD_CLAIM = "mfa:secondary"; + const string SMS_ID_CLAIM = "mfa:sms:id"; + + private UserManager UserManager { get; set; } + private IEmailSender EmailSender { get; set; } + private ISMSSender SMSSender { get; set; } + public MFAService(UserManager _userManager, IEmailSender emailSender, ISMSSender smsSender) + { + UserManager = _userManager; + EmailSender = emailSender; + SMSSender = smsSender; + } + + public async Task EnableMFAAsync(User user, string primaryMethod) + { + var result = await UserManager.SetTwoFactorEnabledAsync(user, true); + if (!result.Succeeded) return; + + await this.RemovePrimaryMethodAsync(user); + await UserManager.AddClaimAsync(user, new Claim(MFAService.PRIMARY_METHOD_CLAIM, primaryMethod)); + } + + public async Task DisableMFAAsync(User user) + { + var result = await UserManager.SetTwoFactorEnabledAsync(user, false); + if (!result.Succeeded) return false; + + await this.RemovePrimaryMethodAsync(user); + await this.RemoveSecondaryMethodAsync(user); + + await UserManager.ResetAuthenticatorKeyAsync(user); + return true; + } + + public async Task SetSecondaryMethodAsync(User user, string secondaryMethod) + { + await this.ReplaceClaimAsync(user, MFAService.SECONDARY_METHOD_CLAIM, secondaryMethod); + } + + private async Task ReplaceClaimAsync(User user, string claimType, string claimValue) + { + await this.RemoveClaimAsync(user, claimType); + await UserManager.AddClaimAsync(user, new Claim(claimType, claimValue)); + } + + public string GetPrimaryMethod(User user) + { + return this.GetClaimValue(user, MFAService.PRIMARY_METHOD_CLAIM); + } + + public string GetSecondaryMethod(User user) + { + return this.GetClaimValue(user, MFAService.SECONDARY_METHOD_CLAIM); + } + + public string GetClaimValue(User user, string claimType) + { + var claim = user.Claims.FirstOrDefault((c) => c.ClaimType == claimType); + return claim != null ? claim.ClaimValue : null; + } + + public Task GetRemainingValidCodesAsync(User user) + { + return UserManager.CountRecoveryCodesAsync(user); + } + + public bool IsValidMFAMethod(string method) + { + return method == MFAMethods.App || method == MFAMethods.Email || method == MFAMethods.SMS || method == MFAMethods.RecoveryCode; + } + + private Task RemoveSecondaryMethodAsync(User user) + { + return this.RemoveClaimAsync(user, MFAService.SECONDARY_METHOD_CLAIM); + } + + private Task RemovePrimaryMethodAsync(User user) + { + return this.RemoveClaimAsync(user, MFAService.PRIMARY_METHOD_CLAIM); + } + + private async Task RemoveClaimAsync(User user, string claimType) + { + var claim = user.Claims.FirstOrDefault((c) => c.ClaimType == claimType); + if (claim != null) await UserManager.RemoveClaimAsync(user, claim.ToClaim()); + } + + public async Task GetAuthenticatorDetailsAsync(User user, IClient client) + { + // Load the authenticator key & QR code URI to display on the form + var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); + if (string.IsNullOrEmpty(unformattedKey)) + { + await UserManager.ResetAuthenticatorKeyAsync(user); + unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); + } + + return new AuthenticatorDetails + { + SharedKey = FormatKey(unformattedKey), + AuthenticatorUri = GenerateQrCodeUri(user.Email, unformattedKey, client.Name) + }; + } + + public async Task SendOTPAsync(User user, IClient client, MultiFactorSetupForm form, bool isSetup = false) + { + var method = form.Type; + if (method != MFAMethods.Email && method != MFAMethods.SMS) throw new Exception("Invalid method."); + + + if (isSetup && + method == MFAMethods.SMS && + !UserService.IsUserPremium(client.Id, user)) + throw new Exception("Due to the high costs of SMS, currently 2FA via SMS is only available for Pro users."); + + // if (!user.EmailConfirmed) throw new Exception("Please confirm your email before activating 2FA by email."); + await GetAuthenticatorDetailsAsync(user, client); + + switch (method) + { + case "email": + string emailOTP = await UserManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultPhoneProvider); + await EmailSender.Send2FACodeEmailAsync(user.Email, emailOTP, client); + break; + case "sms": + await UserManager.SetPhoneNumberAsync(user, form.PhoneNumber); + var id = SMSSender.SendOTP(form.PhoneNumber, client); + await this.ReplaceClaimAsync(user, MFAService.SMS_ID_CLAIM, id); + break; + + } + } + + public async Task VerifyOTPAsync(User user, string code, string method) + { + if (method == MFAMethods.SMS) + { + var id = this.GetClaimValue(user, MFAService.SMS_ID_CLAIM); + if (string.IsNullOrEmpty(id)) throw new Exception("Could not find associated SMS verify id. Please try sending the code again."); + if (SMSSender.VerifyOTP(id, code)) + { + // Auto confirm user phone number if not confirmed + if (!await UserManager.IsPhoneNumberConfirmedAsync(user)) + { + var token = await UserManager.GenerateChangePhoneNumberTokenAsync(user, user.PhoneNumber); + await UserManager.VerifyChangePhoneNumberTokenAsync(user, token, user.PhoneNumber); + } + await this.RemoveClaimAsync(user, MFAService.SMS_ID_CLAIM); + return true; + } + return false; + } + else if (method == MFAMethods.Email) + { + if (await UserManager.VerifyTwoFactorTokenAsync(user, GetProvider(method), code)) + { + // Auto confirm user email if not confirmed + if (!await UserManager.IsEmailConfirmedAsync(user)) + { + var token = await UserManager.GenerateEmailConfirmationTokenAsync(user); + await UserManager.ConfirmEmailAsync(user, token); + } + return true; + } + return false; + } + else + return await UserManager.VerifyTwoFactorTokenAsync(user, GetProvider(method), code); + } + + private string GetProvider(string method) + { + return method == MFAMethods.Email || method == MFAMethods.SMS ? TokenOptions.DefaultPhoneProvider : UserManager.Options.Tokens.AuthenticatorTokenProvider; + } + + private string FormatKey(string unformattedKey) + { + var result = new StringBuilder(); + int currentPosition = 0; + while (currentPosition + 4 < unformattedKey.Length) + { + result.Append(unformattedKey.Substring(currentPosition, 4)).Append(" "); + currentPosition += 4; + } + if (currentPosition < unformattedKey.Length) + { + result.Append(unformattedKey.Substring(currentPosition)); + } + + return result.ToString().ToLowerInvariant(); + } + + private string GenerateQrCodeUri(string email, string unformattedKey, string issuer) + { + const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; + + return string.Format( + AuthenticatorUriFormat, + UrlEncoder.Default.Encode(issuer), + UrlEncoder.Default.Encode(email), + unformattedKey); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/PasswordHasher.cs b/Streetwriters.Identity/Services/PasswordHasher.cs new file mode 100644 index 0000000..9dbb4c9 --- /dev/null +++ b/Streetwriters.Identity/Services/PasswordHasher.cs @@ -0,0 +1,47 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using Microsoft.AspNetCore.Identity; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Helpers; + +namespace Streetwriters.Identity.Services +{ + public class Argon2PasswordHasher : IPasswordHasher where TUser : User + { + public string HashPassword(TUser user, string password) + { + if (password == null) + throw new ArgumentNullException(nameof(password)); + + return PasswordHelper.CreatePasswordHash(password); + } + + public PasswordVerificationResult VerifyHashedPassword(TUser user, string hashedPassword, string providedPassword) + { + if (hashedPassword == null) + throw new ArgumentNullException(nameof(hashedPassword)); + if (providedPassword == null) + throw new ArgumentNullException(nameof(providedPassword)); + + return PasswordHelper.VerifyPassword(providedPassword, hashedPassword) ? PasswordVerificationResult.Success : PasswordVerificationResult.Failed; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/ProfileService.cs b/Streetwriters.Identity/Services/ProfileService.cs new file mode 100644 index 0000000..b0acbff --- /dev/null +++ b/Streetwriters.Identity/Services/ProfileService.cs @@ -0,0 +1,61 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using IdentityModel; +using IdentityServer4.Models; +using IdentityServer4.Services; +using Microsoft.AspNetCore.Identity; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Models; +using Streetwriters.Data.Repositories; + +namespace Streetwriters.Identity.Services +{ + public class ProfileService : IProfileService + { + protected UserManager UserManager { get; set; } + + public ProfileService(UserManager userManager) + { + UserManager = userManager; + } + + public async Task GetProfileDataAsync(ProfileDataRequestContext context) + { + User user = await UserManager.GetUserAsync(context.Subject); + if (user == null) return; + + IList roles = await UserManager.GetRolesAsync(user); + IList claims = user.Claims.Select((c) => c.ToClaim()).ToList(); + + context.IssuedClaims.AddRange(roles.Select((r) => new Claim(JwtClaimTypes.Role, r))); + context.IssuedClaims.AddRange(claims); + } + + public Task IsActiveAsync(IsActiveContext context) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/SMSSender.cs b/Streetwriters.Identity/Services/SMSSender.cs new file mode 100644 index 0000000..341c946 --- /dev/null +++ b/Streetwriters.Identity/Services/SMSSender.cs @@ -0,0 +1,59 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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 Streetwriters.Identity.Interfaces; +using Streetwriters.Common.Interfaces; +using MessageBird; +using MessageBird.Objects; +using Microsoft.Extensions.Options; +using Streetwriters.Identity.Models; + +namespace Streetwriters.Identity.Services +{ + public class SMSSender : ISMSSender + { + private Client client; + public SMSSender(IOptions messageBirdOptions) + { + client = Client.CreateDefault(messageBirdOptions.Value.AccessKey); + } + + public string SendOTP(string number, IClient app) + { + VerifyOptionalArguments optionalArguments = new VerifyOptionalArguments + { + Originator = app.Name, + Reference = app.Name, + Type = MessageType.Sms, + Template = $"Your {app.Name} 2FA code is: %token. Valid for 5 minutes.", + TokenLength = 6, + Timeout = 60 * 5 + }; + Verify verify = client.CreateVerify(number, optionalArguments); + if (verify.Status == VerifyStatus.Sent) return verify.Id; + return null; + } + + public bool VerifyOTP(string id, string code) + { + Verify verify = client.SendVerifyToken(id, code); + return verify.Status == VerifyStatus.Verified; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/TokenGenerationService.cs b/Streetwriters.Identity/Services/TokenGenerationService.cs new file mode 100644 index 0000000..2918408 --- /dev/null +++ b/Streetwriters.Identity/Services/TokenGenerationService.cs @@ -0,0 +1,116 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using IdentityModel; +using IdentityServer4; +using IdentityServer4.Configuration; +using IdentityServer4.Models; +using IdentityServer4.Services; +using IdentityServer4.Stores; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Identity; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Interfaces; + +namespace Streetwriters.Identity.Helpers +{ + public class TokenGenerationService : ITokenGenerationService + { + private IPersistedGrantStore PersistedGrantStore { get; set; } + private ITokenService TokenService { get; set; } + private IUserClaimsPrincipalFactory PrincipalFactory { get; set; } + private IdentityServerOptions ISOptions { get; set; } + private IdentityServerTools Tools { get; set; } + private IResourceStore ResourceStore { get; set; } + public TokenGenerationService(ITokenService tokenService, + IUserClaimsPrincipalFactory principalFactory, + IdentityServerOptions identityServerOptions, + IPersistedGrantStore persistedGrantStore, + IdentityServerTools tools, + IResourceStore resourceStore) + { + TokenService = tokenService; + PrincipalFactory = principalFactory; + ISOptions = identityServerOptions; + PersistedGrantStore = persistedGrantStore; + Tools = tools; + ResourceStore = resourceStore; + } + + public async Task CreateAccessTokenAsync(User user, string 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 Request = new TokenCreationRequest + { + Subject = IdentityUser.CreatePrincipal(), + IncludeAllIdentityClaims = true, + ValidatedRequest = new ValidatedRequest() + }; + Request.ValidatedRequest.Subject = Request.Subject; + Request.ValidatedRequest.SetClient(Config.Clients.FirstOrDefault((c) => c.ClientId == clientId)); + Request.ValidatedRequest.AccessTokenType = AccessTokenType.Reference; + Request.ValidatedRequest.AccessTokenLifetime = 18000; + Request.ValidatedResources = new ResourceValidationResult(new Resources(Config.IdentityResources, Config.ApiResources, Config.ApiScopes)); + Request.ValidatedRequest.Options = ISOptions; + Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims; + var accessToken = await TokenService.CreateAccessTokenAsync(Request); + return await TokenService.CreateSecurityTokenAsync(accessToken); + } + + public async Task TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60) + { + var principal = await PrincipalFactory.CreateAsync(user); + var identityUser = new IdentityServerUser(user.Id.ToString()); + identityUser.DisplayName = user.UserName; + identityUser.AuthenticationTime = System.DateTime.UtcNow; + identityUser.IdentityProvider = IdentityServerConstants.LocalIdentityProvider; + identityUser.AdditionalClaims = principal.Claims.ToArray(); + + request.AccessTokenType = AccessTokenType.Jwt; + request.AccessTokenLifetime = lifetime; + request.GrantType = grantType; + request.ValidatedResources = await ResourceStore.CreateResourceValidationResult(new ParsedScopesResult() + { + ParsedScopes = scopes.Select((scope) => new ParsedScopeValue(scope)).ToArray() + }); + return identityUser.CreatePrincipal(); + } + + public async Task CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 20 * 60) + { + var request = new TokenCreationRequest + { + Subject = await this.TransformTokenRequestAsync(validatedRequest, user, validatedRequest.GrantType, scopes, lifetime), + IncludeAllIdentityClaims = true, + ValidatedRequest = validatedRequest, + ValidatedResources = validatedRequest.ValidatedResources + }; + var accessToken = await TokenService.CreateAccessTokenAsync(request); + return await TokenService.CreateSecurityTokenAsync(accessToken); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/UserService.cs b/Streetwriters.Identity/Services/UserService.cs new file mode 100644 index 0000000..c32a107 --- /dev/null +++ b/Streetwriters.Identity/Services/UserService.cs @@ -0,0 +1,84 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Linq; +using System.Security.Claims; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Models; + +namespace Streetwriters.Identity.Services +{ + public class UserService + { + public static SubscriptionType GetUserSubscriptionStatus(string clientId, User user) + { + var claimKey = GetClaimKey(clientId); + var status = user.Claims.FirstOrDefault((c) => c.ClaimType == claimKey).ClaimValue; + switch (status) + { + case "basic": + return SubscriptionType.BASIC; + case "trial": + return SubscriptionType.TRIAL; + case "premium": + return SubscriptionType.PREMIUM; + case "premium_canceled": + return SubscriptionType.PREMIUM_CANCELED; + case "premium_expired": + return SubscriptionType.PREMIUM_EXPIRED; + default: + return SubscriptionType.BASIC; + } + } + + + + public static bool IsUserPremium(string clientId, User user) + { + var status = GetUserSubscriptionStatus(clientId, user); + string[] allowedClaims = { "trial", "premium", "premium_canceled" }; + + return status == SubscriptionType.TRIAL || status == SubscriptionType.PREMIUM || status == SubscriptionType.PREMIUM_CANCELED; + } + + public static Claim SubscriptionTypeToClaim(string clientId, SubscriptionType type) + { + var claimKey = GetClaimKey(clientId); + switch (type) + { + case SubscriptionType.BASIC: + return new Claim(claimKey, "basic"); + case SubscriptionType.TRIAL: + return new Claim(claimKey, "trial"); + case SubscriptionType.PREMIUM: + return new Claim(claimKey, "premium"); + case SubscriptionType.PREMIUM_CANCELED: + return new Claim(claimKey, "premium_canceled"); + case SubscriptionType.PREMIUM_EXPIRED: + return new Claim(claimKey, "premium_expired"); + } + return null; + } + + public static string GetClaimKey(string clientId) + { + return $"{clientId}:status"; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Startup.cs b/Streetwriters.Identity/Startup.cs new file mode 100644 index 0000000..427fcc5 --- /dev/null +++ b/Streetwriters.Identity/Startup.cs @@ -0,0 +1,219 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.IO; +using AspNetCore.Identity.Mongo; +using IdentityServer4.ResponseHandling; +using IdentityServer4.Services; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.Logging; +using Microsoft.IdentityModel.Tokens; +using Streetwriters.Common; +using Streetwriters.Common.Extensions; +using Streetwriters.Common.Messages; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Helpers; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; +using Streetwriters.Identity.Services; +using Streetwriters.Identity.Validation; + +namespace Streetwriters.Identity +{ + public class Startup + { + + public Startup(IConfiguration configuration, IWebHostEnvironment environment) + { + Configuration = configuration; + WebHostEnvironment = environment; + } + + private IConfiguration Configuration { get; } + private IWebHostEnvironment WebHostEnvironment { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + var connectionString = Configuration["MongoDbSettings:ConnectionString"]; + + services.Configure(Configuration.GetSection("SmtpSettings")); + services.Configure(Configuration.GetSection("MessageBirdSettings")); + services.AddTransient(); + services.AddTransient(); + services.AddTransient, Argon2PasswordHasher>(); + + services.AddCors(); + + //services.AddSingleton(); + services.AddIdentityMongoDbProvider(options => + { + // Password settings. + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.Password.RequiredLength = 8; + options.Password.RequiredUniqueChars = 0; + + // Lockout settings. + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.AllowedForNewUsers = true; + + // User settings. + //options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._"; + options.User.RequireUniqueEmail = true; + }, (options) => + { + options.RolesCollection = "roles"; + options.UsersCollection = "users"; + // options.MigrationCollection = "migration"; + options.ConnectionString = connectionString; + }).AddDefaultTokenProviders(); + + var builder = services.AddIdentityServer( + options => + { + options.Events.RaiseSuccessEvents = true; + options.Events.RaiseFailureEvents = true; + options.Events.RaiseErrorEvents = true; + options.IssuerUri = Servers.IdentityServer.ToString(); + }) + .AddExtensionGrantValidator() + .AddExtensionGrantValidator() + .AddExtensionGrantValidator() + .AddOperationalStore(options => + { + options.ConnectionString = connectionString; + }) + .AddConfigurationStore(options => + { + options.ConnectionString = connectionString; + }) + .AddAspNetIdentity() + .AddInMemoryClients(Config.Clients) + .AddInMemoryApiResources(Config.ApiResources) + .AddInMemoryApiScopes(Config.ApiScopes) + .AddInMemoryIdentityResources(Config.IdentityResources) + .AddKeyManagement() + .AddFileSystemPersistence(Path.Combine(WebHostEnvironment.ContentRootPath, @"keystore")); + + services.Configure(options => + { + options.TokenLifespan = TimeSpan.FromHours(2); + }); + + services.AddAuthorization(options => + { + options.AddPolicy("mfa", policy => + { + policy.AddAuthenticationSchemes("Bearer+jwt"); + policy.RequireClaim("scope", Config.MFA_GRANT_TYPE_SCOPE); + }); + }); + + + services.AddLocalApiAuthentication(); + services.AddAuthentication() + .AddJwtBearer("Bearer+jwt", options => + { + options.MapInboundClaims = false; + options.Authority = Servers.IdentityServer.ToString(); + options.RequireHttpsMetadata = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidTypes = new[] { "at+jwt" }, + ValidateAudience = false, + ValidateIssuerSigningKey = true, + ValidateIssuer = true, + }; + }); + + services.AddTransient(); + services.AddControllers(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddHealthChecks(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (!env.IsDevelopment()) + { + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedForHeaderName = "CF_CONNECTING_IP", + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto + }); + } + + app.UseCors("notesnook"); + + app.UseRouting(); + + app.UseIdentityServer(); + + app.UseAuthorization(); + app.UseAuthentication(); + + app.UseWamp(WampServers.IdentityServer, (realm, server) => + { + realm.Subscribe(server.Topics.CreateSubscriptionTopic, async (CreateSubscriptionMessage message) => + { + using (var serviceScope = app.ApplicationServices.CreateScope()) + { + var services = serviceScope.ServiceProvider; + var userManager = services.GetRequiredService>(); + await MessageHandlers.CreateSubscription.Process(message, userManager); + } + }); + realm.Subscribe(server.Topics.DeleteSubscriptionTopic, async (DeleteSubscriptionMessage message) => + { + using (var serviceScope = app.ApplicationServices.CreateScope()) + { + var services = serviceScope.ServiceProvider; + var userManager = services.GetRequiredService>(); + await MessageHandlers.DeleteSubscription.Process(message, userManager); + } + }); + }); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHealthChecks("/health"); + }); + } + } +} diff --git a/Streetwriters.Identity/Streetwriters.Identity.csproj b/Streetwriters.Identity/Streetwriters.Identity.csproj new file mode 100644 index 0000000..93fa154 --- /dev/null +++ b/Streetwriters.Identity/Streetwriters.Identity.csproj @@ -0,0 +1,46 @@ + + + + net7.0 + Streetwriters.Identity.Program + 10.0 + linux-x64 + true + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + diff --git a/Streetwriters.Identity/Templates/ConfirmEmail.html b/Streetwriters.Identity/Templates/ConfirmEmail.html new file mode 100644 index 0000000..b4c59e6 --- /dev/null +++ b/Streetwriters.Identity/Templates/ConfirmEmail.html @@ -0,0 +1,823 @@ + + + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + +
+ + + + +
+ + + + + +
+ + + + +
+

+ Confirm your email to activate your + {{app_name}} account. +

+
+ + + + + + +
+
+

+ {{app_name}} +

+
+
+
+ + + + + + +
+
+
+ Hey there! +
+
+
+
+
+ Thank you so much for signing up on + {{app_name}}! +
+
+
+
+
+ Please confirm your email by + clicking + here. +
+
+
+
+ + + + + + +
+ + + + + + +
+ Confirm Email +
+
+ + + + + + +
+ + + + + + +
+
+ + + + + + +
+
+
+ This email has been sent to you + because you signed up on {{app_name}} + a service of Streetwriters + (Private) Ltd. +
+
+ 1st Floor, Valley Plaza, Mardowal + Chowk, Naushera +
+
+ Khushab, + Punjab + 41100 Pakistan +
+
+
+
+
+ +
+
+
+
+
+ + diff --git a/Streetwriters.Identity/Templates/ConfirmEmail.txt b/Streetwriters.Identity/Templates/ConfirmEmail.txt new file mode 100644 index 0000000..478b662 --- /dev/null +++ b/Streetwriters.Identity/Templates/ConfirmEmail.txt @@ -0,0 +1,17 @@ +Confirm your email to activate your {{app_name}} account. + +************ +{{app_name}} +************ + +Hey there! + +Thank you so much for signing up on {{app_name}}! + +Please confirm your {{app_name}} account by going to this link: {{confirm_link}}. + +----------- + +This email has been sent to you because you signed up on {{app_name}} a service of Streetwriters (Private) Ltd. +1st Floor, Valley Plaza, Mardowal Chowk, Naushera +Khushab, Punjab 41100 Pakistan \ No newline at end of file diff --git a/Streetwriters.Identity/Templates/Email2FACode.html b/Streetwriters.Identity/Templates/Email2FACode.html new file mode 100644 index 0000000..2a9b0ae --- /dev/null +++ b/Streetwriters.Identity/Templates/Email2FACode.html @@ -0,0 +1,435 @@ + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + +
+ + + + +
+ + + + + +
+ + + + +
+

+ Your 2FA code for {{app_name}} is + {{code}} +

+
+ + + + + + +
+
+

+ {{app_name}} +

+
+
+
+ + + + + + +
+
+
+ Please use the following 2FA code to + login to your account: +
+
+
+
+ + + + + + +
+
+
+ {{code}} +
+
+
+
+ + + + + + +
+
+
+ If you did not request to a 2FA + code, please report this to us + at support@streetwriters.co +
+
+
+
+ + + + + + +
+ + + + + + +
+
+ + + + + + +
+
+
+ This email has been sent to you + because you signed up on {{app_name}} + - a service by Streetwriters + (Private) Ltd. +
+
+ 1st Floor, Valley Plaza, Mardowal + Chowk, Naushera +
+
+ Khushab, Punjab 41100 + Pakistan +
+
+
+
+
+ +
+
+
+
+
+ + + \ No newline at end of file diff --git a/Streetwriters.Identity/Templates/Email2FACode.txt b/Streetwriters.Identity/Templates/Email2FACode.txt new file mode 100644 index 0000000..bf52dfd --- /dev/null +++ b/Streetwriters.Identity/Templates/Email2FACode.txt @@ -0,0 +1,17 @@ +Your 2FA code for {{app_name}} is: {{code}} + +************ +{{app_name}} +************ + +Please use the following 2FA code to login to your account: + +{{code}} + +If you did not request to a 2FA code, please report this to us at support@streetwriters.co + +------------- + +This email has been sent to you because you signed up on ** - a service by Streetwriters (Private) Ltd. +1st Floor, Valley Plaza, Mardowal Chowk, Naushera +Khushab, Punjab 41100 Pakistan \ No newline at end of file diff --git a/Streetwriters.Identity/Templates/EmailChangeConfirmation.html b/Streetwriters.Identity/Templates/EmailChangeConfirmation.html new file mode 100644 index 0000000..94b0c5f --- /dev/null +++ b/Streetwriters.Identity/Templates/EmailChangeConfirmation.html @@ -0,0 +1,807 @@ + + + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + +
+ + + + +
+ + + + + +
+ + + + +
+

+ Confirm your new email to change it for + your {{app_name}} account. +

+
+ + + + + + +
+
+

+ {{app_name}} +

+
+
+
+ + + + + + +
+
+
+ Hey there! +
+
+
+
+
+ Please confirm your new email by + clicking + here + or the button below. +
+
+
+
+ + + + + + +
+ + + + + + +
+ Confirm Email +
+
+ + + + + + +
+ + + + + + +
+
+ + + + + + +
+
+
+ This email has been sent to you + because you signed up on {{app_name}} + a service of Streetwriters + (Private) Ltd. +
+
+ 1st Floor, Valley Plaza, Mardowal + Chowk, Naushera +
+
+ Khushab, + Punjab + 41100 Pakistan +
+
+
+
+
+ +
+
+
+
+
+ + diff --git a/Streetwriters.Identity/Templates/EmailChangeConfirmation.txt b/Streetwriters.Identity/Templates/EmailChangeConfirmation.txt new file mode 100644 index 0000000..5e1d3d8 --- /dev/null +++ b/Streetwriters.Identity/Templates/EmailChangeConfirmation.txt @@ -0,0 +1,15 @@ +Confirm your new email to change it for your {{app_name}} account. + +************ +{{app_name}} +************ + +Hey there! + +Please confirm your new email by going to this link: {{confirm_link}} + +------------ + +This email has been sent to you because you signed up on {{app_name}} a service of Streetwriters (Private) Ltd. +1st Floor, Valley Plaza, Mardowal Chowk, Naushera +Khushab, Punjab 41100 Pakistan \ No newline at end of file diff --git a/Streetwriters.Identity/Templates/FailedLoginAlert.html b/Streetwriters.Identity/Templates/FailedLoginAlert.html new file mode 100644 index 0000000..a4c0d4e --- /dev/null +++ b/Streetwriters.Identity/Templates/FailedLoginAlert.html @@ -0,0 +1,505 @@ + + + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + +
+ + + + +
+ + + + + +
+ + + + +
+

+
+ + + + + + +
+
+

+ {{app_name}} +

+
+
+
+ + + + + + +
+
+
+ We detected a failed login attempt + on your {{app_name}} account. +
+
+
+
+
+ {{device_info}} +
+
+
+
+
+ If this was not you + please immediately change your + password & 2FA methods. +
+
+
+
+ + + + + + +
+ + + + + + +
+
+ + + + + + +
+
+
+ This email has been sent to you + because you signed up on {{app_name}} + - a service by Streetwriters + (Private) Ltd. +
+
+ 1st Floor, Valley Plaza, Mardowal + Chowk, Naushera +
+
+ Khushab, Punjab 41100 + Pakistan +
+
+
+
+
+ +
+
+
+
+
+ + diff --git a/Streetwriters.Identity/Templates/FailedLoginAlert.txt b/Streetwriters.Identity/Templates/FailedLoginAlert.txt new file mode 100644 index 0000000..bf98c59 --- /dev/null +++ b/Streetwriters.Identity/Templates/FailedLoginAlert.txt @@ -0,0 +1,15 @@ +************ +{{app_name}} +************ + +We detected a failed login attempt on your {{app_name}} account. + +{{device_info}} + +If this was not you *please immediately reset your password & 2FA methods*. + +------------- + +This email has been sent to you because you signed up on *{{app_name}}* - a service by Streetwriters (Private) Ltd. +1st Floor, Valley Plaza, Mardowal Chowk, Naushera +Khushab, Punjab 41100 Pakistan \ No newline at end of file diff --git a/Streetwriters.Identity/Templates/ResetAccountPassword.html b/Streetwriters.Identity/Templates/ResetAccountPassword.html new file mode 100644 index 0000000..6b93a5c --- /dev/null +++ b/Streetwriters.Identity/Templates/ResetAccountPassword.html @@ -0,0 +1,658 @@ + + + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + +
+ + + + +
+ + + + + +
+ + + + +
+

+ Lost access to your {{app_name}} + account? +

+
+ + + + + + +
+
+

+ {{app_name}} +

+
+
+
+ + + + + + +
+
+
+ Hey there! +
+
+
+
+
+ You requested to + reset your {{app_name}} account + password. +
+
+
+
+
+ Please + click here + to reset your account password and + recover your account. +
+
+
+
+ + + + + + +
+ + + + + + +
+ Reset your password +
+
+ + + + + + +
+
+
+ If you did not request to reset + your account password, you can + safely ignore this email. +
+
+
+
+ + + + + + +
+ + + + + + +
+
+ + + + . + + + +
+
+
+ This email has been sent to you + because you signed up on {{app_name}} + - a service by Streetwriters + (Private) Ltd. +
+
+ 1st Floor, Valley Plaza, Mardowal + Chowk, Naushera +
+
+ Khushab, Punjab 41100 + Pakistan +
+
+
+
+
+ +
+
+
+
+
+ + diff --git a/Streetwriters.Identity/Templates/ResetAccountPassword.txt b/Streetwriters.Identity/Templates/ResetAccountPassword.txt new file mode 100644 index 0000000..137456a --- /dev/null +++ b/Streetwriters.Identity/Templates/ResetAccountPassword.txt @@ -0,0 +1,17 @@ +************ +{{app_name}} +************ + +Hey there! + +You requested to *reset your {{app_name}} account password*. + +Please go to this link to reset your account password: {{reset_link}} + +If you did not request to reset your account password, you can safely ignore this email. + +------------ + +This email has been sent to you because you signed up on {{app_name}} - a service by Streetwriters (Private) Ltd. +1st Floor, Valley Plaza, Mardowal Chowk, Naushera +Khushab, Punjab 41100 Pakistan \ No newline at end of file diff --git a/Streetwriters.Identity/Validation/BearerTokenValidator.cs b/Streetwriters.Identity/Validation/BearerTokenValidator.cs new file mode 100644 index 0000000..023e09c --- /dev/null +++ b/Streetwriters.Identity/Validation/BearerTokenValidator.cs @@ -0,0 +1,62 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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.Linq; +using IdentityModel; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Http; + +namespace Streetwriters.Identity.Validation +{ + public class BearerTokenValidator + { + /// + /// Validates the authorization header. + /// + /// The context. + /// + public static BearerTokenUsageValidationResult ValidateAuthorizationHeader(HttpContext context) + { + var authorizationHeader = context.Request.Headers["Authorization"].FirstOrDefault(); + if (!string.IsNullOrEmpty(authorizationHeader)) + { + var header = authorizationHeader.Trim(); + if (header.StartsWith(OidcConstants.AuthenticationSchemes.AuthorizationHeaderBearer)) + { + var value = header.Substring(OidcConstants.AuthenticationSchemes.AuthorizationHeaderBearer.Length).Trim(); + if (!string.IsNullOrEmpty(value)) + { + return new BearerTokenUsageValidationResult + { + TokenFound = true, + Token = value, + UsageType = BearerTokenUsageType.AuthorizationHeader + }; + } + } + else + { + + } + } + + return new BearerTokenUsageValidationResult(); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Validation/CustomResourceOwnerValidator.cs b/Streetwriters.Identity/Validation/CustomResourceOwnerValidator.cs new file mode 100644 index 0000000..874db66 --- /dev/null +++ b/Streetwriters.Identity/Validation/CustomResourceOwnerValidator.cs @@ -0,0 +1,152 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using IdentityServer4; +using IdentityServer4.AspNetIdentity; +using IdentityServer4.Models; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; +using static IdentityModel.OidcConstants; + +namespace Streetwriters.Identity.Validation +{ + public class CustomResourceOwnerValidator : IResourceOwnerPasswordValidator + { + private UserManager UserManager { get; set; } + private SignInManager SignInManager { get; set; } + private IMFAService MFAService { get; set; } + private ITokenGenerationService TokenGenerationService { get; set; } + private IdentityServerTools Tools { get; set; } + public CustomResourceOwnerValidator(UserManager userManager, SignInManager signInManager, IMFAService mfaService, ITokenGenerationService tokenGenerationService, IdentityServerTools tools) + { + UserManager = userManager; + SignInManager = signInManager; + MFAService = mfaService; + TokenGenerationService = tokenGenerationService; + Tools = tools; + } + + public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) + { + var user = await UserManager.FindByNameAsync(context.UserName); + if (user != null) + { + var result = await SignInManager.CheckPasswordSignInAsync(user, context.Password, true); + + if (result.IsLockedOut) + { + var timeLeft = user.LockoutEnd - DateTimeOffset.Now; + context.Result.IsError = true; + context.Result.Error = "user_locked_out"; + context.Result.ErrorDescription = $"You have been locked out. Please try again in {Pluralize(timeLeft?.Minutes, "minute", "minutes")} and {Pluralize(timeLeft?.Seconds, "second", "seconds")}."; + return; + } + + var success = result.Succeeded; + var isMultiFactor = await UserManager.GetTwoFactorEnabledAsync(user); + + // We'll ask for 2FA regardless of password being incorrect to prevent an attacker + // from knowing whether the password is correct or not. + if (isMultiFactor) + { + var primaryMethod = MFAService.GetPrimaryMethod(user); + var secondaryMethod = MFAService.GetSecondaryMethod(user); + + var mfaCode = context.Request.Raw["mfa:code"]; + var mfaMethod = context.Request.Raw["mfa:method"]; + + if (string.IsNullOrEmpty(mfaCode) || !MFAService.IsValidMFAMethod(mfaMethod)) + { + var sendPhoneNumber = primaryMethod == MFAMethods.SMS || secondaryMethod == MFAMethods.SMS; + + var token = await TokenGenerationService.CreateAccessTokenFromValidatedRequestAsync(context.Request, user, new[] { Config.MFA_GRANT_TYPE_SCOPE }); + context.Result.CustomResponse = new System.Collections.Generic.Dictionary + { + ["error"] = "mfa_required", + ["error_description"] = "Multifactor authentication required.", + ["data"] = JsonSerializer.Serialize(new MFARequiredResponse + { + PhoneNumber = sendPhoneNumber ? Regex.Replace(user.PhoneNumber, @"\d(?!\d{0,3}$)", "*") : null, + PrimaryMethod = primaryMethod, + SecondaryMethod = secondaryMethod, + Token = token, + }) + }; + context.Result.IsError = true; + return; + } + else if (mfaMethod == MFAMethods.RecoveryCode) + { + var recoveryCodeResult = await UserManager.RedeemTwoFactorRecoveryCodeAsync(user, mfaCode); + if (!recoveryCodeResult.Succeeded) + { + context.Result.IsError = true; + context.Result.Error = "invalid_2fa_recovery_code"; + context.Result.ErrorDescription = recoveryCodeResult.Errors.ToErrors().First(); + return; + } + } + else + { + var provider = mfaMethod == MFAMethods.Email || mfaMethod == MFAMethods.SMS ? TokenOptions.DefaultPhoneProvider : UserManager.Options.Tokens.AuthenticatorTokenProvider; + var isMFACodeValid = await MFAService.VerifyOTPAsync(user, mfaCode, mfaMethod); + if (!isMFACodeValid) + { + context.Result.IsError = true; + context.Result.Error = "invalid_2fa_code"; + context.Result.ErrorDescription = "Please provide a valid multi factor authentication code."; + return; + } + } + + // if we are here, it means we succeeded. + success = true; + } + + if (success) + { + var sub = await UserManager.GetUserIdAsync(user); + context.Result = new GrantValidationResult(sub, AuthenticationMethods.Password); + return; + } + } + + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); + } + + string Pluralize(int? value, string singular, string plural) + { + if (value == null) return $"0 {plural}"; + return value == 1 ? $"{value} {singular}" : $"{value} {plural}"; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Validation/EmailGrantValidator.cs b/Streetwriters.Identity/Validation/EmailGrantValidator.cs new file mode 100644 index 0000000..c11160e --- /dev/null +++ b/Streetwriters.Identity/Validation/EmailGrantValidator.cs @@ -0,0 +1,106 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using IdentityServer4; +using IdentityServer4.Models; +using IdentityServer4.Stores; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Identity; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; +using static IdentityModel.OidcConstants; + +namespace Streetwriters.Identity.Validation +{ + public class EmailGrantValidator : IExtensionGrantValidator + { + private UserManager UserManager { get; set; } + private SignInManager SignInManager { get; set; } + private IMFAService MFAService { get; set; } + private ITokenGenerationService TokenGenerationService { get; set; } + private JwtRequestValidator JWTRequestValidator { get; set; } + private IResourceStore ResourceStore { get; set; } + private IUserClaimsPrincipalFactory PrincipalFactory { get; set; } + public EmailGrantValidator(UserManager userManager, SignInManager signInManager, IMFAService mfaService, ITokenGenerationService tokenGenerationService, + IResourceStore resourceStore, IUserClaimsPrincipalFactory principalFactory) + { + UserManager = userManager; + SignInManager = signInManager; + MFAService = mfaService; + TokenGenerationService = tokenGenerationService; + ResourceStore = resourceStore; + PrincipalFactory = principalFactory; + } + + public string GrantType => Config.EMAIL_GRANT_TYPE; + + + public async Task ValidateAsync(ExtensionGrantValidationContext context) + { + var email = context.Request.Raw["email"]; + var user = await UserManager.FindByEmailAsync(email); + if (user == null) + { + user = new User + { + Id = MongoDB.Bson.ObjectId.GenerateNewId(), + Email = email, + UserName = email, + NormalizedEmail = email, + NormalizedUserName = email, + EmailConfirmed = false, + SecurityStamp = "" + }; + } + var isMultiFactor = await UserManager.GetTwoFactorEnabledAsync(user); + + var primaryMethod = isMultiFactor ? MFAService.GetPrimaryMethod(user) : MFAMethods.Email; + var secondaryMethod = MFAService.GetSecondaryMethod(user); + var sendPhoneNumber = primaryMethod == MFAMethods.SMS || secondaryMethod == MFAMethods.SMS; + + context.Result.CustomResponse = new Dictionary + { + ["additional_data"] = new MFARequiredResponse + { + PhoneNumber = sendPhoneNumber ? Regex.Replace(user.PhoneNumber, @"\d(?!\d{0,3}$)", "*") : null, + PrimaryMethod = primaryMethod, + SecondaryMethod = secondaryMethod, + } + }; + context.Result.IsError = false; + context.Result.Subject = await TokenGenerationService.TransformTokenRequestAsync(context.Request, user, GrantType, new string[] { Config.MFA_GRANT_TYPE_SCOPE }); + } + + + string Pluralize(int? value, string singular, string plural) + { + if (value == null) return $"0 {plural}"; + return value == 1 ? $"{value} {singular}" : $"{value} {plural}"; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Validation/LockedOutValidationResult.cs b/Streetwriters.Identity/Validation/LockedOutValidationResult.cs new file mode 100644 index 0000000..937e38d --- /dev/null +++ b/Streetwriters.Identity/Validation/LockedOutValidationResult.cs @@ -0,0 +1,36 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using IdentityServer4.Validation; + +namespace Streetwriters.Identity.Validation +{ + public class LockedOutValidationResult : GrantValidationResult + { + public LockedOutValidationResult(TimeSpan? timeLeft) + { + base.Error = "locked_out"; + if (timeLeft.HasValue) + base.ErrorDescription = $"You have been locked out. Please try again in {timeLeft?.Minutes.Pluralize("minute", "minutes")} and {timeLeft?.Seconds.Pluralize("second", "seconds")}."; + else + base.ErrorDescription = $"You have been locked out."; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Validation/MFAGrantValidator.cs b/Streetwriters.Identity/Validation/MFAGrantValidator.cs new file mode 100644 index 0000000..737aaba --- /dev/null +++ b/Streetwriters.Identity/Validation/MFAGrantValidator.cs @@ -0,0 +1,142 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using IdentityModel; +using IdentityServer4.Models; +using IdentityServer4.Stores; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Ng.Services; +using Streetwriters.Common; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; +using static IdentityModel.OidcConstants; + +namespace Streetwriters.Identity.Validation +{ + public class MFAGrantValidator : IExtensionGrantValidator + { + private UserManager UserManager { get; set; } + private SignInManager SignInManager { get; set; } + private IMFAService MFAService { get; set; } + private IHttpContextAccessor HttpContextAccessor { get; set; } + private ITokenValidator TokenValidator { get; set; } + private ITokenGenerationService TokenGenerationService { get; set; } + private IEmailSender EmailSender { get; set; } + public MFAGrantValidator(UserManager userManager, SignInManager signInManager, IMFAService mfaService, IHttpContextAccessor httpContextAccessor, ITokenValidator tokenValidator, ITokenGenerationService tokenGenerationService, IEmailSender emailSender) + { + UserManager = userManager; + SignInManager = signInManager; + MFAService = mfaService; + HttpContextAccessor = httpContextAccessor; + TokenValidator = tokenValidator; + TokenGenerationService = tokenGenerationService; + EmailSender = emailSender; + } + + public string GrantType => Config.MFA_GRANT_TYPE; + + public async Task ValidateAsync(ExtensionGrantValidationContext context) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); + + var httpContext = HttpContextAccessor.HttpContext; + var tokenResult = BearerTokenValidator.ValidateAuthorizationHeader(httpContext); + if (!tokenResult.TokenFound) return; + + var tokenValidationResult = await TokenValidator.ValidateAccessTokenAsync(tokenResult.Token, Config.MFA_GRANT_TYPE_SCOPE); + if (tokenValidationResult.IsError) return; + + var client = Clients.FindClientById(tokenValidationResult.Claims.GetClaimValue("client_id")); + if (client == null) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidClient); + return; + } + + var userId = tokenValidationResult.Claims.GetClaimValue("sub"); + var mfaCode = context.Request.Raw["mfa:code"]; + var mfaMethod = context.Request.Raw["mfa:method"]; + + if (string.IsNullOrEmpty(userId)) return; + + var user = await UserManager.FindByIdAsync(userId); + if (user == null) return; + + context.Result.Error = "invalid_mfa"; + context.Result.ErrorDescription = "Please provide a valid multi-factor authentication code."; + + if (string.IsNullOrEmpty(mfaCode)) return; + if (string.IsNullOrEmpty(mfaMethod)) + { + context.Result.ErrorDescription = "Please provide a valid multi-factor authentication method."; + return; + } + + var isLockedOut = await UserManager.IsLockedOutAsync(user); + if (isLockedOut) + { + var timeLeft = user.LockoutEnd - DateTimeOffset.Now; + context.Result = new LockedOutValidationResult(timeLeft); + return; + } + + if (mfaMethod == MFAMethods.RecoveryCode) + { + var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(user, mfaCode); + if (!result.Succeeded) + { + await UserManager.AccessFailedAsync(user); + context.Result.ErrorDescription = "Please provide a valid multi-factor authentication recovery code."; + await EmailSender.SendFailedLoginAlertAsync(user.Email, httpContext.GetClientInfo(), client).ConfigureAwait(false); + return; + } + } + + var provider = mfaMethod == MFAMethods.Email || mfaMethod == MFAMethods.SMS ? TokenOptions.DefaultPhoneProvider : UserManager.Options.Tokens.AuthenticatorTokenProvider; + var isMFACodeValid = await MFAService.VerifyOTPAsync(user, mfaCode, mfaMethod); + if (!isMFACodeValid) + { + await UserManager.AccessFailedAsync(user); + await EmailSender.SendFailedLoginAlertAsync(user.Email, httpContext.GetClientInfo(), client).ConfigureAwait(false); + return; + } + + context.Result.IsError = false; + context.Result.Subject = await TokenGenerationService.TransformTokenRequestAsync(context.Request, user, GrantType, new string[] { Config.MFA_PASSWORD_GRANT_TYPE_SCOPE }); + } + + + string Pluralize(int? value, string singular, string plural) + { + if (value == null) return $"0 {plural}"; + return value == 1 ? $"{value} {singular}" : $"{value} {plural}"; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Validation/MFAPasswordGrantValidator.cs b/Streetwriters.Identity/Validation/MFAPasswordGrantValidator.cs new file mode 100644 index 0000000..ab3d50d --- /dev/null +++ b/Streetwriters.Identity/Validation/MFAPasswordGrantValidator.cs @@ -0,0 +1,106 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2022 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; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using IdentityServer4.Models; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Streetwriters.Common; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Interfaces; +using static IdentityModel.OidcConstants; + +namespace Streetwriters.Identity.Validation +{ + public class MFAPasswordGrantValidator : IExtensionGrantValidator + { + private UserManager UserManager { get; set; } + private SignInManager SignInManager { get; set; } + private IMFAService MFAService { get; set; } + private IHttpContextAccessor HttpContextAccessor { get; set; } + private ITokenValidator TokenValidator { get; set; } + private IEmailSender EmailSender { get; set; } + + public MFAPasswordGrantValidator(UserManager userManager, SignInManager signInManager, IMFAService mfaService, IHttpContextAccessor httpContextAccessor, ITokenValidator tokenValidator, IEmailSender emailSender) + { + UserManager = userManager; + SignInManager = signInManager; + MFAService = mfaService; + HttpContextAccessor = httpContextAccessor; + TokenValidator = tokenValidator; + EmailSender = emailSender; + } + public string GrantType => Config.MFA_PASSWORD_GRANT_TYPE; + + public async Task ValidateAsync(ExtensionGrantValidationContext context) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); + + var httpContext = HttpContextAccessor.HttpContext; + var tokenResult = BearerTokenValidator.ValidateAuthorizationHeader(httpContext); + if (!tokenResult.TokenFound) return; + + + var tokenValidationResult = await TokenValidator.ValidateAccessTokenAsync(tokenResult.Token, Config.MFA_PASSWORD_GRANT_TYPE_SCOPE); + if (tokenValidationResult.IsError) return; + + + var client = Clients.FindClientById(tokenValidationResult.Claims.GetClaimValue("client_id")); + if (client == null) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidClient); + return; + } + + var userId = tokenValidationResult.Claims.GetClaimValue("sub"); + var password = context.Request.Raw["password"]; + + if (string.IsNullOrEmpty(userId)) return; + + context.Result.Error = "unauthorized"; + context.Result.ErrorDescription = "Password is incorrect."; + + if (string.IsNullOrEmpty(password)) return; + + var user = await UserManager.FindByIdAsync(userId); + if (user == null) return; + + var result = await SignInManager.CheckPasswordSignInAsync(user, password, true); + if (!result.Succeeded) + { + await EmailSender.SendFailedLoginAlertAsync(user.Email, httpContext.GetClientInfo(), client).ConfigureAwait(false); + return; + } + else if (result.IsLockedOut) + { + var timeLeft = user.LockoutEnd - DateTimeOffset.Now; + context.Result = new LockedOutValidationResult(timeLeft); + return; + } + + var sub = await UserManager.GetUserIdAsync(user); + context.Result = new GrantValidationResult(sub, AuthenticationMethods.Password); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/appsettings.Development.json b/Streetwriters.Identity/appsettings.Development.json new file mode 100644 index 0000000..8081306 --- /dev/null +++ b/Streetwriters.Identity/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "MongoDbSettings": { + "ConnectionString": "mongodb://localhost:27017/identity", + "DatabaseName": "identity" + } +}