Browse Source

improved password field: added caps lock indicator (see #458) and a warning + tooltip if control characters are found in the password (fixes #841)

Sebastian Stenzel 6 năm trước cách đây
mục cha
commit
d62edcda73

+ 96 - 2
main/ui/src/main/java/org/cryptomator/ui/controls/SecPasswordField.java

@@ -9,11 +9,21 @@
 package org.cryptomator.ui.controls;
 
 import com.google.common.base.Strings;
+import javafx.beans.Observable;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.geometry.Pos;
+import javafx.scene.control.Label;
+import javafx.scene.control.OverrunStyle;
 import javafx.scene.control.PasswordField;
+import javafx.scene.control.Tooltip;
 import javafx.scene.input.DragEvent;
 import javafx.scene.input.Dragboard;
+import javafx.scene.input.KeyCode;
+import javafx.scene.input.KeyEvent;
 import javafx.scene.input.TransferMode;
 
+import java.awt.Toolkit;
 import java.nio.CharBuffer;
 import java.text.Normalizer;
 import java.text.Normalizer.Form;
@@ -31,12 +41,32 @@ public class SecPasswordField extends PasswordField {
 	private static final int GROW_BUFFER_SIZE = 50;
 	private static final String PLACEHOLDER = "*";
 
+	private final Tooltip tooltip = new Tooltip();
+	private final Label indicator = new Label();
+	private final StringProperty nonPrintableCharsWarning = new SimpleStringProperty();
+	private final StringProperty capslockWarning = new SimpleStringProperty();
+
 	private char[] content = new char[INITIAL_BUFFER_SIZE];
 	private int length = 0;
 
 	public SecPasswordField() {
-		this.onDragOverProperty().set(this::handleDragOver);
-		this.onDragDroppedProperty().set(this::handleDragDropped);
+		indicator.setAlignment(Pos.CENTER_RIGHT);
+		indicator.setMouseTransparent(true);
+		indicator.setTextOverrun(OverrunStyle.CLIP);
+		this.getChildren().add(indicator);
+		this.setTooltip(tooltip);
+		this.addEventHandler(DragEvent.DRAG_OVER, this::handleDragOver);
+		this.addEventHandler(DragEvent.DRAG_DROPPED, this::handleDragDropped);
+		this.addEventHandler(KeyEvent.ANY, this::handleKeyEvent);
+		this.focusedProperty().addListener(this::focusedChanged);
+	}
+
+	@Override
+	protected void layoutChildren() {
+		super.layoutChildren();
+		indicator.resize(50.0, getHeight());
+		indicator.relocate(getWidth() - indicator.getWidth(), 0);
+		indicator.layout();
 	}
 
 	private void handleDragOver(DragEvent event) {
@@ -55,6 +85,50 @@ public class SecPasswordField extends PasswordField {
 		event.consume();
 	}
 
+	private void handleKeyEvent(KeyEvent e) {
+		if (e.getCode() == KeyCode.CAPS) {
+			updateVisualHints(true);
+		}
+	}
+
+	private void focusedChanged(@SuppressWarnings("unused") Observable observable) {
+		updateVisualHints(isFocused());
+	}
+
+	private void updateVisualHints(boolean focused) {
+		StringBuilder tooltipSb = new StringBuilder();
+		StringBuilder indicatorSb = new StringBuilder();
+		if (containsNonPrintableCharacters()) {
+			indicatorSb.append('⚠');
+			tooltipSb.append(nonPrintableCharsWarning.get()).append('\n');
+		}
+		// AWT code needed until https://bugs.openjdk.java.net/browse/JDK-8090882 is closed:
+		if (focused && Toolkit.getDefaultToolkit().getLockingKeyState(java.awt.event.KeyEvent.VK_CAPS_LOCK)) {
+			indicatorSb.append('⇪');
+			tooltipSb.append(capslockWarning.get()).append('\n');
+		}
+		indicator.setText(indicatorSb.toString());
+		tooltip.setText(tooltipSb.toString());
+		if (tooltip.getText().isEmpty()) {
+			setTooltip(null);
+		} else {
+			setTooltip(tooltip);
+		}
+	}
+
+	/**
+	 * @return <code>true</code> if any {@link Character#isISOControl(char) control character} is present in the current value of this password field.
+	 * @implNote runs in O(n)
+	 */
+	boolean containsNonPrintableCharacters() {
+		for (int i = 0; i < length; i++) {
+			if (Character.isISOControl(content[i])) {
+				return true;
+			}
+		}
+		return false;
+	}
+
 	/**
 	 * Replaces a range of characters with the given text.
 	 * The text will be normalized to <a href="https://www.unicode.org/glossary/#normalization_form_c">NFC</a>.
@@ -86,6 +160,8 @@ public class SecPasswordField extends PasswordField {
 		// copy new text to content buffer
 		normalizedText.getChars(0, normalizedText.length(), content, start);
 
+		// trigger visual hints
+		updateVisualHints(true);
 		String placeholderString = Strings.repeat(PLACEHOLDER, normalizedText.length());
 		super.replaceText(start, end, placeholderString);
 	}
@@ -156,4 +232,22 @@ public class SecPasswordField extends PasswordField {
 		Arrays.fill(buffer, SWIPE_CHAR);
 	}
 
+	/* Getter/Setter */
+
+	public void setNonPrintableCharsWarning(String value) {
+		nonPrintableCharsWarning.set(value);
+	}
+
+	public String getNonPrintableCharsWarning() {
+		return nonPrintableCharsWarning.get();
+	}
+
+	public void setCapslockWarning(String value) {
+		capslockWarning.set(value);
+	}
+
+	public String getCapslockWarning() {
+		return capslockWarning.get();
+	}
+
 }

+ 3 - 3
main/ui/src/main/resources/fxml/change_password.fxml

@@ -38,15 +38,15 @@
 	<children>
 		<!-- Row 0 -->
 		<Label text="%changePassword.label.oldPassword" GridPane.rowIndex="0" GridPane.columnIndex="0" cacheShape="true" cache="true" />
-		<SecPasswordField fx:id="oldPasswordField" GridPane.rowIndex="0" GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
+		<SecPasswordField fx:id="oldPasswordField" capslockWarning="%ctrl.secPasswordField.capsLocked" nonPrintableCharsWarning="%ctrl.secPasswordField.nonPrintableChars" GridPane.rowIndex="0" GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
 		
 		<!-- Row 1 -->
 		<Label text="%changePassword.label.newPassword" GridPane.rowIndex="1" GridPane.columnIndex="0" cacheShape="true" cache="true" />
-		<SecPasswordField fx:id="newPasswordField" GridPane.rowIndex="1" GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
+		<SecPasswordField fx:id="newPasswordField" capslockWarning="%ctrl.secPasswordField.capsLocked" nonPrintableCharsWarning="%ctrl.secPasswordField.nonPrintableChars" GridPane.rowIndex="1" GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
 		
 		<!-- Row 2 -->
 		<Label text="%changePassword.label.retypePassword" GridPane.rowIndex="2" GridPane.columnIndex="0" cacheShape="true" cache="true" />
-		<SecPasswordField fx:id="retypePasswordField" GridPane.rowIndex="2" GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
+		<SecPasswordField fx:id="retypePasswordField" capslockWarning="%ctrl.secPasswordField.capsLocked" nonPrintableCharsWarning="%ctrl.secPasswordField.nonPrintableChars" GridPane.rowIndex="2" GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
 		
 		<!-- Row 3 -->
 		<VBox GridPane.columnIndex="1" GridPane.rowIndex="3" spacing="6.0">

+ 2 - 2
main/ui/src/main/resources/fxml/initialize.fxml

@@ -36,11 +36,11 @@
 	<children>
 		<!-- Row 0 -->
 		<Label GridPane.rowIndex="0" GridPane.columnIndex="0" text="%initialize.label.password" cacheShape="true" cache="true" />
-		<SecPasswordField fx:id="passwordField" GridPane.rowIndex="0" GridPane.columnIndex="1" cacheShape="true" cache="true" />
+		<SecPasswordField fx:id="passwordField" capslockWarning="%ctrl.secPasswordField.capsLocked" nonPrintableCharsWarning="%ctrl.secPasswordField.nonPrintableChars" GridPane.rowIndex="0" GridPane.columnIndex="1" cacheShape="true" cache="true" />
 
 		<!-- Row 1 -->
 		<Label GridPane.rowIndex="1" GridPane.columnIndex="0" text="%initialize.label.retypePassword" cacheShape="true" cache="true" />
-		<SecPasswordField fx:id="retypePasswordField" GridPane.rowIndex="1" GridPane.columnIndex="1" cacheShape="true" cache="true" />
+		<SecPasswordField fx:id="retypePasswordField" capslockWarning="%ctrl.secPasswordField.capsLocked" nonPrintableCharsWarning="%ctrl.secPasswordField.nonPrintableChars" GridPane.rowIndex="1" GridPane.columnIndex="1" cacheShape="true" cache="true" />
 		
 		<!-- Row 2 -->
 		<VBox GridPane.columnIndex="1" GridPane.rowIndex="2" spacing="6.0">

+ 1 - 1
main/ui/src/main/resources/fxml/unlock.fxml

@@ -34,7 +34,7 @@
 	<children>
 		<!-- Row 0 -->
 		<Label text="%unlock.label.password" GridPane.rowIndex="0" GridPane.columnIndex="0" cacheShape="true" cache="true" />
-		<SecPasswordField fx:id="passwordField" GridPane.rowIndex="0" GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
+		<SecPasswordField fx:id="passwordField" capslockWarning="%ctrl.secPasswordField.capsLocked" nonPrintableCharsWarning="%ctrl.secPasswordField.nonPrintableChars" GridPane.rowIndex="0" GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
 
 		<!-- Row 1 -->
 		<HBox GridPane.rowIndex="2" GridPane.columnIndex="0" GridPane.columnSpan="2" spacing="12.0" alignment="CENTER_RIGHT" cacheShape="true" cache="true">

+ 3 - 0
main/ui/src/main/resources/localization/en.txt

@@ -7,6 +7,9 @@
 
 app.name=Cryptomator
 
+ctrl.secPasswordField.nonPrintableChars=Password contains control characters. Recommendation: Remove them to ensure compatibility with other clients.
+ctrl.secPasswordField.capsLocked=Caps Lock is activated.
+
 # main.fxml
 main.emptyListInstructions=Click here to add a vault
 main.directoryList.contextMenu.remove=Remove from List

+ 14 - 1
main/ui/src/test/java/org/cryptomator/ui/controls/SecPasswordFieldTest.java

@@ -152,7 +152,7 @@ class SecPasswordFieldTest {
 
 	@Test
 	@DisplayName("test swipe char[]")
-	public void swipe() {
+	public void testSwipe() {
 		pwField.appendText("topSecret");
 
 		CharSequence result1 = pwField.getCharacters();
@@ -163,4 +163,17 @@ class SecPasswordFieldTest {
 		Assertions.assertEquals("", result2.toString());
 	}
 
+	@Test
+	@DisplayName("test control characters")
+	public void testControlCharacters() {
+		pwField.appendText("normal");
+		Assertions.assertFalse(pwField.containsNonPrintableCharacters());
+
+		pwField.appendText("\00\01\02");
+		Assertions.assertTrue(pwField.containsNonPrintableCharacters());
+
+		CharSequence result1 = pwField.getCharacters();
+		Assertions.assertEquals("normal\00\01\02", result1.toString());
+	}
+
 }