diff --git a/README.md b/README.md index 0c8a9514ed..f6cf7d375c 100644 --- a/README.md +++ b/README.md @@ -740,6 +740,60 @@ Use this from APIManager (or a `curl` with appropriate auth headers) to confirm that signup / password-reset emails will be deliverable on this instance, without needing to create a real account or trigger a real reset. +### Resend validation email (recovery for stuck signups) + +If a user signs up but does not receive the validation email — bad SMTP day, +typo'd address, spam folder, server misconfiguration — they are otherwise +stuck. They can't log in (not validated), and the anonymous password-reset +endpoint can't help them either (it filters on `validated=true`). The resend +endpoint closes that gap: + +``` +POST /obp/v7.0.0/users/validation-emails +Body: { "username": "alice", "email": "alice@example.com" } +``` + +- **No authentication or role required** — an unvalidated user has no + entitlements, so self-service is the only model that works. +- **Anti-enumeration**: always returns `201` with the same message regardless + of whether the user exists, is already validated, the rate limit was hit, or + the SMTP send failed: + ```json + { "message": "If an unvalidated account exists for this username and email, a validation email has been sent." } + ``` +- **Rate-limited**: 3 attempts per email per hour, Redis-backed. Over-limit + requests still get the same 201. +- **Local-provider only**: scoped to `Constant.localIdentityProvider`. OIDC / + SSO users never use the email-validation flow. +- **Token reuse**: deliberately does NOT rotate `AuthUser.uniqueId`. The same + validation JWT is regenerated each call. Multiple resends produce the same + link, so clicking any of the delivered emails works. +- **All decisions are server-side only**: the log line at INFO/WARN level is + the operator's only way to know what actually happened. Server log will + show one of: sent (with messageId), skipped (user not found / already + validated / email mismatch / rate-limit), or skipped with WARN (portal_url + unset / sender address default / SMTP failure). + +### Signup and anonymous-reset flow logging + +The signup endpoint (`POST /obp/v6.0.0/users`) and the anonymous password-reset +endpoint (`POST /obp/v6.0.0/users/password-reset-url`) both now log explicitly +on every silent skip, so operators can diagnose "user complained, no email +arrived" without server-side guesswork. WARN is logged when: + +- `portal_external_url` is unset — link cannot be built, no email sent +- `mail.users.userinfo.sender.address` is still the default `noreply@example.com` +- the SMTP send threw (exception class + first 200 chars of message) + +INFO is logged when the send succeeded (`messageId=...`) and when the request +was skipped for a non-error reason (user not found, already validated, etc.). +External response shape is unchanged — the user still gets the same 201, so +this is purely a server-side diagnostic improvement. + +The anonymous password-reset endpoint also now scopes its user lookup to the +local identity provider, matching the behaviour of the new resend-validation +endpoint and avoiding cross-provider false matches. + ### Logging Every successful send is logged at INFO level by `CommonsEmailWrapper`: @@ -747,14 +801,16 @@ Every successful send is logged at INFO level by `CommonsEmailWrapper`: ``` sendTextEmail says: sent to=alice@example.com subject='OBP test email from ...' messageId=<...@smtp...> sendHtmlEmail says: sent to=alice@example.com subject='Sign up confirmation' messageId=<...@smtp...> +sendHtmlEmailEither says: sent to=alice@example.com subject='Reset your password - alice' messageId=<...@smtp...> ``` -Failures are logged at ERROR level with the exception stack trace. The signup -and anonymous password-reset flows currently treat send failures as -fire-and-forget (the user request still succeeds with a 201), so the log line -is the authoritative record that an email did or did not leave the JVM. The -self-test endpoint above is the operator's complement for confirming the SMTP -path works at all. +Failures are logged at ERROR level with the exception stack trace. The two +`Either`-returning variants (`sendTextEmailEither`, `sendHtmlEmailEither`) +preserve the exception so callers can classify the failure category (auth / +connect / TLS / recipient-rejected) and log a specific reason instead of a +generic "send failed". The self-test endpoint and the new resend endpoint both +use this; the existing signup/anon-reset endpoints have been switched to +`sendHtmlEmailEither` too so they can log specific failure causes. ### SMTP-level debugging diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 3cb264cf40..6cede202c2 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -4155,6 +4155,12 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ // Register defaults so they appear in getConfigPropsPairs publicAppUrlDefaults.foreach { case (key, default) => getPropsValue(key, default) } + // Canonical portal URL. Prefer the new public_*_url convention; fall back to the legacy + // portal_external_url. An empty-string prop is treated as unset so the fallback fires. + def getPortalUrl: Box[String] = + getPropsValue("public_obp_portal_url").filter(_.trim.nonEmpty) + .or(getPropsValue("portal_external_url").filter(_.trim.nonEmpty)) + // Returns config props matching the public_*_url convention. // Empty values are excluded (prop not configured). def getAppDiscoveryPairs: List[(String, String)] = { diff --git a/obp-api/src/main/scala/code/api/util/CommonsEmailWrapper.scala b/obp-api/src/main/scala/code/api/util/CommonsEmailWrapper.scala index e34688a7d3..6948f9178d 100644 --- a/obp-api/src/main/scala/code/api/util/CommonsEmailWrapper.scala +++ b/obp-api/src/main/scala/code/api/util/CommonsEmailWrapper.scala @@ -155,6 +155,38 @@ object CommonsEmailWrapper extends MdcLoggable { } } + def sendHtmlEmailEither(content: EmailContent): Either[Throwable, String] = { + if (isTestMode) Right("test-mode-message-id-" + System.currentTimeMillis()) + else sendHtmlEmailEither(getDefaultEmailConfig(), content) + } + + def sendHtmlEmailEither(config: EmailConfig, content: EmailContent): Either[Throwable, String] = { + try { + val session = createSession(config) + val message = new MimeMessage(session) + setCommonHeaders(message, content) + val multipart = new MimeMultipart("alternative") + content.textContent.foreach { text => + val textPart = new MimeBodyPart() + textPart.setText(text, "UTF-8") + multipart.addBodyPart(textPart) + } + content.htmlContent.foreach { html => + val htmlPart = new MimeBodyPart() + htmlPart.setContent(html, "text/html; charset=UTF-8") + multipart.addBodyPart(htmlPart) + } + message.setContent(multipart) + Transport.send(message) + logger.info(s"sendHtmlEmailEither says: sent to=${content.to.mkString(",")} subject='${content.subject}' messageId=${message.getMessageID}") + Right(message.getMessageID) + } catch { + case e: Throwable => + logger.error(s"sendHtmlEmailEither says: failed to send html email: ${e.getMessage}", e) + Left(e) + } + } + def sendHtmlEmail(config: EmailConfig, content: EmailContent): Box[String] = { try { logger.debug(s"Sending HTML email from ${content.from} to ${content.to.mkString(", ")}") diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala index af28d2a8f0..db1e41ac6c 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -93,15 +93,15 @@ object StatusPage extends MdcLoggable { } private def runEmailChecks: EmailChecks = { - val portalUrl = APIUtil.getPropsValue("portal_external_url") + val portalUrl = APIUtil.getPortalUrl val sender = APIUtil.getPropsValue("mail.users.userinfo.sender.address", "noreply@example.com") - val portalMissing = portalUrl.isEmpty || portalUrl.exists(_.trim.isEmpty) + val portalMissing = portalUrl.isEmpty val senderIsDefault = sender == "noreply@example.com" val (configStatus, configDetail) = if (portalMissing && senderIsDefault) - "warn" -> "portal_external_url not set; mail.users.userinfo.sender.address is default 'noreply@example.com'" + "warn" -> "public_obp_portal_url (or legacy portal_external_url) not set; mail.users.userinfo.sender.address is default 'noreply@example.com'" else if (portalMissing) - "warn" -> "portal_external_url not set — validation/reset emails will be silently skipped" + "warn" -> "public_obp_portal_url (or legacy portal_external_url) not set — validation/reset emails will be silently skipped" else if (senderIsDefault) "warn" -> "mail.users.userinfo.sender.address is default 'noreply@example.com' — most SMTP servers will reject it" else diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 910cf9ddcb..a4d95448c9 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -121,7 +121,7 @@ object Http4s600 { val versionStatus: String = ApiVersionStatus.BLEEDING_EDGE.toString val resourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]() - object Implementations6_0_0 { + object Implementations6_0_0 extends code.util.Helper.MdcLoggable { val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString @@ -963,7 +963,16 @@ object Http4s600 { } yield { val skipEmailValidation = APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false) if (!skipEmailValidation) { - APIUtil.getPropsValue("portal_external_url").foreach { portalUrl => + val portalUrlBox = APIUtil.getPortalUrl + val senderAddress = AuthUser.emailFrom + val portalMissing = portalUrlBox.isEmpty + val senderIsDefault = senderAddress == "noreply@example.com" + if (portalMissing) { + logger.warn(s"createUser says: validation email NOT sent for user '${savedUser.username.get}' — public_obp_portal_url (or legacy portal_external_url) is not set. The user will be unable to validate via email. They can use POST /obp/v7.0.0/users/validation-emails to retry once the prop is configured.") + } else if (senderIsDefault) { + logger.warn(s"createUser says: validation email NOT sent for user '${savedUser.username.get}' — mail.users.userinfo.sender.address is still the default 'noreply@example.com' (most SMTP servers will reject this From address).") + } else { + val portalUrl = portalUrlBox.openOr("") val expiryMinutes = APIUtil.getPropsAsIntValue("email_validation_token_expiry_minutes", 1440) val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() .subject(savedUser.uniqueId.get) @@ -971,14 +980,20 @@ object Http4s600 { .issueTime(new java.util.Date()).build() val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) val emailLink = portalUrl + "/user-validation?token=" + java.net.URLEncoder.encode(jwtToken, "UTF-8") - CommonsEmailWrapper.sendHtmlEmail(CommonsEmailWrapper.EmailContent( - from = AuthUser.emailFrom, + val sendOutcome = CommonsEmailWrapper.sendHtmlEmailEither(CommonsEmailWrapper.EmailContent( + from = senderAddress, to = List(savedUser.email.get), bcc = AuthUser.bccEmail.toList, subject = "Sign up confirmation", textContent = Some(s"Welcome! Please validate your account: $emailLink"), htmlContent = Some(s"
Welcome! Please validate your account.
") )) + sendOutcome match { + case Right(msgId) => + logger.info(s"createUser says: validation email sent to '${savedUser.email.get}' messageId=$msgId") + case Left(e) => + logger.warn(s"createUser says: validation email send FAILED for user '${savedUser.username.get}' (${savedUser.email.get}): ${e.getClass.getSimpleName}: ${Option(e.getMessage).getOrElse("").take(200)}. The user can retry via POST /obp/v7.0.0/users/validation-emails once the SMTP issue is resolved.") + } } } AuthUser.grantDefaultEntitlementsToAuthUser(savedUser) @@ -1014,9 +1029,9 @@ object Http4s600 { case _ => throw new Exception("User not found, not validated, or email mismatch") } } - portalUrl <- APIUtil.getPropsValue("portal_external_url") match { + portalUrl <- APIUtil.getPortalUrl match { case Full(url) => Future.successful(url) - case _ => Future.failed(new Exception(s"$IncompleteServerConfiguration portal_external_url is not set")) + case _ => Future.failed(new Exception(s"$IncompleteServerConfiguration public_obp_portal_url (or legacy portal_external_url) is not set")) } } yield { val user: AuthUser = authUser @@ -1037,7 +1052,11 @@ object Http4s600 { textContent = Some(s"Please reset your password: $resetLink"), htmlContent = Some(s"Please reset your password: $resetLink
") )) - JSONFactory600.ResetPasswordUrlJsonV600(resetLink) + // The reset URL is intentionally NOT returned in the response. Returning + // it would let any caller with canCreateResetPasswordUrl complete a reset + // without controlling the target mailbox, defeating the email-proves- + // mailbox-ownership property of the flow. The link goes via email only. + JSONFactory600.ResetPasswordEmailSentJsonV600(status = "sent", to = user.email.get) } } } @@ -4127,9 +4146,15 @@ object Http4s600 { } } yield { val authUserBox = code.model.dataAccess.AuthUser.find( - net.liftweb.mapper.By(code.model.dataAccess.AuthUser.username, postedData.username)) - (authUserBox, APIUtil.getPropsValue("portal_external_url")) match { - case (Full(u), Full(portalUrl)) if u.validated.get && u.email.get == postedData.email => + net.liftweb.mapper.By(code.model.dataAccess.AuthUser.username, postedData.username), + net.liftweb.mapper.By(code.model.dataAccess.AuthUser.provider, Constant.localIdentityProvider)) + val portalUrlBox = APIUtil.getPortalUrl + val senderAddress = code.model.dataAccess.AuthUser.emailFrom + val portalMissing = portalUrlBox.isEmpty + val senderIsDefault = senderAddress == "noreply@example.com" + (authUserBox, portalMissing, senderIsDefault) match { + case (Full(u), false, false) if u.validated.get && u.email.get == postedData.email => + val portalUrl = portalUrlBox.openOr("") u.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", "")) u.save val expiryMinutes = APIUtil.getPropsAsIntValue("password_reset_token_expiry_minutes", 120) @@ -4139,14 +4164,25 @@ object Http4s600 { .issueTime(new java.util.Date()).build() val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) val resetLink = portalUrl + "/reset-password/" + java.net.URLEncoder.encode(jwtToken, "UTF-8") - CommonsEmailWrapper.sendHtmlEmail(CommonsEmailWrapper.EmailContent( - from = code.model.dataAccess.AuthUser.emailFrom, + val sendOutcome = CommonsEmailWrapper.sendHtmlEmailEither(CommonsEmailWrapper.EmailContent( + from = senderAddress, to = List(u.email.get), bcc = code.model.dataAccess.AuthUser.bccEmail.toList, subject = "Reset your password - " + u.username.get, textContent = Some(s"Please use the following link to reset your password: $resetLink"), htmlContent = Some(s"Please use the following link to reset your password:
"))) - case _ => // do nothing — return same response to prevent user enumeration + sendOutcome match { + case Right(msgId) => + logger.info(s"resetPasswordUrlAnonymous says: reset email sent to '${u.email.get}' messageId=$msgId") + case Left(e) => + logger.warn(s"resetPasswordUrlAnonymous says: SMTP send failed for user '${u.username.get}': ${e.getClass.getSimpleName}: ${Option(e.getMessage).getOrElse("").take(200)}") + } + case (_, true, _) => + logger.warn("resetPasswordUrlAnonymous says: skipped — public_obp_portal_url (or legacy portal_external_url) not set; cannot build reset link. Response returned as if successful (anti-enumeration).") + case (_, _, true) => + logger.warn("resetPasswordUrlAnonymous says: skipped — mail.users.userinfo.sender.address is still the default 'noreply@example.com'. Response returned as if successful (anti-enumeration).") + case _ => + logger.info("resetPasswordUrlAnonymous says: skipped (no matching validated local-provider user, or email mismatch). Response returned as if successful (anti-enumeration).") } JSONFactory600.ResetPasswordUrlAnonymousResponseJsonV600( "If the account exists, a password reset email has been sent.") @@ -7785,16 +7821,17 @@ object Http4s600 { 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. + "Create Password Reset URL and Send by Email", + s"""Create a new password reset URL for a user and send it to them by email. + |The URL travels only via email — it is NOT returned in the response. | |Authentication is Required. | |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 + |- Generates a unique password reset token (rotates the user's uniqueId) + |- Builds a reset URL using the portal_external_url property + |- Sends the URL to the user by email + |- Returns only delivery acknowledgement ({"status": "sent", "to": "Welcome! Please validate your account.
") + )) + outcome match { + case Right(msgId) => + logger.info(s"createValidationEmail says: resent validation email messageId=$msgId") + case Left(e) => + val (errMsg, _) = classifySmtpException(e) + logger.warn(s"createValidationEmail says: SMTP send failed: $errMsg") + } + } + case Full(_) => + logger.info("createValidationEmail says: skipped (user already validated or email mismatch)") + case _ => + logger.info("createValidationEmail says: skipped (no local-provider user with that username)") + } + } + } + standardAck + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createValidationEmail), + "POST", + "/users/validation-emails", + "Create Validation Email (Resend)", + """Create a new account-validation email for a user and send it by email. + |The validation link travels only via email; it is NOT returned in the + |response. + | + |This is the recovery endpoint for users who signed up but did not receive + |(or lost) the original validation email. The anonymous password-reset + |endpoint cannot help them — it filters on `validated=true`, which an + |unvalidated user is by definition not. + | + |No authentication or role is required. The endpoint is self-service: an + |unvalidated user cannot authenticate, so any auth requirement would make + |the endpoint useless to its intended caller. + | + |Anti-enumeration: the response is always the same generic acknowledgement, + |regardless of whether the user exists, is already validated, the rate + |limit was hit, or the SMTP send failed. The only way to find out what + |actually happened is the server log. + | + |Rate-limit: 3 attempts per email per hour (Redis-backed). Over-limit + |requests still receive the same 201 acknowledgement. + | + |The endpoint only operates on users whose provider is the local identity + |provider (`local_identity_provider` prop). OIDC / SSO users never have a + |validation-email flow and are not eligible. + | + |The validation token is the same one minted at signup (reuses + |`AuthUser.uniqueId`). Multiple resends produce the same link, not + |competing tokens — clicking any of the delivered emails works. + | + |Email configuration (portal_external_url, SMTP, sender address) must be + |set up correctly for delivery to succeed. See /status (Email section) and + |POST /obp/v7.0.0/management/self-test-emails for diagnostics. + |""".stripMargin, + JSONFactory700.PostValidationEmailRequestJsonV700( + username = "alice", + email = "alice@example.com" + ), + JSONFactory700.validationEmailResponseJsonV700Example, + List(InvalidJsonFormat, UnknownError), + apiTagUser :: apiTagEmail :: Nil, + None, + http4sPartialFunction = Some(createValidationEmail) + ) + // ── Organisations ───────────────────────────────────────────────────────── // CRUD for the Organisation resource. Migrated from v6.0.0 (Lift) to v7.0.0 // (http4s). Path uses ORGANISATION_ID; not resolved by middleware (only BANK_ID diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala index bc664cda84..e0e0529ad3 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala @@ -1048,4 +1048,20 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { max_time_to_live_in_seconds = code.api.Constant.DEFAULT_CONSENT_TTL, sca_enabled = true ) + + // ─── Validation email (anonymous resend) ──────────────────────────────────── + // The request identifies the target by (username, email). The response is the + // same generic acknowledgement regardless of whether the user exists, is + // already validated, the rate limit was hit, or the SMTP send failed — this + // is the anti-enumeration property of the endpoint. + case class PostValidationEmailRequestJsonV700( + username: String, + email: String + ) + + case class ValidationEmailResponseJsonV700(message: String) + + lazy val validationEmailResponseJsonV700Example = ValidationEmailResponseJsonV700( + message = "If an unvalidated account exists for this username and email, a validation email has been sent." + ) } 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 index e66439a392..c2f018db87 100644 --- 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 @@ -131,11 +131,13 @@ class PasswordResetTest extends V600ServerSetup { withClue(s"Response body: ${response600.body} ") { 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("/reset-password/") - resetUrl.split("/reset-password/").last.length should be > 0 + response600.body.extractOpt[JSONFactory600.ResetPasswordEmailSentJsonV600].isDefined should equal(true) + And("The response should acknowledge delivery without leaking the reset URL") + val ack = response600.body.extract[JSONFactory600.ResetPasswordEmailSentJsonV600] + ack.status should equal("sent") + ack.to should equal(postJson.email) + And("The response body must NOT contain a reset_password_url field") + (response600.body \ "reset_password_url") should equal(net.liftweb.json.JNothing) } scenario("We will call the endpoint with unvalidated user", ApiEndpoint1, VersionOfApi) { @@ -381,20 +383,27 @@ class PasswordResetTest extends V600ServerSetup { .saveMe() val resourceUser: Box[User] = Users.users.vend.getUserByResourceUserId(authUser.user.get) - When("We request a password reset URL via the authenticated endpoint") + When("We request a password reset email via the authenticated endpoint") val resetUrlRequest = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1) val resetUrlJson = JSONFactory600.PostResetPasswordUrlJsonV600(testUsername, testEmail, resourceUser.map(_.userId).getOrElse("")) val resetUrlResponse = makePostRequest(resetUrlRequest, write(resetUrlJson)) - Then("We should get a 201 with a reset URL") + Then("We should get a 201 acknowledgement (no URL leaked in the body)") withClue(s"Response body: ${resetUrlResponse.body} ") { resetUrlResponse.code should equal(201) } - val resetUrl = (resetUrlResponse.body \ "reset_password_url").extract[String] - resetUrl should include("/reset-password/") - - And("We extract the JWT token from the URL (URL-decoded)") - val encodedToken = resetUrl.split("/reset-password/").last - val token = java.net.URLDecoder.decode(encodedToken, "UTF-8") + val ack = resetUrlResponse.body.extract[JSONFactory600.ResetPasswordEmailSentJsonV600] + ack.status should equal("sent") + ack.to should equal(testEmail) + + And("The endpoint rotated the user's uniqueId; we mint a matching JWT to drive the complete step") + val rotatedAuthUser = AuthUser.find(By(AuthUser.username, testUsername)).openOrThrowException("user gone after reset request") + val expiryMinutes = code.api.util.APIUtil.getPropsAsIntValue("password_reset_token_expiry_minutes", 120) + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(rotatedAuthUser.uniqueId.get) + .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) + .issueTime(new java.util.Date()) + .build() + val token = CertificateUtil.jwtWithHmacProtection(claimsSet) token.length should be > 0 When("We complete the password reset with the JWT token") diff --git a/obp-api/src/test/scala/code/api/v6_0_0/V6EntitlementCascadeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/V6EntitlementCascadeTest.scala new file mode 100644 index 0000000000..7daae43356 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/V6EntitlementCascadeTest.scala @@ -0,0 +1,44 @@ +package code.api.v6_0_0 + +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON +import code.api.util.APIUtil.OAuth._ +import code.api.util.ErrorMessages +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import code.setup.DefaultUsers +import net.liftweb.json.Serialization.write +import org.scalatest.Tag + +class V6EntitlementCascadeTest extends V600ServerSetup with DefaultUsers { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + + feature(s"POST /users/USER_ID/entitlements must reach a handler at $VersionOfApi via the bridge cascade") { + + scenario("Unauthenticated POST /obp/v6.0.0/users/USER_ID/entitlements must NOT 404", VersionOfApi) { + When("We POST without credentials to the v6.0.0 path") + val requestPost = + (v6_0_0_Request / "users" / resourceUser1.userId / "entitlements").POST + val body = write(SwaggerDefinitionsJSON.createEntitlementJSON) + val response = makePostRequest(requestPost, body) + + Then("We should NOT get 404 — addEntitlement (v2.0.0) must be reachable via cascade") + info(s"Status: ${response.code}; body: ${response.body}") + response.code should not equal 404 + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + } + + scenario("Unauthenticated GET /obp/v6.0.0/users/USER_ID/entitlements must NOT 404", VersionOfApi) { + When("We GET without credentials") + val requestGet = + (v6_0_0_Request / "users" / resourceUser1.userId / "entitlements").GET + val response = makeGetRequest(requestGet) + + Then("We should NOT get 404 — getEntitlements (v4.0.0 override / v2.0.0) must be reachable via cascade") + info(s"Status: ${response.code}; body: ${response.body}") + response.code should not equal 404 + response.code should equal(401) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 4505fb3c97..77678eb7a2 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -2908,4 +2908,114 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ── POST /obp/v7.0.0/users/validation-emails (anonymous resend) ───────────── + // Anti-enumeration design: every reachable response shape is the same 201. + // We assert the response shape and (separately, via DB / log inspection if + // wanted) that the right server-side branch was taken. Here we just confirm + // the contract: 201 + standard message for every input that parses. + feature("POST /obp/v7.0.0/users/validation-emails — anonymous resend validation email") { + + val expectedMessage = + "If an unvalidated account exists for this username and email, a validation email has been sent." + + scenario("Returns 201 standard message for an unknown user (no enumeration)", Http4s700RoutesTag) { + When("we POST a (username, email) pair that does not match any user") + val body = """{"username":"definitely-not-a-real-user","email":"nobody@example.com"}""" + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", "/obp/v7.0.0/users/validation-emails", body) + Then("we get 201 with the standard anti-enumeration message") + statusCode shouldBe 201 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(m)) => m shouldBe expectedMessage + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Returns 201 standard message for an already-validated user (no enumeration)", Http4s700RoutesTag) { + Given("a validated local-provider user") + val username = "already-validated-" + System.currentTimeMillis() + val email = s"$username@example.com" + val u = code.model.dataAccess.AuthUser.create + .username(username) + .email(email) + .provider(code.api.Constant.localIdentityProvider) + .password("Aa1!" + java.util.UUID.randomUUID().toString) + .validated(true) + .saveMe() + try { + When("we POST the resend request") + val body = s"""{"username":"$username","email":"$email"}""" + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", "/obp/v7.0.0/users/validation-emails", body) + Then("we get the same 201 standard message — caller cannot tell the user exists or is already validated") + statusCode shouldBe 201 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(m)) => m shouldBe expectedMessage + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } finally u.delete_! + } + + scenario("Returns 201 standard message for an unvalidated user (mail.test.mode logs the would-be send)", Http4s700RoutesTag) { + Given("an unvalidated local-provider user (validation email enabled)") + val username = "needs-validation-" + System.currentTimeMillis() + val email = s"$username@example.com" + val u = code.model.dataAccess.AuthUser.create + .username(username) + .email(email) + .provider(code.api.Constant.localIdentityProvider) + .password("Aa1!" + java.util.UUID.randomUUID().toString) + .validated(false) + .saveMe() + try { + When("we POST the resend request") + val body = s"""{"username":"$username","email":"$email"}""" + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", "/obp/v7.0.0/users/validation-emails", body) + Then("we get the standard 201 acknowledgement") + statusCode shouldBe 201 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(m)) => m shouldBe expectedMessage + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } finally u.delete_! + } + + scenario("Returns 400 InvalidJsonFormat for a malformed body (not anti-enumeration territory)", Http4s700RoutesTag) { + When("we POST a body that cannot parse") + val (statusCode, _, _) = makeHttpRequestWithBody( + "POST", "/obp/v7.0.0/users/validation-emails", "not json at all") + Then("we get 400 — body-shape errors are about the request, not user existence, so they don't leak") + statusCode shouldBe 400 + } + + scenario("Returns 201 standard message when username and email are blank (silently no-ops)", Http4s700RoutesTag) { + When("we POST empty strings") + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", "/obp/v7.0.0/users/validation-emails", """{"username":"","email":""}""") + Then("we still get the same 201 message") + statusCode shouldBe 201 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(m)) => m shouldBe expectedMessage + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + } + }