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);
+ }
}
}