diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 7b39921fd..5f8764dfe 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -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"

Please use the following link to reset your password:

$resetPasswordLink

") + 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) + ) + } + } + } + } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 9bd982300..7a2d75184 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -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) + } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala new file mode 100644 index 000000000..188b03e0c --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala @@ -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 . + +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") + } + } +} \ No newline at end of file