From 163ad248af8cc10148eb3ed478e0996a5c15cb29 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Tue, 26 Sep 2017 14:38:12 -0400 Subject: [PATCH] autofill custom fields for iOS extension --- src/iOS.Extension/LoadingViewController.cs | 8 +- src/iOS.Extension/LoginAddViewController.cs | 2 +- src/iOS.Extension/LoginListViewController.cs | 3 +- src/iOS.Extension/Models/FillScript.cs | 162 ++++++++++++++++--- src/iOS.Extension/Models/LoginViewModel.cs | 19 +++ src/iOS.Extension/Models/PageDetails.cs | 6 +- 6 files changed, 175 insertions(+), 25 deletions(-) diff --git a/src/iOS.Extension/LoadingViewController.cs b/src/iOS.Extension/LoadingViewController.cs index 995069cf9..ddeeb5d60 100644 --- a/src/iOS.Extension/LoadingViewController.cs +++ b/src/iOS.Extension/LoadingViewController.cs @@ -20,6 +20,7 @@ using Bit.App.Resources; using Bit.iOS.Core.Controllers; using SimpleInjector; using XLabs.Ioc.SimpleInjectorContainer; +using System.Collections.Generic; namespace Bit.iOS.Extension { @@ -191,12 +192,13 @@ namespace Bit.iOS.Extension } } - public void CompleteUsernamePasswordRequest(string username, string password, string totp) + public void CompleteUsernamePasswordRequest(string username, string password, + List> fields, string totp) { NSDictionary itemData = null; if(_context.ProviderType == UTType.PropertyList) { - var fillScript = new FillScript(_context.Details, username, password); + var fillScript = new FillScript(_context.Details, username, password, fields); var scriptJson = JsonConvert.SerializeObject(fillScript, _jsonSettings); var scriptDict = new NSDictionary(Constants.AppExtensionWebViewPageFillScript, scriptJson); itemData = new NSDictionary(NSJavaScriptExtension.FinalizeArgumentKey, scriptDict); @@ -210,7 +212,7 @@ namespace Bit.iOS.Extension else if(_context.ProviderType == Constants.UTTypeAppExtensionFillBrowserAction || _context.ProviderType == Constants.UTTypeAppExtensionFillWebViewAction) { - var fillScript = new FillScript(_context.Details, username, password); + var fillScript = new FillScript(_context.Details, username, password, fields); var scriptJson = JsonConvert.SerializeObject(fillScript, _jsonSettings); itemData = new NSDictionary(Constants.AppExtensionWebViewPageFillScript, scriptJson); } diff --git a/src/iOS.Extension/LoginAddViewController.cs b/src/iOS.Extension/LoginAddViewController.cs index 402d58eee..a5cc108a5 100644 --- a/src/iOS.Extension/LoginAddViewController.cs +++ b/src/iOS.Extension/LoginAddViewController.cs @@ -177,7 +177,7 @@ namespace Bit.iOS.Extension else if(LoadingController != null) { LoadingController.CompleteUsernamePasswordRequest(UsernameCell.TextField.Text, PasswordCell.TextField.Text, - null); + null, null); } } else if(saveTask.Result.Errors.Count() > 0) diff --git a/src/iOS.Extension/LoginListViewController.cs b/src/iOS.Extension/LoginListViewController.cs index 4b201563f..62fd39081 100644 --- a/src/iOS.Extension/LoginListViewController.cs +++ b/src/iOS.Extension/LoginListViewController.cs @@ -207,7 +207,8 @@ namespace Bit.iOS.Extension totp = GetTotp(item); } - _controller.LoadingController.CompleteUsernamePasswordRequest(item.Username, item.Password, totp); + _controller.LoadingController.CompleteUsernamePasswordRequest(item.Username, item.Password, + item.Fields.Value, totp); } else if(!string.IsNullOrWhiteSpace(item.Username) || !string.IsNullOrWhiteSpace(item.Password) || !string.IsNullOrWhiteSpace(item.Totp.Value)) diff --git a/src/iOS.Extension/Models/FillScript.cs b/src/iOS.Extension/Models/FillScript.cs index a228238d2..7c5c916e0 100644 --- a/src/iOS.Extension/Models/FillScript.cs +++ b/src/iOS.Extension/Models/FillScript.cs @@ -2,12 +2,17 @@ using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; +using System.Text.RegularExpressions; namespace Bit.iOS.Extension.Models { public class FillScript { - public FillScript(PageDetails pageDetails, string fillUsername, string fillPassword) + 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) { @@ -16,6 +21,38 @@ namespace Bit.iOS.Extension.Models DocumentUUID = pageDetails.DocumentUUID; + var filledOpIds = new HashSet(); + + if(fillFields?.Any() ?? false) + { + var fieldNames = fillFields.Select(f => f.Item1?.ToLower()).ToArray(); + foreach(var field in pageDetails.Fields.Where(f => f.Viewable)) + { + if(filledOpIds.Contains(field.OpId)) + { + continue; + } + + var matchingIndex = FindMatchingFieldIndex(field, fieldNames); + if(matchingIndex > -1) + { + filledOpIds.Add(field.OpId); + 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? + if(filledOpIds.Any()) + { + Script.Add(new List { "focus_by_opid", filledOpIds.Last() }); + } + return; + } + List usernames = new List(); List passwords = new List(); @@ -76,38 +113,132 @@ namespace Bit.iOS.Extension.Models } } - foreach(var username in usernames) + 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.Type == "text" || f.Type == "email" || f.Type == "tel") && + FieldIsFuzzyMatch(f, usernameFieldNamesList)) + { + usernames.Add(f); + } + } + } + + foreach(var username in usernames.Where(u => !filledOpIds.Contains(u.OpId))) + { + filledOpIds.Add(username.OpId); Script.Add(new List { "click_on_opid", username.OpId }); Script.Add(new List { "fill_by_opid", username.OpId, fillUsername }); } - foreach(var password in passwords) + foreach(var password in passwords.Where(p => !filledOpIds.Contains(p.OpId))) { + filledOpIds.Add(password.OpId); Script.Add(new List { "click_on_opid", password.OpId }); Script.Add(new List { "fill_by_opid", password.OpId, fillPassword }); } - if(passwords.Any()) + if(filledOpIds.Any()) { - AutoSubmit = new Submit { FocusOpId = passwords.First().OpId }; + Script.Add(new List { "focus_by_opid", filledOpIds.Last() }); } } private PageDetails.Field FindUsernameField(PageDetails pageDetails, PageDetails.Field passwordField, bool canBeHidden, bool checkForm) { - return pageDetails.Fields.LastOrDefault(f => - (!checkForm || f.Form == passwordField.Form) - && (canBeHidden || f.Viewable) - && f.ElementNumber < passwordField.ElementNumber - && (f.Type == "text" || f.Type == "email" || f.Type == "tel")); + 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 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 = "autosubmit")] - public Submit AutoSubmit { get; set; } [JsonProperty(PropertyName = "documentUUID")] public object DocumentUUID { get; set; } [JsonProperty(PropertyName = "properties")] @@ -116,12 +247,5 @@ namespace Bit.iOS.Extension.Models public object Options { get; set; } = new { animate = false }; [JsonProperty(PropertyName = "metadata")] public object MetaData { get; set; } = new object(); - - public class Submit - { - [JsonProperty(PropertyName = "focusOpid")] - public string FocusOpId { get; set; } - } } - } diff --git a/src/iOS.Extension/Models/LoginViewModel.cs b/src/iOS.Extension/Models/LoginViewModel.cs index a5e4bd5b8..924e4d908 100644 --- a/src/iOS.Extension/Models/LoginViewModel.cs +++ b/src/iOS.Extension/Models/LoginViewModel.cs @@ -1,5 +1,7 @@ using Bit.App.Models; using System; +using System.Collections.Generic; +using System.Linq; namespace Bit.iOS.Extension.Models { @@ -13,6 +15,22 @@ namespace Bit.iOS.Extension.Models Password = login.Password?.Decrypt(login.OrganizationId); Uri = login.Uri?.Decrypt(login.OrganizationId); Totp = new Lazy(() => login.Totp?.Decrypt(login.OrganizationId)); + Fields = new Lazy>>(() => + { + if(login.Fields?.Any() ?? true) + { + return null; + } + + var fields = new List>(); + foreach(var field in login.Fields) + { + fields.Add(new Tuple( + field.Name?.Decrypt(login.OrganizationId), + field.Value?.Decrypt(login.OrganizationId))); + } + return fields; + }); } public string Id { get; set; } @@ -21,5 +39,6 @@ namespace Bit.iOS.Extension.Models public string Password { get; set; } public string Uri { get; set; } public Lazy Totp { get; set; } + public Lazy>> Fields { get; set; } } } diff --git a/src/iOS.Extension/Models/PageDetails.cs b/src/iOS.Extension/Models/PageDetails.cs index 0b60f6c00..004173bfc 100644 --- a/src/iOS.Extension/Models/PageDetails.cs +++ b/src/iOS.Extension/Models/PageDetails.cs @@ -1,4 +1,5 @@ -using System; +using Newtonsoft.Json; +using System; using System.Collections.Generic; using System.Linq; @@ -36,6 +37,9 @@ namespace Bit.iOS.Extension.Models 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; }