diff --git a/config/sample-databases/DefaultConfiguration/cloudbeaver.conf b/config/sample-databases/DefaultConfiguration/cloudbeaver.conf index daa718284c..f65b0eee16 100644 --- a/config/sample-databases/DefaultConfiguration/cloudbeaver.conf +++ b/config/sample-databases/DefaultConfiguration/cloudbeaver.conf @@ -19,6 +19,13 @@ enableSecurityManager: false, + sm: { + enableBruteForceProtection: "${CLOUDBEAVER_BRUTE_FORCE_PROTECTION_ENABLED:true}", + maxFailedLogin: "${CLOUDBEAVER_MAX_FAILED_LOGINS:10}", + minimumLoginTimeout: "${CLOUDBEAVER_MINIMUM_LOGIN_TIMEOUT:1}", + blockLoginPeriod: "${CLOUDBEAVER_BLOCK_PERIOD:300}" + }, + database: { driver="h2_embedded_v2", url: "jdbc:h2:${workspace}/.data/cb.h2v2.dat", diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMBruteForceProtected.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMBruteForceProtected.java new file mode 100644 index 0000000000..ec32704024 --- /dev/null +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMBruteForceProtected.java @@ -0,0 +1,25 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * 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.cloudbeaver.auth; + +import org.jkiss.code.NotNull; + +import java.util.Map; + +public interface SMBruteForceProtected { + Object getInputUsername(@NotNull Map cred); +} diff --git a/server/bundles/io.cloudbeaver.service.security/db/cb_schema_create.sql b/server/bundles/io.cloudbeaver.service.security/db/cb_schema_create.sql index 300af38bdb..03c7a6b5bb 100644 --- a/server/bundles/io.cloudbeaver.service.security/db/cb_schema_create.sql +++ b/server/bundles/io.cloudbeaver.service.security/db/cb_schema_create.sql @@ -277,6 +277,7 @@ CREATE TABLE {table_prefix}CB_AUTH_ATTEMPT SESSION_TYPE VARCHAR(64) NOT NULL, APP_SESSION_STATE TEXT NOT NULL, IS_MAIN_AUTH CHAR(1) DEFAULT 'Y' NOT NULL, + AUTH_USERNAME VARCHAR(128) NULL, CREATE_TIME TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, PRIMARY KEY (AUTH_ID), diff --git a/server/bundles/io.cloudbeaver.service.security/db/cb_schema_update_15.sql b/server/bundles/io.cloudbeaver.service.security/db/cb_schema_update_15.sql new file mode 100644 index 0000000000..50324a3188 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.security/db/cb_schema_update_15.sql @@ -0,0 +1,2 @@ +ALTER TABLE {table_prefix}CB_AUTH_ATTEMPT + ADD COLUMN AUTH_USERNAME VARCHAR(128) NULL; \ No newline at end of file diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java index 9559f513b4..c230413da7 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java @@ -16,6 +16,7 @@ */ package io.cloudbeaver.auth.provider.local; +import io.cloudbeaver.auth.SMBruteForceProtected; import io.cloudbeaver.model.session.WebSession; import io.cloudbeaver.registry.WebAuthProviderDescriptor; import io.cloudbeaver.registry.WebAuthProviderRegistry; @@ -35,7 +36,7 @@ /** * Local auth provider */ -public class LocalAuthProvider implements SMAuthProvider { +public class LocalAuthProvider implements SMAuthProvider, SMBruteForceProtected { public static final String PROVIDER_ID = LocalAuthProviderConstants.PROVIDER_ID; public static final String CRED_USER = LocalAuthProviderConstants.CRED_USER; @@ -126,4 +127,8 @@ public static boolean changeUserPassword(@NotNull WebSession webSession, @NotNul return true; } + @Override + public Object getInputUsername(@NotNull Map cred) { + return cred.get("user"); + } } diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java index 09e794f9c9..f6063e732d 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java @@ -16,19 +16,17 @@ */ package io.cloudbeaver.service.security; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; +import com.google.gson.*; import com.google.gson.reflect.TypeToken; -import io.cloudbeaver.auth.SMAuthProviderAssigner; -import io.cloudbeaver.auth.SMAuthProviderExternal; -import io.cloudbeaver.auth.SMAuthProviderFederated; -import io.cloudbeaver.auth.SMAutoAssign; +import io.cloudbeaver.auth.*; import io.cloudbeaver.model.app.WebAppConfiguration; import io.cloudbeaver.model.app.WebAuthApplication; import io.cloudbeaver.model.app.WebAuthConfiguration; import io.cloudbeaver.registry.WebAuthProviderDescriptor; import io.cloudbeaver.registry.WebAuthProviderRegistry; import io.cloudbeaver.registry.WebMetaParametersRegistry; +import io.cloudbeaver.service.security.bruteforce.BruteForceUtils; +import io.cloudbeaver.service.security.bruteforce.UserLoginRecord; import io.cloudbeaver.service.security.db.CBDatabase; import io.cloudbeaver.service.security.internal.AuthAttemptSessionInfo; import io.cloudbeaver.service.security.internal.SMTokenInfo; @@ -348,7 +346,7 @@ public SMUser getCurrentUser() throws DBException { public int countUsers(@NotNull SMUserFilter filter) throws DBCException { try (Connection dbCon = database.openConnection()) { try (PreparedStatement dbStat = dbCon.prepareStatement(database.normalizeTableNames( - "SELECT COUNT(*) FROM {table_prefix}CB_USER" + buildUsersFilter(filter)))) { + "SELECT COUNT(*) FROM {table_prefix}CB_USER" + buildUsersFilter(filter)))) { setUsersFilterValues(dbStat, filter, 1); try (ResultSet dbResult = dbStat.executeQuery()) { if (dbResult.next()) { @@ -484,7 +482,7 @@ private void readSubjectMetas(Connection dbCon, SMSubject subject) throws SQLExc } private void readSubjectsMetas(Connection dbCon, SMSubjectType subjectType, String userIdMask, - Map result) throws SQLException { + Map result) throws SQLException { // Read metas try (PreparedStatement dbStat = dbCon.prepareStatement( database.normalizeTableNames("SELECT m.SUBJECT_ID,m.META_ID,m.META_VALUE FROM {table_prefix}CB_AUTH_SUBJECT s, " + @@ -921,8 +919,8 @@ public SMTeam[] readAllTeams() throws DBCException { } try (ResultSet dbResult = dbStat.executeQuery( database.normalizeTableNames("SELECT SUBJECT_ID,PERMISSION_ID\n" + - "FROM {table_prefix}CB_AUTH_PERMISSIONS AP, {table_prefix}CB_TEAM R\n" + - "WHERE AP.SUBJECT_ID=R.TEAM_ID\n"))) { + "FROM {table_prefix}CB_AUTH_PERMISSIONS AP, {table_prefix}CB_TEAM R\n" + + "WHERE AP.SUBJECT_ID=R.TEAM_ID\n"))) { while (dbResult.next()) { SMTeam team = teams.get(dbResult.getString(1)); if (team != null) { @@ -1303,21 +1301,36 @@ public SMAuthInfo authenticate( ? null : application.getAuthConfiguration().getAuthProviderConfiguration(authProviderConfigurationId); - if (SMAuthProviderExternal.class.isAssignableFrom(authProviderInstance.getClass())) { - var authProviderExternal = (SMAuthProviderExternal) authProviderInstance; - securedUserIdentifyingCredentials = authProviderExternal.authExternalUser( - authProgressMonitor, - providerConfig, - userCredentials - ); - } - var filteredUserCreds = filterSecuredUserData( securedUserIdentifyingCredentials, authProviderDescriptor ); + String authAttemptId; + if (SMAuthProviderExternal.class.isAssignableFrom(authProviderInstance.getClass())) { + var authProviderExternal = (SMAuthProviderExternal) authProviderInstance; + try { + securedUserIdentifyingCredentials = authProviderExternal.authExternalUser( + authProgressMonitor, + providerConfig, + userCredentials + ); + } catch (DBException e) { + createNewAuthAttempt( + SMAuthStatus.ERROR, + authProviderId, + authProviderConfigurationId, + filteredUserCreds, + appSessionId, + previousSmSessionId, + sessionType, + sessionParameters, + isMainSession + ); + throw e; + } + } - var authAttemptId = createNewAuthAttempt( + authAttemptId = createNewAuthAttempt( SMAuthStatus.IN_PROGRESS, authProviderId, authProviderConfigurationId, @@ -1386,12 +1399,17 @@ private String createNewAuthAttempt( String authAttemptId = UUID.randomUUID().toString(); try (Connection dbCon = database.openConnection()) { try (JDBCTransaction txn = new JDBCTransaction(dbCon)) { + if (smConfig.isCheckBruteforce() + && this.getAuthProvider(authProviderId).getInstance() instanceof SMBruteForceProtected bruteforceProtected) { + BruteForceUtils.checkBruteforce(smConfig, + getLatestUserLogins(dbCon, authProviderId, bruteforceProtected.getInputUsername(authData).toString())); + } try (PreparedStatement dbStat = dbCon.prepareStatement( database.normalizeTableNames( "INSERT INTO {table_prefix}CB_AUTH_ATTEMPT" + "(AUTH_ID,AUTH_STATUS,APP_SESSION_ID,SESSION_TYPE,APP_SESSION_STATE," + - "SESSION_ID,IS_MAIN_AUTH) " + - "VALUES(?,?,?,?,?,?,?)" + "SESSION_ID,IS_MAIN_AUTH, AUTH_USERNAME) " + + "VALUES(?,?,?,?,?,?,?,?)" ) )) { dbStat.setString(1, authAttemptId); @@ -1405,6 +1423,11 @@ private String createNewAuthAttempt( dbStat.setNull(6, Types.VARCHAR); } dbStat.setString(7, isMainSession ? CHAR_BOOL_TRUE : CHAR_BOOL_FALSE); + if (this.getAuthProvider(authProviderId).getInstance() instanceof SMBruteForceProtected bruteforceProtected) { + dbStat.setString(8, bruteforceProtected.getInputUsername(authData).toString()); + } else { + dbStat.setString(8, null); + } dbStat.execute(); } @@ -1429,6 +1452,37 @@ private String createNewAuthAttempt( } } + private List getLatestUserLogins(Connection dbCon, String authProviderId, String inputLogin) throws SQLException { + List userLoginRecords = new ArrayList<>(); + try (PreparedStatement dbStat = dbCon.prepareStatement( + database.normalizeTableNames( + "SELECT" + + " attempt.AUTH_STATUS," + + " attempt.CREATE_TIME" + + " FROM" + + " {table_prefix}CB_AUTH_ATTEMPT attempt" + + " JOIN" + + " {table_prefix}CB_AUTH_ATTEMPT_INFO info ON attempt.AUTH_ID = info.AUTH_ID" + + " WHERE AUTH_PROVIDER_ID = ? AND AUTH_USERNAME = ?" + + " ORDER BY attempt.CREATE_TIME DESC " + + database.getDialect().getOffsetLimitQueryPart(0, smConfig.getMaxFailedLogin()) + ) + )) { + dbStat.setString(1, authProviderId); + dbStat.setString(2, inputLogin); + try (ResultSet dbResult = dbStat.executeQuery()) { + while (dbResult.next()) { + UserLoginRecord loginDto = new UserLoginRecord( + SMAuthStatus.valueOf(dbResult.getString(1)), + dbResult.getTimestamp(2).toLocalDateTime() + ); + userLoginRecords.add(loginDto); + } + } + } + return userLoginRecords; + } + private boolean isSmSessionNotExpired(String prevSessionId) { //TODO: implement after we start tracking user logout return true; diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java index 02b7971792..199a038cda 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java @@ -27,6 +27,16 @@ public class SMControllerConfiguration { private int refreshTokenTtl = DEFAULT_REFRESH_TOKEN_TTL; private int expiredAuthAttemptInfoTtl = DEFAULT_EXPIRED_AUTH_ATTEMPT_INFO_TTL; + private boolean enableBruteForceProtection = true; + + //in seconds + public static final int DEFAULT_MAX_FAILED_LOGIN = 10; + public static final int DEFAULT_MINIMUM_LOGIN_TIMEOUT = 1; //1sec + public static final int DEFAULT_BLOCK_LOGIN_PERIOD = 300; //5min + private int maxFailedLogin = DEFAULT_MAX_FAILED_LOGIN; + private int minimumLoginTimeout = DEFAULT_MINIMUM_LOGIN_TIMEOUT; + private int blockLoginPeriod = DEFAULT_BLOCK_LOGIN_PERIOD; + public int getAccessTokenTtl() { return accessTokenTtl; } @@ -50,4 +60,36 @@ public int getExpiredAuthAttemptInfoTtl() { public void setExpiredAuthAttemptInfoTtl(int expiredAuthAttemptInfoTtl) { this.expiredAuthAttemptInfoTtl = expiredAuthAttemptInfoTtl; } + + public void setCheckBruteforce(boolean checkBruteforce) { + this.enableBruteForceProtection = checkBruteforce; + } + + public boolean isCheckBruteforce() { + return enableBruteForceProtection; + } + + public int getMaxFailedLogin() { + return maxFailedLogin; + } + + public int getMinimumLoginTimeout() { + return minimumLoginTimeout; + } + + public int getBlockLoginPeriod() { + return blockLoginPeriod; + } + + public void setMaxFailedLogin(int maxFailed) { + this.maxFailedLogin = maxFailed; + } + + public void setMinimumLoginTimeout(int minimumTimeout) { + this.minimumLoginTimeout = minimumTimeout; + } + + public void setBlockLoginPeriod(int blockPeriod) { + this.blockLoginPeriod = blockPeriod; + } } diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/BruteForceUtils.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/BruteForceUtils.java new file mode 100644 index 0000000000..0652a5a1a6 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/BruteForceUtils.java @@ -0,0 +1,67 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * 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.cloudbeaver.service.security.bruteforce; + +import io.cloudbeaver.service.security.SMControllerConfiguration; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.auth.SMAuthStatus; +import org.jkiss.dbeaver.model.security.exception.SMException; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +public class BruteForceUtils { + + private static final Log log = Log.getLog(BruteForceUtils.class); + + public static void checkBruteforce(SMControllerConfiguration smConfig, List latestLogins) throws DBException { + if (latestLogins.isEmpty()) { + return; + } + + var latestLogin = latestLogins.get(0); + checkLoginInterval(latestLogin.time(), smConfig.getMinimumLoginTimeout()); + + long errorsCount = latestLogins.stream() + .filter(authAttemptSessionInfo -> authAttemptSessionInfo.smAuthStatus() == SMAuthStatus.ERROR).count(); + + boolean shouldBlock = errorsCount >= smConfig.getMaxFailedLogin(); + if (shouldBlock) { + int blockPeriod = smConfig.getBlockLoginPeriod(); + LocalDateTime unblockTime = latestLogin.time().plusSeconds(blockPeriod); + + LocalDateTime now = LocalDateTime.now(); + shouldBlock = unblockTime.isAfter(now); + + if (shouldBlock) { + log.error("User login is blocked due to exceeding the limit of incorrect password entry"); + Duration lockDuration = Duration.ofSeconds(smConfig.getBlockLoginPeriod()); + + throw new SMException("User blocked for " + + lockDuration.minus(Duration.between(latestLogin.time(), now)).getSeconds() + " seconds"); + } + } + } + + private static void checkLoginInterval(LocalDateTime createTime, int timeout) throws DBException { + if (createTime != null && Duration.between(createTime, LocalDateTime.now()).getSeconds() < timeout) { + throw new DBException("You are trying to log in too fast"); + } + } +} diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/UserLoginRecord.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/UserLoginRecord.java new file mode 100644 index 0000000000..7e0185078f --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/UserLoginRecord.java @@ -0,0 +1,24 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * 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.cloudbeaver.service.security.bruteforce; + +import org.jkiss.dbeaver.model.auth.SMAuthStatus; + +import java.time.LocalDateTime; + +public record UserLoginRecord(SMAuthStatus smAuthStatus, LocalDateTime time) { +} diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/db/CBDatabase.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/db/CBDatabase.java index b02eb0f698..623f08168e 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/db/CBDatabase.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/db/CBDatabase.java @@ -73,7 +73,7 @@ public class CBDatabase { public static final String SCHEMA_UPDATE_SQL_PATH = "db/cb_schema_update_"; private static final int LEGACY_SCHEMA_VERSION = 1; - private static final int CURRENT_SCHEMA_VERSION = 14; + private static final int CURRENT_SCHEMA_VERSION = 15; private static final String DEFAULT_DB_USER_NAME = "cb-data"; private static final String DEFAULT_DB_PWD_FILE = ".database-credentials.dat"; diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/internal/AuthAttemptSessionInfo.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/internal/AuthAttemptSessionInfo.java index 7354896a78..275e3f11f2 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/internal/AuthAttemptSessionInfo.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/internal/AuthAttemptSessionInfo.java @@ -19,8 +19,10 @@ import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.auth.SMAuthStatus; import org.jkiss.dbeaver.model.auth.SMSessionType; +import java.time.LocalDateTime; import java.util.Map; public class AuthAttemptSessionInfo { @@ -72,4 +74,5 @@ public String getSmSessionId() { public boolean isMainAuth() { return mainAuth; } + } diff --git a/server/test/io.cloudbeaver.test.platform/workspace/conf/cloudbeaver.conf b/server/test/io.cloudbeaver.test.platform/workspace/conf/cloudbeaver.conf index 13b7b24448..c74507b25a 100644 --- a/server/test/io.cloudbeaver.test.platform/workspace/conf/cloudbeaver.conf +++ b/server/test/io.cloudbeaver.test.platform/workspace/conf/cloudbeaver.conf @@ -16,6 +16,10 @@ develMode: false, + sm: { + enableBruteForceProtection: "${CLOUDBEAVER_BRUTE_FORCE_PROTECTION_ENABLED:false}" + }, + database: { driver="h2_embedded_v2", url: "jdbc:h2:mem:testdb",