Skip to content

Commit

Permalink
[feature/password-policy] Password Policy support (#1325)
Browse files Browse the repository at this point in the history
* - SDK update
- ShareViewController: add "generate" button to the password field that uses the password policy's generator to generate a password
- BottomButtonBar: add new alternative button to allow for an alternative main action; fix layout bug

* - SDK update: latest password policy progress and bug fixes
- PasswordComposerViewController:
	- new view controller to compose passwords
	- interactive feedback based on OCPasswordPolicy
	- integration with password generator based on OCPasswordPolicy
	- ability to copy passwords to clipboard
	- ability to show/hide entered passwords
	- support for editing and creation of password strings
- ShareViewController:
	- replace UIAlert with PasswordComposerViewController for entering passwords
	- add Generate button to generate a password based on the currently applicable password policy
	- add new "Share" button for links (in addition to "Create") that invoke the share sheet to directly share a link (including password) to the clipboard or directly to other apps like Mail or Messages
- add missing localizations
- BottomButtonBar: include alternativeButton in .modalActionRunning auto-enable/disable
- SegmentViewItem / SegmentViewItemView:
	- add extension to easily create button segments
	- add support for UIImage rendering modes for .icon
- ThemeCollection: add CSS entry for proper PasswordComposerViewController cell background coloring in dark mode

* - add line to copy the password to the clipboard (accidentally left out from previous commit)

* - update SDK to address findings
- PasswordComposerViewController: add notification upon copying password to clipboard

* Address findings (1) (2) (3) in #1325:
- ios-sdk:
	- add basic support for OC10 password policies
	- fix password generator error due to "empty" password policies derived from capabilities (finding 2)
- ShareViewController:
	- support for the requirement to set a password by disabling buttons and adding a warning triangle if no password is set (finding 1)
	- support for expiration date constraints:
		- support for the requirement by disabling buttons and adding a warning triangle if no date is set
		- add support for maximum date
		- add support for pre-setting an expiration date
	- add a "Copy" button next to the password if the password is known (finding 3)
	- clean up button creation code, avoiding duplication

* - ShareViewController: enforce password requirement even if files_sharing.public.password.enforced in capabilities is false, but the respectively matching files_sharing.public.password.enforced_for is true

---------

Co-authored-by: felix-schwarz <[email protected]>
  • Loading branch information
felix-schwarz and felix-schwarz authored Apr 12, 2024
1 parent f387e8b commit f0399b0
Show file tree
Hide file tree
Showing 10 changed files with 632 additions and 55 deletions.
12 changes: 12 additions & 0 deletions ownCloud.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@
DCC5E4472326564F002E5B84 /* NSObject+AnnotatedProperties.h in Headers */ = {isa = PBXBuildFile; fileRef = DCC5E444232654DE002E5B84 /* NSObject+AnnotatedProperties.h */; settings = {ATTRIBUTES = (Public, ); }; };
DCC6564A20C9B7E400110A97 /* FileProviderExtension.m in Sources */ = {isa = PBXBuildFile; fileRef = DCC6564920C9B7E400110A97 /* FileProviderExtension.m */; };
DCC6566520C9B7E400110A97 /* ownCloud File Provider.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DCC6564620C9B7E300110A97 /* ownCloud File Provider.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DCC73F2E2B86BC960009A210 /* PasswordComposerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC73F2D2B86BC960009A210 /* PasswordComposerViewController.swift */; };
DCC832DE242C0C3700153F8C /* DisplaySleepPreventer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC832DD242C0C3700153F8C /* DisplaySleepPreventer.swift */; };
DCC832F1242CC27B00153F8C /* NotificationMessagePresenter.h in Headers */ = {isa = PBXBuildFile; fileRef = DCC832E6242CB18700153F8C /* NotificationMessagePresenter.h */; settings = {ATTRIBUTES = (Public, ); }; };
DCC832F2242CC28400153F8C /* NotificationMessagePresenter.m in Sources */ = {isa = PBXBuildFile; fileRef = DCC832E7242CB18700153F8C /* NotificationMessagePresenter.m */; };
Expand Down Expand Up @@ -1576,6 +1577,7 @@
DCC6564920C9B7E400110A97 /* FileProviderExtension.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FileProviderExtension.m; sourceTree = "<group>"; };
DCC6565120C9B7E400110A97 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DCC6565220C9B7E400110A97 /* ownCloud_File_Provider.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ownCloud_File_Provider.entitlements; sourceTree = "<group>"; };
DCC73F2D2B86BC960009A210 /* PasswordComposerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordComposerViewController.swift; sourceTree = "<group>"; };
DCC832DD242C0C3700153F8C /* DisplaySleepPreventer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySleepPreventer.swift; sourceTree = "<group>"; };
DCC832E1242C0EAC00153F8C /* MessageSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSelector.swift; sourceTree = "<group>"; };
DCC832E6242CB18700153F8C /* NotificationMessagePresenter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NotificationMessagePresenter.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2002,6 +2004,7 @@
DCE4E43424C199860051722F /* Actions */,
DC16213C2B8FF02500EB17F8 /* Sidebar Items */,
399EA6ED25E6544000B6FF11 /* Sharing */,
DCC73F2C2B86BC170009A210 /* Password Composer */,
DCE4E42F24C1963F0051722F /* User Interface */,
);
path = Client;
Expand Down Expand Up @@ -3252,6 +3255,14 @@
path = "ownCloud File Provider";
sourceTree = "<group>";
};
DCC73F2C2B86BC170009A210 /* Password Composer */ = {
isa = PBXGroup;
children = (
DCC73F2D2B86BC960009A210 /* PasswordComposerViewController.swift */,
);
path = "Password Composer";
sourceTree = "<group>";
};
DCC832D1242BB3E900153F8C /* Messages */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -4872,6 +4883,7 @@
DCA2EDE4279B1789001F04E6 /* ResourceItemIcon.swift in Sources */,
DC8E99E8297F3BA700594697 /* ActionTapGestureRecognizer.swift in Sources */,
DCB5D56B2861BEBE004AF425 /* SearchViewController.swift in Sources */,
DCC73F2E2B86BC960009A210 /* PasswordComposerViewController.swift in Sources */,
DCB1B8A729C75EBD00BFF393 /* ThemeCSS+AutoSelectors.swift in Sources */,
0287DD7D249131E000C912CA /* AppStatistics.swift in Sources */,
DCB1B8A429C73DB800BFF393 /* ThemeCSSRecord.swift in Sources */,
Expand Down
8 changes: 8 additions & 0 deletions ownCloud/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -627,9 +627,17 @@

"Share with" = "Teilen mit";
"Add" = "Hinzufügen";
"Set" = "Setzen";
"Generate" = "Generieren";
"Save changes" = "Änderung speichern";

"Enter password" = "Passwort eingeben";
"Change password" = "Passwort ändern";
"Show" = "Zeigen";
"Hide" = "Verbergen";

"{{itemName}} ({{link}}) | password: {{password}}" = "{{itemName}} ({{link}}) | Passwort: {{password}}";
"{{link}} | password: {{password}}" = "{{link}} | Passwort: {{password}}";

/* Quick Access view */
"Quick Access" = "Schnellzugriff";
Expand Down
11 changes: 9 additions & 2 deletions ownCloud/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@
"Shared with {{recipients}}" = "Shared with {{recipients}}";
"Expires {{expirationDate}}" = "Expires {{expirationDate}}";
"Share {{itemName}}" = "Share {{itemName}}";
"Create link" = "Create link";
"Create" = "Create";
"Invite" = "Invite";
"Invite Recipient" = "Invite Recipient";
"Recipients" = "Recipients";
Expand All @@ -556,7 +556,6 @@
"Shared with" = "Shared with";
"Remove Recipient failed" = "Remove Recipient failed";
"Remove Recipient" = "Remove Recipient";
"Create" = "Create";
"Change" = "Change";
"Recipients can view or download contents." = "Recipients can view or download contents.";
"Recipients can view, download, edit, delete and upload contents." = "Recipients can view, download, edit, delete and upload contents.";
Expand Down Expand Up @@ -627,9 +626,17 @@

"Share with" = "Share with";
"Add" = "Add";
"Set" = "Set";
"Generate" = "Generate";
"Save changes" = "Save changes";

"Enter password" = "Enter password";
"Change password" = "Change password";
"Show" = "Show";
"Hide" = "Hide";

"{{itemName}} ({{link}}) | password: {{password}}" = "{{itemName}} ({{link}}) | password: {{password}}";
"{{link}} | password: {{password}}" = "{{link}} | password: {{password}}";

/* Quick Access search suggestions */
"Quick Access" = "Quick Access";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
//
// PasswordComposerViewController.swift
// ownCloudAppShared
//
// Created by Felix Schwarz on 23.02.24.
// Copyright © 2024 ownCloud GmbH. All rights reserved.
//

/*
* Copyright (C) 2024, ownCloud GmbH.
*
* This code is covered by the GNU Public License Version 3.
*
* For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/
* You should have received a copy of this license along with this program. If not, see <http://www.gnu.org/licenses/gpl-3.0.en.html>.
*
*/

import UIKit
import ownCloudSDK

class PasswordComposerViewController: UIViewController {
typealias ResultHandler = (_ password: String?, _ cancelled: Bool) -> Void

var resultHandler: ResultHandler?

let passwordLabel = ThemeCSSLabel(withSelectors: [ .label, .secondary ])
let passwordFieldContainer = ThemeCSSView(withSelectors: [ .cell ])
let passwordField = ThemeCSSTextField()

let componentToolbar = SegmentView(with: [], truncationMode: .none, scrollable: false)

let validationReportContainerView = ThemeCSSView(withSelectors: [ .cell ])

lazy var showPasswordSegment: SegmentViewItem = {
return SegmentViewItem.button(title: "Show".localized, customizeButton: { _, config in
var buttonConfig = config
buttonConfig.image = OCSymbol.icon(forSymbolName: "eye")?.applyingSymbolConfiguration(UIImage.SymbolConfiguration(scale: .small))
buttonConfig.imagePadding = 5
return buttonConfig
}, action: UIAction(handler: { [weak self] _ in
self?.showPassword = true
}))
}()
lazy var hidePasswordSegment: SegmentViewItem = {
return SegmentViewItem.button(title: "Hide".localized, customizeButton: { _, config in
var buttonConfig = config
buttonConfig.image = OCSymbol.icon(forSymbolName: "eye.slash")?.applyingSymbolConfiguration(UIImage.SymbolConfiguration(scale: .small))
buttonConfig.imagePadding = 5
return buttonConfig
}, action: UIAction(handler: { [weak self] _ in
self?.showPassword = false
}))
}()
lazy var generatePasswordSegment: SegmentViewItem = {
return SegmentViewItem.button(title: "Generate".localized, action: UIAction(handler: { [weak self] _ in
self?.generatePassword()
}))
}()
lazy var copyPasswordSegment: SegmentViewItem = {
return SegmentViewItem.button(title: "Copy".localized, action: UIAction(handler: { [weak self] _ in
self?.copyToClipboard()
}))
}()

var saveButton: UIBarButtonItem?

var passwordPolicy: OCPasswordPolicy

init(password: String, policy: OCPasswordPolicy, saveButtonTitle: String, resultHandler: @escaping ResultHandler) {
self.passwordPolicy = policy

super.init(nibName: nil, bundle: nil)

defer {
// Placing this in a defer block makes sure that didSet is called for the respective properties
self.password = password
self.showPassword = false
}

self.resultHandler = resultHandler

saveButton = UIBarButtonItem(title: saveButtonTitle, style: .done, target: self, action: #selector(save))

navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Cancel".localized, style: .plain, target: self, action: #selector(cancel))
navigationItem.rightBarButtonItem = saveButton
navigationItem.title = "Password".localized
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func loadView() {
let rootView = ThemeCSSView(withSelectors: [ .grouped, .collection ])
let padding = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
let labelFieldSpacing: CGFloat = 10
let fieldToolbarSpacing: CGFloat = 15
let toolbarValidationReportSpacing: CGFloat = 15

passwordLabel.translatesAutoresizingMaskIntoConstraints = false
passwordFieldContainer.translatesAutoresizingMaskIntoConstraints = false
passwordField.translatesAutoresizingMaskIntoConstraints = false
componentToolbar.translatesAutoresizingMaskIntoConstraints = false
componentToolbar.setContentHuggingPriority(.defaultHigh, for: .horizontal)
validationReportContainerView.translatesAutoresizingMaskIntoConstraints = false

passwordFieldContainer.layer.cornerRadius = 5
validationReportContainerView.layer.cornerRadius = 10

passwordField.cssSelectors = [ .cell ]
passwordFieldContainer.embed(toFillWith: passwordField, insets: NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10))

componentToolbar.insets = .zero
componentToolbar.itemSpacing = 0

rootView.addSubview(passwordLabel)
rootView.addSubview(passwordFieldContainer)
rootView.addSubview(componentToolbar)
rootView.addSubview(validationReportContainerView)

passwordLabel.text = "Password".localized
passwordLabel.font = UIFont.systemFont(ofSize: UIFont.smallSystemFontSize)

passwordField.placeholder = "Password".localized
passwordField.clearButtonMode = .always
passwordField.addAction(UIAction(handler: { [weak self] _ in
self?.passwordChanged()
}), for: .editingChanged)

rootView.addConstraints([
passwordLabel.topAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.topAnchor, constant: padding.top),
passwordLabel.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left),
passwordLabel.trailingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right),

passwordFieldContainer.topAnchor.constraint(equalTo: passwordLabel.bottomAnchor, constant: labelFieldSpacing),
passwordFieldContainer.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left),
passwordFieldContainer.trailingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right),

componentToolbar.topAnchor.constraint(equalTo: passwordField.bottomAnchor, constant: fieldToolbarSpacing),
componentToolbar.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left - 5),
componentToolbar.trailingAnchor.constraint(lessThanOrEqualTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right),

validationReportContainerView.topAnchor.constraint(equalTo: componentToolbar.bottomAnchor, constant: toolbarValidationReportSpacing),
validationReportContainerView.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left),
validationReportContainerView.trailingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right)
])

view = rootView
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
validatePasssword()
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

passwordField.becomeFirstResponder()
}

func passwordChanged() {
password = passwordField.text ?? ""
}

func validatePasssword() {
let report = passwordPolicy.validate(password)
var lines : [UIView] = []
var failures: Int = 0

for rule in report.rules {
var ruleDescription: String? = rule.localizedDescription

if !(rule is OCPasswordPolicyRuleCharacters), let result = report.result(for: rule) {
ruleDescription = result
}

if let ruleDescription {
let passedValidation = report.passedValidation(for: rule)
let symbolConfiguration = UIImage.SymbolConfiguration(hierarchicalColor: passedValidation ? .systemGreen : .systemRed)
let line = SegmentView(with: [
SegmentViewItem(with: UIImage(systemName: passedValidation ? "checkmark.circle.fill" : "xmark.circle.fill")?.withConfiguration(symbolConfiguration), iconRenderingMode: .automatic, title: ruleDescription)
], truncationMode: .truncateTail)
line.translatesAutoresizingMaskIntoConstraints = false
line.insets = .zero

if passedValidation {
lines.append(line)
} else {
lines.insert(line, at: failures)
failures += 1
}
}
}

for subview in validationReportContainerView.subviews {
subview.removeFromSuperview()
}

validationReportContainerView.embedVertically(views: lines, insets: NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), enclosingAnchors: validationReportContainerView.safeAreaAnchorSet, centered: false)

saveButton?.isEnabled = report.passedValidation
}

func updateSegments() {
var items: [SegmentViewItem] = []

// Show/Hide password
if showPassword {
items.append(hidePasswordSegment)
} else {
items.append(showPasswordSegment)
}

// Generate password
items.append(SegmentViewItem(title: "|", style: .label))
items.append(generatePasswordSegment)

// Copy password
if password.count > 0 {
items.append(SegmentViewItem(title: "|", style: .label))
items.append(copyPasswordSegment)
}

if componentToolbar.items != items {
componentToolbar.items = items
}
}

var password: String {
get {
return passwordField.text ?? ""
}

set {
passwordField.text = newValue

updateSegments()
validatePasssword()
}
}
var showPassword: Bool = false {
didSet {
passwordField.isSecureTextEntry = !showPassword
updateSegments()
}
}

func generatePassword() {
var generatedPassword: String?
do {
try generatedPassword = passwordPolicy.generatePassword(withMinLength: nil, maxLength: nil)
} catch let error as NSError {
Log.error("Error generating password: \(error)")
}
if let generatedPassword {
password = generatedPassword
}
}

func copyToClipboard() {
UIPasteboard.general.string = password

_ = NotificationHUDViewController(on: self, title: "Password".localized, subtitle: "The password was copied to the clipboard".localized, completion: nil)
}

func viewControllerForPresentation() -> ThemeNavigationController {
let navigationViewController = ThemeNavigationController(rootViewController: self)
navigationViewController.cssSelectors = [ .modal ]

return navigationViewController
}

@objc func save() {
presentingViewController?.dismiss(animated: true, completion: {
self.resultHandler?(self.password, false)
})
}

@objc func cancel() {
presentingViewController?.dismiss(animated: true, completion: {
self.resultHandler?(nil, true)
})
}
}
Loading

0 comments on commit f0399b0

Please sign in to comment.