password reset tests

This commit is contained in:
simonredfern 2025-12-03 07:57:29 +01:00
parent 634d583105
commit b461724299
3 changed files with 297 additions and 1 deletions

View File

@ -24,7 +24,7 @@ import code.api.v4_0_0.JSONFactory400.createCallsLimitJson
import code.api.v5_0_0.JSONFactory500
import code.api.v5_0_0.{ViewJsonV500, ViewsJsonV500}
import code.api.v5_1_0.{JSONFactory510, PostCustomerLegalNameJsonV510}
import code.api.v6_0_0.JSONFactory600.{DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupJsonV600, GroupMembershipJsonV600, GroupMembershipsJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson}
import code.api.v6_0_0.JSONFactory600.{DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupJsonV600, GroupMembershipJsonV600, GroupMembershipsJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson}
import code.api.v6_0_0.OBPAPI6_0_0
import code.metrics.APIMetrics
import code.bankconnectors.LocalMappedConnectorInternal
@ -3212,6 +3212,133 @@ trait APIMethods600 {
}
}
staticResourceDocs += ResourceDoc(
resetPasswordUrl,
implementedInApiVersion,
nameOf(resetPasswordUrl),
"POST",
"/management/user/reset-password-url",
"Create Password Reset URL and Send Email",
s"""Create a password reset URL for a user and automatically send it via email.
|
|This endpoint generates a password reset URL and sends it to the user's email address.
|
|${userAuthenticationMessage(true)}
|
|Behavior:
|- Generates a unique password reset token
|- Creates a reset URL using the portal_external_url property (falls back to API hostname)
|- Sends an email to the user with the reset link
|- Returns the reset URL in the response for logging/tracking purposes
|
|Required fields:
|- username: The user's username (typically email)
|- email: The user's email address (must match username)
|- user_id: The user's UUID
|
|The user must exist and be validated before a reset URL can be generated.
|
|Email configuration must be set up correctly for email delivery to work.
|
|""".stripMargin,
PostResetPasswordUrlJsonV600(
"user@example.com",
"user@example.com",
"74a8ebcc-10e4-4036-bef3-9835922246bf"
),
ResetPasswordUrlJsonV600(
"https://api.example.com/user_mgt/reset_password/QOL1CPNJPCZ4BRMPX3Z01DPOX1HMGU3L"
),
List(
$UserNotLoggedIn,
UserHasMissingRoles,
InvalidJsonFormat,
UnknownError
),
List(apiTagUser),
Some(List(canCreateResetPasswordUrl))
)
lazy val resetPasswordUrl: OBPEndpoint = {
case "management" :: "user" :: "reset-password-url" :: Nil JsonPost json -> _ => {
cc => implicit val ec = EndpointContext(Some(cc))
for {
(Full(u), callContext) <- authenticatedAccess(cc)
_ <- Helper.booleanToFuture(
failMsg = ErrorMessages.NotAllowedEndpoint,
cc = callContext
) {
APIUtil.getPropsAsBoolValue("ResetPasswordUrlEnabled", false)
}
postedData <- NewStyle.function.tryons(
s"$InvalidJsonFormat The Json body should be the ${classOf[PostResetPasswordUrlJsonV600]}",
400,
callContext
) {
json.extract[PostResetPasswordUrlJsonV600]
}
// Find the AuthUser
authUserBox <- Future {
code.model.dataAccess.AuthUser.find(
net.liftweb.mapper.By(code.model.dataAccess.AuthUser.username, postedData.username)
)
}
authUser <- NewStyle.function.tryons(
s"$UnknownError User not found or validation failed",
400,
callContext
) {
authUserBox match {
case Full(user) if user.validated.get && user.email.get == postedData.email =>
// Verify user_id matches
Users.users.vend.getUserByUserId(postedData.user_id) match {
case Full(resourceUser) if resourceUser.name == postedData.username &&
resourceUser.emailAddress == postedData.email =>
user
case _ => throw new Exception("User ID does not match username and email")
}
case _ => throw new Exception("User not found, not validated, or email mismatch")
}
}
} yield {
// Explicitly type the user to ensure proper method resolution
val user: code.model.dataAccess.AuthUser = authUser
// Generate new reset token
// Reset the unique ID token by generating a new random value (32 chars, no hyphens)
user.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", ""))
user.save
// Construct reset URL using portal_hostname
// Get the unique ID value for the reset token URL
val resetPasswordLink = APIUtil.getPropsValue("portal_external_url", Constant.HostName) +
"/user_mgt/reset_password/" +
java.net.URLEncoder.encode(user.uniqueId.get, "UTF-8")
// Send email using CommonsEmailWrapper (like createUser does)
val textContent = Some(s"Please use the following link to reset your password: $resetPasswordLink")
val htmlContent = Some(s"<p>Please use the following link to reset your password:</p><p><a href='$resetPasswordLink'>$resetPasswordLink</a></p>")
val subjectContent = "Reset your password - " + user.username.get
val emailContent = code.api.util.CommonsEmailWrapper.EmailContent(
from = code.model.dataAccess.AuthUser.emailFrom,
to = List(user.email.get),
bcc = code.model.dataAccess.AuthUser.bccEmail.toList,
subject = subjectContent,
textContent = textContent,
htmlContent = htmlContent
)
code.api.util.CommonsEmailWrapper.sendHtmlEmail(emailContent)
(
ResetPasswordUrlJsonV600(resetPasswordLink),
HttpCode.`201`(callContext)
)
}
}
}
}
}

View File

@ -642,4 +642,8 @@ case class RoleWithEntitlementCountJsonV600(
case class RolesWithEntitlementCountsJsonV600(roles: List[RoleWithEntitlementCountJsonV600])
case class PostResetPasswordUrlJsonV600(username: String, email: String, user_id: String)
case class ResetPasswordUrlJsonV600(reset_password_url: String)
}

View File

@ -0,0 +1,165 @@
/**
Open Bank Project - API
Copyright (C) 2011-2019, TESOBE GmbH
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Email: contact@tesobe.com
TESOBE GmbH
Osloerstrasse 16/17
Berlin 13359, Germany
This product includes software developed at
TESOBE (http://www.tesobe.com/)
*/
package code.api.v6_0_0
import java.util.UUID
import com.openbankproject.commons.model.ErrorMessage
import code.api.util.APIUtil.OAuth._
import code.api.util.ApiRole._
import com.openbankproject.commons.util.ApiVersion
import code.api.util.ErrorMessages._
import code.api.v6_0_0.APIMethods600
import code.entitlement.Entitlement
import code.model.dataAccess.{AuthUser, ResourceUser}
import code.users.Users
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.User
import net.liftweb.common.Box
import net.liftweb.json.Serialization.write
import net.liftweb.mapper.By
import org.scalatest.Tag
/**
* Test suite for Password Reset URL endpoint (POST /obp/v6.0.0/management/user/reset-password-url)
*
* Tests cover:
* - Unauthorized access (no authentication)
* - Missing role (authenticated but no CanCreateResetPasswordUrl)
* - Successful password reset URL creation (with proper role)
* - User validation requirements
* - Email sending functionality
*/
class PasswordResetTest extends V600ServerSetup {
override def beforeEach() = {
wipeTestData()
super.beforeEach()
AuthUser.bulkDelete_!!(By(AuthUser.username, postJson.username))
ResourceUser.bulkDelete_!!(By(ResourceUser.providerId, postJson.username))
}
/**
* Test tags
* Example: To run tests with tag "getPermissions":
* mvn test -D tagsToInclude
*
* This is made possible by the scalatest maven plugin
*/
object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString)
object ApiEndpoint1 extends Tag(nameOf(APIMethods600.Implementations6_0_0.resetPasswordUrl))
lazy val postUserId = UUID.randomUUID.toString
lazy val postJson = JSONFactory600.PostResetPasswordUrlJsonV600("marko", "marko@tesobe.com", postUserId)
feature("Reset password url v6.0.0 - Unauthorized access") {
scenario("We will call the endpoint without user credentials", ApiEndpoint1, VersionOfApi) {
When("We make a request v6.0.0")
val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST
val response600 = makePostRequest(request600, write(postJson))
Then("We should get a 401")
response600.code should equal(401)
And("error should be " + UserNotLoggedIn)
response600.body.extract[ErrorMessage].message should equal(UserNotLoggedIn)
}
}
feature("Reset password url v6.0.0 - Authorized access") {
scenario("We will call the endpoint without the proper Role " + canCreateResetPasswordUrl, ApiEndpoint1, VersionOfApi) {
When("We make a request v6.0.0 without a Role " + canCreateResetPasswordUrl)
val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1)
val response600 = makePostRequest(request600, write(postJson))
Then("We should get a 400")
response600.code should equal(400)
And("error should be " + UserHasMissingRoles + CanCreateResetPasswordUrl)
response600.body.extract[ErrorMessage].message should equal((UserHasMissingRoles + CanCreateResetPasswordUrl))
}
scenario("We will call the endpoint with the proper Role " + canCreateResetPasswordUrl, ApiEndpoint1, VersionOfApi) {
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateResetPasswordUrl.toString)
val authUser: AuthUser = AuthUser.create.email(postJson.email).username(postJson.username).validated(true).saveMe()
val resourceUser: Box[User] = Users.users.vend.getUserByResourceUserId(authUser.user.get)
When("We make a request v6.0.0")
val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1)
val response600 = makePostRequest(request600, write(postJson.copy(user_id = resourceUser.map(_.userId).getOrElse(""))))
Then("We should get a 201")
response600.code should equal(201)
response600.body.extractOpt[JSONFactory600.ResetPasswordUrlJsonV600].isDefined should equal(true)
And("The response should contain a valid reset URL")
val resetUrl = (response600.body \ "reset_password_url").extract[String]
resetUrl should include("/user_mgt/reset_password/")
resetUrl.split("/user_mgt/reset_password/").last.length should be > 0
}
scenario("We will call the endpoint with unvalidated user", ApiEndpoint1, VersionOfApi) {
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateResetPasswordUrl.toString)
val testUsername = "unvalidated@tesobe.com"
val testEmail = "unvalidated@tesobe.com"
val authUser: AuthUser = AuthUser.create.email(testEmail).username(testUsername).validated(false).saveMe()
val resourceUser: Box[User] = Users.users.vend.getUserByResourceUserId(authUser.user.get)
When("We make a request v6.0.0 with unvalidated user")
val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1)
val testJson = JSONFactory600.PostResetPasswordUrlJsonV600(testUsername, testEmail, resourceUser.map(_.userId).getOrElse(""))
val response600 = makePostRequest(request600, write(testJson))
Then("We should get a 400")
response600.code should equal(400)
And("error should indicate user validation issue")
response600.body.extract[ErrorMessage].message should include("not validated")
// Clean up
authUser.delete_!
}
scenario("We will call the endpoint with mismatched email", ApiEndpoint1, VersionOfApi) {
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateResetPasswordUrl.toString)
val testUsername = "mismatch@tesobe.com"
val testEmail = "correct@tesobe.com"
val wrongEmail = "wrong@tesobe.com"
val authUser: AuthUser = AuthUser.create.email(testEmail).username(testUsername).validated(true).saveMe()
val resourceUser: Box[User] = Users.users.vend.getUserByResourceUserId(authUser.user.get)
When("We make a request v6.0.0 with mismatched email")
val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1)
val testJson = JSONFactory600.PostResetPasswordUrlJsonV600(testUsername, wrongEmail, resourceUser.map(_.userId).getOrElse(""))
val response600 = makePostRequest(request600, write(testJson))
Then("We should get a 400")
response600.code should equal(400)
And("error should indicate email mismatch")
response600.body.extract[ErrorMessage].message should include("email mismatch")
// Clean up
authUser.delete_!
}
scenario("We will call the endpoint with non-existent user", ApiEndpoint1, VersionOfApi) {
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateResetPasswordUrl.toString)
When("We make a request v6.0.0 with non-existent user")
val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1)
val nonExistentJson = JSONFactory600.PostResetPasswordUrlJsonV600("nonexistent@tesobe.com", "nonexistent@tesobe.com", UUID.randomUUID.toString)
val response600 = makePostRequest(request600, write(nonExistentJson))
Then("We should get a 400")
response600.code should equal(400)
And("error should indicate user not found")
response600.body.extract[ErrorMessage].message should include("User not found")
}
}
}