Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feature/password-policy] Password Policy support #1325

Merged
merged 13 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading