1. Overview
Implementing password validation mechanisms to ensure that users create strong and secure passwords is a common task.
In this tutorial, we’ll explore how to implement a flexible, extensible, and robust password validation in Kotlin.
2. Introduction to the Problem
A strong password may typically require a minimum length, including a mix of uppercase and lowercase letters, numbers, special characters, etc.
At first glance, password validation looks like an easy problem. Some of us may come up with a regex-based solution like this one:
val pattern = "... A sophisticated Regex Pattern ... ".toRegex()
if (password.length < minLength || !password.matches(pattern)) {
... // invalid password
}
This may work. However, we must modify the regex pattern whenever the password requirement changes.
So, our goal is to build an idiomatic, flexible, extensible, yet effective password validation mechanism in Kotlin.
Let’s take the following rules as an example in this tutorial:
const val ERR_LEN = "Password must have at least eight characters!"
const val ERR_WHITESPACE = "Password must not contain whitespace!"
const val ERR_DIGIT = "Password must contain at least one digit!"
const val ERR_UPPER = "Password must have at least one uppercase letter!"
const val ERR_SPECIAL = "Password must have at least one special character, such as: _%-=+#@"
As we can see, we’ve defined these strings as Kotlin const string variables. This is because if a password breaks a rule, we want to include the corresponding rule text as a hint to the user.
Next, we’ll look at approaches to achieving our goal. As usual, for simplicity, we’ll use unit test assertions to verify whether each approach works as expected.
Later, we’ll use these inputs to test our approaches:
val tooShort = "a1A-"
val noDigit = "_+Addabc"
val withSpace = "abcd A#1#"
val noUpper = "1234abc#"
val noSpecial = "1abcdABCD"
val okPwd = "1234abc#ABC"
So next, let’s dive in.
3. Creating a Validation Function
We can check each rule separately to make the validation flexible and extensible. First, let’s create a validation function:
fun validatePassword(pwd: String) = runCatching {
require(pwd.length >= 8) { ERR_LEN }
require(pwd.none { it.isWhitespace() }) { ERR_WHITESPACE }
require(pwd.any { it.isDigit() }) { ERR_DIGIT }
require(pwd.any { it.isUpperCase() }) { ERR_UPPER }
require(pwd.any { !it.isLetterOrDigit() }) { ERR_SPECIAL }
}
As we can see, the validation function doesn’t contain a set of if-else blocks. Instead, we use the runCathcing() function to wrap a series of require() function calls.
The require() function validates input parameters and enforces specific conditions. Also, require() improves code reliability and maintainability. The require() function throws an IllegalArgumentException with the given message if the expectation isn’t met.
Also, the runCatching() function returns a Result object so that we can easily determine whether the validation isSuccess or isFailure. When validation fails, we know the exact cause from the wrapped IllegalArgumentException. We can then decide whether to throw an exception or apply further handling in the onFailure{..} call.
Next, let’s test the validatePassword() function with our inputs:
validatePassword(tooShort).apply {
assertTrue { isFailure }
assertEquals(ERR_LEN, exceptionOrNull()?.message)
}
validatePassword(noDigit).apply {
assertTrue { isFailure }
assertEquals(ERR_DIGIT, exceptionOrNull()?.message)
}
validatePassword(withSpace).apply {
assertTrue { isFailure }
assertEquals(ERR_WHITESPACE, exceptionOrNull()?.message)
}
validatePassword(noUpper).apply {
assertTrue { isFailure }
assertEquals(ERR_UPPER, exceptionOrNull()?.message)
}
validatePassword(noSpecial).apply {
assertTrue { isFailure }
assertEquals(ERR_SPECIAL, exceptionOrNull()?.message)
}
validatePassword(okPwd).apply {
assertTrue { isSuccess }
}
When we run the test, it passes.
If we must change the validation requirements, we only need to come to this function and adjust those require() calls. This is easier than adjusting a complex regex pattern for adapting new requirements.
Next, let’s move one step further and build a more general and flexible password validation solution.
4. Creating the Password Inline Class
The validatePassword() function wraps a series of require() calls in the runCatching() function. In this section, we’ll create a more flexible and extensible solution.
4.1. The String Extensions
Some may consider that if we break the function into multiple rule-check functions, we can flexibly combine various rule-check functions to perform password validation.
Further, **we can create those rule-check functions as String‘s extension functions**:
fun String.longerThan8() = require(length >= 8) { ERR_LEN }
fun String.withoutWhitespace() = require(none { it.isWhitespace() }) { ERR_WHITESPACE }
fun String.hasDigit() = require(any { it.isDigit() }) { ERR_DIGIT }
fun String.hasUppercase() = require(any { it.isUpperCase() }) { ERR_UPPER }
fun String.hasSpecialChar() = require(any { !it.isLetterOrDigit() }) { ERR_SPECIAL }
Now, we can freely combine the check functions as function references in a list:
val checks = listOf(
String::longerThan8,
String::withoutWhitespace,
String::hasDigit,
String::hasUppercase,
String::hasSpecialChar,
)
Then we can validate a password string using runCatching { checks.forEach{ it(thePassword) }}:
runCatching { checks.forEach { it(noDigit) } }.apply {
assertTrue { isFailure }
assertEquals(ERR_DIGIT, exceptionOrNull()?.message)
}
runCatching { checks.forEach { it(withSpace) } }.apply {
assertTrue { isFailure }
assertEquals(ERR_WHITESPACE, exceptionOrNull()?.message)
}
runCatching { checks.forEach { it(noUpper) } }.apply {
assertTrue { isFailure }
assertEquals(ERR_UPPER, exceptionOrNull()?.message)
}
runCatching { checks.forEach { it(noSpecial) } }.apply {
assertTrue { isFailure }
assertEquals(ERR_SPECIAL, exceptionOrNull()?.message)
}
runCatching { checks.forEach { it(okPwd) } }.apply {
assertTrue { isSuccess }
}
With this approach, we can combine those String extension functions to meet different validation requirements. If we need new check logic, we can add more extensions. However, the disadvantage is obvious too. It “pollutes” the String class. These extension check functions are available for all Strings*.* So, developers who don’t know the story behind it may wonder why String.hasDigit() returns Unit instead of a Boolean.
Next, let’s see how to improve this approach further.
4.2. The Inline Class
We want password strings to have a set of new functions, such as hasDigit(), without polluting regular string objects. Kotlin’s inline classes perfectly fit this use case. An inline class wraps a value. A typical use case is using an extra type-safe way to avoid passing a wrong parameter to a function or misusing some functions.
Next, let’s create the Password inline class and see how it works:
@JvmInline
value class Password(val pwd: String) : CharSequence by pwd {
fun longerThan8Rule() = require(pwd.length >= 8) { ERR_LEN }
fun withoutWhitespaceRule() = require(pwd.none { it.isWhitespace() })) { ERR_WHITESPACE }
fun hasDigitRule() = require(pwd.any { it.isDigit() }) { ERR_DIGIT }
fun hasUppercaseRule() = require(pwd.any { it.isUpperCase() }) { ERR_UPPER }
fun hasSpecialCharRule() = require(pwd.any { !it.isLetterOrDigit() }) { ERR_SPECIAL }
infix fun checkWith(rules: List<KFunction1<Password, Unit>>) = runCatching { rules.forEach { it(this) } }
}
In the code above, the functions declared in Password are only available for Password objects. Moreover, as Password wraps String and is delegated by the wrapped String object (pwd), Password objects can use all CharSequence’s functionalities.
It’s worth noting that we’ve created the checkWith() function as an infix function to make the caller’s code easier to read. We’ll see it in the test code later.
Similarly, we can combine the rules defined in the inline class and validate passwords:
val rules = listOf(
Password::hasDigitRule,
Password::longerThan8Rule,
Password::withoutWhitespaceRule,
Password::hasSpecialCharRule,
Password::hasUppercaseRule
)
(Password(tooShort) checkWith rules).apply {
assertTrue { isFailure }
assertEquals(ERR_LEN, exceptionOrNull()?.message)
}
(Password(noDigit) checkWith rules).apply {
assertTrue { isFailure }
assertEquals(ERR_DIGIT, exceptionOrNull()?.message)
}
(Password(withSpace) checkWith rules).apply {
assertTrue { isFailure }
assertEquals(ERR_WHITESPACE, exceptionOrNull()?.message)
}
(Password(noUpper) checkWith rules).apply {
assertTrue { isFailure }
assertEquals(ERR_UPPER, exceptionOrNull()?.message)
}
(Password(noSpecial) checkWith rules).apply {
assertTrue { isFailure }
assertEquals(ERR_SPECIAL, exceptionOrNull()?.message)
}
(Password(okPwd) checkWith rules).apply {
assertTrue { isSuccess }
}
We can also make Password a general validation module. Let’s suppose, for example, we have two new rules for the administrator’s passwords:
- Length >= 10
- Must not contain “admin” (case insensitive)
We can create extensions to the Password class and create a new set of validation rules:
fun Password.withoutAdminRule() = require("admin" !in pwd.lowercase()) { "Admin password cannot contain 'admin'" }
fun Password.longerThan10Rule() = require(pwd.length >= 10) { "Admin password must be longer than 10!" }
val adminRules = listOf(
Password::hasDigitRule,
Password::withoutWhitespaceRule,
Password::hasSpecialCharRule,
Password::hasUppercaseRule,
Password::withoutAdminRule,
Password::longerThan10Rule
)
val adminPwdOK = "1234abCD%%@"
val withAdmin = "1234adMIN%%@"
val shortPwd = "1234adX%@" //len=9
(Password(shortPwd) checkWith adminRules).apply {
assertTrue { isFailure }
assertEquals("Admin password must be longer than 10!", exceptionOrNull()?.message)
}
(Password(withAdmin) checkWith adminRules).apply {
assertTrue { isFailure }
assertEquals("Admin password cannot contain 'admin'", exceptionOrNull()?.message)
}
(Password(adminPwdOK) checkWith adminRules).apply {
assertTrue { isSuccess }
}
5. What If We Need All Validation Errors?
So far, all our validation solutions check the input password with a set of rules. Once a rule is broken, our method stops further validation and returns a Result object, indicating the validation is failed with the corresponding message. However, sometimes, we want to check through all rules whether the validation failed or not. Of course, in the final result, if the validation is failed, we want to get error messages of all broken rules.
So next, let’s extend our Password inline class solution to meet this requirement.
Let’s quickly revisit the inline class approach. In our Password inline class, each rule function is a require() call. And our checkWith() calls each rule function in a runCatching block. Therefore, once one require() call raises the IllegalArgumentException, the validation stops.
To ensure all rule functions are invoked, we can wrap each require() rule function call in a runCatching(). In this way, we’ll have a set of Result objects after checking all rule functions.
The rest is gathering and concatenating the exception messages from all Result.isFailure objects. This won’t be a challenge for us. Of course, if all Result objects are with “isSuccess“, the password passes the validation.
Let’s create the withValidate() function in the Password inline class to implement the idea above:
@JvmInline
value class Password(val pwd: String) : CharSequence by pwd {
fun longerThan8Rule() = require(pwd.length >= 8) { ERR_LEN }
fun withoutWhitespaceRule() = require(pwd.none { it.isWhitespace() }) { ERR_WHITESPACE }
... // other rule functions
infix fun checkWith(rules: List<KFunction1<Password, Unit>>) = runCatching { rules.forEach { it(this) } }
infix fun validateWith(rules: List<KFunction1<Password, Unit>>) = runCatching {
val message = rules.mapNotNull { runCatching { it(this) }.exceptionOrNull()?.message }.joinToString(separator = "\n")
require(message.isEmpty()) { message }
}
}
Now, let’s test the validateWith() function. As usual, let’s first create a rule set:
val rules = listOf(
Password::hasDigitRule,
Password::longerThan8Rule,
Password::withoutWhitespaceRule,
Password::hasSpecialCharRule,
Password::hasUppercaseRule
)
We’ll use an input string with only one space to verify whether validateWith() can give us multiple error messages on validation failure. If we examine the rules, we see only “Password::hasSpecialCharRule” passes since the space character is neither a digit nor a letter. Therefore, the validation result should contain the messages of the rest four rule functions:
val onlyOneSpace = " " // breaks four password rules
(Password(onlyOneSpace) validateWith rules).apply {
assertTrue { isFailure }
assertEquals(
setOf(ERR_LEN, ERR_WHITESPACE, ERR_DIGIT, ERR_UPPER), exceptionOrNull()?.message?.split("\n")?.toSet()
)
}
Of course, if we use okPwd as the input*,* the validation should pass:
(Password(okPwd) validateWith rules).apply {
assertTrue { isSuccess }
}
6. Conclusion
In this article, we first created a password validation function using runCatching() and require(). It’s more flexible and extendable than a regex-based approach.
Then, we improved this validation function by breaking the checking rules into String‘s extension functions. This approach is more flexible. But it pollutes the standard String class and can lead to confusion.
To resolve this problem, we came to the Password inline class solution. It achieves flexibility and extensibility without polluting the String class.
Finally, we discussed extending the inline class approach to make the result carry all validation error messages.
As usual, all code snippets presented here are available over on GitHub.