diff --git a/src/iOS.Autofill/Utilities/AutofillHelpers.cs b/src/iOS.Autofill/Utilities/AutofillHelpers.cs new file mode 100644 index 000000000..d61f92575 --- /dev/null +++ b/src/iOS.Autofill/Utilities/AutofillHelpers.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; +using Bit.App.Resources; +using Bit.iOS.Core.Utilities; +using Bit.iOS.Core.Views; +using Foundation; +using UIKit; + +namespace Bit.iOS.Autofill.Utilities +{ + public static class AutofillHelpers + { + /* + public static void TableRowSelected(UITableView tableView, NSIndexPath indexPath, + ExtensionTableSource tableSource, CredentialProviderViewController cpViewController, + UITableViewController controller, ISettings settings, string loginAddSegue) + { + tableView.DeselectRow(indexPath, true); + tableView.EndEditing(true); + + if(tableSource.Items == null || tableSource.Items.Count() == 0) + { + controller.PerformSegue(loginAddSegue, tableSource); + return; + } + + var item = tableSource.Items.ElementAt(indexPath.Row); + if(item == null) + { + cpViewController.CompleteRequest(null); + return; + } + + if(!string.IsNullOrWhiteSpace(item.Username) && !string.IsNullOrWhiteSpace(item.Password)) + { + string totp = null; + if(!settings.GetValueOrDefault(App.Constants.SettingDisableTotpCopy, false)) + { + totp = tableSource.GetTotp(item); + } + + cpViewController.CompleteRequest(item.Username, item.Password, totp); + } + else if(!string.IsNullOrWhiteSpace(item.Username) || !string.IsNullOrWhiteSpace(item.Password) || + !string.IsNullOrWhiteSpace(item.Totp.Value)) + { + var sheet = Dialogs.CreateActionSheet(item.Name, controller); + if(!string.IsNullOrWhiteSpace(item.Username)) + { + sheet.AddAction(UIAlertAction.Create(AppResources.CopyUsername, UIAlertActionStyle.Default, a => + { + UIPasteboard clipboard = UIPasteboard.General; + clipboard.String = item.Username; + var alert = Dialogs.CreateMessageAlert(AppResources.CopyUsername); + controller.PresentViewController(alert, true, () => + { + controller.DismissViewController(true, null); + }); + })); + } + + if(!string.IsNullOrWhiteSpace(item.Password)) + { + sheet.AddAction(UIAlertAction.Create(AppResources.CopyPassword, UIAlertActionStyle.Default, a => + { + UIPasteboard clipboard = UIPasteboard.General; + clipboard.String = item.Password; + var alert = Dialogs.CreateMessageAlert(AppResources.CopiedPassword); + controller.PresentViewController(alert, true, () => + { + controller.DismissViewController(true, null); + }); + })); + } + + if(!string.IsNullOrWhiteSpace(item.Totp.Value)) + { + sheet.AddAction(UIAlertAction.Create(AppResources.CopyTotp, UIAlertActionStyle.Default, a => + { + var totp = tableSource.GetTotp(item); + if(string.IsNullOrWhiteSpace(totp)) + { + return; + } + + UIPasteboard clipboard = UIPasteboard.General; + clipboard.String = totp; + var alert = Dialogs.CreateMessageAlert(AppResources.CopiedTotp); + controller.PresentViewController(alert, true, () => + { + controller.DismissViewController(true, null); + }); + })); + } + + sheet.AddAction(UIAlertAction.Create(AppResources.Cancel, UIAlertActionStyle.Cancel, null)); + controller.PresentViewController(sheet, true, null); + } + else + { + var alert = Dialogs.CreateAlert(null, AppResources.NoUsernamePasswordConfigured, AppResources.Ok); + controller.PresentViewController(alert, true, null); + } + } + */ + } +} \ No newline at end of file diff --git a/src/iOS.Autofill/iOS.Autofill.csproj b/src/iOS.Autofill/iOS.Autofill.csproj index 50e89a395..ac3e04f28 100644 --- a/src/iOS.Autofill/iOS.Autofill.csproj +++ b/src/iOS.Autofill/iOS.Autofill.csproj @@ -66,6 +66,7 @@ + @@ -85,6 +86,10 @@ + + {ee44c6a1-2a85-45fe-8d9b-bf1d5f88809c} + App + {e71f3053-056c-4381-9638-048ed73bdff6} iOS.Core diff --git a/src/iOS.Extension/Models/Context.cs b/src/iOS.Extension/Models/Context.cs new file mode 100644 index 000000000..20961344e --- /dev/null +++ b/src/iOS.Extension/Models/Context.cs @@ -0,0 +1,17 @@ +using Foundation; +using Bit.iOS.Core.Models; + +namespace Bit.iOS.Extension.Models +{ + public class Context : AppExtensionContext + { + public NSExtensionContext ExtContext { get; set; } + public string ProviderType { get; set; } + public string LoginTitle { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string OldPassword { get; set; } + public string Notes { get; set; } + public PageDetails Details { get; set; } + } +} diff --git a/src/iOS.Extension/Models/FillScript.cs b/src/iOS.Extension/Models/FillScript.cs new file mode 100644 index 000000000..0364d14dd --- /dev/null +++ b/src/iOS.Extension/Models/FillScript.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using System.Text.RegularExpressions; + +namespace Bit.iOS.Extension.Models +{ + public class FillScript + { + private static string[] _usernameFieldNames = new[]{ "username", "user name", "email", + "email address", "e-mail", "e-mail address", "userid", "user id" }; + + public FillScript(PageDetails pageDetails, string fillUsername, string fillPassword, + List> fillFields) + { + if(pageDetails == null) + { + return; + } + + DocumentUUID = pageDetails.DocumentUUID; + + var filledFields = new Dictionary(); + + if(fillFields?.Any() ?? false) + { + var fieldNames = fillFields.Select(f => f.Item1?.ToLower()).ToArray(); + foreach(var field in pageDetails.Fields.Where(f => f.Viewable)) + { + if(filledFields.ContainsKey(field.OpId)) + { + continue; + } + + var matchingIndex = FindMatchingFieldIndex(field, fieldNames); + if(matchingIndex > -1) + { + filledFields.Add(field.OpId, field); + Script.Add(new List { "click_on_opid", field.OpId }); + Script.Add(new List { "fill_by_opid", field.OpId, fillFields[matchingIndex].Item2 }); + } + } + } + + if(string.IsNullOrWhiteSpace(fillPassword)) + { + // No password for this login. Maybe they just wanted to auto-fill some custom fields? + SetFillScriptForFocus(filledFields); + return; + } + + List usernames = new List(); + List passwords = new List(); + + var passwordFields = pageDetails.Fields.Where(f => f.Type == "password" && f.Viewable).ToArray(); + if(!passwordFields.Any()) + { + // not able to find any viewable password fields. maybe there are some "hidden" ones? + passwordFields = pageDetails.Fields.Where(f => f.Type == "password").ToArray(); + } + + foreach(var form in pageDetails.Forms) + { + var passwordFieldsForForm = passwordFields.Where(f => f.Form == form.Key).ToArray(); + passwords.AddRange(passwordFieldsForForm); + + if(string.IsNullOrWhiteSpace(fillUsername)) + { + continue; + } + + foreach(var pf in passwordFieldsForForm) + { + var username = FindUsernameField(pageDetails, pf, false, true); + if(username == null) + { + // not able to find any viewable username fields. maybe there are some "hidden" ones? + username = FindUsernameField(pageDetails, pf, true, true); + } + + if(username != null) + { + usernames.Add(username); + } + } + } + + if(passwordFields.Any() && !passwords.Any()) + { + // The page does not have any forms with password fields. Use the first password field on the page and the + // input field just before it as the username. + + var pf = passwordFields.First(); + passwords.Add(pf); + + if(!string.IsNullOrWhiteSpace(fillUsername) && pf.ElementNumber > 0) + { + var username = FindUsernameField(pageDetails, pf, false, false); + if(username == null) + { + // not able to find any viewable username fields. maybe there are some "hidden" ones? + username = FindUsernameField(pageDetails, pf, true, false); + } + + if(username != null) + { + usernames.Add(username); + } + } + } + + if(!passwordFields.Any()) + { + // No password fields on this page. Let's try to just fuzzy fill the username. + var usernameFieldNamesList = _usernameFieldNames.ToList(); + foreach(var f in pageDetails.Fields) + { + if(f.Viewable && (f.Type == "text" || f.Type == "email" || f.Type == "tel") && + FieldIsFuzzyMatch(f, usernameFieldNamesList)) + { + usernames.Add(f); + } + } + } + + foreach(var username in usernames.Where(u => !filledFields.ContainsKey(u.OpId))) + { + filledFields.Add(username.OpId, username); + Script.Add(new List { "click_on_opid", username.OpId }); + Script.Add(new List { "fill_by_opid", username.OpId, fillUsername }); + } + + foreach(var password in passwords.Where(p => !filledFields.ContainsKey(p.OpId))) + { + filledFields.Add(password.OpId, password); + Script.Add(new List { "click_on_opid", password.OpId }); + Script.Add(new List { "fill_by_opid", password.OpId, fillPassword }); + } + + SetFillScriptForFocus(filledFields); + } + + private PageDetails.Field FindUsernameField(PageDetails pageDetails, PageDetails.Field passwordField, bool canBeHidden, + bool checkForm) + { + PageDetails.Field usernameField = null; + + foreach(var f in pageDetails.Fields) + { + if(f.ElementNumber >= passwordField.ElementNumber) + { + break; + } + + if((!checkForm || f.Form == passwordField.Form) + && (canBeHidden || f.Viewable) + && f.ElementNumber < passwordField.ElementNumber + && (f.Type == "text" || f.Type == "email" || f.Type == "tel")) + { + usernameField = f; + + if(FindMatchingFieldIndex(f, _usernameFieldNames) > -1) + { + // We found an exact match. No need to keep looking. + break; + } + } + } + + return usernameField; + } + + private int FindMatchingFieldIndex(PageDetails.Field field, string[] names) + { + var matchingIndex = -1; + if(!string.IsNullOrWhiteSpace(field.HtmlId)) + { + matchingIndex = Array.IndexOf(names, field.HtmlId.ToLower()); + } + if(matchingIndex < 0 && !string.IsNullOrWhiteSpace(field.HtmlName)) + { + matchingIndex = Array.IndexOf(names, field.HtmlName.ToLower()); + } + if(matchingIndex < 0 && !string.IsNullOrWhiteSpace(field.LabelTag)) + { + matchingIndex = Array.IndexOf(names, CleanLabel(field.LabelTag)); + } + if(matchingIndex < 0 && !string.IsNullOrWhiteSpace(field.Placeholder)) + { + matchingIndex = Array.IndexOf(names, field.Placeholder.ToLower()); + } + + return matchingIndex; + } + + private bool FieldIsFuzzyMatch(PageDetails.Field field, List names) + { + if(!string.IsNullOrWhiteSpace(field.HtmlId) && FuzzyMatch(names, field.HtmlId.ToLower())) + { + return true; + } + if(!string.IsNullOrWhiteSpace(field.HtmlName) && FuzzyMatch(names, field.HtmlName.ToLower())) + { + return true; + } + if(!string.IsNullOrWhiteSpace(field.LabelTag) && FuzzyMatch(names, CleanLabel(field.LabelTag))) + { + return true; + } + if(!string.IsNullOrWhiteSpace(field.Placeholder) && FuzzyMatch(names, field.Placeholder.ToLower())) + { + return true; + } + + return false; + } + + private bool FuzzyMatch(List options, string value) + { + if((!options?.Any() ?? true) || string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return options.Any(o => value.Contains(o)); + } + + private void SetFillScriptForFocus(IDictionary filledFields) + { + if(!filledFields.Any()) + { + return; + } + + PageDetails.Field lastField = null, lastPasswordField = null; + foreach(var field in filledFields) + { + if(field.Value.Viewable) + { + lastField = field.Value; + if(field.Value.Type == "password") + { + lastPasswordField = field.Value; + } + } + } + + // Prioritize password field over others. + if(lastPasswordField != null) + { + Script.Add(new List { "focus_by_opid", lastPasswordField.OpId }); + } + else if(lastField != null) + { + Script.Add(new List { "focus_by_opid", lastField.OpId }); + } + } + + private string CleanLabel(string label) + { + return Regex.Replace(label, @"(?:\r\n|\r|\n)", string.Empty).Trim().ToLower(); + } + + [JsonProperty(PropertyName = "script")] + public List> Script { get; set; } = new List>(); + [JsonProperty(PropertyName = "documentUUID")] + public object DocumentUUID { get; set; } + [JsonProperty(PropertyName = "properties")] + public object Properties { get; set; } = new object(); + [JsonProperty(PropertyName = "options")] + public object Options { get; set; } = new { animate = false }; + [JsonProperty(PropertyName = "metadata")] + public object MetaData { get; set; } = new object(); + } +} diff --git a/src/iOS.Extension/Models/PageDetails.cs b/src/iOS.Extension/Models/PageDetails.cs new file mode 100644 index 000000000..004173bfc --- /dev/null +++ b/src/iOS.Extension/Models/PageDetails.cs @@ -0,0 +1,52 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Bit.iOS.Extension.Models +{ + public class PageDetails + { + public string DocumentUUID { get; set; } + public string Title { get; set; } + public string Url { get; set; } + public string DocumentUrl { get; set; } + public string TabUrl { get; set; } + public Dictionary Forms { get; set; } + public List Fields { get; set; } + public long CollectedTimestamp { get; set; } + public bool HasPasswordField => Fields.Any(f => f.Type == "password"); + + public class Form + { + public string OpId { get; set; } + public string HtmlName { get; set; } + public string HtmlId { get; set; } + public string HtmlAction { get; set; } + public string HtmlMethod { get; set; } + } + + public class Field + { + public string OpId { get; set; } + public int ElementNumber { get; set; } + public bool Visible { get; set; } + public bool Viewable { get; set; } + public string HtmlId { get; set; } + public string HtmlName { get; set; } + public string HtmlClass { get; set; } + public string LabelRight { get; set; } + public string LabelLeft { get; set; } + [JsonProperty("label-tag")] + public string LabelTag { get; set; } + public string Placeholder { get; set; } + public string Type { get; set; } + public string Value { get; set; } + public bool Disabled { get; set; } + public bool Readonly { get; set; } + public string OnePasswordFieldType { get; set; } + public string Form { get; set; } + } + } + +} diff --git a/src/iOS.Extension/iOS.Extension.csproj b/src/iOS.Extension/iOS.Extension.csproj index 1c6623935..d82db32aa 100644 --- a/src/iOS.Extension/iOS.Extension.csproj +++ b/src/iOS.Extension/iOS.Extension.csproj @@ -68,6 +68,9 @@ + + + ActionViewController.cs @@ -84,6 +87,10 @@ + + {ee44c6a1-2a85-45fe-8d9b-bf1d5f88809c} + App + {e71f3053-056c-4381-9638-048ed73bdff6} iOS.Core @@ -108,8 +115,6 @@ - - - + \ No newline at end of file