diff --git a/README.md b/README.md index 69444c2..f3a2895 100644 --- a/README.md +++ b/README.md @@ -98,16 +98,48 @@ Accepted formats: - `/@//[:]/` - `/@::` - `/@` +- `/@` - credentials are taken from an Oracle Wallet (Secure External Password Store), see [Oracle Wallet](#oracle-wallet-secure-external-password-store) -To connect using TNS, you need to have the ORACLE_HOME environment variable set. -The file tnsnames.ora must exist in path %ORACLE_HOME%/network/admin -The file tnsnames.ora must contain valid TNS entries. +To connect using a TNS name, the file `tnsnames.ora` with a valid entry for that name must be found. +The directory holding `tnsnames.ora` (and `ojdbc.properties`, if used) is taken from the first of these that is set: + +1. `TNS_ADMIN` parameter in the connect string, e.g. `app/pass@MYDATABASE?TNS_ADMIN=/path/to/network/admin` +2. Java system property `oracle.net.tns_admin`, e.g. `export JAVA_OPTS="-Doracle.net.tns_admin=/path/to/network/admin"` +3. `TNS_ADMIN` environment variable +4. `$ORACLE_HOME/network/admin`, when the `ORACLE_HOME` environment variable is set + +Options 1-3 are handled by the Oracle JDBC driver. Option 4 is a fallback provided by utPLSQL-cli, used only when none of the others is set. In case you use a username containing `/` or a password containing `@` you should encapsulate it with double quotes `"`: ``` utplsql run "my/Username"/"myP@ssword"@connectstring ``` +#### Oracle Wallet (Secure External Password Store) + +To avoid passing the password on the command line, store the credentials in an Oracle Wallet and connect with `/@`: +``` +utplsql run /@MYDATABASE +``` + +Setup example: +``` +# create an auto-login wallet with credentials for TNS alias MYDATABASE +orapki wallet create -wallet $HOME/oracle/wallet -auto_login_local +mkstore -wrl $HOME/oracle/wallet -createCredential MYDATABASE someusername + +# point the JDBC driver to the wallet +echo "oracle.net.wallet_location=(SOURCE=(METHOD=FILE)(METHOD_DATA=(DIRECTORY=$HOME/oracle/wallet)))" \ + > $HOME/oracle/network/admin/ojdbc.properties + +# tnsnames.ora with the MYDATABASE entry must be in the same directory as ojdbc.properties +export TNS_ADMIN=$HOME/oracle/network/admin +``` + +`ojdbc.properties` is read from the same directory as `tnsnames.ora`, so any of the options listed under [ConnectionURL](#connectionurl) can be used instead of `TNS_ADMIN`, for example `utplsql run "/@MYDATABASE?TNS_ADMIN=/path/to/network/admin"`. + +The TNS alias used in the connect string must match the alias of the credential stored in the wallet. + ### run `utplsql run []` diff --git a/pom.xml b/pom.xml index 8e6966b..5d2501b 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ UTF-8 17 - 3.2.4 + 3.2.5-SNAPSHOT 5.12.2 4.7.7 diff --git a/src/main/java/org/utplsql/cli/ConnectionConfig.java b/src/main/java/org/utplsql/cli/ConnectionConfig.java index 05e2de7..d0ce618 100644 --- a/src/main/java/org/utplsql/cli/ConnectionConfig.java +++ b/src/main/java/org/utplsql/cli/ConnectionConfig.java @@ -5,15 +5,21 @@ public class ConnectionConfig { + /** + * Either {@code /@} or {@code /@}. + */ + private static final Pattern CONNECT_STRING_PATTERN = + Pattern.compile("^(?:(\".+\"|[^/]+)/(\".+\"|[^@]+)|/)@(.*)$"); + private final String user; private final String password; private final String connect; public ConnectionConfig(String connectString) { - Matcher m = Pattern.compile("^(\".+\"|[^/]+)/(\".+\"|[^@]+)@(.*)$").matcher(connectString); + Matcher m = CONNECT_STRING_PATTERN.matcher(connectString); if (m.find()) { - user = stripEnclosingQuotes(m.group(1)); - password = stripEnclosingQuotes(m.group(2)); + user = m.group(1) == null ? null : stripEnclosingQuotes(m.group(1)); + password = m.group(2) == null ? null : stripEnclosingQuotes(m.group(2)); connect = m.group(3); } else { throw new IllegalArgumentException("Not a valid connectString: '" + connectString + "'"); @@ -42,7 +48,18 @@ public String getPassword() { return password; } + /** + * @return true when no user/password was given (connect string {@code /@}), + * meaning credentials are provided externally, e.g. by an Oracle Wallet + */ + public boolean isExternalAuthentication() { + return user == null; + } + public String getConnectString() { + if (isExternalAuthentication()) { + return "/@" + connect; + } return user + "/" + password + "@" + connect; } diff --git a/src/main/java/org/utplsql/cli/DataSourceProvider.java b/src/main/java/org/utplsql/cli/DataSourceProvider.java index 4eee258..d3dbfcc 100644 --- a/src/main/java/org/utplsql/cli/DataSourceProvider.java +++ b/src/main/java/org/utplsql/cli/DataSourceProvider.java @@ -13,12 +13,32 @@ */ public class DataSourceProvider { + private static final String TNS_ADMIN_PROPERTY = "oracle.net.tns_admin"; + static { - String oracleHome = System.getenv("ORACLE_HOME"); - if (oracleHome != null && System.getProperty("oracle.net.tns_admin") == null) { - System.setProperty("oracle.net.tns_admin", - String.join(File.separator, oracleHome, "NETWORK", "ADMIN")); + String tnsAdminFallback = getTnsAdminFallback( + System.getProperty(TNS_ADMIN_PROPERTY), System.getenv("TNS_ADMIN"), System.getenv("ORACLE_HOME")); + if (tnsAdminFallback != null) { + System.setProperty(TNS_ADMIN_PROPERTY, tnsAdminFallback); + } + } + + /** + * The JDBC driver resolves tnsnames.ora / ojdbc.properties from the {@value TNS_ADMIN_PROPERTY} property + * or the TNS_ADMIN environment variable on its own, but it doesn't look into ORACLE_HOME. + * The property takes precedence over the environment variable, so it must not be set when TNS_ADMIN is. + * + * @return ORACLE_HOME/network/admin (lowercase, as in Oracle installations; paths are case-sensitive on Linux) when neither {@value TNS_ADMIN_PROPERTY} nor TNS_ADMIN is set, otherwise null + */ + static String getTnsAdminFallback(String tnsAdminProperty, String tnsAdminEnv, String oracleHome) { + if (isEmpty(tnsAdminProperty) && isEmpty(tnsAdminEnv) && !isEmpty(oracleHome)) { + return String.join(File.separator, oracleHome, "network", "admin"); } + return null; + } + + private static boolean isEmpty(String value) { + return value == null || value.isEmpty(); } public static DataSource getDataSource(String connectString, int maxConnections) throws SQLException { diff --git a/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java b/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java index 34fbb4b..f871c03 100644 --- a/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java +++ b/src/main/java/org/utplsql/cli/datasource/TestedDataSourceProvider.java @@ -49,8 +49,11 @@ private void setThickOrThinJdbcUrl(InitializableOracleDataSource ds) throws SQLE List errors = new ArrayList<>(); Throwable lastException = null; - ds.setUser(config.getUser()); - ds.setPassword(config.getPassword()); + // With external authentication (Oracle Wallet) the driver looks up the credentials itself + if (!config.isExternalAuthentication()) { + ds.setUser(config.getUser()); + ds.setPassword(config.getPassword()); + } for (ConnectStringPossibility possibility : possibilities) { logger.debug("Try connecting {}", possibility.getMaskedConnectString(config)); @@ -84,7 +87,7 @@ private void setInitSqlFrom_NLS_LANG(InitializableOracleDataSource ds) { sqlCommands.add(String.format("ALTER SESSION SET NLS_TERRITORY='%s'", matcher.group(2))); } - if (sqlCommands.size() > 0) { + if (!sqlCommands.isEmpty()) { StringBuilder sb = new StringBuilder(); sb.append("BEGIN\n"); for (String command : sqlCommands) { @@ -92,7 +95,7 @@ private void setInitSqlFrom_NLS_LANG(InitializableOracleDataSource ds) { } sb.append("END;"); - logger.debug("NLS settings: {}", sb.toString()); + logger.debug("NLS settings: {}", sb); ds.setConnectionInitSql(sb.toString()); } } @@ -107,7 +110,7 @@ public String getConnectString(ConnectionConfig config) { @Override public String getMaskedConnectString(ConnectionConfig config) { - return "jdbc:oracle:oci8:****/****@" + config.getConnect(); + return "jdbc:oracle:oci8:" + maskedCredentials(config) + "@" + config.getConnect(); } } @@ -119,7 +122,11 @@ public String getConnectString(ConnectionConfig config) { @Override public String getMaskedConnectString(ConnectionConfig config) { - return "jdbc:oracle:thin:****/****@" + config.getConnect(); + return "jdbc:oracle:thin:" + maskedCredentials(config) + "@" + config.getConnect(); } } + + private static String maskedCredentials(ConnectionConfig config) { + return config.isExternalAuthentication() ? "/" : "****/****"; + } } diff --git a/src/test/java/org/utplsql/cli/ConnectionConfigTest.java b/src/test/java/org/utplsql/cli/ConnectionConfigTest.java index ee1e0b9..f32ad4d 100644 --- a/src/test/java/org/utplsql/cli/ConnectionConfigTest.java +++ b/src/test/java/org/utplsql/cli/ConnectionConfigTest.java @@ -1,6 +1,8 @@ package org.utplsql.cli; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.*; @@ -54,4 +56,54 @@ void parseSpecialCharsUser() { assertEquals("my.local.host/service", info.getConnect()); assertFalse(info.isSysDba()); } + + @Test + void parseCredentialsIsNotExternalAuthentication() { + ConnectionConfig info = new ConnectionConfig("test/pw@MY_TNS_ALIAS"); + + assertFalse(info.isExternalAuthentication()); + assertEquals("test/pw@MY_TNS_ALIAS", info.getConnectString()); + } + + @Test + void parseExternalAuthentication() { + ConnectionConfig info = new ConnectionConfig("/@MY_TNS_ALIAS"); + + assertNull(info.getUser()); + assertNull(info.getPassword()); + assertEquals("MY_TNS_ALIAS", info.getConnect()); + assertTrue(info.isExternalAuthentication()); + assertFalse(info.isSysDba()); + assertEquals("/@MY_TNS_ALIAS", info.getConnectString()); + } + + @Test + void parseExternalAuthenticationWithTnsAdminInUrl() { + ConnectionConfig info = new ConnectionConfig("/@MY_TNS_ALIAS?TNS_ADMIN=/home/me/oracle/network/admin"); + + assertNull(info.getUser()); + assertNull(info.getPassword()); + assertEquals("MY_TNS_ALIAS?TNS_ADMIN=/home/me/oracle/network/admin", info.getConnect()); + assertTrue(info.isExternalAuthentication()); + } + + @Test + void parseExternalAuthenticationWithEzConnect() { + ConnectionConfig info = new ConnectionConfig("/@//my.local.host:1521/service"); + + assertEquals("//my.local.host:1521/service", info.getConnect()); + assertTrue(info.isExternalAuthentication()); + } + + @ParameterizedTest + @ValueSource(strings = { + "/pw@MY_TNS_ALIAS", // password without user + "test/@MY_TNS_ALIAS", // user without password + "@MY_TNS_ALIAS", + "test@MY_TNS_ALIAS", + "MY_TNS_ALIAS" + }) + void rejectInvalidConnectString(String connectString) { + assertThrows(IllegalArgumentException.class, () -> new ConnectionConfig(connectString)); + } } diff --git a/src/test/java/org/utplsql/cli/DataSourceProviderTest.java b/src/test/java/org/utplsql/cli/DataSourceProviderTest.java new file mode 100644 index 0000000..8e274f6 --- /dev/null +++ b/src/test/java/org/utplsql/cli/DataSourceProviderTest.java @@ -0,0 +1,36 @@ +package org.utplsql.cli; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class DataSourceProviderTest { + + private static final String ORACLE_HOME = "/opt/oracle"; + + @Test + void tnsAdminFallsBackToOracleHome() { + assertEquals(String.join(File.separator, ORACLE_HOME, "network", "admin"), + DataSourceProvider.getTnsAdminFallback(null, null, ORACLE_HOME)); + } + + @Test + void tnsAdminEnvVariableIsLeftToJdbcDriver() { + // Setting the property would override the TNS_ADMIN environment variable in the JDBC driver + assertNull(DataSourceProvider.getTnsAdminFallback(null, "/my/tns_admin", ORACLE_HOME)); + } + + @Test + void tnsAdminPropertyIsNotOverridden() { + assertNull(DataSourceProvider.getTnsAdminFallback("/my/tns_admin", null, ORACLE_HOME)); + } + + @Test + void noFallbackWithoutOracleHome() { + assertNull(DataSourceProvider.getTnsAdminFallback(null, null, null)); + assertNull(DataSourceProvider.getTnsAdminFallback("", "", "")); + } +} diff --git a/src/test/java/org/utplsql/cli/WalletConnectionIT.java b/src/test/java/org/utplsql/cli/WalletConnectionIT.java new file mode 100644 index 0000000..4b1d5eb --- /dev/null +++ b/src/test/java/org/utplsql/cli/WalletConnectionIT.java @@ -0,0 +1,172 @@ +package org.utplsql.cli; + +import oracle.security.pki.OracleSecretStore; +import oracle.security.pki.OracleWallet; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.utplsql.cli.datasource.TestedDataSourceProvider; + +import javax.sql.DataSource; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Connecting with {@code /@} using credentials stored in an Oracle Wallet + * (Secure External Password Store), see issue #225. + *

+ * The wallet, tnsnames.ora and ojdbc.properties are created on the fly from DB_URL / DB_USER / DB_PASS, + * so no Oracle client tooling (mkstore/orapki) is needed. + *

+ * TNS_ADMIN and ORACLE_HOME are read once per JVM and environment variables can't be changed at runtime, + * so the scenarios depending on them run the CLI in a separate JVM with a controlled environment. + */ +class WalletConnectionIT { + + private static final String TNS_ALIAS = "UTPLSQL_CLI_WALLET"; + private static final Pattern EZ_CONNECT = Pattern.compile("^//([^:/]+)(?::(\\d+))?/(.+)$"); + private static final long SUBPROCESS_TIMEOUT_MINUTES = 3; + + @TempDir + static Path tempDir; + + private static Path tnsAdmin; + private static Path oracleHome; + private static Path emptyOracleHome; + private static String walletConnectString; + + @BeforeAll + static void createWallet() throws Exception { + Matcher m = EZ_CONNECT.matcher(TestHelper.getUrl()); + assumeTrue(m.matches(), "DB_URL must be in //host[:port]/service format to generate tnsnames.ora"); + String host = m.group(1); + String port = m.group(2) == null ? "1521" : m.group(2); + String service = m.group(3); + + Path walletDir = Files.createDirectories(tempDir.resolve("wallet")); + char[] walletPassword = "Wallet_Pwd_123".toCharArray(); + + OracleWallet wallet = new OracleWallet(); + wallet.create(walletPassword); + OracleSecretStore secretStore = wallet.getSecretStore(); + secretStore.createCredential(TNS_ALIAS.toCharArray(), + TestHelper.getUser().toCharArray(), + TestHelper.getPass().toCharArray()); + wallet.setSecretStore(secretStore); + wallet.saveAs(walletDir.toString()); + wallet.createSSO(); + wallet.saveSSO(); + + assertTrue(Files.exists(walletDir.resolve("cwallet.sso")), "auto-login wallet was not created"); + + String tnsnamesOra = TNS_ALIAS + " = (DESCRIPTION = (ADDRESS = (PROTOCOL = TCP)(HOST = " + host + ")(PORT = " + port + "))" + + "(CONNECT_DATA = (SERVICE_NAME = " + service + ")))\n"; + String ojdbcProperties = "oracle.net.wallet_location=(SOURCE=(METHOD=FILE)(METHOD_DATA=(DIRECTORY=" + + forwardSlashes(walletDir) + ")))\n"; + + tnsAdmin = writeNetworkConfig(tempDir.resolve("tns_admin"), tnsnamesOra, ojdbcProperties); + oracleHome = tempDir.resolve("oracle_home"); + writeNetworkConfig(oracleHome.resolve("network").resolve("admin"), tnsnamesOra, ojdbcProperties); + emptyOracleHome = Files.createDirectories(tempDir.resolve("empty_oracle_home")); + + walletConnectString = "/@" + TNS_ALIAS + "?TNS_ADMIN=" + forwardSlashes(tnsAdmin); + } + + private static Path writeNetworkConfig(Path dir, String tnsnamesOra, String ojdbcProperties) throws IOException { + Files.createDirectories(dir); + Files.writeString(dir.resolve("tnsnames.ora"), tnsnamesOra); + Files.writeString(dir.resolve("ojdbc.properties"), ojdbcProperties); + return dir; + } + + private static String forwardSlashes(Path path) { + return path.toAbsolutePath().toString().replace('\\', '/'); + } + + @Test + void connectsAsWalletUser() throws Exception { + ConnectionConfig config = new ConnectionConfig(walletConnectString); + DataSource dataSource = new TestedDataSourceProvider(config, 1).getDataSource(); + + try (Connection con = dataSource.getConnection(); + Statement stmt = con.createStatement(); + ResultSet rs = stmt.executeQuery("select user from dual")) { + assertTrue(rs.next()); + assertEquals(TestHelper.getUser().toUpperCase(), rs.getString(1)); + } + } + + @Test + void runCommandWithWallet() { + int result = TestHelper.runApp("run", + walletConnectString, + "-f=ut_documentation_reporter", + "-s", + "--failure-exit-code=0"); + + assertEquals(0, result); + } + + @Test + void runCommandWithTnsAdminEnvironmentVariable() throws Exception { + assertCliConnectsWithWallet(Map.of("TNS_ADMIN", tnsAdmin.toString())); + } + + @Test + void runCommandWithOracleHomeFallback() throws Exception { + assertCliConnectsWithWallet(Map.of("ORACLE_HOME", oracleHome.toString())); + } + + @Test + void tnsAdminEnvironmentVariableTakesPrecedenceOverOracleHome() throws Exception { + // Fails if ORACLE_HOME/network/admin (without tnsnames.ora) is used instead of TNS_ADMIN + assertCliConnectsWithWallet(Map.of( + "TNS_ADMIN", tnsAdmin.toString(), + "ORACLE_HOME", emptyOracleHome.toString())); + } + + /** + * Runs {@code utplsql run /@} in a separate JVM with TNS_ADMIN and ORACLE_HOME + * replaced by the given environment + */ + private void assertCliConnectsWithWallet(Map environment) throws Exception { + Path java = Paths.get(System.getProperty("java.home"), "bin", "java"); + ProcessBuilder processBuilder = new ProcessBuilder( + java.toString(), "-cp", System.getProperty("java.class.path"), + Cli.class.getName(), "run", "/@" + TNS_ALIAS, + "-f=ut_documentation_reporter", "-s", "--failure-exit-code=0"); + + processBuilder.environment().remove("TNS_ADMIN"); + processBuilder.environment().remove("ORACLE_HOME"); + processBuilder.environment().putAll(environment); + + Path output = Files.createTempFile(tempDir, "cli-output", ".log"); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(output.toFile()); + + Process process = processBuilder.start(); + if (!process.waitFor(SUBPROCESS_TIMEOUT_MINUTES, TimeUnit.MINUTES)) { + process.destroyForcibly(); + fail("CLI did not finish within " + SUBPROCESS_TIMEOUT_MINUTES + " minutes. Output:\n" + Files.readString(output)); + } + + String cliOutput = Files.readString(output); + assertEquals(0, process.exitValue(), () -> "CLI failed with environment " + environment + ". Output:\n" + cliOutput); + assertTrue(cliOutput.contains("Use connection string jdbc:oracle:thin:/@" + TNS_ALIAS), + () -> "Expected wallet connection with environment " + environment + ". Output:\n" + cliOutput); + } +}