diff --git a/obp-api/src/main/scala/code/api/directlogin.scala b/obp-api/src/main/scala/code/api/directlogin.scala index b279326832..62514d48b6 100644 --- a/obp-api/src/main/scala/code/api/directlogin.scala +++ b/obp-api/src/main/scala/code/api/directlogin.scala @@ -165,6 +165,9 @@ object DirectLogin extends RestHelper with MdcLoggable { } else if (userId == AuthUser.usernameLockedStateCode) { message = ErrorMessages.UsernameHasBeenLocked httpCode = 401 + } else if (userId == AuthUser.userEmailNotValidatedStateCode) { + message = ErrorMessages.UserEmailNotValidated + httpCode = 401 } else { val jwtPayloadAsJson = """{ diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 55dc7d756e..1298d8bc65 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -159,6 +159,7 @@ object ErrorMessages { val InvalidDirectLoginParameters = "OBP-20012: Invalid direct login parameters" val UsernameHasBeenLocked = "OBP-20013: The account has been locked, please contact an administrator!" + val UserEmailNotValidated = "OBP-20073: The user email has not been validated. Please validate your email address first." val InvalidConsumerId = "OBP-20014: Invalid Consumer ID. Please specify a valid value for CONSUMER_ID." @@ -883,6 +884,7 @@ object ErrorMessages { InvalidConsumerKey -> 401, // InvalidConsumerCredentials -> 401, // or 400 UsernameHasBeenLocked -> 401, + UserEmailNotValidated -> 401, UserNoPermissionAccessView -> 403, UserLacksPermissionCanGrantAccessToViewForTargetAccount -> 403, UserLacksPermissionCanRevokeAccessToViewForTargetAccount -> 403, 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 3272b4912f..e30f8d6f45 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 @@ -71,6 +71,8 @@ import code.api.util.ExampleValue import code.api.util.ExampleValue.dynamicEntityResponseBodyExample import net.liftweb.common.Box +import java.net.URLDecoder +import java.nio.charset.StandardCharsets import java.text.SimpleDateFormat import java.util.UUID.randomUUID import scala.collection.immutable.{List, Nil} @@ -8754,10 +8756,12 @@ trait APIMethods600 { postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostVerifyUserCredentialsJsonV600", 400, callContext) { json.extract[PostVerifyUserCredentialsJsonV600] } + // Decode the provider in case it's URL-encoded (e.g., "http%3A%2F%2Fexample.com" -> "http://example.com") + decodedProvider = URLDecoder.decode(postedData.provider, StandardCharsets.UTF_8) // Validate credentials using the existing AuthUser mechanism resourceUserIdBox = - if (postedData.provider == Constant.localIdentityProvider || postedData.provider.isEmpty) { + if (decodedProvider == Constant.localIdentityProvider || decodedProvider.isEmpty) { // Local provider: only check local credentials. No external fallback. val result = code.model.dataAccess.AuthUser.getResourceUserId( postedData.username, postedData.password, Constant.localIdentityProvider @@ -8767,8 +8771,8 @@ trait APIMethods600 { } else { // External provider: validate via connector. Local DB stores a random UUID // as password for external users, so getResourceUserId would always fail. - if (LoginAttempt.userIsLocked(postedData.provider, postedData.username)) { - logger.info(s"verifyUserCredentials says: external user is locked, provider: ${postedData.provider}, username: ${postedData.username}") + if (LoginAttempt.userIsLocked(decodedProvider, postedData.username)) { + logger.info(s"verifyUserCredentials says: external user is locked, provider: ${decodedProvider}, username: ${postedData.username}") Full(code.model.dataAccess.AuthUser.usernameLockedStateCode) } else { val connectorResult = code.model.dataAccess.AuthUser.externalUserHelper( @@ -8777,10 +8781,10 @@ trait APIMethods600 { logger.info(s"verifyUserCredentials says: externalUserHelper result: $connectorResult") connectorResult match { case Full(_) => - LoginAttempt.resetBadLoginAttempts(postedData.provider, postedData.username) + LoginAttempt.resetBadLoginAttempts(decodedProvider, postedData.username) connectorResult case _ => - LoginAttempt.incrementBadLoginAttempts(postedData.provider, postedData.username) + LoginAttempt.incrementBadLoginAttempts(decodedProvider, postedData.username) connectorResult } } @@ -8803,7 +8807,7 @@ trait APIMethods600 { } // Verify provider matches if specified and not empty _ <- Helper.booleanToFuture(s"$InvalidLoginCredentials Authentication provider mismatch.", 401, callContext) { - postedData.provider.isEmpty || user.provider == postedData.provider + decodedProvider.isEmpty || user.provider == decodedProvider } } yield { (JSONFactory200.createUserJSON(user), HttpCode.`200`(callContext)) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 27df94dd3f..90efbea8c9 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -40,7 +40,6 @@ import code.kycstatuses.KycStatuses import code.meetings.Meetings import code.metadata.counterparties.Counterparties import code.model._ -import code.model.dataAccess.AuthUser.findAuthUserByUsernameLocallyLegacy import code.model.dataAccess._ import code.productAttributeattribute.MappedProductAttribute import code.productattribute.ProductAttributeX @@ -5356,7 +5355,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { //NOTE: this method is not for mapped connector, we put it here for the star default implementation. // : we call that method only when we set external authentication and provider is not OBP-API override def checkExternalUserExists(username: String, callContext: Option[CallContext]): Box[InboundExternalUser] = { - findAuthUserByUsernameLocallyLegacy(username).map(user => + AuthUser.findAuthUserByUsernameAndProvider(username, Constant.localIdentityProvider).map(user => InboundExternalUser(aud = "", exp = "", iat = "", diff --git a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala index a7b1a340b6..9f21a89f39 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala @@ -431,6 +431,8 @@ import net.liftweb.util.Helpers._ /**Marking the locked state to show different error message */ val usernameLockedStateCode = Long.MaxValue + /**Marking the email not validated state to show different error message */ + val userEmailNotValidatedStateCode = Long.MaxValue - 1 val connector = code.api.Constant.CONNECTOR.openOrThrowException(s"$MandatoryPropertyIsNotSet. The missing prop is `connector` ") val starConnectorSupportedTypes = APIUtil.getPropsValue("starConnector_supported_types","") @@ -448,9 +450,13 @@ import net.liftweb.util.Helpers._ // To force validation of email addresses set this to false (default as of 29 June 2021) override def skipEmailValidation = APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", false) - // loginXhtml simplified - API-only mode, no portal pages - // Login is handled via OIDC/DirectLogin, not HTML forms + // Legacy Lift login UI - no longer used (API-only mode) + // Login is handled via OIDC/DirectLogin APIs, not HTML forms override def loginXhtml =
+ + // Legacy Lift login method - no longer used (no frontend pages) + // Authentication is now handled via DirectLogin API endpoints + override def login: NodeSeq = // Update ResourceUser.LastUsedLocale only once per session in 60 seconds @@ -579,40 +585,9 @@ import net.liftweb.util.Helpers._ override def userNameNotFoundString: String = "Thank you. If we found a matching user, password reset instructions have been sent." - /** - * Overridden to use the hostname set in the props file - */ - override def sendPasswordReset(name: String) { - findAuthUserByUsernameLocallyLegacy(name).toList ::: findUsersByEmailLocally(name) map { - case u if u.validated_? => - u.resetUniqueId().save - val resetPasswordLinkProps = Constant.HostName - val resetPasswordLink = APIUtil.getPropsValue("portal_hostname", resetPasswordLinkProps)+ - passwordResetPath.mkString("/", "/", "/")+urlEncode(u.getUniqueId()) - // Directly generate content using JakartaMail/CommonsEmailWrapper - 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:
") - val emailContent = EmailContent( - from = emailFrom, - to = List(u.getEmail), - bcc = bccEmail.toList, - subject = passwordResetEmailSubject + " - " + u.username, - textContent = textContent, - htmlContent = htmlContent - ) - sendHtmlEmail(emailContent) match { - case Full(messageId) => - logger.debug(s"Password reset email sent successfully with Message-ID: $messageId") - S.notice("Password reset email sent successfully. Please check your email.") - S.redirectTo(homePage) - case Empty => - logger.error("Failed to send password reset email") - S.error("Failed to send password reset email. Please try again.") - S.redirectTo(homePage) - } - case u => - sendValidationEmail(u) - } + // sendPasswordReset removed - legacy Lift method, replaced by API endpoint /obp/v6.0.0/users/password-reset-url + override def sendPasswordReset(name: String) = { + // No-op: Password reset now handled via RESTful API endpoints } // lostPasswordXhtml simplified - API-only mode, no portal pages @@ -790,7 +765,7 @@ import net.liftweb.util.Helpers._ case Full(user) if (user.getProvider() == Constant.localIdentityProvider) => if (!user.validated_?) { logger.info(s"getResourceUserId says: user not validated, username: $username, provider: $provider") - Empty + Full(userEmailNotValidatedStateCode) } else if (LoginAttempt.userIsLocked(user.getProvider(), username)) { logger.info(s"getResourceUserId says: user is locked, username: $username, provider: $provider") @@ -929,224 +904,6 @@ def restoreSomeSessions(): Unit = { Nil } - - //overridden to allow a redirection if login fails - /** - * Success cases: - * case1: user validated && user not locked && user.provider from localhost && password correct --> Login in - * case2: user validated && user not locked && user.provider not localhost && password correct --> Login in - * case3: user from remote && checked over connector --> Login in - * - * Error cases: - * case1: user is locked --> UsernameHasBeenLocked - * case2: user.validated_? --> account.validation.error - * case3: right username but wrong password --> Invalid Login Credentials - * case4: wrong username --> Invalid Login Credentials - * case5: UnKnow error --> UnexpectedErrorDuringLogin - */ - override def login: NodeSeq = { - // This query parameter is specific to ORY Hydra login request - val loginChallenge: Box[String] = ObpS.param("login_challenge").or(S.getSessionAttribute("login_challenge")) - def redirectUri(user: Box[ResourceUser]): String = { - val userId = user.map(_.userId).getOrElse("") - val hashedAgreementTextOfUser = - UserAgreementProvider.userAgreementProvider.vend.getLastUserAgreement(userId, "terms_and_conditions") - .map(_.agreementHash).getOrElse(HashUtil.Sha256Hash("not set")) - val agreementText = getWebUiPropsValue("webui_terms_and_conditions", "not set") - val hashedAgreementText = HashUtil.Sha256Hash(agreementText) - if(hashedAgreementTextOfUser == hashedAgreementText) { // Check terms and conditions - val hashedAgreementTextOfUser = - UserAgreementProvider.userAgreementProvider.vend.getLastUserAgreement(userId, "privacy_conditions") - .map(_.agreementHash).getOrElse(HashUtil.Sha256Hash("not set")) - val agreementText = getWebUiPropsValue("webui_privacy_policy", "not set") - val hashedAgreementText = HashUtil.Sha256Hash(agreementText) - if(hashedAgreementTextOfUser == hashedAgreementText) { // Check privacy policy - loginRedirect.get match { - case Full(url) => - loginRedirect(Empty) - url - case _ => - homePage - } - } else { - "/privacy-policy" - } - } else { - "/terms-and-conditions" - } - - } - //Check the internal redirect, in case for open redirect issue. - // variable redirect is from loginRedirect, it is set-up in OAuthAuthorisation.scala as following code: - // val currentUrl = ObpS.uriAndQueryString.getOrElse("/") - // AuthUser.loginRedirect.set(Full(Helpers.appendParams(currentUrl, List((LogUserOutParam, "false"))))) - def checkInternalRedirectAndLogUserIn(preLoginState: () => Unit, redirect: String, user: AuthUser) = { - if (Helper.isValidInternalRedirectUrl(redirect)) { - logUserIn(user, () => { - S.notice(S.?("logged.in")) - preLoginState() - if(emailDomainToSpaceMappings.nonEmpty){ - Future{ - tryo{AuthUser.grantEntitlementsToUseDynamicEndpointsInSpaces(user)} - .openOr(logger.error(s"${user} checkInternalRedirectAndLogUserIn.grantEntitlementsToUseDynamicEndpointsInSpaces throw exception! ")) - }} - if(emailDomainToEntitlementMappings.nonEmpty){ - Future{ - tryo{AuthUser.grantEmailDomainEntitlementsToUser(user)} - .openOr(logger.error(s"${user} checkInternalRedirectAndLogUserIn.grantEmailDomainEntitlementsToUser throw exception! ")) - }} - // We use Hydra as an Headless Identity Provider which implies OBP-API must provide User Management. - // If there is the query parameter login_challenge in a url we know it is tha Hydra request - // TODO Write standalone application for Login and Consent Request of Hydra as Identity Provider - integrateWithHydra match { - case true => - if (loginChallenge.isEmpty == false) { - val acceptLoginRequest = new AcceptLoginRequest - val adminApi: AdminApi = new AdminApi - acceptLoginRequest.setSubject(user.username.get) - val result = adminApi.acceptLoginRequest(loginChallenge.getOrElse(""), acceptLoginRequest) - S.redirectTo(result.getRedirectTo) - } else { - S.redirectTo(redirect) - } - case false => - S.redirectTo(redirect) - } - }) - } else { - S.error(S.?(ErrorMessages.InvalidInternalRedirectUrl)) - logger.info(ErrorMessages.InvalidInternalRedirectUrl + loginRedirect.get) - } - } - - def isObpProvider(user: AuthUser) = { - // TODO Consider does http://host should match https://host in development mode - user.getProvider() == Constant.localIdentityProvider - } - - def obpUserIsValidatedAndNotLocked(usernameFromGui: String, user: AuthUser) = { - user.validated_? && !LoginAttempt.userIsLocked(user.getProvider(), usernameFromGui) && - isObpProvider(user) - } - - def externalUserIsValidatedAndNotLocked(usernameFromGui: String, user: AuthUser) = { - user.validated_? && !LoginAttempt.userIsLocked(user.getProvider(), usernameFromGui) && - !isObpProvider(user) - } - - def loginAction = { - if (S.post_?) { - val usernameFromGui = ObpS.param("username").getOrElse("") - val passwordFromGui = ObpS.param("password").getOrElse("") - val usernameEmptyField = ObpS.param("username").map(_.isEmpty()).getOrElse(true) - val passwordEmptyField = ObpS.param("password").map(_.isEmpty()).getOrElse(true) - val emptyField = usernameEmptyField || passwordEmptyField - emptyField match { - case true => - if(usernameEmptyField) - S.error("login-form-username-error", Helper.i18n("please.enter.your.username")) - if(passwordEmptyField) - S.error("login-form-password-error", Helper.i18n("please.enter.your.password")) - case false => - findAuthUserByUsernameLocallyLegacy(usernameFromGui) match { - case Full(user) if !user.validated_? => - S.error(S.?("account.validation.error")) - - // Check if user comes from localhost and - case Full(user) if obpUserIsValidatedAndNotLocked(usernameFromGui, user) => - if(user.testPassword(Full(passwordFromGui))) { // if User is NOT locked and password is good - // Reset any bad attempt - LoginAttempt.resetBadLoginAttempts(user.getProvider(), usernameFromGui) - val preLoginState = capturePreLoginState() - // User init actions - AfterApiAuth.innerLoginUserInitAction(Full(user)) - logger.info("login redirect: " + loginRedirect.get) - val redirect = redirectUri(user.user.foreign) - checkInternalRedirectAndLogUserIn(preLoginState, redirect, user) - } else { // If user is NOT locked AND password is wrong => increment bad login attempt counter. - LoginAttempt.incrementBadLoginAttempts(user.getProvider(),usernameFromGui) - S.error(Helper.i18n("invalid.login.credentials")) - } - - // If user is locked, send the error to GUI - case Full(user) if LoginAttempt.userIsLocked(user.getProvider(), usernameFromGui) => - LoginAttempt.incrementBadLoginAttempts(user.getProvider(),usernameFromGui) - S.error(S.?(ErrorMessages.UsernameHasBeenLocked)) - loginRedirect(ObpS.param("Referer").or(S.param("Referer"))) - - // Check if user came from CBS and - // if User is NOT locked. Then check username and password - // from connector in case they changed on the south-side - case Full(user) if externalUserIsValidatedAndNotLocked(usernameFromGui, user) && testExternalPassword(usernameFromGui, passwordFromGui) => - // Reset any bad attempts - LoginAttempt.resetBadLoginAttempts(user.getProvider(), usernameFromGui) - val preLoginState = capturePreLoginState() - logger.info("login redirect: " + loginRedirect.get) - val redirect = redirectUri(user.user.foreign) - //This method is used for connector = cbs* || obpjvm* - //It will update the views and createAccountHolder .... - registeredUserHelper(user.getProvider(),user.username.get) - // User init actions - AfterApiAuth.innerLoginUserInitAction(Full(user)) - checkInternalRedirectAndLogUserIn(preLoginState, redirect, user) - - - // Error case: - // the username exist but provider cannot be matched - // It can happen via next scenario: - // - sign up user at some obp-api cluster - // - change a url of the cluster - // - try to log on user at the cluster - case Full(user) if !isObpProvider(user) => - S.error(S.?(s"${ErrorMessages.InvalidProviderUrl} Actual: ${Constant.localIdentityProvider}, Expected: ${user.provider}")) - - - // If user cannot be found locally, try to authenticate user via connector - case Empty if (APIUtil.getPropsAsBoolValue("connector.user.authentication", false)) => - - val preLoginState = capturePreLoginState() - logger.info("login redirect: " + loginRedirect.get) - val redirect = redirectUri(user.foreign) - externalUserHelper(usernameFromGui, passwordFromGui) match { - case Full(user: AuthUser) => - LoginAttempt.resetBadLoginAttempts(user.getProvider(), usernameFromGui) - // User init actions - AfterApiAuth.innerLoginUserInitAction(Full(user)) - checkInternalRedirectAndLogUserIn(preLoginState, redirect, user) - case _ => - LoginAttempt.incrementBadLoginAttempts(user.foreign.map(_.provider).getOrElse(Constant.HostName), username.get) - Empty - S.error(Helper.i18n("invalid.login.credentials")) - } - - //If there is NO the username, throw the error message. - case Empty => - S.error(Helper.i18n("invalid.login.credentials")) - case unhandledCase => - logger.error("------------------------------------------------------") - logger.error(s"username from GUI: $usernameFromGui") - logger.error("An unexpected login error occurred:") - logger.error(unhandledCase) - logger.error("------------------------------------------------------") - LoginAttempt.incrementBadLoginAttempts(user.foreign.map(_.provider).getOrElse(Constant.HostName), usernameFromGui) - S.error(S.?(ErrorMessages.UnexpectedErrorDuringLogin)) // Note we hit this if user has not clicked email validation link - } - } - } - } - - // In this function we bind submit button to loginAction function. - // In case that unique token of submit button cannot be paired submit action will be omitted. - // Implemented in order to prevent a CSRF attack - def insertSubmitButton = { - scala.xml.XML.loadString(loginSubmitButton(loginButtonText, loginAction _).toString().replace("type=\"submit\"","class=\"submit\" type=\"submit\"")) - } - - val bind = - "submit" #> insertSubmitButton - bind(loginXhtml) - } - override def logout = { logoutCurrentUser S.request match { @@ -1157,9 +914,7 @@ def restoreSomeSessions(): Unit = { case _ => S.redirectTo(homePage) } } - - - /** + /** * The user authentications is not exciting in obp side, it need get the user via connector */ def testExternalPassword(usernameFromGui: String, passwordFromGui: String): Boolean = { @@ -1473,41 +1228,21 @@ def restoreSomeSessions(): Unit = { │FIND A USER │ │AT MAPPER DB│ └──────┬─────┘ - ___________▽___________ ┌────────────────────────┐ - ╱ props: ╲ │FIND USER BY COMPOSITE │ - ╱ local_identity_provider ╲______________________│KEY (username, │ - ╲ ╱yes │local_identity_provider)│ - ╲_______________________╱ └────────────┬───────────┘ - │no │ - ___▽____ ┌────────────────────────┐ │ - ╱ props: ╲ │FIND USER BY COMPOSITE │ │ - ╱ hostname ╲___│KEY (username, hostname)│ │ - ╲ ╱yes└────────────┬───────────┘ │ - ╲________╱ │ │ - │no │ │ - ┌──▽──┐ │ │ - │ERROR│ │ │ - └─────┘ │ │ - └──────┬──────────────────┘ - ┌────▽────┐ - │BOX[USER]│ - └─────────┘ + │ + │ Find by composite key: + │ (username, provider) + │ + │ provider = parameter value + │ + ┌──▽──────────────────────┐ + │FIND USER BY COMPOSITE │ + │KEY (username, provider) │ + └──────┬──────────────────┘ + │ + ┌────▽────┐ + │BOX[USER]│ + └─────────┘ */ - /** - * Find the Auth User by the composite key (username, provider). - * Only search at the local database. - * Please note that provider is implicitly defined i.e. not provided via a parameter - */ - @deprecated("AuthUser unique key is username and provider, please use @findAuthUserByUsernameAndProvider instead.","06.06.2024") - def findAuthUserByUsernameLocallyLegacy(name: String): Box[TheUserType] = { - // 1st try is provider with local_identity_provider or hostname value - find(By(this.username, name), By(this.provider, Constant.localIdentityProvider)) - // 2nd try is provider with null value - .or(find(By(this.username, name), NullRef(this.provider))) - // 3rd try is provider with empty string value - .or(find(By(this.username, name), By(this.provider, ""))) - } - def findAuthUserByUsernameAndProvider(name: String, provider: String): Box[TheUserType] = { find(By(this.username, name), By(this.provider, provider)) } diff --git a/obp-api/src/test/scala/code/api/DirectLoginTest.scala b/obp-api/src/test/scala/code/api/DirectLoginTest.scala index 2cb1c92c3b..6546a99d7c 100644 --- a/obp-api/src/test/scala/code/api/DirectLoginTest.scala +++ b/obp-api/src/test/scala/code/api/DirectLoginTest.scala @@ -450,6 +450,37 @@ class DirectLoginTest extends ServerSetup with BeforeAndAfter { responseCurrentUserOldStyle.body.extract[ErrorMessage].message should include(ErrorMessages.UsernameHasBeenLocked) } + scenario("Login with correct credentials but user email is not validated", ApiEndpoint1, ApiEndpoint2) { + lazy val username = "unvalidated.user" + lazy val email = randomString(10).toLowerCase + "@example.com" + lazy val header = ("DirectLogin", "username=%s, password=%s, consumer_key=%s". + format(username, VALID_PW, KEY)) + + Given("A user exists but email is not validated") + // Delete the user if exists + AuthUser.findAll(By(AuthUser.username, username)).map(_.delete_!()) + // Create the user with validated = false + AuthUser.create. + email(email). + username(username). + password(VALID_PW). + validated(false). + firstName(randomString(10)). + lastName(randomString(10)). + saveMe + + When("we try to login with correct credentials but unvalidated email") + lazy val request = directLoginRequest + lazy val response = makePostRequestAdditionalHeader(request, "", List(accessControlOriginHeader, header)) + + Then("We should get a 401 - Unauthorized") + response.code should equal(401) + assertResponse(response, ErrorMessages.UserEmailNotValidated) + + // Clean up: delete the test user + AuthUser.findAll(By(AuthUser.username, username)).map(_.delete_!()) + } + scenario("Test the last issued token is valid as well as a previous one", ApiEndpoint2) { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala index 643184ba41..bb5ad4d290 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala @@ -612,5 +612,172 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { response.body.extract[ErrorMessage].message should include("OBP-10001") } + scenario("Successfully verify credentials with URL-encoded local provider", ApiEndpoint, VersionOfApi) { + // Test that URL-encoded local provider strings are correctly decoded + // The local provider constant might be URL-encoded in some scenarios + val urlEncodedLocalProvider = java.net.URLEncoder.encode(Constant.localIdentityProvider, "UTF-8") + val username = "encoded_local_test_" + randomString(8).toLowerCase + val password = "TestPassword123!" + val email = username + "@example.com" + + // Create a local user + val testUser = AuthUser.create + .email(email) + .username(username) + .password(password) + .validated(true) + .firstName("Test") + .lastName("EncodedLocal") + .provider(Constant.localIdentityProvider) + .saveMe() + + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) + + try { + When("We verify credentials with URL-encoded local provider") + val postJson = Map( + "username" -> username, + "password" -> password, + "provider" -> urlEncodedLocalProvider // Send encoded version of local provider + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 200 because the local provider is decoded correctly") + response.code should equal(200) + + And("The response should contain user details with local provider") + (response.body \ "username").extract[String] should equal(username) + (response.body \ "provider").extract[String] should equal(Constant.localIdentityProvider) + } finally { + testUser.delete_! + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + } + + scenario("Successfully verify credentials with provider containing special characters", ApiEndpoint, VersionOfApi) { + // Test that the provider field correctly handles URL encoding/decoding + // In this test, we verify that empty provider (treated as local) works correctly + val username = "special_chars_test_" + randomString(8).toLowerCase + val password = "TestPassword123!" + val email = username + "@example.com" + + // Create a local user (empty provider is treated as local) + val testUser = AuthUser.create + .email(email) + .username(username) + .password(password) + .validated(true) + .firstName("Test") + .lastName("SpecialChars") + .provider(Constant.localIdentityProvider) + .saveMe() + + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) + + try { + When("We verify credentials with empty provider (should be treated as local)") + val postJson = Map( + "username" -> username, + "password" -> password, + "provider" -> "" // Empty provider + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 200 because empty provider is treated as local") + response.code should equal(200) + + And("The response should contain user details") + (response.body \ "username").extract[String] should equal(username) + (response.body \ "provider").extract[String] should equal(Constant.localIdentityProvider) + } finally { + testUser.delete_! + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + } + + scenario("Verify credentials with non-encoded local provider should work", ApiEndpoint, VersionOfApi) { + // Test that non-encoded local provider (the normal case) still works correctly + val username = "non_encoded_test_" + randomString(8).toLowerCase + val password = "TestPassword123!" + val email = username + "@example.com" + + // Create a local user + val testUser = AuthUser.create + .email(email) + .username(username) + .password(password) + .validated(true) + .firstName("Test") + .lastName("NonEncoded") + .provider(Constant.localIdentityProvider) + .saveMe() + + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) + + try { + When("We verify credentials with non-encoded local provider") + val postJson = Map( + "username" -> username, + "password" -> password, + "provider" -> Constant.localIdentityProvider // Send non-encoded local provider + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 200 because non-encoded local provider works normally") + response.code should equal(200) + + And("The response should contain user details") + (response.body \ "username").extract[String] should equal(username) + (response.body \ "provider").extract[String] should equal(Constant.localIdentityProvider) + } finally { + testUser.delete_! + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + } + + scenario("URL-encoded provider mismatch should fail with 401", ApiEndpoint, VersionOfApi) { + // Test that provider mismatch is detected even with URL encoding + // User has local provider, but request sends a different (encoded) provider + val wrongProvider = "https://github.com/login/oauth" + val urlEncodedWrongProvider = java.net.URLEncoder.encode(wrongProvider, "UTF-8") + val username = "encoded_mismatch_test_" + randomString(8).toLowerCase + val password = "TestPassword123!" + val email = username + "@example.com" + + // Create a local user + val testUser = AuthUser.create + .email(email) + .username(username) + .password(password) + .validated(true) + .firstName("Test") + .lastName("Mismatch") + .provider(Constant.localIdentityProvider) + .saveMe() + + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) + + try { + When("We verify credentials with URL-encoded wrong provider") + val postJson = Map( + "username" -> username, + "password" -> password, + "provider" -> urlEncodedWrongProvider // Send encoded wrong provider + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 401 because this is treated as external provider auth (no connector)") + response.code should equal(401) + response.body.extract[ErrorMessage].message should include("OBP-20004") + } finally { + testUser.delete_! + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + } + } }