diff --git a/src/App/Pages/Accounts/LoginPageViewModel.cs b/src/App/Pages/Accounts/LoginPageViewModel.cs index 4a91c29a8..04fe7178e 100644 --- a/src/App/Pages/Accounts/LoginPageViewModel.cs +++ b/src/App/Pages/Accounts/LoginPageViewModel.cs @@ -9,6 +9,7 @@ using Bit.App.Utilities; using Xamarin.Forms; using System.Windows.Input; +using System.Net; namespace Bit.App.Pages { @@ -141,7 +142,7 @@ await _platformUtilsService.ShowDialogAsync( #region cozy // Email field is used as CozyURL, it is not renamed not to change the original code // too much. - var cozyURL = Email; + var cozyURL = UrlHelper.SanitizeUrl(Email); await _cozyClientService.ConfigureEnvironmentFromCozyURLAsync(cozyURL); var email = _cozyClientService.GetEmailFromCozyURL(cozyURL); var response = await _authService.LogInAsync(email, MasterPassword, _captchaToken); @@ -187,6 +188,15 @@ await _platformUtilsService.ShowDialogAsync( LogInSuccessAction?.Invoke(); } } + // Cozy customization, intercept SanitizeUrl exceptions + //* + catch (CozyException e) + { + await _deviceActionService.HideLoadingAsync(); + var translatedErrorMessage = AppResources.ResourceManager.GetString(e.GetType().Name, AppResources.Culture); + await _platformUtilsService.ShowDialogAsync(translatedErrorMessage, AppResources.AnErrorHasOccurred, AppResources.Ok); + } + //*/ catch (ApiException e) { _captchaToken = null; @@ -194,8 +204,24 @@ await _platformUtilsService.ShowDialogAsync( await _deviceActionService.HideLoadingAsync(); if (e?.Error != null) { + // Cozy customization, set custom message for 401 response + // As the stack does not translate error messages and 401 is the most common error + // then we intercept this specific error to translate it on client side + /* await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(), AppResources.AnErrorHasOccurred, AppResources.Ok); + /*/ + if (e.Error.StatusCode == HttpStatusCode.Unauthorized) + { + var translatedErrorMessage = AppResources.ResourceManager.GetString("CozyInvalidLoginException", AppResources.Culture); + await _platformUtilsService.ShowDialogAsync(translatedErrorMessage, AppResources.AnErrorHasOccurred, AppResources.Ok); + } + else + { + await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(), + AppResources.AnErrorHasOccurred, AppResources.Ok); + } + //*/ } } } diff --git a/src/App/Resources/AppResources.fr.resx b/src/App/Resources/AppResources.fr.resx index 7c594152b..da7de97e7 100644 --- a/src/App/Resources/AppResources.fr.resx +++ b/src/App/Resources/AppResources.fr.resx @@ -185,6 +185,22 @@ J'ai déjà mon Cozy + + L'adresse du Cozy est requise + Exception message when no CozyUrl is set + + + L'adresse de votre Cozy n'est pas votre email + Exception message when no user set an email in login field + + + Oups, ce n'est pas la bonne adresse. Essayez d'écrire "cozy" avec un "z" ! + Exception message when user mispels Cozy with Cosy + + + L'adresse et le mot de passe que vous avez saisi ne semblent pas correspondre + Exception message when user enter wrong login or password + Remerciements Title for page that we use to give credit to resources that we use. diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx index 42cc1920c..f520d65dd 100644 --- a/src/App/Resources/AppResources.resx +++ b/src/App/Resources/AppResources.resx @@ -185,6 +185,22 @@ I already have my Cozy + + Cozy address is required + Exception message when no CozyUrl is set + + + The Cozy address is not your email + Exception message when no user set an email in login field + + + Woops, the address is not correct. Try with "cozy" with a "z"! + Exception message when user mispels Cozy with Cosy + + + The address and password you entered seems to not match + Exception message when user enter wrong login or password + Credits Title for page that we use to give credit to resources that we use. diff --git a/src/Core/Exceptions/CozyException.cs b/src/Core/Exceptions/CozyException.cs new file mode 100644 index 000000000..2f510b628 --- /dev/null +++ b/src/Core/Exceptions/CozyException.cs @@ -0,0 +1,8 @@ +using System; + +namespace Bit.Core.Exceptions +{ + public class CozyException : Exception + { + } +} diff --git a/src/Core/Models/Response/ErrorResponse.cs b/src/Core/Models/Response/ErrorResponse.cs index cd240e44c..d72112c85 100644 --- a/src/Core/Models/Response/ErrorResponse.cs +++ b/src/Core/Models/Response/ErrorResponse.cs @@ -28,6 +28,19 @@ public ErrorResponse(JObject response, HttpStatusCode status, bool identityRespo if (errorModel != null) { var model = errorModel.ToObject(); + + // Cozy customization, add specific parser for Stack error messages + // CozyStack error messages do not fit Bitwarden's errors format + // so we have to create a custom parser for them + //* + if (model.Message == null && model.ValidationErrors == null) + { + var cozyModel = errorModel.ToObject(); + + model.Message = cozyModel.Error; + } + //*/ + Message = model.Message; ValidationErrors = model.ValidationErrors ?? new Dictionary>(); CaptchaSiteKey = ValidationErrors.ContainsKey("HCaptcha_SiteKey") ? @@ -72,5 +85,15 @@ private class ErrorModel public string Message { get; set; } public Dictionary> ValidationErrors { get; set; } } + + // Cozy customization, add specific parser for Stack error messages + // CozyStack error messages do not fit Bitwarden's errors format + // so we have to create a custom parser for them + //* + private class CozyErrorModel + { + public string Error { get; set; } + } + //*/ } } diff --git a/src/Core/Utilities/UrlHelper.cs b/src/Core/Utilities/UrlHelper.cs index 5210621d2..a3d8703fa 100644 --- a/src/Core/Utilities/UrlHelper.cs +++ b/src/Core/Utilities/UrlHelper.cs @@ -1,12 +1,16 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; +using Bit.Core.Exceptions; using Flurl; namespace Bit.Core.Utilities { public static class UrlHelper { + public static string COZY_DOMAIN = ".mycozy.cloud"; + // Code taken from CozyClient.EnsureFirstSlash() private static string EnsureFirstSlash(string path) { @@ -76,5 +80,121 @@ string subDomainType return url.ToString(); } + + /// + /// Sanitize the given url in order to fit CozyUrl format + /// A CozyUrl : + /// - should not be null + /// - should not be an email + /// - should not misspell Cozy with Cosy + /// - has a valid scheme (http or https) + /// - has a hostname + /// - do not has an app slug in it (except for custom domains) + /// - do no ends with a trailing space + /// + /// To have a complete list of CozyUrl rules, please refer to UrlHelperTests.cs tests + /// + /// Url to sanitize + /// Sanitized url + public static string SanitizeUrl(string inputUrl) + { + // Prevent empty url + if (string.IsNullOrEmpty(inputUrl)) + { + throw new CozyUrlRequiredException(); + } + + // Prevent email input + if (inputUrl.Contains('@')) + { + throw new NoEmailAsCozyUrlException(); + } + + if (HasMispelledCozy(inputUrl)) + { + throw new HasMispelledCozyException(); + } + + return NormalizeUrl(inputUrl, COZY_DOMAIN); + } + + private static string NormalizeUrl(string value, string defaultDomain) + { + var valueWithProtocol = PrependProtocol(value); + var valueWithoutTrailingSlash = RemoveTrailingSlash(valueWithProtocol); + var valueWithProtocolAndDomain = AppendDomain( + valueWithoutTrailingSlash, + defaultDomain + ); + + var isDefaultDomain = valueWithProtocolAndDomain.Contains(defaultDomain); + + return isDefaultDomain + ? RemoveAppSlug(valueWithProtocolAndDomain) + : valueWithProtocolAndDomain; + } + + private static bool HasMispelledCozy(string value) + { + return value.Contains("mycosy"); + } + + private static string AppendDomain(string value, string domain) + { + var regex = new Regex(@"\.", RegexOptions.IgnoreCase); + if (regex.IsMatch(value)) + { + return value; + } + + return $"{value}{domain}"; + } + + private static string PrependProtocol(string value) + { + var regex = new Regex(@"^http(s)?:\/\/", RegexOptions.IgnoreCase); + if (regex.IsMatch(value)) + { + return value; + } + + return $"https://{value}"; + } + + private static string RemoveTrailingSlash(string value) + { + if (value.EndsWith("/")) + { + return value.Substring(0, value.Length - 1); + } + + return value; + } + + private static string RemoveAppSlug(string value) + { + var regex = new Regex(@"^https?:\/\/\w+(-\w+)\.", RegexOptions.IgnoreCase); + + var matches = regex.Match(value); + + if (matches.Groups.Count > 1) + { + return value.Replace(matches.Groups[1].Value, ""); + } + + return value; + } + } + + public class CozyUrlRequiredException : CozyException + { + } + + public class NoEmailAsCozyUrlException : CozyException + { + } + + public class HasMispelledCozyException : CozyException + { } } diff --git a/test/Core.Test/Utilities/UrlHelperTests.cs b/test/Core.Test/Utilities/UrlHelperTests.cs index 3bcb0b41e..e1ea933ad 100644 --- a/test/Core.Test/Utilities/UrlHelperTests.cs +++ b/test/Core.Test/Utilities/UrlHelperTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Bit.Core.Utilities; using Xunit; @@ -58,5 +59,113 @@ public void GenerateWebLink_CorrectlySetsSlashBeforeFragmentIfNoPath() Assert.Equal("https://alice-passwords.cozy.tools/#/vault?action=import", webLink); } + + [Fact] + public void NormalizeUrl_ShouldReturnUndefinedIfTheInputIsEmpty() + { + var inputUrl = ""; + Assert.Throws(() => { + UrlHelper.SanitizeUrl(inputUrl); + }); + } + + [Fact] + public void NormalizeUrl_ShouldReturnUndefinedIfTheInputIsAnEmail() + { + var inputUrl = "claude@cozycloud.cc"; + Assert.Throws(() => + { + UrlHelper.SanitizeUrl(inputUrl); + }); + } + + [Fact] + public void NormalizeUrl_ShouldReturnTheUrlWithoutTheAppSlugIfPresent() + { + var inputUrl = "claude-drive.mycozy.cloud"; + var url = UrlHelper.SanitizeUrl(inputUrl); + Assert.Equal("https://claude.mycozy.cloud", url); + } + + [Fact] + public void NormalizeUrl_ShouldReturnTheUrlWithTheDefaultDomainIfMissing() + { + var inputUrl = "claude-drive"; + var url = UrlHelper.SanitizeUrl(inputUrl); + Assert.Equal("https://claude.mycozy.cloud", url); + } + + [Fact] + public void NormalizeUrl_ShouldReturnTheUrlWithTheDefaultSchemeIfMissing() + { + var inputUrl = "claude.mycozy.cloud"; + var url = UrlHelper.SanitizeUrl(inputUrl); + Assert.Equal("https://claude.mycozy.cloud", url); + } + + [Fact] + public void NormalizeUrl_ShouldReturnTheUrlIfTheInputIsCorrect() + { + var inputUrl = "https://claude.mycozy.cloud"; + var url = UrlHelper.SanitizeUrl(inputUrl); + Assert.Equal("https://claude.mycozy.cloud", url); + } + + [Fact] + public void NormalizeUrl_ShouldAcceptLocalUrl() + { + var inputUrl = "http://claude.cozy.tools:8080"; + var url = UrlHelper.SanitizeUrl(inputUrl); + Assert.Equal("http://claude.cozy.tools:8080", url); + } + + [Fact] + public void NormalizeUrl_ShouldNotTryToRemoveSlugIfPresentAndUrlHasACustomDomain() + { + var inputUrl = "claude-drive.on-premise.cloud"; + var url = UrlHelper.SanitizeUrl(inputUrl); + Assert.Equal("https://claude-drive.on-premise.cloud", url); + } + + [Fact] + public void NormalizeUrl_ShouldReturnTheCorrectUrlIfDomainsContainsADash() + { + var inputUrl = "claude.on-premise.cloud"; + var url = UrlHelper.SanitizeUrl(inputUrl); + Assert.Equal("https://claude.on-premise.cloud", url); + } + + [Fact] + public void NormalizeUrl_ShouldReturnTheCorrectUrlIfDomainsContainsADashAndCozyIsInstalledOnDomainRoot() + { + var inputUrl = "https://on-premise.cloud"; + var url = UrlHelper.SanitizeUrl(inputUrl); + Assert.Equal("https://on-premise.cloud", url); + } + + [Fact] + public void NormalizeUrl_ShouldThrowIfUserWriteMycosyInsteadOfMycozy() + { + var inputUrl = "https://claude.mycosy.cloud"; + Assert.Throws(() => { + UrlHelper.SanitizeUrl(inputUrl); + }); + } + + [Fact] + public void NormalizeUrl_ShouldAcceptRealCosyUrl() + { + var inputUrl = "https://claude.realdomaincosy.cloud"; + var url = UrlHelper.SanitizeUrl(inputUrl); + Assert.Equal("https://claude.realdomaincosy.cloud", url); + } + + [Fact] + public void NormalizeUrl_ShouldRemoveTrailingInUrl() + { + var inputUrl = "https://claude.realdomaincosy.cloud/"; + var url = UrlHelper.SanitizeUrl(inputUrl); + Assert.Equal("https://claude.realdomaincosy.cloud", url); + } } }