Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -740,21 +740,77 @@ 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`:

```
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

Expand Down
6 changes: 6 additions & 0 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)] = {
Expand Down
32 changes: 32 additions & 0 deletions obp-api/src/main/scala/code/api/util/CommonsEmailWrapper.scala
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
}
}
logger.info("=" * 80)
Full("test-mode-message-id-" + System.currentTimeMillis())

Check failure on line 89 in obp-api/src/main/scala/code/api/util/CommonsEmailWrapper.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "test-mode-message-id-" 3 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZ5hHGN8GJg5i4Xl2gdS&open=AZ5hHGN8GJg5i4Xl2gdS&pullRequest=2812
}

def sendTextEmail(content: EmailContent): Box[String] = {
Expand Down Expand Up @@ -155,6 +155,38 @@
}
}

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")

Check failure on line 176 in obp-api/src/main/scala/code/api/util/CommonsEmailWrapper.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "text/html; charset=UTF-8" 3 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZ5hHGN8GJg5i4Xl2gdT&open=AZ5hHGN8GJg5i4Xl2gdT&pullRequest=2812
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(", ")}")
Expand Down
8 changes: 4 additions & 4 deletions obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 67 additions & 22 deletions obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -963,22 +963,37 @@ 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)
.expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L))
.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"<p>Welcome! Please <a href='$emailLink'>validate your account</a>.</p>")
))
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)
Expand Down Expand Up @@ -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
Expand All @@ -1037,7 +1052,11 @@ object Http4s600 {
textContent = Some(s"Please reset your password: $resetLink"),
htmlContent = Some(s"<p>Please reset your password: <a href='$resetLink'>$resetLink</a></p>")
))
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)
}
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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"<p>Please use the following link to reset your password:</p><p><a href='$resetLink'>$resetLink</a></p>")))
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.")
Expand Down Expand Up @@ -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": "<email>"})
|
|Required fields:
|- username: The user's username (typically email)
Expand All @@ -7803,16 +7840,24 @@ object Http4s600 {
|
|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.
|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.
|
|Security note: 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.
|
|""".stripMargin,
PostResetPasswordUrlJsonV600(
"user@example.com",
"user@example.com",
"74a8ebcc-10e4-4036-bef3-9835922246bf"
),
ResetPasswordUrlJsonV600(
"https://api.example.com/reset-password/QOL1CPNJPCZ4BRMPX3Z01DPOX1HMGU3L"
ResetPasswordEmailSentJsonV600(
status = "sent",
to = "user@example.com"
),
List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError),
apiTagUser :: Nil,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2003,7 +2003,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
user_id: String
)

case class ResetPasswordUrlJsonV600(reset_password_url: String)
case class ResetPasswordEmailSentJsonV600(status: String, to: String)

case class PostResetPasswordUrlAnonymousJsonV600(
username: String,
Expand Down
Loading
Loading