diff --git a/CHANGELOG.md b/CHANGELOG.md index e91e117..f5bbbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,31 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - This enables smooth switching between `useDynamicAccessTokenSigningKey` settings by allowing refresh calls to change the signing key type of a session +## [6.0.0] - 2024-03-05 + +- Implements `deleteAllUserRoleAssociationsForRole` +- Drops `(app_id, role)` foreign key constraint on `user_roles` table + +### Migration + +```sql +ALTER TABLE user_roles DROP FOREIGN KEY user_roles_ibfk_1; +ALTER TABLE user_roles DROP FOREIGN KEY user_roles_ibfk_2; +ALTER TABLE user_roles + ADD FOREIGN KEY (app_id, tenant_id) + REFERENCES tenants (app_id, tenant_id) ON DELETE CASCADE; +``` + +## [5.0.7] - 2024-02-19 + +- Fixes vulnerabilities in dependencies + +## [5.0.6] - 2024-01-25 + +- Fixes the issue where passwords were inadvertently logged in the logs. +- Adds tests to check connection pool behaviour. +- Adds `mysql_idle_connection_timeout` and `mysql_minimum_idle_connections` configs to control active connections to the database. + ## [5.0.5] - 2023-12-06 - Validates db config types in `canBeUsed` function diff --git a/build.gradle b/build.gradle index 85e59dd..12b2dca 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java-library' } -version = "5.0.5" +version = "6.0.0" repositories { mavenCentral() @@ -20,7 +20,7 @@ dependencies { implementation group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '2.6.0' // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic - compileOnly group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3' + compileOnly group: 'ch.qos.logback', name: 'logback-classic', version: '1.4.14' // https://mvnrepository.com/artifact/com.google.code.gson/gson compileOnly group: 'com.google.code.gson', name: 'gson', version: '2.3.1' @@ -32,10 +32,10 @@ dependencies { compileOnly group: 'org.jetbrains', name: 'annotations', version: '13.0' // https://mvnrepository.com/artifact/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml - compileOnly group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: '2.14.0' + compileOnly group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: '2.16.1' // https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core - compileOnly group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.14.0' + compileOnly group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.16.1' testImplementation 'junit:junit:4.12' @@ -43,10 +43,10 @@ dependencies { testImplementation group: 'org.mockito', name: 'mockito-core', version: '3.1.0' // https://mvnrepository.com/artifact/org.apache.tomcat.embed/tomcat-embed-core - testImplementation group: 'org.apache.tomcat.embed', name: 'tomcat-embed-core', version: '10.1.1' + testImplementation group: 'org.apache.tomcat.embed', name: 'tomcat-embed-core', version: '10.1.18' // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic - testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3' + testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.4.14' // https://mvnrepository.com/artifact/com.google.code.gson/gson testImplementation group: 'com.google.code.gson', name: 'gson', version: '2.3.1' @@ -54,10 +54,10 @@ dependencies { testImplementation 'com.tngtech.archunit:archunit-junit4:0.22.0' // https://mvnrepository.com/artifact/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml - testImplementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: '2.14.0' + testImplementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: '2.16.1' // https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core - testImplementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.14.0' + testImplementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.16.1' } jar { diff --git a/config.yaml b/config.yaml index 39df059..30a9555 100644 --- a/config.yaml +++ b/config.yaml @@ -75,4 +75,13 @@ mysql_config_version: 0 # (DIFFERENT_ACROSS_TENANTS | OPTIONAL | Default: "thirdparty_users") string value. Specify the name of the table that will # store the thirdparty recipe users. -# mysql_thirdparty_users_table_name \ No newline at end of file +# mysql_thirdparty_users_table_name + + +# (DIFFERENT_ACROSS_TENANTS | OPTIONAL | Default: 60000) long value. Timeout in milliseconds for the idle connections +# to be closed. +# mysql_idle_connection_timeout: + +# (DIFFERENT_ACROSS_TENANTS | OPTIONAL | Default: null) integer value. Minimum number of idle connections to be kept +# active. If not set, minimum idle connections will be same as the connection pool size. +# mysql_minimum_idle_connections: diff --git a/devConfig.yaml b/devConfig.yaml index b24223d..94c7164 100644 --- a/devConfig.yaml +++ b/devConfig.yaml @@ -71,4 +71,13 @@ mysql_password: "root" # (DIFFERENT_ACROSS_TENANTS | OPTIONAL | Default: "thirdparty_users") string value. Specify the name of the table that will # store the thirdparty recipe users. -# mysql_thirdparty_users_table_name \ No newline at end of file +# mysql_thirdparty_users_table_name + + +# (DIFFERENT_ACROSS_TENANTS | OPTIONAL | Default: 60000) long value. Timeout in milliseconds for the idle connections +# to be closed. +# mysql_idle_connection_timeout: + +# (DIFFERENT_ACROSS_TENANTS | OPTIONAL | Default: 1) integer value. Minimum number of idle connections to be kept +# active. If not set, minimum idle connections will be same as the connection pool size. +# mysql_minimum_idle_connections: diff --git a/implementationDependencies.json b/implementationDependencies.json index b814823..f6add0f 100644 --- a/implementationDependencies.json +++ b/implementationDependencies.json @@ -12,9 +12,9 @@ "src": "https://repo1.maven.org/maven2/com/zaxxer/HikariCP/3.4.1/HikariCP-3.4.1-sources.jar" }, { - "jar": "https://repo1.maven.org/maven2/org/slf4j/slf4j-api/1.7.25/slf4j-api-1.7.25.jar", - "name": "SLF4j API 1.7.25", - "src": "https://repo1.maven.org/maven2/org/slf4j/slf4j-api/1.7.25/slf4j-api-1.7.25-sources.jar" + "jar": "https://repo1.maven.org/maven2/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar", + "name": "SLF4j API 2.0.7", + "src": "https://repo1.maven.org/maven2/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7-sources.jar" } ] } diff --git a/jar/mysql-plugin-5.0.5.jar b/jar/mysql-plugin-6.0.0.jar similarity index 71% rename from jar/mysql-plugin-5.0.5.jar rename to jar/mysql-plugin-6.0.0.jar index 574146d..7364546 100644 Binary files a/jar/mysql-plugin-5.0.5.jar and b/jar/mysql-plugin-6.0.0.jar differ diff --git a/pluginInterfaceSupported.json b/pluginInterfaceSupported.json index a5fdc62..e9d4c14 100644 --- a/pluginInterfaceSupported.json +++ b/pluginInterfaceSupported.json @@ -1,6 +1,6 @@ { "_comment": "contains a list of plugin interfaces branch names that this core supports", "versions": [ - "4.0" + "5.0" ] } \ No newline at end of file diff --git a/src/main/java/io/supertokens/storage/mysql/ConnectionPool.java b/src/main/java/io/supertokens/storage/mysql/ConnectionPool.java index 6f0e26f..7f496a5 100644 --- a/src/main/java/io/supertokens/storage/mysql/ConnectionPool.java +++ b/src/main/java/io/supertokens/storage/mysql/ConnectionPool.java @@ -81,6 +81,10 @@ private synchronized void initialiseHikariDataSource() throws SQLException { } config.setMaximumPoolSize(userConfig.getConnectionPoolSize()); config.setConnectionTimeout(5000); + if (userConfig.getMinimumIdleConnections() != null) { + config.setMinimumIdle(userConfig.getMinimumIdleConnections()); + config.setIdleTimeout(userConfig.getIdleConnectionTimeout()); + } config.addDataSourceProperty("cachePrepStmts", "true"); config.addDataSourceProperty("prepStmtCacheSize", "250"); config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); @@ -129,7 +133,7 @@ static boolean isAlreadyInitialised(Start start) { } static void initPool(Start start, boolean shouldWait) throws DbInitException, SQLException { - if (isAlreadyInitialised(start)) { + if (isAlreadyInitialised(start)) { return; } Logging.info(start, "Setting up MySQL connection pool.", true); diff --git a/src/main/java/io/supertokens/storage/mysql/Start.java b/src/main/java/io/supertokens/storage/mysql/Start.java index ddf2527..a4dd0e3 100644 --- a/src/main/java/io/supertokens/storage/mysql/Start.java +++ b/src/main/java/io/supertokens/storage/mysql/Start.java @@ -102,6 +102,10 @@ import java.util.List; import java.util.Set; +import static io.supertokens.storage.mysql.QueryExecutorTemplate.execute; +import static io.supertokens.storage.mysql.QueryExecutorTemplate.update; + + public class Start implements SessionSQLStorage, EmailPasswordSQLStorage, EmailVerificationSQLStorage, ThirdPartySQLStorage, JWTRecipeSQLStorage, PasswordlessSQLStorage, UserMetadataSQLStorage, UserRolesSQLStorage, UserIdMappingStorage, @@ -112,7 +116,7 @@ public class Start // SaaS. If the core is not running in SuperTokens SaaS, this array has no effect. private static final String[] PROTECTED_DB_CONFIG = new String[]{"mysql_connection_pool_size", "mysql_connection_uri", "mysql_host", "mysql_port", "mysql_user", "mysql_password", - "mysql_database_name"}; + "mysql_database_name", "mysql_idle_connection_timeout", "mysql_minimum_idle_connections"}; private static final Object appenderLock = new Object(); public static boolean silent = false; @@ -1851,7 +1855,7 @@ public int deleteUserMetadata(AppIdentifier appIdentifier, String userId) throws @Override public void addRoleToUser(TenantIdentifier tenantIdentifier, String userId, String role) - throws StorageQueryException, UnknownRoleException, DuplicateUserRoleMappingException, + throws StorageQueryException, DuplicateUserRoleMappingException, TenantOrAppNotFoundException { try { UserRolesQueries.addRoleToUser(this, tenantIdentifier, userId, role); @@ -1860,9 +1864,6 @@ public void addRoleToUser(TenantIdentifier tenantIdentifier, String userId, Stri MySQLConfig config = Config.getConfig(this); String serverErrorMessage = e.getMessage(); - if (isForeignKeyConstraintError(serverErrorMessage, config.getUserRolesTable(), "role")) { - throw new UnknownRoleException(); - } if (isPrimaryKeyError(serverErrorMessage, config.getUserRolesTable())) { throw new DuplicateUserRoleMappingException(); } @@ -1933,6 +1934,16 @@ public boolean deleteRole(AppIdentifier appIdentifier, String role) throws Stora } } + @Override + public boolean deleteAllUserRoleAssociationsForRole(AppIdentifier appIdentifier, String role) + throws StorageQueryException { + try { + return UserRolesQueries.deleteAllUserRoleAssociationsForRole(this, appIdentifier, role); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + @Override public String[] getRoles(AppIdentifier appIdentifier) throws StorageQueryException { try { @@ -2966,4 +2977,17 @@ public static void setEnableForDeadlockTesting(boolean value) { assert(isTesting); enableForDeadlockTesting = value; } + + @TestOnly + public int getDbActivityCount(String dbname) throws SQLException, StorageQueryException { + String QUERY = "SELECT COUNT(*) as c FROM information_schema.processlist WHERE DB = ?;"; + return execute(this, QUERY, pst -> { + pst.setString(1, dbname); + }, result -> { + if (result.next()) { + return result.getInt("c"); + } + return -1; + }); + } } diff --git a/src/main/java/io/supertokens/storage/mysql/config/MySQLConfig.java b/src/main/java/io/supertokens/storage/mysql/config/MySQLConfig.java index 4ac4af3..52bc3fe 100644 --- a/src/main/java/io/supertokens/storage/mysql/config/MySQLConfig.java +++ b/src/main/java/io/supertokens/storage/mysql/config/MySQLConfig.java @@ -108,6 +108,14 @@ public class MySQLConfig { @ConnectionPoolProperty private String mysql_connection_scheme = "mysql"; + @JsonProperty + @ConnectionPoolProperty + private long mysql_idle_connection_timeout = 60000; + + @JsonProperty + @ConnectionPoolProperty + private Integer mysql_minimum_idle_connections = null; + @IgnoreForAnnotationCheck boolean isValidAndNormalised = false; @@ -236,6 +244,14 @@ public String getThirdPartyUsersTable() { return mysql_thirdparty_users_table_name; } + public long getIdleConnectionTimeout() { + return mysql_idle_connection_timeout; + } + + public Integer getMinimumIdleConnections() { + return mysql_minimum_idle_connections; + } + public String getThirdPartyUserToTenantTable() { return addPrefixToTableName("thirdparty_user_to_tenant"); } @@ -331,6 +347,19 @@ void validateAndNormalise() throws InvalidConfigException { "'mysql_connection_pool_size' in the config.yaml file must be > 0"); } + if (mysql_minimum_idle_connections != null) { + if (mysql_minimum_idle_connections < 0) { + throw new InvalidConfigException( + "'mysql_minimum_idle_connections' must be a >= 0"); + } + + if (mysql_minimum_idle_connections > mysql_connection_pool_size) { + throw new InvalidConfigException( + "'mysql_minimum_idle_connections' must be less than or equal to " + + "'mysql_connection_pool_size'"); + } + } + // Normalisation if (mysql_connection_uri != null) { { // mysql_connection_attributes @@ -517,10 +546,18 @@ public String getConnectionPoolId() { StringBuilder connectionPoolId = new StringBuilder(); for (Field field : MySQLConfig.class.getDeclaredFields()) { if (field.isAnnotationPresent(ConnectionPoolProperty.class)) { - connectionPoolId.append("|"); try { - if (field.get(this) != null) { - connectionPoolId.append(field.get(this).toString()); + String fieldName = field.getName(); + String fieldValue = field.get(this) != null ? field.get(this).toString() : null; + if(fieldValue == null) { + continue; + } + // To ensure a unique connectionPoolId we include the database password and use the "|db_pass|" identifier. + // This facilitates easy removal of the password from logs when necessary. + if (fieldName.equals("mysql_password")) { + connectionPoolId.append("|db_pass|" + fieldValue + "|db_pass"); + } else { + connectionPoolId.append("|" + fieldValue); } } catch (IllegalAccessException e) { throw new RuntimeException(e); diff --git a/src/main/java/io/supertokens/storage/mysql/output/CustomLayout.java b/src/main/java/io/supertokens/storage/mysql/output/CustomLayout.java index 27f1e4c..74dca68 100644 --- a/src/main/java/io/supertokens/storage/mysql/output/CustomLayout.java +++ b/src/main/java/io/supertokens/storage/mysql/output/CustomLayout.java @@ -20,7 +20,7 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.CoreConstants; import ch.qos.logback.core.LayoutBase; -import io.supertokens.storage.mysql.Start; +import io.supertokens.storage.mysql.utils.Utils; import java.text.DateFormat; import java.text.SimpleDateFormat; @@ -58,7 +58,7 @@ public String doLayout(ILoggingEvent event) { sbuf.append(event.getCallerData()[1]); sbuf.append(" | "); - sbuf.append(event.getFormattedMessage()); + sbuf.append(Utils.maskDBPassword(event.getFormattedMessage())); sbuf.append(CoreConstants.LINE_SEPARATOR); sbuf.append(CoreConstants.LINE_SEPARATOR); diff --git a/src/main/java/io/supertokens/storage/mysql/output/Logging.java b/src/main/java/io/supertokens/storage/mysql/output/Logging.java index 531d76e..084f1a6 100644 --- a/src/main/java/io/supertokens/storage/mysql/output/Logging.java +++ b/src/main/java/io/supertokens/storage/mysql/output/Logging.java @@ -37,10 +37,10 @@ public class Logging extends ResourceDistributor.SingletonResource { private Logging(Start start, String infoLogPath, String errorLogPath) { this.infoLogger = infoLogPath.equals("null") - ? createLoggerForConsole(start, "io.supertokens.storage.mysql.Info") + ? createLoggerForConsole(start, "io.supertokens.storage.mysql.Info", LOG_LEVEL.INFO) : createLoggerForFile(start, infoLogPath, "io.supertokens.storage.mysql.Info"); this.errorLogger = errorLogPath.equals("null") - ? createLoggerForConsole(start, "io.supertokens.storage.mysql.Error") + ? createLoggerForConsole(start, "io.supertokens.storage.mysql.Error", LOG_LEVEL.ERROR) : createLoggerForFile(start, errorLogPath, "io.supertokens.storage.mysql.Error"); } @@ -154,12 +154,12 @@ public static void error(Start start, String message, boolean toConsoleAsWell, E private static void systemOut(String msg) { if (!Start.silent) { - System.out.println(msg); + System.out.println(Utils.maskDBPassword(msg)); } } private static void systemErr(String err) { - System.err.println(err); + System.err.println(Utils.maskDBPassword(err)); } public static void stopLogging(Start start) { @@ -198,7 +198,7 @@ private Logger createLoggerForFile(Start start, String file, String name) { return logger; } - private Logger createLoggerForConsole(Start start, String name) { + private Logger createLoggerForConsole(Start start, String name, LOG_LEVEL logLevel) { Logger logger = (Logger) LoggerFactory.getLogger(name); // We don't need to add appender if it is already added @@ -211,6 +211,7 @@ private Logger createLoggerForConsole(Start start, String name) { ple.setContext(lc); ple.start(); ConsoleAppender logConsoleAppender = new ConsoleAppender<>(); + logConsoleAppender.setTarget(logLevel == LOG_LEVEL.ERROR ? "System.err" : "System.out"); logConsoleAppender.setEncoder(ple); logConsoleAppender.setContext(lc); logConsoleAppender.start(); diff --git a/src/main/java/io/supertokens/storage/mysql/queries/UserRolesQueries.java b/src/main/java/io/supertokens/storage/mysql/queries/UserRolesQueries.java index 6208cb3..303bebb 100644 --- a/src/main/java/io/supertokens/storage/mysql/queries/UserRolesQueries.java +++ b/src/main/java/io/supertokens/storage/mysql/queries/UserRolesQueries.java @@ -69,8 +69,6 @@ public static String getQueryToCreateUserRolesTable(Start start) { + "user_id VARCHAR(128) NOT NULL, " + "role VARCHAR(255) NOT NULL," + "PRIMARY KEY (app_id, tenant_id, user_id, role)," - + "FOREIGN KEY (app_id, role)" - + " REFERENCES " + Config.getConfig(start).getRolesTable() + "(app_id, role) ON DELETE CASCADE," + "FOREIGN KEY (app_id, tenant_id)" + " REFERENCES " + Config.getConfig(start).getTenantsTable() + "(app_id, tenant_id) ON DELETE CASCADE" + ")"; @@ -331,4 +329,14 @@ public static int deleteAllRolesForUser_Transaction(Connection con, Start start, pst.setString(2, userId); }); } + + public static boolean deleteAllUserRoleAssociationsForRole(Start start, AppIdentifier appIdentifier, String role) + throws SQLException, StorageQueryException { + String QUERY = "DELETE FROM " + Config.getConfig(start).getUserRolesTable() + + " WHERE app_id = ? AND role = ? ;"; + return update(start, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, role); + }) >= 1; + } } diff --git a/src/main/java/io/supertokens/storage/mysql/utils/Utils.java b/src/main/java/io/supertokens/storage/mysql/utils/Utils.java index 9dcb1f9..e68d6dd 100644 --- a/src/main/java/io/supertokens/storage/mysql/utils/Utils.java +++ b/src/main/java/io/supertokens/storage/mysql/utils/Utils.java @@ -19,6 +19,8 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class Utils { public static String exceptionStacktraceToString(Exception e) { @@ -39,4 +41,19 @@ public static String generateCommaSeperatedQuestionMarks(int size) { } return builder.toString(); } + + public static String maskDBPassword(String log) { + String regex = "(\\|db_pass\\|)(.*?)(\\|db_pass\\|)"; + + Matcher matcher = Pattern.compile(regex).matcher(log); + StringBuffer maskedLog = new StringBuffer(); + + while (matcher.find()) { + String maskedPassword = "*".repeat(8); + matcher.appendReplacement(maskedLog, "|" + maskedPassword + "|"); + } + + matcher.appendTail(maskedLog); + return maskedLog.toString(); + } } diff --git a/src/test/java/io/supertokens/storage/mysql/test/DbConnectionPoolTest.java b/src/test/java/io/supertokens/storage/mysql/test/DbConnectionPoolTest.java new file mode 100644 index 0000000..dfc72fb --- /dev/null +++ b/src/test/java/io/supertokens/storage/mysql/test/DbConnectionPoolTest.java @@ -0,0 +1,396 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.storage.mysql.test; + +import com.google.gson.JsonObject; +import io.supertokens.ProcessState; +import io.supertokens.emailpassword.exceptions.EmailChangeNotAllowedException; +import io.supertokens.featureflag.EE_FEATURES; +import io.supertokens.featureflag.FeatureFlagTestContent; +import io.supertokens.multitenancy.Multitenancy; +import io.supertokens.multitenancy.exception.BadPermissionException; +import io.supertokens.pluginInterface.Storage; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.*; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.storage.mysql.Start; +import io.supertokens.storageLayer.StorageLayer; +import io.supertokens.thirdparty.ThirdParty; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.*; + +public class DbConnectionPoolTest { + @Rule + public TestRule watchman = Utils.getOnFailure(); + + @AfterClass + public static void afterTesting() { + Utils.afterTesting(); + } + + @Before + public void beforeEach() { + Utils.reset(); + } + + @Test + public void testActiveConnectionsWithTenants() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.MULTI_TENANCY}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); + assertEquals(10, start.getDbActivityCount("supertokens")); + + JsonObject config = new JsonObject(); + start.modifyConfigToAddANewUserPoolForTesting(config, 1); + + Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( + new TenantIdentifier(null, null, "t1"), + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + config + ), false); + + Thread.sleep(1000); // let the new tenant be ready + + assertEquals(10, start.getDbActivityCount("st1")); + + // change connection pool size + config.addProperty("mysql_connection_pool_size", 20); + + Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( + new TenantIdentifier(null, null, "t1"), + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + config + ), false); + + Thread.sleep(2000); // let the new tenant be ready + + assertEquals(20, start.getDbActivityCount("st1")); + + // delete tenant + Multitenancy.deleteTenant(new TenantIdentifier(null, null, "t1"), process.getProcess()); + Thread.sleep(2000); // let the tenant be deleted + + assertEquals(0, start.getDbActivityCount("st1")); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testDownTimeWhenChangingConnectionPoolSize() throws Exception { + String[] args = {"../"}; + + for (int t = 0; t < 5; t++) { + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.MULTI_TENANCY}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); + assertEquals(10, start.getDbActivityCount("supertokens")); + + JsonObject config = new JsonObject(); + start.modifyConfigToAddANewUserPoolForTesting(config, 1); + config.addProperty("mysql_connection_pool_size", 300); + AtomicLong firstErrorTime = new AtomicLong(-1); + AtomicLong successAfterErrorTime = new AtomicLong(-1); + AtomicInteger errorCount = new AtomicInteger(0); + + Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( + new TenantIdentifier(null, null, "t1"), + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + config + ), false); + + Thread.sleep(5000); // let the new tenant be ready + + assertEquals(300, start.getDbActivityCount("st1")); + + ExecutorService es = Executors.newFixedThreadPool(300); + + for (int i = 0; i < 10000; i++) { + int finalI = i; + es.execute(() -> { + try { + TenantIdentifier t1 = new TenantIdentifier(null, null, "t1"); + Storage t1Storage = (StorageLayer.getStorage(t1, process.getProcess())); + ThirdParty.signInUp(t1, t1Storage, process.getProcess(), "google", "googleid"+ finalI, "user" + + finalI + "@example.com"); + + if (firstErrorTime.get() != -1 && successAfterErrorTime.get() == -1) { + successAfterErrorTime.set(System.currentTimeMillis()); + } + } catch (StorageQueryException e) { + if (e.getMessage().contains("called on closed connection") || e.getMessage().contains("Connection is closed")) { + if (firstErrorTime.get() == -1) { + firstErrorTime.set(System.currentTimeMillis()); + } + } else { + errorCount.incrementAndGet(); + throw new RuntimeException(e); + } + } catch (EmailChangeNotAllowedException e) { + errorCount.incrementAndGet(); + throw new RuntimeException(e); + } catch (TenantOrAppNotFoundException e) { + errorCount.incrementAndGet(); + throw new RuntimeException(e); + } catch (BadPermissionException e) { + errorCount.incrementAndGet(); + throw new RuntimeException(e); + } catch (IllegalStateException e) { + if (e.getMessage().contains("Please call initPool before getConnection")) { + if (firstErrorTime.get() == -1) { + firstErrorTime.set(System.currentTimeMillis()); + } + } else { + errorCount.incrementAndGet(); + throw e; + } + } + }); + } + + // change connection pool size + config.addProperty("mysql_connection_pool_size", 200); + + Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( + new TenantIdentifier(null, null, "t1"), + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + config + ), false); + + Thread.sleep(3000); // let the new tenant be ready + + es.shutdown(); + es.awaitTermination(2, TimeUnit.MINUTES); + + assertEquals(0, errorCount.get()); + + assertEquals(200, start.getDbActivityCount("st1")); + + // delete tenant + Multitenancy.deleteTenant(new TenantIdentifier(null, null, "t1"), process.getProcess()); + Thread.sleep(3000); // let the tenant be deleted + + assertEquals(0, start.getDbActivityCount("st1")); + + System.out.println(successAfterErrorTime.get() - firstErrorTime.get() + "ms"); + assertTrue(successAfterErrorTime.get() - firstErrorTime.get() < 250); + + if (successAfterErrorTime.get() - firstErrorTime.get() == 0) { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + + continue; // retry + } + + assertTrue(successAfterErrorTime.get() - firstErrorTime.get() > 0); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + return; + } + + fail(); // tried 5 times + } + + + @Test + public void testMinimumIdleConnections() throws Exception { + String[] args = {"../"}; + { + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.MULTI_TENANCY}); + Utils.setValueInConfig("mysql_connection_pool_size", "20"); + Utils.setValueInConfig("mysql_minimum_idle_connections", "10"); + Utils.setValueInConfig("mysql_idle_connection_timeout", "30000"); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + Thread.sleep(65000); // let the idle connections time out + + Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); + assertEquals(10, start.getDbActivityCount("supertokens")); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + @Test + public void testMinimumIdleConnectionForTenants() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.MULTI_TENANCY}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); + assertEquals(10, start.getDbActivityCount("supertokens")); + + JsonObject config = new JsonObject(); + start.modifyConfigToAddANewUserPoolForTesting(config, 1); + + Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( + new TenantIdentifier(null, null, "t1"), + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + config + ), false); + + Thread.sleep(1000); // let the new tenant be ready + + assertEquals(10, start.getDbActivityCount("st1")); + + // change connection pool size + config.addProperty("mysql_connection_pool_size", 20); + config.addProperty("mysql_minimum_idle_connections", 5); + + Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( + new TenantIdentifier(null, null, "t1"), + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + config + ), false); + + Thread.sleep(2000); // let the new tenant be ready + + assertEquals(5, start.getDbActivityCount("st1")); + + // delete tenant + Multitenancy.deleteTenant(new TenantIdentifier(null, null, "t1"), process.getProcess()); + Thread.sleep(2000); // let the tenant be deleted + + assertEquals(0, start.getDbActivityCount("st1")); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testIdleConnectionTimeout() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.MULTI_TENANCY}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + Start start = (Start) StorageLayer.getBaseStorage(process.getProcess()); + assertEquals(10, start.getDbActivityCount("supertokens")); + + JsonObject config = new JsonObject(); + start.modifyConfigToAddANewUserPoolForTesting(config, 1); + config.addProperty("mysql_connection_pool_size", 300); + config.addProperty("mysql_minimum_idle_connections", 5); + config.addProperty("mysql_idle_connection_timeout", 30000); + + AtomicLong errorCount = new AtomicLong(0); + + Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( + new TenantIdentifier(null, null, "t1"), + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + config + ), false); + + Thread.sleep(3000); // let the new tenant be ready + + assertTrue(10 >= start.getDbActivityCount("st1")); + + ExecutorService es = Executors.newFixedThreadPool(150); + + for (int i = 0; i < 10000; i++) { + int finalI = i; + es.execute(() -> { + try { + TenantIdentifier t1 = new TenantIdentifier(null, null, "t1"); + Storage t1Storage = (StorageLayer.getStorage(t1, process.getProcess())); + ThirdParty.signInUp(t1, t1Storage, process.getProcess(), "google", "googleid"+ finalI, "user" + + finalI + "@example.com"); + + } catch (StorageQueryException e) { + errorCount.incrementAndGet(); + throw new RuntimeException(e); + } catch (EmailChangeNotAllowedException e) { + errorCount.incrementAndGet(); + throw new RuntimeException(e); + } catch (TenantOrAppNotFoundException e) { + errorCount.incrementAndGet(); + throw new RuntimeException(e); + } catch (BadPermissionException e) { + errorCount.incrementAndGet(); + throw new RuntimeException(e); + } + }); + } + + es.shutdown(); + es.awaitTermination(2, TimeUnit.MINUTES); + + assertTrue(5 < start.getDbActivityCount("st1")); + + assertEquals(0, errorCount.get()); + + Thread.sleep(65000); // let the idle connections time out + + assertEquals(5, start.getDbActivityCount("st1")); + + // delete tenant + Multitenancy.deleteTenant(new TenantIdentifier(null, null, "t1"), process.getProcess()); + Thread.sleep(3000); // let the tenant be deleted + + assertEquals(0, start.getDbActivityCount("st1")); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } +} diff --git a/src/test/java/io/supertokens/storage/mysql/test/LoggingTest.java b/src/test/java/io/supertokens/storage/mysql/test/LoggingTest.java index fdc2f95..0f05944 100644 --- a/src/test/java/io/supertokens/storage/mysql/test/LoggingTest.java +++ b/src/test/java/io/supertokens/storage/mysql/test/LoggingTest.java @@ -21,6 +21,8 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.Appender; import com.google.gson.JsonObject; + +import io.supertokens.Main; import io.supertokens.ProcessState; import io.supertokens.config.Config; import io.supertokens.featureflag.EE_FEATURES; @@ -28,6 +30,7 @@ import io.supertokens.multitenancy.Multitenancy; import io.supertokens.pluginInterface.multitenancy.*; import io.supertokens.storage.mysql.Start; +import io.supertokens.storage.mysql.config.MySQLConfig; import io.supertokens.storage.mysql.output.Logging; import io.supertokens.storageLayer.StorageLayer; import org.apache.tomcat.util.http.fileupload.FileUtils; @@ -309,6 +312,279 @@ public void confirmHikariLoggerClosedOnlyWhenProcessEnds() throws Exception { assertFalse(hikariLogger.iteratorForAppenders().hasNext()); } + @Test + public void testDBPasswordMaskingOnDBConnectionFailUsingConnectionUri() throws Exception { + String[] args = { "../" }; + + String dbUser = "db_user"; + String dbPassword = "db_password"; + String dbName = "db_does_not_exist"; + String dbConnectionUri = "mysql://" + dbUser + ":" + dbPassword + "@localhost:3306/" + dbName; + + Utils.setValueInConfig("mysql_connection_uri", dbConnectionUri); + Utils.commentConfigValue("mysql_user"); + Utils.commentConfigValue("mysql_password"); + Utils.setValueInConfig("error_log_path", "null"); + + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + System.setErr(new PrintStream(errorOutput)); + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + try { + process.startProcess(); + process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.INIT_FAILURE); + + assertTrue(fileContainsString(errorOutput, dbUser)); + assertTrue(fileContainsString(errorOutput, dbName)); + assertTrue(fileContainsString(errorOutput, "********")); + assertFalse(fileContainsString(errorOutput, dbPassword)); + + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + System.setErr(new PrintStream(new FileOutputStream(FileDescriptor.err))); + } + } + + @Test + public void testDBPasswordMaskingOnDBConnectionFailUsingCredentials() throws Exception { + String[] args = { "../" }; + + String dbUser = "db_user"; + String dbPassword = "db_password"; + String dbName = "db_does_not_exist"; + + Utils.commentConfigValue("mysql_connection_uri"); + Utils.setValueInConfig("mysql_user", dbUser); + Utils.setValueInConfig("mysql_password", dbPassword); + Utils.setValueInConfig("mysql_database_name", dbName); + Utils.setValueInConfig("error_log_path", "null"); + + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + System.setErr(new PrintStream(errorOutput)); + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + try { + process.startProcess(); + process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.INIT_FAILURE); + + assertTrue(fileContainsString(errorOutput, dbUser)); + assertTrue(fileContainsString(errorOutput, dbName)); + assertTrue(fileContainsString(errorOutput, "********")); + assertFalse(fileContainsString(errorOutput, dbPassword)); + + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + System.setErr(new PrintStream(new FileOutputStream(FileDescriptor.err))); + } + } + + @Test + public void testDBPasswordMasking() throws Exception { + String[] args = { "../" }; + + ByteArrayOutputStream stdOutput = new ByteArrayOutputStream(); + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + + Utils.setValueInConfig("info_log_path", "null"); + Utils.setValueInConfig("error_log_path", "null"); + + System.setOut(new PrintStream(stdOutput)); + System.setErr(new PrintStream(errorOutput)); + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + + try { + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + Logging.info((Start) StorageLayer.getStorage(process.getProcess()), "INFO LOG: |db_pass|password|db_pass|", + false); + Logging.error((Start) StorageLayer.getStorage(process.getProcess()), + "ERROR LOG: |db_pass|password|db_pass|", false); + + assertTrue(fileContainsString(stdOutput, "INFO LOG: |********|")); + assertTrue(fileContainsString(errorOutput, "ERROR LOG: |********|")); + + } finally { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + System.setOut(new PrintStream(new FileOutputStream(FileDescriptor.out))); + System.setErr(new PrintStream(new FileOutputStream(FileDescriptor.err))); + } + } + + @Test + public void testDBPasswordIsNotLoggedWhenProcessStartsEnds() throws Exception { + String[] args = { "../" }; + + Utils.setValueInConfig("error_log_path", "null"); + Utils.setValueInConfig("info_log_path", "null"); + + ByteArrayOutputStream stdOutput = new ByteArrayOutputStream(); + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + + System.setOut(new PrintStream(stdOutput)); + System.setErr(new PrintStream(errorOutput)); + + try { + // Case 1: DB Password shouldn't be logged after starting/stopping the process with correct credentials + { + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + Start start = (Start) StorageLayer.getStorage(process.getProcess()); + MySQLConfig userConfig = io.supertokens.storage.mysql.config.Config.getConfig(start); + String dbPasswordFromConfig = userConfig.getPassword(); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + + assertFalse(fileContainsString(stdOutput, dbPasswordFromConfig)); + assertFalse(fileContainsString(errorOutput, dbPasswordFromConfig)); + } + + // Case 2: DB Password shouldn't be logged after starting/stopping the process with incorrect credentials + { + String dbUser = "db_user"; + String dbPassword = "db_password"; + String dbName = "db_does_not_exist"; + + Utils.setValueInConfig("mysql_user", dbUser); + Utils.setValueInConfig("mysql_password", dbPassword); + Utils.setValueInConfig("mysql_database_name", dbName); + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.INIT_FAILURE)); + + Start start = (Start) StorageLayer.getStorage(process.getProcess()); + MySQLConfig userConfig = io.supertokens.storage.mysql.config.Config.getConfig(start); + String dbPasswordFromConfig = userConfig.getPassword(); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + + assertFalse(fileContainsString(stdOutput, dbPasswordFromConfig)); + assertFalse(fileContainsString(errorOutput, dbPasswordFromConfig)); + } + + } finally { + System.setOut(new PrintStream(new FileOutputStream(FileDescriptor.out))); + System.setErr(new PrintStream(new FileOutputStream(FileDescriptor.err))); + } + } + + @Test + public void testDBPasswordIsNotLoggedWhenTenantIsCreated() throws Exception { + String[] args = { "../" }; + + Utils.setValueInConfig("error_log_path", "null"); + Utils.setValueInConfig("info_log_path", "null"); + + ByteArrayOutputStream stdOutput = new ByteArrayOutputStream(); + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + + System.setOut(new PrintStream(stdOutput)); + System.setErr(new PrintStream(errorOutput)); + + try { + // Case 1: DB Password shouldn't be logged when tenant is created with valid credentials + { + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + Start start = (Start) StorageLayer.getStorage(process.getProcess()); + MySQLConfig userConfig = io.supertokens.storage.mysql.config.Config.getConfig(start);; + String dbPasswordFromConfig = userConfig.getPassword(); + + Main main = process.getProcess(); + + FeatureFlagTestContent.getInstance(main) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[] { + EE_FEATURES.ACCOUNT_LINKING, EE_FEATURES.MULTI_TENANCY }); + + JsonObject config = new JsonObject(); + TenantIdentifier tenantIdentifier = new TenantIdentifier(null, "a1", null); + StorageLayer.getBaseStorage(process.getProcess()).modifyConfigToAddANewUserPoolForTesting(config, 1); + Multitenancy.addNewOrUpdateAppOrTenant( + main, + new TenantIdentifier(null, null, null), + new TenantConfig( + tenantIdentifier, + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + config + )); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + + assertFalse(fileContainsString(stdOutput, dbPasswordFromConfig)); + assertFalse(fileContainsString(errorOutput, dbPasswordFromConfig)); + } + + // Case 2: DB Password shouldn't be logged when tenant is created with invalid credentials + { + String dbUser = "db_user"; + String dbPassword = "db_password"; + String dbName = "db_does_not_exist"; + String dbConnectionUri = "mysql://" + dbUser + ":" + dbPassword + "@localhost:3306/" + dbName; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + Start start = (Start) StorageLayer.getStorage(process.getProcess()); + MySQLConfig userConfig = io.supertokens.storage.mysql.config.Config.getConfig(start); + String dbPasswordFromConfig = userConfig.getPassword(); + + Main main = process.getProcess(); + + FeatureFlagTestContent.getInstance(main) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[] { + EE_FEATURES.ACCOUNT_LINKING, EE_FEATURES.MULTI_TENANCY }); + + TenantIdentifier tenantIdentifier = new TenantIdentifier(null, "a1", null); + JsonObject config = new JsonObject(); + config.addProperty("mysql_connection_uri", dbConnectionUri); + StorageLayer.getBaseStorage(process.getProcess()).modifyConfigToAddANewUserPoolForTesting(config, 1); + try { + Multitenancy.addNewOrUpdateAppOrTenant( + main, + new TenantIdentifier(null, null, null), + new TenantConfig( + tenantIdentifier, + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + new JsonObject())); + + } catch (Exception e) { + + } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + + assertFalse(fileContainsString(stdOutput, dbPasswordFromConfig)); + assertFalse(fileContainsString(errorOutput, dbPasswordFromConfig)); + } + + } finally { + System.setOut(new PrintStream(new FileOutputStream(FileDescriptor.out))); + System.setErr(new PrintStream(new FileOutputStream(FileDescriptor.err))); + } + } + private static int countAppenders(ch.qos.logback.classic.Logger logger) { int count = 0; Iterator> appenderIter = logger.iteratorForAppenders(); diff --git a/src/test/java/io/supertokens/storage/mysql/test/SuperTokensSaaSSecretTest.java b/src/test/java/io/supertokens/storage/mysql/test/SuperTokensSaaSSecretTest.java new file mode 100644 index 0000000..5e052e9 --- /dev/null +++ b/src/test/java/io/supertokens/storage/mysql/test/SuperTokensSaaSSecretTest.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.storage.mysql.test; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.supertokens.ProcessState; +import io.supertokens.featureflag.EE_FEATURES; +import io.supertokens.featureflag.FeatureFlagTestContent; +import io.supertokens.featureflag.exceptions.FeatureNotEnabledException; +import io.supertokens.multitenancy.Multitenancy; +import io.supertokens.multitenancy.exception.BadPermissionException; +import io.supertokens.multitenancy.exception.CannotModifyBaseConfigException; +import io.supertokens.pluginInterface.STORAGE_TYPE; +import io.supertokens.pluginInterface.exceptions.InvalidConfigException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.*; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.storage.mysql.test.httpRequest.HttpRequestForTesting; +import io.supertokens.storage.mysql.test.httpRequest.HttpResponseException; +import io.supertokens.storageLayer.StorageLayer; +import io.supertokens.thirdparty.InvalidProviderConfigException; +import io.supertokens.utils.SemVer; +import org.junit.*; +import org.junit.rules.TestRule; + +import java.io.IOException; + +import static junit.framework.TestCase.assertEquals; +import static org.junit.Assert.*; +import static org.junit.Assert.assertNotNull; + +public class SuperTokensSaaSSecretTest { + @Rule + public TestRule watchman = Utils.getOnFailure(); + + @AfterClass + public static void afterTesting() { + Utils.afterTesting(); + } + + @Before + public void beforeEach() { + Utils.reset(); + } + + private static final String[] PROTECTED_CORE_CONFIG = new String[]{ + "mysql_connection_pool_size", + "mysql_connection_uri", + "mysql_host", + "mysql_port", + "mysql_user", + "mysql_password", + "mysql_database_name", + "mysql_idle_connection_timeout", + "mysql_minimum_idle_connections", + }; + private static final Object[] PROTECTED_CORE_CONFIG_VALUES = new Object[]{ + 20, // mysql_connection_pool_size + "mysql://root:root@localhost:3306/st10", // mysql_connection_uri + "localhost", // mysql_host + 3306, // mysql_port + "root", // mysql_user + "root", // mysql_password + "st10", // mysql_database_name + 40000, // mysql_idle_connection_timeout + 5, // mysql_minimum_idle_connections + }; + + @Test + public void testThatTenantCannotSetProtectedConfigIfSuperTokensSaaSSecretIsSet() + throws InterruptedException, IOException, InvalidConfigException, TenantOrAppNotFoundException, + InvalidProviderConfigException, StorageQueryException, + FeatureNotEnabledException, CannotModifyBaseConfigException, HttpResponseException { + String[] args = {"../"}; + + String saasSecret = "hg40239oirjgBHD9450=Beew123--hg40239oirjgBHD9450=Beew123--hg40239oirjgBHD9450=Beew123-"; + String apiKey = "hg40239oirjgBHD9450=Beew123--hg40239oiBeew123-"; + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + Utils.setValueInConfig("supertokens_saas_secret", saasSecret); + Utils.setValueInConfig("api_keys", apiKey); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.MULTI_TENANCY}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + for (int i = 0; i < PROTECTED_CORE_CONFIG.length; i++) { + try { + JsonObject j = new JsonObject(); + j.addProperty(PROTECTED_CORE_CONFIG[i], ""); + Multitenancy.addNewOrUpdateAppOrTenant(process.main, new TenantConfig(new TenantIdentifier(null, null, "t1"), new EmailPasswordConfig(false), + new ThirdPartyConfig(false, new ThirdPartyConfig.Provider[0]), + new PasswordlessConfig(false), + j), true); + fail(); + } catch (BadPermissionException e) { + assertEquals(e.getMessage(), "Not allowed to modify DB related configs."); + } + } + + try { + JsonObject coreConfig = new JsonObject(); + StorageLayer.getBaseStorage(process.getProcess()).modifyConfigToAddANewUserPoolForTesting(coreConfig, 1); + + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("appId", "a1"); + requestBody.addProperty("emailPasswordEnabled", true); + requestBody.addProperty("thirdPartyEnabled", true); + requestBody.addProperty("passwordlessEnabled", true); + requestBody.add("coreConfig", coreConfig); + + JsonObject response = HttpRequestForTesting.sendJsonRequest(process.getProcess(), "", + HttpRequestForTesting.getMultitenantUrl(TenantIdentifier.BASE_TENANT, "/recipe/multitenancy/app"), + requestBody, 1000, 2500, null, + SemVer.v3_0.get(), "PUT", apiKey, "multitenancy"); + + Assert.assertEquals("OK", response.getAsJsonPrimitive("status").getAsString()); + fail(); + } catch (HttpResponseException e) { + Assert.assertTrue(e.getMessage().contains("Not allowed to modify DB related configs.")); + } + + JsonObject coreConfig = new JsonObject(); + StorageLayer.getBaseStorage(process.getProcess()).modifyConfigToAddANewUserPoolForTesting(coreConfig, 1); + + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("appId", "a1"); + requestBody.addProperty("emailPasswordEnabled", true); + requestBody.addProperty("thirdPartyEnabled", true); + requestBody.addProperty("passwordlessEnabled", true); + requestBody.add("coreConfig", coreConfig); + + JsonObject response = HttpRequestForTesting.sendJsonRequest(process.getProcess(), "", + HttpRequestForTesting.getMultitenantUrl(TenantIdentifier.BASE_TENANT, "/recipe/multitenancy/app"), + requestBody, 1000, 2500, null, + SemVer.v3_0.get(), "PUT", saasSecret, "multitenancy"); + + Assert.assertEquals("OK", response.getAsJsonPrimitive("status").getAsString()); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testThatTenantCannotGetProtectedConfigIfSuperTokensSaaSSecretIsSet() + throws InterruptedException, IOException, InvalidConfigException, TenantOrAppNotFoundException, + InvalidProviderConfigException, StorageQueryException, + FeatureNotEnabledException, CannotModifyBaseConfigException, BadPermissionException, HttpResponseException { + String[] args = {"../"}; + + String saasSecret = "hg40239oirjgBHD9450=Beew123--hg40239oirjgBHD9450=Beew123--hg40239oirjgBHD9450=Beew123-"; + String apiKey = "hg40239oirjgBHD9450=Beew123--hg40239oiBeew123-"; + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + Utils.setValueInConfig("supertokens_saas_secret", saasSecret); + Utils.setValueInConfig("api_keys", apiKey); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.MULTI_TENANCY}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.isInMemDb(process.getProcess())) { + return; + } + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + for (int i = 0; i < PROTECTED_CORE_CONFIG.length; i++) { + JsonObject j = new JsonObject(); + if (PROTECTED_CORE_CONFIG_VALUES[i] instanceof String) { + j.addProperty(PROTECTED_CORE_CONFIG[i], (String) PROTECTED_CORE_CONFIG_VALUES[i]); + } else if (PROTECTED_CORE_CONFIG_VALUES[i] instanceof Integer) { + j.addProperty(PROTECTED_CORE_CONFIG[i], (Integer) PROTECTED_CORE_CONFIG_VALUES[i]); + } + Multitenancy.addNewOrUpdateAppOrTenant(process.main, new TenantIdentifier(null, null, null), + new TenantConfig(new TenantIdentifier(null, null, "t" + i), new EmailPasswordConfig(false), + new ThirdPartyConfig(false, new ThirdPartyConfig.Provider[0]), + new PasswordlessConfig(false), + j)); + + { + JsonObject response = HttpRequestForTesting.sendJsonRequest(process.getProcess(), "", + HttpRequestForTesting.getMultitenantUrl(TenantIdentifier.BASE_TENANT, "/recipe/multitenancy/tenant/list"), + null, 1000, 1000, null, + SemVer.v3_0.get(), "GET", apiKey, "multitenancy"); + + Assert.assertEquals("OK", response.getAsJsonPrimitive("status").getAsString()); + + boolean found = false; + for (JsonElement tenant : response.get("tenants").getAsJsonArray()) { + JsonObject tenantObj = tenant.getAsJsonObject(); + + if (tenantObj.get("tenantId").getAsString().equals("t" + i)) { + found = true; + + assertFalse(tenantObj.get("coreConfig").getAsJsonObject().has(PROTECTED_CORE_CONFIG[i])); + } + } + Assert.assertTrue(found); + } + + { + JsonObject response = HttpRequestForTesting.sendJsonRequest(process.getProcess(), "", + HttpRequestForTesting.getMultitenantUrl(TenantIdentifier.BASE_TENANT, "/recipe/multitenancy/tenant/list"), + null, 1000, 1000, null, + SemVer.v3_0.get(), "GET", saasSecret, "multitenancy"); + + Assert.assertEquals("OK", response.getAsJsonPrimitive("status").getAsString()); + + boolean found = false; + for (JsonElement tenant : response.get("tenants").getAsJsonArray()) { + JsonObject tenantObj = tenant.getAsJsonObject(); + + if (tenantObj.get("tenantId").getAsString().equals("t" + i)) { + found = true; + + Assert.assertTrue(tenantObj.get("coreConfig").getAsJsonObject().has(PROTECTED_CORE_CONFIG[i])); + } + } + Assert.assertTrue(found); + } + } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + +} diff --git a/src/test/java/io/supertokens/storage/mysql/test/multitenancy/StorageLayerTest.java b/src/test/java/io/supertokens/storage/mysql/test/multitenancy/StorageLayerTest.java index f5f53ce..a01b71b 100644 --- a/src/test/java/io/supertokens/storage/mysql/test/multitenancy/StorageLayerTest.java +++ b/src/test/java/io/supertokens/storage/mysql/test/multitenancy/StorageLayerTest.java @@ -748,7 +748,7 @@ public void testTenantCreationAndThenDbDownDbThrowsErrorInRecipesAndDoesntAffect MultitenancyHelper.getInstance(process.getProcess()).refreshTenantsInCoreBasedOnChangesInCoreConfigOrIfTenantListChanged(true); try { - EmailPassword.signIn(tid.withStorage(StorageLayer.getStorage(tid, process.getProcess())), + EmailPassword.signIn(tid, (StorageLayer.getStorage(tid, process.getProcess())), process.getProcess(), "", ""); fail(); } catch (StorageQueryException e) { @@ -758,7 +758,7 @@ public void testTenantCreationAndThenDbDownDbThrowsErrorInRecipesAndDoesntAffect // we do this again just to check that if this function is called again, it fails again and there is no // side effect of calling the above function try { - EmailPassword.signIn(tid.withStorage(StorageLayer.getStorage(tid, process.getProcess())), + EmailPassword.signIn(tid, (StorageLayer.getStorage(tid, process.getProcess())), process.getProcess(), "", ""); fail(); } catch (StorageQueryException e) { @@ -784,7 +784,7 @@ public void testTenantCreationAndThenDbDownDbThrowsErrorInRecipesAndDoesntAffect TenantIdentifier tid = new TenantIdentifier("abc", null, null); try { - EmailPassword.signIn(tid.withStorage(StorageLayer.getStorage(tid, process.getProcess())), + EmailPassword.signIn(tid, (StorageLayer.getStorage(tid, process.getProcess())), process.getProcess(), "", ""); fail(); } catch (StorageQueryException e) { diff --git a/src/test/java/io/supertokens/storage/mysql/test/multitenancy/TestUserPoolIdChangeBehaviour.java b/src/test/java/io/supertokens/storage/mysql/test/multitenancy/TestUserPoolIdChangeBehaviour.java index b0afd4b..77d1ad3 100644 --- a/src/test/java/io/supertokens/storage/mysql/test/multitenancy/TestUserPoolIdChangeBehaviour.java +++ b/src/test/java/io/supertokens/storage/mysql/test/multitenancy/TestUserPoolIdChangeBehaviour.java @@ -24,6 +24,7 @@ import io.supertokens.multitenancy.Multitenancy; import io.supertokens.multitenancy.exception.BadPermissionException; import io.supertokens.multitenancy.exception.CannotModifyBaseConfigException; +import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; @@ -86,13 +87,13 @@ public void testUsersWorkAfterUserPoolIdChanges() throws Exception { coreConfig ), false); - TenantIdentifierWithStorage tenantIdentifierWithStorage = tenantIdentifier.withStorage( + Storage storage = ( StorageLayer.getStorage(tenantIdentifier, process.getProcess())); - String userPoolId = tenantIdentifierWithStorage.getStorage().getUserPoolId(); + String userPoolId = storage.getUserPoolId(); - AuthRecipeUserInfo userInfo = EmailPassword.signUp( - tenantIdentifierWithStorage, process.getProcess(), "user@example.com", "password"); + AuthRecipeUserInfo userInfo = EmailPassword.signUp(tenantIdentifier, + storage, process.getProcess(), "user@example.com", "password"); coreConfig.addProperty("mysql_host", "127.0.0.1"); Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( @@ -104,12 +105,12 @@ public void testUsersWorkAfterUserPoolIdChanges() throws Exception { coreConfig ), false); - tenantIdentifierWithStorage = tenantIdentifier.withStorage( + storage = ( StorageLayer.getStorage(tenantIdentifier, process.getProcess())); - String userPoolId2 = tenantIdentifierWithStorage.getStorage().getUserPoolId(); + String userPoolId2 = storage.getUserPoolId(); assertNotEquals(userPoolId, userPoolId2); - AuthRecipeUserInfo user2 = EmailPassword.signIn(tenantIdentifierWithStorage, process.getProcess(), "user@example.com", "password"); + AuthRecipeUserInfo user2 = EmailPassword.signIn(tenantIdentifier, storage, process.getProcess(), "user@example.com", "password"); assertEquals(userInfo, user2); } @@ -130,13 +131,13 @@ public void testUsersWorkAfterUserPoolIdChangesAndServerRestart() throws Excepti coreConfig ), false); - TenantIdentifierWithStorage tenantIdentifierWithStorage = tenantIdentifier.withStorage( + Storage storage = ( StorageLayer.getStorage(tenantIdentifier, process.getProcess())); - String userPoolId = tenantIdentifierWithStorage.getStorage().getUserPoolId(); + String userPoolId = storage.getUserPoolId(); AuthRecipeUserInfo userInfo = EmailPassword.signUp( - tenantIdentifierWithStorage, process.getProcess(), "user@example.com", "password"); + tenantIdentifier, storage, process.getProcess(), "user@example.com", "password"); coreConfig.addProperty("mysql_host", "127.0.0.1"); Multitenancy.addNewOrUpdateAppOrTenant(process.getProcess(), new TenantConfig( @@ -154,12 +155,12 @@ public void testUsersWorkAfterUserPoolIdChangesAndServerRestart() throws Excepti this.process = TestingProcessManager.start(args); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); - tenantIdentifierWithStorage = tenantIdentifier.withStorage( + storage = ( StorageLayer.getStorage(tenantIdentifier, process.getProcess())); - String userPoolId2 = tenantIdentifierWithStorage.getStorage().getUserPoolId(); + String userPoolId2 = storage.getUserPoolId(); assertNotEquals(userPoolId, userPoolId2); - AuthRecipeUserInfo user2 = EmailPassword.signIn(tenantIdentifierWithStorage, process.getProcess(), "user@example.com", "password"); + AuthRecipeUserInfo user2 = EmailPassword.signIn(tenantIdentifier, storage, process.getProcess(), "user@example.com", "password"); assertEquals(userInfo, user2); }