diff --git a/README.md b/README.md index 33e7df4c4d..d9302b322c 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,28 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO obp; 1. Then, restart OBP-API. +### Notes on using MS SQL Server + +Set the database connection properties in your props file. You can either embed credentials in the URL or use separate props: + +**Option 1: Credentials in the URL** + +``` +db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver +db.url=jdbc:sqlserver://YOUR_HOST:1433;databaseName=YOUR_DB;user=YOUR_USER;password=YOUR_PASSWORD;encrypt=true;trustServerCertificate=true +``` + +**Option 2: Separate props (recommended)** + +``` +db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver +db.url=jdbc:sqlserver://YOUR_HOST:1433;databaseName=YOUR_DB;encrypt=true;trustServerCertificate=true +db.user=YOUR_USER +db.password=YOUR_PASSWORD +``` + +Option 2 is recommended because it keeps credentials out of the URL and avoids URL parsing issues. Note that `db.user` and `db.password` take priority over any credentials in the URL. + ### Notes on using Postgres with SSL Postgres needs to be compiled with SSL support. diff --git a/obp-api/src/main/scala/code/api/util/DBUtil.scala b/obp-api/src/main/scala/code/api/util/DBUtil.scala index 638a7446bf..de2e63b2b8 100644 --- a/obp-api/src/main/scala/code/api/util/DBUtil.scala +++ b/obp-api/src/main/scala/code/api/util/DBUtil.scala @@ -82,12 +82,13 @@ object DBUtil { } private def getOtherDbConnectionParameters: (String, String, String) = { - val usernameAndPassword = dbUrl.split("\\?").filter(_.contains("user")).mkString - val username = usernameAndPassword.split("&").filter(_.contains("user")).mkString.split("=")(1) - val password = usernameAndPassword.split("&").filter(_.contains("password")).mkString.split("=")(1) - val dbUser = APIUtil.getPropsValue("db.user").getOrElse(username) - val dbPassword = APIUtil.getPropsValue("db.password").getOrElse(password) - (dbUrl, dbUser, dbPassword) + // Split URL parameters by both ? / & (PostgreSQL, MySQL) and ; (SQL Server) + val params = dbUrl.split("[?&;]") + val username = params.find(_.startsWith("user=")).map(_.split("=", 2)(1)) + val password = params.find(_.startsWith("password=")).map(_.split("=", 2)(1)) + val dbUser = APIUtil.getPropsValue("db.user").orElse(username) + val dbPassword = APIUtil.getPropsValue("db.password").orElse(password) + (dbUrl, dbUser.getOrElse(""), dbPassword.getOrElse("")) } // H2 database has specific bd url string which is different compared to other databases private def getH2DbConnectionParameters: (String, String, String) = { 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 d0b977de8b..387f271c87 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -292,7 +292,7 @@ object ErrorMessages { val NotValidRfc7231Date = "OBP-20257: Request header Date is not in accordance with RFC 7231 " val DuplicateUsername = "OBP-20258: Duplicate Username. Cannot create Username because it already exists. " - + val ExternalUserCheckFailed = "OBP-20259: Could not check username uniqueness against the external provider. The Connector or Adapter may not be running. " // X.509 diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index f648a413b7..7b7553eba4 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -5147,6 +5147,243 @@ object Glossary extends MdcLoggable { | """) + glossaryItems += GlossaryItem( + title = "Credential Checking Flow", + description = + s""" + |### Overview + | + |OBP supports both **local** and **external** credential checking. Local credentials are verified against the AuthUser table (bcrypt). External credentials are delegated to a core banking system or identity provider via the Connector. + | + |### Login Flow (Web Form and DirectLogin) + | + |``` + | ┌─────────────────────────┐ + | │ LOGIN REQUEST │ + | │ (username + password) │ + | │ │ + | │ Via: Web Form login() │ + | │ or DirectLogin header │ + | └────────────┬─────────────┘ + | │ + | ▼ + | ┌─────────────────────────┐ + | │ Look up AuthUser by │ + | │ username in local DB │ + | └────────────┬─────────────┘ + | │ + | ┌─────────────────┼─────────────────┐ + | │ │ │ + | ▼ ▼ ▼ + | ┌──────────┐ ┌─────────────┐ ┌───────────┐ + | │ FOUND │ │ FOUND │ │ NOT FOUND │ + | │ Local │ │ External │ │ │ + | │ Provider │ │ Provider │ │ │ + | └────┬─────┘ └──────┬──────┘ └─────┬─────┘ + | │ │ │ + | ▼ ▼ ▼ + | ┌────────────┐ ┌─────────────┐ ┌──────────────┐ + | │ Validated? │ │ Validated? │ │ Props: │ + | │ Locked? │ │ Locked? │ │ connector. │ + | └─────┬──────┘ └──────┬──────┘ │ user.auth │ + | │ │ │ == true? │ + | ┌──Yes─┘ │ └──────┬───────┘ + | │ │ No┌──┘Yes + | ▼ │ ▼ │ + | ┌───────────────┐ │ ┌──────┐ │ + | │ testPassword() │ │ │REJECT│ │ + | │ (local bcrypt │ │ └──────┘ │ + | │ check) │ │ │ + | └───────┬────────┘ │ │ + | │ ▼ ▼ + | │ ┌─────────────┐ ┌──────────────────┐ + | │ │ Props: │ │ │ + | │ │ connector. │ │ externalUser │ + | │ │ user.auth │ │ Helper() │ + | │ │ == true? │ │ │ + | │ └──────┬──────┘ └────────┬─────────┘ + | │ No┌──┘Yes │ + | │ ▼ │ │ + | │ ┌──────┐ │ │ + | │ │REJECT│ │ │ + | │ └──────┘ │ │ + | │ ▼ │ + | │ ┌──────────────────────────────┘ + | │ │ + | │ ▼ + | │ ╔══════════════════════════════════════════════════╗ + | │ ║ checkExternalUserViaConnector() ║ + | │ ║ ║ + | │ ║ Connector.checkExternalUserCredentials ║ + | │ ║ (username, password) ║ + | │ ║ ║ + | │ ║ ┌──────────────┬──────────────┬──────────────┐ ║ + | │ ║ │ Akka │ StoredProc │ LocalMapped │ ║ + | │ ║ │ Connector │ Connector │ Connector │ ║ + | │ ║ │ │ │ │ ║ + | │ ║ │ southSide │ HTTP call to │ Returns │ ║ + | │ ║ │ Actor msg │ stored proc │ Failure("") │ ║ + | │ ║ │ "obp.check │ "obp_check_ │ (N/A) │ ║ + | │ ║ │ External │ external_ │ │ ║ + | │ ║ │ UserCreds" │ user_creds" │ │ ║ + | │ ║ └──────┬───────┴──────┬───────┴──────────────┘ ║ + | │ ║ │ │ ║ + | │ ║ ▼ ▼ ║ + | │ ║ ┌──────────────────────────┐ ║ + | │ ║ │ External System / │ ║ + | │ ║ │ Core Banking Adapter │ ║ + | │ ║ │ │ ║ + | │ ║ │ Validates credentials │ ║ + | │ ║ │ Returns: │ ║ + | │ ║ │ InboundExternalUser │ ║ + | │ ║ │ - sub (user id) │ ║ + | │ ║ │ - iss (provider) │ ║ + | │ ║ │ - email │ ║ + | │ ║ │ - emailVerified │ ║ + | │ ║ │ - name │ ║ + | │ ║ │ - userAuthContext │ ║ + | │ ║ └────────────┬─────────────┘ ║ + | │ ╚════════════════╪═════════════════════════════════╝ + | │ │ + | │ ┌─────┴──────┐ + | │ │ │ + | │ Success Failure + | │ │ │ + | │ ▼ ▼ + | │ ┌────────────────┐ ┌────────────┐ + | │ │ User exists │ │ Increment │ + | │ │ locally by │ │ bad login │ + | │ │ (sub, iss)? │ │ attempts │ + | │ └───┬────────┬───┘ │ → REJECT │ + | │ │ │ └────────────┘ + | │ Yes No + | │ │ │ + | │ ▼ ▼ + | │ ┌───────┐ ┌──────────────────┐ + | │ │ Use │ │ Create new │ + | │ │ exist-│ │ AuthUser + │ + | │ │ ing │ │ ResourceUser │ + | │ │ Auth │ │ user = sub │ + | │ │ User │ │ provider = iss │ + | │ │ │ │ password = UUID │ + | │ │ │ │ (dummy, unused) │ + | │ └───┬───┘ └────────┬─────────┘ + | │ └──────┬───────┘ + | │ │ + | ┌─────┴──────────────┘ + | │ + | ▼ + |┌─────────────┐ ┌──────────────┐ + |│ SUCCESS │ │ FAILURE │ + |│ │ │ │ + |│ Reset bad │ │ Increment │ + |│ login │ │ bad login │ + |│ attempts │ │ attempts │ + |│ │ │ │ + |│ Establish │ │ Lock if max │ + |│ session │ │ exceeded │ + |│ │ │ │ + |│ Redirect │ │ Return error │ + |└─────────────┘ └──────────────┘ + |``` + | + |### Decision Logic + | + |The **provider** field on the AuthUser record determines which path is taken: + | + |- **Local provider** (e.g. the OBP instance URL) → bcrypt password check via `testPassword()` + |- **External provider** (e.g. `google.com`) → delegated to the Connector via `checkExternalUserCredentials()` + |- **User not found locally** → can still succeed if `connector.user.authentication=true` is set. The system creates a new AuthUser + ResourceUser on the fly from the adapter response. + | + |The property `connector.user.authentication=true` must be set to enable external credential checking. Without it, external auth is rejected. + | + |### Verify Credentials Endpoint (POST /users/verify-credentials) + | + |In addition to the login flows above, OBP v6.0.0 provides a **credential verification endpoint** that validates credentials **without** creating a session or token. + | + |``` + | ┌──────────────────────────────────────────────┐ + | │ POST /obp/v6.0.0/users/verify-credentials │ + | │ │ + | │ Body: { username, password, provider } │ + | │ │ + | │ → Does NOT create session/token │ + | │ → Just validates and returns user info │ + | │ → For external systems to verify creds │ + | └────────────────────┬─────────────────────────┘ + | │ + | ▼ + | ┌─────────────────────┐ + | │ authenticatedAccess │ + | │ (caller must already │ + | │ be logged in) │ + | └──────────┬──────────┘ + | │ + | ▼ + | ┌─────────────────────┐ + | │ Check role: │ + | │ isSuperAdmin? │ + | │ OR has │ + | │ canVerifyUserCreds? │ + | └──────────┬──────────┘ + | │ + | ▼ + | ┌────────────────────────────────────────┐ + | │ AuthUser.getResourceUserId │ + | │ (username, password) │ + | │ │ + | │ Same method used by DirectLogin and │ + | │ the login flows above │ + | └──────────────────┬─────────────────────┘ + | │ + | (same local / external / not-found + | branching as the login flow above) + | │ + | ▼ + | ┌───────────────────┐ + | │ Locked? │──Yes──▶ 401 + | └────────┬──────────┘ + | │ No + | ▼ + | ┌───────────────────┐ + | │ Valid userId? │──No───▶ 401 + | └────────┬──────────┘ + | │ Yes + | ▼ + | ┌───────────────────┐ + | │ Provider matches │ + | │ posted provider? │──No───▶ 401 + | │ (if non-empty) │ + | └────────┬──────────┘ + | │ Yes + | ▼ + | ┌───────────────────┐ + | │ 200 OK │ + | │ Return UserJson │ + | │ │ + | │ NO token created │ + | │ NO session created│ + | └───────────────────┘ + |``` + | + |**Key differences from the login flows:** + | + |1. **Check only** — validates credentials and returns user info, but does not create a session or token + |2. **Requires an already-authenticated caller** with `canVerifyUserCredentials` role (or SuperAdmin) + |3. **Does not auto-provision users** — unlike `externalUserHelper()` in the web login flow, this endpoint will not create a new AuthUser if the user doesn't exist locally + |4. **Provider matching** — optionally verifies the user's provider matches what was posted (skipped if provider is empty) + | + |### Key Source Files + | + |- `AuthUser.scala` — `login()` entry point, `getResourceUserId()`, `checkExternalUserViaConnector()` + |- `directlogin.scala` — `getUserId()` with local-then-external fallback + |- `Connector.scala` — `checkExternalUserCredentials()` abstract method + |- `AkkaConnector_vDec2018.scala` — Akka connector implementation + |- `StoredProcedureConnector_vDec2019.scala` — Stored procedure connector implementation + |- `APIMethods600.scala` — `verifyUserCredentials` endpoint definition + | +""") + /////////////////////////////////////////////////////////////////// // NOTE! Some glossary items are generated in ExampleValue.scala ////////////////////////////////////////////////////////////////// diff --git a/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala b/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala index 3115556587..06e72fbe8e 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala @@ -1319,7 +1319,7 @@ trait APIMethods200 { |""", createUserJson, userJsonV200, - List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidStrongPasswordFormat, DuplicateUsername, "Error occurred during user creation.", UnknownError), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidStrongPasswordFormat, DuplicateUsername, ExternalUserCheckFailed, "Error occurred during user creation.", UnknownError), List(apiTagUser, apiTagOnboarding)) lazy val createUser: OBPEndpoint = { 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 98036c6ee1..2fcde5e0b4 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 @@ -4268,7 +4268,7 @@ trait APIMethods600 { |""", createUserJsonV600, userJsonV200, - List(InvalidJsonFormat, InvalidStrongPasswordFormat, DuplicateUsername, "Error occurred during user creation.", UnknownError), + List(InvalidJsonFormat, InvalidStrongPasswordFormat, DuplicateUsername, ExternalUserCheckFailed, "Error occurred during user creation.", UnknownError), List(apiTagUser, apiTagOnboarding)) lazy val createUser: OBPEndpoint = { @@ -5388,7 +5388,7 @@ trait APIMethods600 { "74a8ebcc-10e4-4036-bef3-9835922246bf" ), ResetPasswordUrlJsonV600( - "https://api.example.com/user_mgt/reset_password/QOL1CPNJPCZ4BRMPX3Z01DPOX1HMGU3L" + "https://api.example.com/reset-password/QOL1CPNJPCZ4BRMPX3Z01DPOX1HMGU3L" ), List( $AuthenticatedUserIsRequired, @@ -5452,9 +5452,9 @@ trait APIMethods600 { .build() val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) - // Construct reset URL using portal_hostname + // Construct reset URL using portal_external_url val resetPasswordLink = APIUtil.getPropsValue("portal_external_url", Constant.HostName) + - "/user_mgt/reset_password/" + + "/reset-password/" + java.net.URLEncoder.encode(jwtToken, "UTF-8") // Send email using CommonsEmailWrapper (like createUser does) @@ -5562,7 +5562,7 @@ trait APIMethods600 { // Construct reset URL val resetPasswordLink = APIUtil.getPropsValue("portal_external_url", Constant.HostName) + - "/user_mgt/reset_password/" + + "/reset-password/" + java.net.URLEncoder.encode(jwtToken, "UTF-8") // Send email 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 5b5ef21cc4..2e98b89a3b 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala @@ -225,13 +225,13 @@ class AuthUser extends MegaProtoUser[AuthUser] with CreatedUpdated with MdcLogga Nil // All good. Allow username creation case Failure(failureMsg, exception, chain) => logger.warn(s"valUniqueExternally: checkExternalUserExists failed for username: $uniqueUsername, message: $failureMsg, exception: ${exception.map(_.getMessage)}, chain: $chain") - List(FieldError(this, Text(msg))) + List(FieldError(this, Text(ErrorMessages.ExternalUserCheckFailed))) case Empty => logger.warn(s"valUniqueExternally: checkExternalUserExists returned Empty for username: $uniqueUsername") - List(FieldError(this, Text(msg))) + List(FieldError(this, Text(ErrorMessages.ExternalUserCheckFailed))) case _ => // Any other case we provide error message logger.warn(s"valUniqueExternally: checkExternalUserExists returned unexpected result for username: $uniqueUsername") - List(FieldError(this, Text(msg))) + List(FieldError(this, Text(ErrorMessages.ExternalUserCheckFailed))) } } else { Nil // All good. Allow username creation 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 8bc09f4794..b3cea76206 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 @@ -132,8 +132,8 @@ class PasswordResetTest extends V600ServerSetup { 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 + resetUrl should include("/reset-password/") + resetUrl.split("/reset-password/").last.length should be > 0 } scenario("We will call the endpoint with unvalidated user", ApiEndpoint1, VersionOfApi) { @@ -386,10 +386,10 @@ class PasswordResetTest extends V600ServerSetup { Then("We should get a 201 with a reset URL") resetUrlResponse.code should equal(201) val resetUrl = (resetUrlResponse.body \ "reset_password_url").extract[String] - resetUrl should include("/user_mgt/reset_password/") + resetUrl should include("/reset-password/") And("We extract the JWT token from the URL (URL-decoded)") - val encodedToken = resetUrl.split("/user_mgt/reset_password/").last + val encodedToken = resetUrl.split("/reset-password/").last val token = java.net.URLDecoder.decode(encodedToken, "UTF-8") token.length should be > 0