diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index 5727d988d..b01125a78 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -88,3 +88,13 @@ "leave_space_selection_title" = "SELECT ROOMS"; "leave_space_selection_all_rooms" = "Select all rooms"; "leave_space_selection_no_rooms" = "Select no rooms"; + +// MARK: Password Validation +"password_validation_info_header" = "Your password should meet the criteria below:"; +"password_validation_error_header" = "Given password does not meet the criteria below:"; +"password_validation_error_min_length" = "At least %d characters."; +"password_validation_error_max_length" = "Not exceed %d characters."; +"password_validation_error_contain_lowercase_letter" = "Contain a lower-case letter."; +"password_validation_error_contain_uppercase_letter" = "Contain an upper-case letter."; +"password_validation_error_contain_number" = "Contain a number."; +"password_validation_error_contain_symbol" = "Contain a symbol."; diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index 7d0c6ff7c..b2aa475a5 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -226,6 +226,38 @@ public extension VectorL10n { static var leaveSpaceSelectionTitle: String { return VectorL10n.tr("Untranslated", "leave_space_selection_title") } + /// Contain a lower-case letter. + static var passwordValidationErrorContainLowercaseLetter: String { + return VectorL10n.tr("Untranslated", "password_validation_error_contain_lowercase_letter") + } + /// Contain a number. + static var passwordValidationErrorContainNumber: String { + return VectorL10n.tr("Untranslated", "password_validation_error_contain_number") + } + /// Contain a symbol. + static var passwordValidationErrorContainSymbol: String { + return VectorL10n.tr("Untranslated", "password_validation_error_contain_symbol") + } + /// Contain an upper-case letter. + static var passwordValidationErrorContainUppercaseLetter: String { + return VectorL10n.tr("Untranslated", "password_validation_error_contain_uppercase_letter") + } + /// Given password does not meet the criteria below: + static var passwordValidationErrorHeader: String { + return VectorL10n.tr("Untranslated", "password_validation_error_header") + } + /// Not exceed %d characters. + static func passwordValidationErrorMaxLength(_ p1: Int) -> String { + return VectorL10n.tr("Untranslated", "password_validation_error_max_length", p1) + } + /// At least %d characters. + static func passwordValidationErrorMinLength(_ p1: Int) -> String { + return VectorL10n.tr("Untranslated", "password_validation_error_min_length", p1) + } + /// Your password should meet the criteria below: + static var passwordValidationInfoHeader: String { + return VectorL10n.tr("Untranslated", "password_validation_info_header") + } /// This feature isn't available here. For now, you can do this with %@ on your computer. static func spacesFeatureNotAvailable(_ p1: String) -> String { return VectorL10n.tr("Untranslated", "spaces_feature_not_available", p1) diff --git a/Riot/Utils/PasswordValidator.swift b/Riot/Utils/PasswordValidator.swift new file mode 100644 index 000000000..59c6e15d4 --- /dev/null +++ b/Riot/Utils/PasswordValidator.swift @@ -0,0 +1,118 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct PasswordValidatorError: LocalizedError { + /// Unmet rules + let unmetRules: [PasswordValidatorRule] + + /// Error description for the error + var errorDescription: String? { + var result = VectorL10n.passwordValidationErrorHeader + "\n" + result += unmetRules.map { $0.descriptionInList }.joined(separator: "\n") + return result + } +} + +/// Validation rule for a password +enum PasswordValidatorRule: CustomStringConvertible, Hashable { + case minLength(_ value: Int) + case maxLength(_ value: Int) + case containLowercaseLetter + case containUppercaseLetter + case containNumber + case containSymbol + + var description: String { + switch self { + case .minLength(let value): + return VectorL10n.passwordValidationErrorMinLength(value) + case .maxLength(let value): + return VectorL10n.passwordValidationErrorMaxLength(value) + case .containLowercaseLetter: + return VectorL10n.passwordValidationErrorContainLowercaseLetter + case .containUppercaseLetter: + return VectorL10n.passwordValidationErrorContainUppercaseLetter + case .containNumber: + return VectorL10n.passwordValidationErrorContainNumber + case .containSymbol: + return VectorL10n.passwordValidationErrorContainSymbol + } + } + + var descriptionInList: String { + return "• " + description + } + + func metBy(password: String) -> Bool { + switch self { + case .minLength(let value): + return password.count >= value + case .maxLength(let value): + return password.count <= value + case .containLowercaseLetter: + return password.range(of: "[a-z]", options: .regularExpression) != nil + case .containUppercaseLetter: + return password.range(of: "[A-Z]", options: .regularExpression) != nil + case .containNumber: + return password.range(of: "[0-9]", options: .regularExpression) != nil + case .containSymbol: + return password.range(of: "[!\"#$%&'()*+,-.:;<=>?@\\_`{|}~\\[\\]]", + options: .regularExpression) != nil + } + } +} + +/// A utility class to validate a password against some rules. +class PasswordValidator { + + /// Validation rules + let rules: [PasswordValidatorRule] + + /// Initializer + /// - Parameter rules: validation rules + init(withRules rules: [PasswordValidatorRule]) { + self.rules = rules + } + + /// Validate a given password. + /// - Parameter password: Password to be validated + func validate(password: String) throws { + var unmetRules: [PasswordValidatorRule] = [] + for rule in rules { + if !rule.metBy(password: password) { + unmetRules.append(rule) + } + } + if !unmetRules.isEmpty { + throw PasswordValidatorError(unmetRules: unmetRules) + } + } + + /// Creates a description text with current rules + /// - Parameter header: Header text to include in the result + /// - Returns: Description text containing `header` and rules + func description(with header: String) -> String { + var result = header + if !rules.isEmpty { + result += "\n" + } + result += rules.map { $0.descriptionInList }.joined(separator: "\n") + return result + } + +} diff --git a/RiotTests/PasswordValidatorTests.swift b/RiotTests/PasswordValidatorTests.swift new file mode 100644 index 000000000..f48af6e2a --- /dev/null +++ b/RiotTests/PasswordValidatorTests.swift @@ -0,0 +1,98 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Riot + +class PasswordValidatorTests: XCTestCase { + + func testOnlyLength() throws { + let minLengthRule = PasswordValidatorRule.minLength(8) + let validator = PasswordValidator(withRules: [minLengthRule]) + + // this should pass + try validator.validate(password: "abcdefgh") + + do { + // this should fail + try validator.validate(password: "abcdefg") + XCTFail("Should not pass") + } catch let error as PasswordValidatorError { + XCTAssertEqual(error.unmetRules.count, 1) + XCTAssertEqual(error.unmetRules.first, minLengthRule) + } + } + + func testComplexWithMinimumRequirements() throws { + let validator = PasswordValidator(withRules: [ + .minLength(4), + .maxLength(4), + .containUppercaseLetter, + .containLowercaseLetter, + .containNumber, + .containSymbol + ]) + + // this should pass + try validator.validate(password: "Ab1!") + + do { + // this should fail with only maxLength rule + try validator.validate(password: "Ab1!E") + XCTFail("Should fail with only maxLength rule") + } catch let error as PasswordValidatorError { + XCTAssertEqual(error.unmetRules.count, 1) + XCTAssertEqual(error.unmetRules.first, .maxLength(4)) + } + + do { + // this should fail with only uppercase rule + try validator.validate(password: "ab1!") + XCTFail("Should fail with only uppercase rule") + } catch let error as PasswordValidatorError { + XCTAssertEqual(error.unmetRules.count, 1) + XCTAssertEqual(error.unmetRules.first, .containUppercaseLetter) + } + + do { + // this should fail with only lowercase rule + try validator.validate(password: "AB1!") + XCTFail("Should fail with only lowercase rule") + } catch let error as PasswordValidatorError { + XCTAssertEqual(error.unmetRules.count, 1) + XCTAssertEqual(error.unmetRules.first, .containLowercaseLetter) + } + + do { + // this should fail with only number rule + try validator.validate(password: "Abc!") + XCTFail("Should fail with only number rule") + } catch let error as PasswordValidatorError { + XCTAssertEqual(error.unmetRules.count, 1) + XCTAssertEqual(error.unmetRules.first, .containNumber) + } + + do { + // this should fail with only symbol rule + try validator.validate(password: "Abc1") + XCTFail("Should fail with only symbol rule") + } catch let error as PasswordValidatorError { + XCTAssertEqual(error.unmetRules.count, 1) + XCTAssertEqual(error.unmetRules.first, .containSymbol) + } + } + +}