Masking input in TextBox

57 views Asked by At

The task is «creating a password input field with the option to show or hide the password». There are no security requirements, so working with the password can be done in a string type.

At the moment, this can be solved quite simply by using a font with a single mask character in the TextBox. When triggered by a regular CheckBox, the font is replaced with a masking font or vice versa. But such a masking font must be prepared in advance.

But recently they gave an additional condition “the ability to change the masking symbol.” An easy way to change the character is in PasswordBox. But it does not have the ability to disable masking and additional code must be used to bind Password. At the moment, we have to use a solution in which a TextBox is used in the “without masking” mode, and a PasswordBox in the “with masking” mode.

Can you recommend something more optimal?

2

There are 2 answers

2
BionicCode On BEST ANSWER

I think a good and simple solution is to use an adorner to render the masking symbols as an overlay of the original input.

Show the adorner to render the masking characters while hiding the password by setting the foreground brush to the background brush.

The following example shows how to use an Adorner to decorate the TextBox. I have removed some code to reduce the complexity (the original library code also contained validation of input characters and password length, event logic, routed commands, SecureString support, low-level caret positioning to support any font family for those cases where the font is not a monospace font and password characters and masking characters won't align correctly etc. and a much more complex default ControlTemplate). The current version therefore only supports monospace fonts. The supported fonts have to be registered in the constructor. You could implement monospace font detection instead.

However, it's a fully working example (happy easter!).

UnsecurePasswodBox.cs

public class UnsecurePasswodBox : TextBox
{
  public bool IsShowPasswordEnabled
  {
    get => (bool)GetValue(IsShowPasswordEnabledProperty);
    set => SetValue(IsShowPasswordEnabledProperty, value);
  }

  public static readonly DependencyProperty IsShowPasswordEnabledProperty = DependencyProperty.Register(
    "IsShowPasswordEnabled",
    typeof(bool),
    typeof(UnsecurePasswodBox),
    new FrameworkPropertyMetadata(default(bool), OnIsShowPasswordEnabledChaged));

  public char CharacterMaskSymbol
  {
    get => (char)GetValue(CharacterMaskSymbolProperty);
    set => SetValue(CharacterMaskSymbolProperty, value);
  }

  public static readonly DependencyProperty CharacterMaskSymbolProperty = DependencyProperty.Register(
    "CharacterMaskSymbol",
    typeof(char),
    typeof(UnsecurePasswodBox),
    new PropertyMetadata('●', OnCharacterMaskSymbolChanged));

  private FrameworkElement? part_ContentHost;
  private AdornerLayer? adornerLayer;
  private UnsecurePasswordBoxAdorner? maskingAdorner;
  private Brush foregroundInternal;
  private bool isChangeInternal;
  private readonly HashSet<string> supportedMonospaceFontFamilies;
  private FontFamily fallbackFont;

  static UnsecurePasswodBox()
  {
    DefaultStyleKeyProperty.OverrideMetadata(
      typeof(UnsecurePasswodBox),
      new FrameworkPropertyMetadata(typeof(UnsecurePasswodBox)));

    TextProperty.OverrideMetadata(
      typeof(UnsecurePasswodBox),
      new FrameworkPropertyMetadata(OnTextChanged));

    ForegroundProperty.OverrideMetadata(
      typeof(UnsecurePasswodBox),
      new FrameworkPropertyMetadata(propertyChangedCallback: null, coerceValueCallback: OnCoerceForeground));

    FontFamilyProperty.OverrideMetadata(
      typeof(UnsecurePasswodBox),
      new FrameworkPropertyMetadata(propertyChangedCallback: null, coerceValueCallback: OnCoerceFontFamily));
  }

  public UnsecurePasswodBox()
  {
    this.Loaded += OnLoaded;

    // Only use a monospaced font
    this.supportedMonospaceFontFamilies = new HashSet<string>()
  {
    "Consolas",
    "Courier New",
    "Lucida Console",
    "Cascadia Mono",
    "Global Monospace",
    "Cascadia Code",
  };

    this.fallbackFont = new FontFamily("Consolas");
    this.FontFamily = fallbackFont;

  }

  private void OnLoaded(object sender, RoutedEventArgs e)
  {
    this.Loaded -= OnLoaded;

    FrameworkElement adornerDecoratorChild = this.part_ContentHost ?? this;
    this.adornerLayer = AdornerLayer.GetAdornerLayer(adornerDecoratorChild);
    if (this.adornerLayer is null)
    {
      throw new InvalidOperationException("No AdornerDecorator found in parent visual tree");
    }

    this.maskingAdorner = new UnsecurePasswordBoxAdorner(adornerDecoratorChild, this)
    {
      Foreground = Brushes.Black
    };
    HandleInputMask();
  }

  private static void OnIsShowPasswordEnabledChaged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    var unsecurePasswordBox = (UnsecurePasswodBox)d;
    unsecurePasswordBox.HandleInputMask();
    Keyboard.Focus(unsecurePasswordBox);
  }

  private static void OnCharacterMaskSymbolChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    => ((UnsecurePasswodBox)d).RefreshMask();

  private static void OnTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    => ((UnsecurePasswodBox)d).RefreshMask();

  private static object OnCoerceForeground(DependencyObject d, object baseValue)
  {
    var unsecurePasswordBox = (UnsecurePasswodBox)d;

    // Reject external font color change while in masking mode
    // as this would reveal the password.
    // But store new value and make it available when exiting masking mode.
    if (!unsecurePasswordBox.isChangeInternal && !unsecurePasswordBox.IsShowPasswordEnabled)
    {
      unsecurePasswordBox.foregroundInternal = baseValue as Brush;
    }

    return unsecurePasswordBox.isChangeInternal
      ? baseValue
      : unsecurePasswordBox.IsShowPasswordEnabled
        ? baseValue
        : unsecurePasswordBox.Foreground;
  }

  private static object OnCoerceFontFamily(DependencyObject d, object baseValue)
  {
    var unsecurePasswordBox = (UnsecurePasswodBox)d;
    var desiredFontFamily = baseValue as FontFamily;
    return desiredFontFamily is not null
      && unsecurePasswordBox.supportedMonospaceFontFamilies.Contains(desiredFontFamily.Source)
        ? baseValue
        : unsecurePasswordBox.FontFamily;
  }

  public override void OnApplyTemplate()
  {
    base.OnApplyTemplate();
    this.part_ContentHost = GetTemplateChild("PART_ContentHost") as FrameworkElement;
  }

  private void HandleInputMask()
  {
    this.isChangeInternal = true;
    if (this.IsShowPasswordEnabled)
    {
      this.adornerLayer?.Remove(this.maskingAdorner);
      ShowText();
    }
    else
    {
      HideText();
      this.adornerLayer?.Add(this.maskingAdorner);
    }

    this.isChangeInternal = false;
  }

  private void ShowText()
    => SetCurrentValue(ForegroundProperty, this.foregroundInternal);
  private void HideText()
  {
    this.foregroundInternal = this.Foreground;
    SetCurrentValue(ForegroundProperty, this.Background);
  }

  private void RefreshMask()
  {
    if (!this.IsShowPasswordEnabled)
    {
      this.maskingAdorner?.Update();
    }
  }

  private class UnsecurePasswordBoxAdorner : Adorner
  {
    public Brush Foreground { get; set; }

    private readonly UnsecurePasswodBox unsecurePasswodBox;
    private const int DefaultSystemTextPadding = 2;

    public UnsecurePasswordBoxAdorner(UIElement adornedElement, UnsecurePasswodBox unsecurePasswodBox) : base(adornedElement)
    {
      this.IsHitTestVisible = false;
      this.unsecurePasswodBox = unsecurePasswodBox;
    }

    public void Update()
      => InvalidateVisual();

    protected override void OnRender(DrawingContext drawingContext)
    {
      base.OnRender(drawingContext);

      var typeface = new Typeface(
        this.unsecurePasswodBox.FontFamily,
        this.unsecurePasswodBox.FontStyle,
        this.unsecurePasswodBox.FontWeight,
        this.unsecurePasswodBox.FontStretch,
        this.unsecurePasswodBox.fallbackFont);
      double pixelsPerDip = VisualTreeHelper.GetDpi(this).PixelsPerDip;

      ReadOnlySpan<char> maskedInput = MaskInput(this.unsecurePasswodBox.Text);
      var maskedText = new FormattedText(
      maskedInput.ToString(),
      CultureInfo.CurrentCulture,
      this.unsecurePasswodBox.FlowDirection,
      typeface,
      this.unsecurePasswodBox.FontSize,
      this.Foreground,
      pixelsPerDip)
      {
        MaxTextWidth = ((FrameworkElement)this.AdornedElement).ActualWidth + UnsecurePasswordBoxAdorner.DefaultSystemTextPadding,
        Trimming = TextTrimming.None
      };

      var textOrigin = new Point(0, 0);
      textOrigin.Offset(this.unsecurePasswodBox.Padding.Left + UnsecurePasswordBoxAdorner.DefaultSystemTextPadding, 0);

      drawingContext.DrawText(maskedText, textOrigin);
    }

    private ReadOnlySpan<char> MaskInput(ReadOnlySpan<char> input)
    {
      if (input.Length == 0)
      {
        return input;
      }

      char[] textMask = new char[input.Length];
      Array.Fill(textMask, this.unsecurePasswodBox.CharacterMaskSymbol);
      return new ReadOnlySpan<char>(textMask);
    }
  }
}

Generic.xaml

<Style TargetType="local:UnsecurePasswodBox">
  <Setter Property="BorderBrush"
          Value="{x:Static SystemColors.ActiveBorderBrush}" />
  <Setter Property="BorderThickness"
          Value="1" />
  <Setter Property="Background"
          Value="White" />
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="local:UnsecurePasswodBox">
        <Border Background="{TemplateBinding Background}"
                BorderBrush="{TemplateBinding BorderBrush}"
                BorderThickness="{TemplateBinding BorderThickness}">
          <Grid>
            <Grid.ColumnDefinitions>
              <ColumnDefinition />
              <ColumnDefinition Width="Auto" />
            </Grid.ColumnDefinitions>

            <AdornerDecorator Grid.Column="0">
              <ScrollViewer x:Name="PART_ContentHost" />
            </AdornerDecorator>
            <ToggleButton Grid.Column="1"
                          IsChecked="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=IsShowPasswordEnabled}"
                          Background="Transparent"
                          VerticalContentAlignment="Center"
                          Padding="4,0">
              <ToggleButton.Content>
                <TextBlock Text="&#xE890;"
                            FontFamily="Segoe MDL2 Assets" />
              </ToggleButton.Content>
              <ToggleButton.Template>
                <ControlTemplate TargetType="ToggleButton">
                  <ContentPresenter Margin="{TemplateBinding Padding}"
                                    VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
                                    HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" />
                </ControlTemplate>
              </ToggleButton.Template>
            </ToggleButton>
          </Grid>
        </Border>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>
1
Gilad Waisel On

I suggest using a UserControl that is using the original PasswordBox and an alternate TextBox, controlled by a ToggleButton. The user control XAML:

<UserControl x:Class="Problem300324A.DualPasswordBox"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:Problem300324A"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800" Name="Parent">
    <UserControl.Resources>
        <BooleanToVisibilityConverter x:Key="BolleanToVisibilityConverter" />
        <local:InvertBooleanToVisibilityConverter x:Key="InvertBooleanToVisibilityConverter"/>
        <local:ShowHideConverter x:Key="ShowHideConverter"/>
    </UserControl.Resources>
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <PasswordBox Name="password"
                     PasswordChanged="PasswordBox_PasswordChanged" 
                     Visibility="{Binding ElementName=Parent,Path=ShowPassword,Converter={StaticResource InvertBooleanToVisibilityConverter}}"
                    />
        
        <TextBox Name="text"  Grid.Column="0"
                 TextChanged="text_TextChanged"
                 Text="{Binding ElementName=Parent,Path=Password}" 
                 Visibility="{Binding ElementName=Parent,Path=ShowPassword,Converter={StaticResource BolleanToVisibilityConverter}}" 
                
                 />
        <ToggleButton Name="toggle" Grid.Column="1" 
                      IsChecked="{Binding ElementName=Parent,Path=ShowPassword,Mode=TwoWay}" Width="40"
                      Content="{Binding ElementName=Parent,Path=ShowPassword,Converter={StaticResource ShowHideConverter}}" 
                      />
    </Grid>
</UserControl>

The Code Behind :

public partial class DualPasswordBox : UserControl
    {
        public DualPasswordBox()
        {
            InitializeComponent();
        }
        public static readonly DependencyProperty ShowPasswordProperty =
         DependencyProperty.Register("ShowPassword", typeof(bool), typeof(DualPasswordBox));

        public bool ShowPassword
        {
            get { return (bool)GetValue(ShowPasswordProperty); }
            set { SetValue(ShowPasswordProperty, value); }
            
        }
        private void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e)
        {
            if (text.Text != password.Password)
            {
                text.Text = password.Password;
                text.CaretIndex = text.Text.Length;
            }
            
        }
        private void text_TextChanged(object sender, TextChangedEventArgs e)
        {
            if (text.Text != password.Password)
            {
                password.Password = text.Text;
                password
                    .GetType().
                    GetMethod("Select", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(password, new object[] { text.Text.Length, 0 });


            }
        }
    }

Using example :

<local:DualPasswordBox ShowPassword="True" Height="40" />

Naturally , the code is introducing security breach that has to be evaluated.