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 index 57126cc27d6..ff7782599a8 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMBruteForceProtected.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMBruteForceProtected.java @@ -16,10 +16,27 @@ */ package io.cloudbeaver.auth; +import io.cloudbeaver.model.config.SMControllerConfiguration; import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import java.util.List; import java.util.Map; public interface SMBruteForceProtected { Object getInputUsername(@NotNull Map cred); + + @Nullable + default Map processUserCredBeforeAuthAttempt( + @NotNull Map credBeforeFiltering, + @NotNull Map credAfterFiltering + ) { + return null; + } + + @Nullable + default Boolean shouldBeBlocked(SMControllerConfiguration smConfig, List userLoginRecords) { + return null; + } + } diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/UserLoginRecord.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/UserLoginRecord.java similarity index 57% rename from server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/UserLoginRecord.java rename to server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/UserLoginRecord.java index 3589d92ed3f..9ae73673b39 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/UserLoginRecord.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/UserLoginRecord.java @@ -14,11 +14,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.cloudbeaver.service.security.bruteforce; +package io.cloudbeaver.auth; import org.jkiss.dbeaver.model.auth.SMAuthStatus; import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Map; -public record UserLoginRecord(SMAuthStatus smAuthStatus, LocalDateTime time) { +public record UserLoginRecord(SMAuthStatus smAuthStatus, + LocalDateTime time, + Map authState +) { + + public UserLoginRecord(SMAuthStatus smAuthStatus, LocalDateTime time) { + this(smAuthStatus, time, Collections.emptyMap()); + } + + public UserLoginRecord(SMAuthStatus smAuthStatus, LocalDateTime time, Map authState) { + this.smAuthStatus = smAuthStatus; + this.time = time; + this.authState = authState; + } } diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/provider/local/LocalAuthProviderConstants.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/provider/local/LocalAuthProviderConstants.java index 3e260bb1502..cd8e94db79d 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/provider/local/LocalAuthProviderConstants.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/provider/local/LocalAuthProviderConstants.java @@ -23,4 +23,5 @@ public class LocalAuthProviderConstants { public static final String CRED_USER = "user"; public static final String CRED_DISPLAY_NAME = "displayName"; public static final String CRED_PASSWORD = "password"; + public static final String CRED_PASSWORD_MD5 = "passwordMd5"; } diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSessionAuthProcessor.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSessionAuthProcessor.java index ecf33036daa..f907a0cfba1 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSessionAuthProcessor.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSessionAuthProcessor.java @@ -64,7 +64,7 @@ public List authenticateSession() throws DBException { case SUCCESS: return finishWebSessionAuthorization(smAuthInfo); case ERROR: - var e = new DBException(smAuthInfo.getError()); + var e = new DBWebException(smAuthInfo.getError(), smAuthInfo.getErrorCode()); webSession.addSessionError(e); throw e; case IN_PROGRESS: diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java index eca555a204f..5b4ad12241a 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java @@ -47,6 +47,7 @@ import org.jkiss.dbeaver.model.security.SMConstants; import org.jkiss.dbeaver.model.security.SMController; import org.jkiss.dbeaver.model.security.SMSubjectType; +import org.jkiss.dbeaver.model.security.exception.SMInvalidCredentialException; import org.jkiss.dbeaver.model.security.exception.SMTooManySessionsException; import org.jkiss.dbeaver.model.security.user.SMUser; import org.jkiss.utils.CommonUtils; @@ -88,7 +89,13 @@ public WebAuthStatus authLogin( } } catch (SMTooManySessionsException e) { throw new DBWebException("User authentication failed", e.getErrorType(), e); + } catch (SMInvalidCredentialException e) { + throw new DBWebException("Invalid credentials", e.getErrorType(), e); } catch (Exception e) { + //need to save errorCode + if (e.getCause() instanceof DBWebException dbwe) { + throw dbwe; + } throw new DBWebException("User authentication failed", e); } } 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 60cf8809cce..d356d840e75 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 @@ -17,20 +17,27 @@ package io.cloudbeaver.auth.provider.local; import io.cloudbeaver.auth.SMBruteForceProtected; +import io.cloudbeaver.auth.UserLoginRecord; +import io.cloudbeaver.model.config.SMControllerConfiguration; import io.cloudbeaver.model.session.WebSession; import io.cloudbeaver.registry.WebAuthProviderDescriptor; import io.cloudbeaver.registry.WebAuthProviderRegistry; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.model.auth.Argon2IdHasher; import org.jkiss.dbeaver.model.auth.AuthPropertyEncryption; import org.jkiss.dbeaver.model.auth.SMAuthProvider; import org.jkiss.dbeaver.model.auth.SMSession; import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; +import org.jkiss.dbeaver.model.security.SMAdminController; import org.jkiss.dbeaver.model.security.SMAuthProviderCustomConfiguration; import org.jkiss.dbeaver.model.security.SMController; +import org.jkiss.dbeaver.model.security.exception.SMInvalidCredentialException; import org.jkiss.utils.CommonUtils; +import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -40,7 +47,11 @@ public class LocalAuthProvider implements SMAuthProvider, SMBr public static final String PROVIDER_ID = LocalAuthProviderConstants.PROVIDER_ID; public static final String CRED_USER = LocalAuthProviderConstants.CRED_USER; + public static final String CRED_PASSWORD_MD_5 = LocalAuthProviderConstants.CRED_PASSWORD_MD5; public static final String CRED_PASSWORD = LocalAuthProviderConstants.CRED_PASSWORD; + public static final String AUTH_LOCAL_TYPE = "authLocalType"; + public static final String LEGACY_AUTH_LOCAL_TYPE = "legacy"; + public static final String NEW_AUTH_LOCAL_TYPE = "new"; @NotNull @Override @@ -59,22 +70,49 @@ public String validateLocalAuth(@NotNull DBRProgressMonitor monitor, throw new DBException("Invalid user name or password"); } - String storedPasswordHash = CommonUtils.toString(storedCredentials.get(CRED_PASSWORD), null); + String passwordSha = CommonUtils.toString(userCredentials.get(LocalAuthProviderConstants.CRED_PASSWORD), null); + if (CommonUtils.isNotEmpty(CommonUtils.toString(userCredentials.get(CRED_PASSWORD_MD_5), null))) { + validatePasswordMd5(userCredentials, storedCredentials); + if (securityController instanceof SMAdminController adminController) { + adminController.setUserCredentials(userName, authProvider.getId(), userCredentials); + } else { + throw new DBException("User password hash update is not supported in current context"); + } + } else { + validatePasswordSha(passwordSha, storedCredentials); + } + + return activeUserId == null ? userName : activeUserId; + } + + private void validatePasswordSha(String passwordSha, Map storedCredentials) throws DBException { + try { + if (!Argon2IdHasher.verify(CommonUtils.toString(storedCredentials.get(LocalAuthProviderConstants.CRED_PASSWORD)), passwordSha)) { + throw new SMInvalidCredentialException("Invalid user name or password"); + } + } catch (Exception e) { + throw new SMInvalidCredentialException("Invalid user name or password"); + } + } + + private void validatePasswordMd5(@NotNull Map userCredentials, + Map storedCredentials + ) throws DBException { + String storedPasswordHash = CommonUtils.toString(storedCredentials.get(LocalAuthProviderConstants.CRED_PASSWORD), null); if (CommonUtils.isEmpty(storedPasswordHash)) { - throw new DBException("User has no password (login restricted)"); + throw new SMInvalidCredentialException("User has no password (login restricted)"); } - String clientPassword = CommonUtils.toString(userCredentials.get(CRED_PASSWORD), null); + String clientPassword = CommonUtils.toString(userCredentials.get(CRED_PASSWORD_MD_5), null); if (CommonUtils.isEmpty(clientPassword)) { - throw new DBException("No user password provided"); + throw new SMInvalidCredentialException("No user password provided"); } - String clientPasswordHash = AuthPropertyEncryption.hash.encrypt(userName, clientPassword); + String userName = CommonUtils.toString(userCredentials.get(CRED_USER)); + String clientPasswordHash = AuthPropertyEncryption.hashMd5.encrypt(userName, clientPassword); // we also need to check a hash with lower case (CB-5833) - String clientPasswordHashLowerCase = AuthPropertyEncryption.hash.encrypt(userName.toLowerCase(), clientPassword); + String clientPasswordHashLowerCase = AuthPropertyEncryption.hashMd5.encrypt(userName.toLowerCase(), clientPassword); if (!storedPasswordHash.equals(clientPasswordHash) && !clientPasswordHashLowerCase.equals(storedPasswordHash)) { - throw new DBException("Invalid user name or password"); + throw new SMInvalidCredentialException("Invalid user name or password"); } - - return activeUserId == null ? userName : activeUserId; } @Override @@ -117,13 +155,10 @@ public static boolean changeUserPassword(@NotNull WebSession webSession, @NotNul if (CommonUtils.isEmpty(oldPassword)) { throw new DBException("No user password provided"); } - String oldPasswordHash = AuthPropertyEncryption.hash.encrypt(userName, oldPassword); - if (!storedPasswordHash.equals(oldPasswordHash)) { + if (!Argon2IdHasher.verify(storedPasswordHash, oldPassword)) { throw new DBException("Invalid user name or password"); } - //String newPasswordHash = WebAuthProviderPropertyEncryption.hash.encrypt(userName, newPassword); - storedCredentials.put(CRED_PASSWORD, newPassword); smController.setCurrentUserCredentials(authProvider.getId(), storedCredentials); return true; @@ -133,4 +168,36 @@ public static boolean changeUserPassword(@NotNull WebSession webSession, @NotNul public Object getInputUsername(@NotNull Map cred) { return cred.get("user"); } + + + @Override + public Map processUserCredBeforeAuthAttempt( + @NotNull Map credBefore, + @NotNull Map credAfter + ) { + HashMap result = new HashMap<>(credAfter); + if (credBefore.get(CRED_PASSWORD_MD_5) != null) { + result.put(AUTH_LOCAL_TYPE, LEGACY_AUTH_LOCAL_TYPE); + } else { + result.put(AUTH_LOCAL_TYPE, NEW_AUTH_LOCAL_TYPE); + } + return result; + } + + @Nullable + @Override + public Boolean shouldBeBlocked(SMControllerConfiguration smConfig, List userLoginRecords) { + + long countNewAuthTypeAttempts = userLoginRecords.stream() + .filter(userLoginRecord -> NEW_AUTH_LOCAL_TYPE.equals(userLoginRecord.authState().get(AUTH_LOCAL_TYPE))) + .count(); + long countLegacyAuthTypeAttempts = userLoginRecords.stream() + .filter(userLoginRecord -> LEGACY_AUTH_LOCAL_TYPE.equals(userLoginRecord.authState().get(AUTH_LOCAL_TYPE))) + .count(); + int maxFailedLogin = smConfig.getMaxFailedLogin(); + if (countNewAuthTypeAttempts == maxFailedLogin && countLegacyAuthTypeAttempts + 1 == maxFailedLogin) { + return false; + } + return countNewAuthTypeAttempts >= maxFailedLogin || countLegacyAuthTypeAttempts >= maxFailedLogin; + } } 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 ff12199a98a..6f2bc462441 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 @@ -28,7 +28,6 @@ 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.CBAuthSubjectRepo; @@ -1860,14 +1859,25 @@ private Map filterSecuredUserData( WebAuthProviderDescriptor authProviderDescriptor ) { SMAuthCredentialsProfile credProfile = getCredentialProfileByParameters(authProviderDescriptor, userIdentifyingCredentials.keySet()); - return userIdentifyingCredentials.entrySet() + Map filteredUserData = userIdentifyingCredentials.entrySet() .stream() .filter((cred) -> { AuthPropertyDescriptor property = credProfile.getCredentialParameter(cred.getKey()); - return property != null && property.getEncryption() == AuthPropertyEncryption.none; }) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + if (smConfig.isCheckBruteforce() + && authProviderDescriptor.getInstance() instanceof SMBruteForceProtected bruteforceProtected) { + Map resultFilteredUserData = bruteforceProtected.processUserCredBeforeAuthAttempt( + userIdentifyingCredentials, + filteredUserData + ); + if (resultFilteredUserData != null) { + return resultFilteredUserData; + } + } + return filteredUserData; } private String createNewAuthAttempt( @@ -1891,7 +1901,7 @@ private String createNewAuthAttempt( Object inputUsername = bruteforceProtected.getInputUsername(authData); if (inputUsername != null) { BruteForceUtils.checkBruteforce(smConfig, - getLatestUserLogins(dbCon, authProviderId, inputUsername.toString())); + getLatestUserLogins(dbCon, authProviderId, inputUsername.toString()), bruteforceProtected); } } try (PreparedStatement dbStat = dbCon.prepareStatement( @@ -1952,14 +1962,15 @@ private List getLatestUserLogins(Connection dbCon, String authP try (PreparedStatement dbStat = dbCon.prepareStatement( "SELECT" + " attempt.AUTH_STATUS," + - " attempt.CREATE_TIME" + + " attempt.CREATE_TIME," + + " info.AUTH_STATE" + " 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 = ? AND attempt.CREATE_TIME > ?" + " ORDER BY attempt.CREATE_TIME DESC " + - database.getDialect().getOffsetLimitQueryPart(0, smConfig.getMaxFailedLogin()) + database.getDialect().getOffsetLimitQueryPart(0, smConfig.getMaxFailedLogin() * 2) )) { dbStat.setString(1, authProviderId); dbStat.setString(2, inputLogin); @@ -1969,8 +1980,8 @@ private List getLatestUserLogins(Connection dbCon, String authP while (dbResult.next()) { UserLoginRecord loginDto = new UserLoginRecord( SMAuthStatus.valueOf(dbResult.getString(1)), - dbResult.getTimestamp(2).toLocalDateTime() - ); + dbResult.getTimestamp(2).toLocalDateTime(), + gson.fromJson(dbResult.getString(3), Map.class)); userLoginRecords.add(loginDto); } } @@ -2451,8 +2462,9 @@ protected SMAuthInfo finishAuthentication( if (userIdFromCreds == null) { var error = "Invalid user credentials"; - updateAuthStatus(authId, SMAuthStatus.ERROR, dbStoredUserData, error, null); - return SMAuthInfo.error(authId, error, isMainAuthSession, null, authInfo.getAppSessionId()); + String errorCode = "invalidCredentials"; + updateAuthStatus(authId, SMAuthStatus.ERROR, dbStoredUserData, error, errorCode); + return SMAuthInfo.error(authId, error, isMainAuthSession, errorCode, authInfo.getAppSessionId()); } if (autoAssign != null && !CommonUtils.isEmpty(autoAssign.getExternalTeamIds())) { 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 index 9a0bb08e272..51f9bc1432a 100644 --- 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 @@ -16,6 +16,8 @@ */ package io.cloudbeaver.service.security.bruteforce; +import io.cloudbeaver.auth.SMBruteForceProtected; +import io.cloudbeaver.auth.UserLoginRecord; import io.cloudbeaver.model.config.SMControllerConfiguration; import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.Log; @@ -30,19 +32,28 @@ public class BruteForceUtils { private static final Log log = Log.getLog(BruteForceUtils.class); - public static void checkBruteforce(SMControllerConfiguration smConfig, List latestLoginAttempts) + public static void checkBruteforce(SMControllerConfiguration smConfig, List latestLoginAttempts, + SMBruteForceProtected bruteforceProtectedAuthProvider + ) throws DBException { if (latestLoginAttempts.isEmpty()) { return; } - var oldestLoginAttempt = latestLoginAttempts.get(latestLoginAttempts.size() - 1); - checkLoginInterval(oldestLoginAttempt.time(), smConfig.getMinimumLoginTimeout()); + var oldestLoginAttempt = latestLoginAttempts.getLast(); + //fixme to remove (?) currently we send two authrequests almost simultaneously + //checkLoginInterval(oldestLoginAttempt.time(), smConfig.getMinimumLoginTimeout()); long errorsCount = latestLoginAttempts.stream() - .filter(authAttemptSessionInfo -> authAttemptSessionInfo.smAuthStatus() == SMAuthStatus.ERROR).count(); + .filter(authAttemptSessionInfo -> authAttemptSessionInfo.smAuthStatus() == SMAuthStatus.ERROR) + .count(); boolean shouldBlock = errorsCount >= smConfig.getMaxFailedLogin(); + Boolean shouldBeBlockedByAuthProvider = bruteforceProtectedAuthProvider.shouldBeBlocked(smConfig, latestLoginAttempts); + shouldBlock = shouldBeBlockedByAuthProvider != null + ? shouldBeBlockedByAuthProvider + : shouldBlock; + if (shouldBlock) { int blockPeriod = smConfig.getBlockLoginPeriod(); LocalDateTime unblockTime = oldestLoginAttempt.time().plusSeconds(blockPeriod); 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 412b3fa739b..b0ee019a881 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 @@ -309,11 +309,9 @@ private SMUser createAdminUser( if (!CommonUtils.isEmpty(adminPassword)) { // This is how client password will be transmitted from client - String clientPassword = SecurityUtils.makeDigest(adminPassword); - Map credentials = new LinkedHashMap<>(); credentials.put(LocalAuthProviderConstants.CRED_USER, adminUser.getUserId()); - credentials.put(LocalAuthProviderConstants.CRED_PASSWORD, clientPassword); + credentials.put(LocalAuthProviderConstants.CRED_PASSWORD, adminPassword); WebAuthProviderDescriptor authProvider = WebAuthProviderRegistry.getInstance() .getAuthProvider(LocalAuthProviderConstants.PROVIDER_ID); diff --git a/server/test/io.cloudbeaver.test.platform/src/io/cloudbeaver/app/CEAppStarter.java b/server/test/io.cloudbeaver.test.platform/src/io/cloudbeaver/app/CEAppStarter.java index 22cb8110a91..4e6974e18b0 100644 --- a/server/test/io.cloudbeaver.test.platform/src/io/cloudbeaver/app/CEAppStarter.java +++ b/server/test/io.cloudbeaver.test.platform/src/io/cloudbeaver/app/CEAppStarter.java @@ -36,7 +36,7 @@ public class CEAppStarter { private static final String SERVER_STATUS_URL = SERVER_URL + "/status"; private static final Map TEST_CREDENTIALS = Map.of( LocalAuthProvider.CRED_USER, "test", - LocalAuthProvider.CRED_PASSWORD, SecurityUtils.makeDigest("test") + LocalAuthProvider.CRED_PASSWORD, SecurityUtils.makeDigestSha("test") ); private static CBApplication testApp; diff --git a/server/test/io.cloudbeaver.test.platform/src/io/cloudbeaver/test/platform/AuthenticationTest.java b/server/test/io.cloudbeaver.test.platform/src/io/cloudbeaver/test/platform/AuthenticationTest.java index b6c24f8886e..a665f1809a8 100644 --- a/server/test/io.cloudbeaver.test.platform/src/io/cloudbeaver/test/platform/AuthenticationTest.java +++ b/server/test/io.cloudbeaver.test.platform/src/io/cloudbeaver/test/platform/AuthenticationTest.java @@ -82,7 +82,7 @@ public void testLoginUserWithCamelCase() throws Exception { private Map getUserCredentials(@NotNull String userId) throws Exception { return Map.of( LocalAuthProvider.CRED_USER, userId, - LocalAuthProvider.CRED_PASSWORD, SecurityUtils.makeDigest("test") + LocalAuthProvider.CRED_PASSWORD, SecurityUtils.makeDigestSha("test") ); } diff --git a/server/test/io.cloudbeaver.test.platform/workspace/conf/initial-data.conf b/server/test/io.cloudbeaver.test.platform/workspace/conf/initial-data.conf index fa63f4640a5..76513005b2f 100644 --- a/server/test/io.cloudbeaver.test.platform/workspace/conf/initial-data.conf +++ b/server/test/io.cloudbeaver.test.platform/workspace/conf/initial-data.conf @@ -1,6 +1,6 @@ { adminName: "test", - adminPassword: "test", + adminPassword: "9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08", teams: [ { subjectId: "admin", diff --git a/webapp/packages/core-authentication/src/AuthProviderService.ts b/webapp/packages/core-authentication/src/AuthProviderService.ts index 0c86e3d67b7..f3b8f4e74d3 100644 --- a/webapp/packages/core-authentication/src/AuthProviderService.ts +++ b/webapp/packages/core-authentication/src/AuthProviderService.ts @@ -1,13 +1,13 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ import { injectable } from '@cloudbeaver/core-di'; import { Executor, type IExecutor } from '@cloudbeaver/core-executor'; -import { md5, uuid } from '@cloudbeaver/core-utils'; +import { createHash, uuid } from '@cloudbeaver/core-utils'; import { type AuthProvider, AuthProvidersResource } from './AuthProvidersResource.js'; import type { IAuthCredentials } from './IAuthCredentials.js'; @@ -61,11 +61,7 @@ export class AuthProviderService { return provider.get(); } - hashValue(value: string): string { - return md5(value).toUpperCase(); - } - - async processCredentials(providerId: string, credentials: IAuthCredentials): Promise { + async processCredentials(providerId: string, credentials: IAuthCredentials, hasOldHash: boolean = false): Promise { const provider = await this.authProvidersResource.load(providerId); if (!provider) { @@ -84,7 +80,11 @@ export class AuthProviderService { const value = credentialsProcessed.credentials[parameter.id]; if (typeof value === 'string') { - credentialsProcessed.credentials[parameter.id] = this.hashValue(value); + credentialsProcessed.credentials[parameter.id] = await createHash(value, 'sha256'); + + if (hasOldHash) { + credentialsProcessed.credentials[`${parameter.id}Md5`] = await createHash(value, 'md5'); + } } } } diff --git a/webapp/packages/core-authentication/src/UserInfoResource.ts b/webapp/packages/core-authentication/src/UserInfoResource.ts index 3fdbdc5aa8a..2e605f62cba 100644 --- a/webapp/packages/core-authentication/src/UserInfoResource.ts +++ b/webapp/packages/core-authentication/src/UserInfoResource.ts @@ -17,6 +17,7 @@ import { AUTH_PROVIDER_LOCAL_ID } from './AUTH_PROVIDER_LOCAL_ID.js'; import { AuthProviderService } from './AuthProviderService.js'; import type { ELMRole } from './ELMRole.js'; import type { IAuthCredentials } from './IAuthCredentials.js'; +import { createHash } from '@cloudbeaver/core-utils'; export type UserLogoutInfo = AuthLogoutQuery['result']; @@ -25,6 +26,7 @@ export interface ILoginOptions { configurationId?: string; linkUser?: boolean; forceSessionsLogout?: boolean; + hasOldHash?: boolean; } export type IFederatedLoginOptions = Omit; @@ -103,11 +105,11 @@ export class UserInfoResource extends CachedDataResource return this.data.authTokens.some(token => token.authProvider === providerId); } - async login(provider: string, { credentials, configurationId, linkUser, forceSessionsLogout }: ILoginOptions): Promise { + async login(provider: string, { credentials, configurationId, linkUser, forceSessionsLogout, hasOldHash }: ILoginOptions): Promise { let processedCredentials: Record | undefined; if (credentials) { - const processed = await this.authProviderService.processCredentials(provider, credentials); + const processed = await this.authProviderService.processCredentials(provider, credentials, hasOldHash); processedCredentials = processed.credentials; } @@ -234,9 +236,11 @@ export class UserInfoResource extends CachedDataResource async updateLocalPassword(oldPassword: string, newPassword: string): Promise { await this.performUpdate(undefined, [], async () => { + const [oldPasswordHash, newPasswordHash] = await Promise.all([createHash(oldPassword, 'sha256'), createHash(newPassword, 'sha256')]); + await this.graphQLService.sdk.authChangeLocalPassword({ - oldPassword: this.authProviderService.hashValue(oldPassword), - newPassword: this.authProviderService.hashValue(newPassword), + oldPassword: oldPasswordHash, + newPassword: newPasswordHash, }); }); } diff --git a/webapp/packages/core-root/src/ServerConfigResource.ts b/webapp/packages/core-root/src/ServerConfigResource.ts index b0a78238573..736f1eb614e 100644 --- a/webapp/packages/core-root/src/ServerConfigResource.ts +++ b/webapp/packages/core-root/src/ServerConfigResource.ts @@ -16,6 +16,7 @@ import { ServerConfigEventHandler } from './ServerConfigEventHandler.js'; export const FEATURE_GIT_ID = 'git'; export type ServerConfig = ServerConfigFragment; +export type IServerConfigInput = ServerConfigInput; @injectable(() => [GraphQLService, DataSynchronizationService, ServerConfigEventHandler]) export class ServerConfigResource extends CachedDataResource { diff --git a/webapp/packages/core-sdk/src/EServerErrorCode.ts b/webapp/packages/core-sdk/src/EServerErrorCode.ts index df0895b90d3..7eb5185a7b2 100644 --- a/webapp/packages/core-sdk/src/EServerErrorCode.ts +++ b/webapp/packages/core-sdk/src/EServerErrorCode.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -10,4 +10,5 @@ export enum EServerErrorCode { 'sessionExpired' = 'sessionExpired', 'licenseRequired' = 'licenseRequired', 'tooManySessions' = 'tooManySessions', + 'invalidCredentials' = 'invalidCredentials', } diff --git a/webapp/packages/core-utils/src/createHash.ts b/webapp/packages/core-utils/src/createHash.ts new file mode 100644 index 00000000000..61e74e9710b --- /dev/null +++ b/webapp/packages/core-utils/src/createHash.ts @@ -0,0 +1,19 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import md5 from 'md5'; +import { sha256 } from './sha256.js'; + +export async function createHash(value: string, algorithm: 'md5' | 'sha256'): Promise { + if (algorithm === 'sha256') { + const hash = await sha256(value); + return hash.toUpperCase(); + } + + return md5(value).toUpperCase(); +} diff --git a/webapp/packages/core-utils/src/index.ts b/webapp/packages/core-utils/src/index.ts index 952e988d0c8..65db97f16fe 100644 --- a/webapp/packages/core-utils/src/index.ts +++ b/webapp/packages/core-utils/src/index.ts @@ -43,6 +43,8 @@ export * from './isSafari.js'; export * from './isSameDay.js'; export * from './isValuesEqual.js'; export * from './md5.js'; +export * from './sha256.js'; +export * from './createHash.js'; export * from './MetadataMap.js'; export * from './OrderedMap.js'; export * from './parseJSONFlat.js'; diff --git a/webapp/packages/core-utils/src/sha256.ts b/webapp/packages/core-utils/src/sha256.ts new file mode 100644 index 00000000000..20ff28ba9fa --- /dev/null +++ b/webapp/packages/core-utils/src/sha256.ts @@ -0,0 +1,16 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export async function sha256(message: string): Promise { + const msgUint8 = new TextEncoder().encode(message); + const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + + return hashHex; +} diff --git a/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationFormPart.ts b/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationFormPart.ts index f16b23de7ce..4824b0393db 100644 --- a/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationFormPart.ts +++ b/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationFormPart.ts @@ -10,9 +10,15 @@ import { ADMIN_USERNAME_MIN_LENGTH, AUTH_PROVIDER_LOCAL_ID, AuthProvidersResourc import { DEFAULT_NAVIGATOR_VIEW_SETTINGS } from '@cloudbeaver/core-connections'; import { ExecutorInterrupter, type IExecutionContextProvider } from '@cloudbeaver/core-executor'; import { CachedMapAllKey } from '@cloudbeaver/core-resource'; -import { DefaultNavigatorSettingsResource, PasswordPolicyResource, ProductInfoResource, ServerConfigResource } from '@cloudbeaver/core-root'; +import { + DefaultNavigatorSettingsResource, + PasswordPolicyResource, + ProductInfoResource, + ServerConfigResource, + type IServerConfigInput, +} from '@cloudbeaver/core-root'; import { FormPart, formValidationContext, type IFormState } from '@cloudbeaver/core-ui'; -import { isIp, isObjectsEqual, isValuesEqual } from '@cloudbeaver/core-utils'; +import { createHash, isIp, isObjectsEqual, isValuesEqual } from '@cloudbeaver/core-utils'; import { LocalizationService } from '@cloudbeaver/core-localization'; import { MIN_SESSION_EXPIRE_TIME } from './Form/MIN_SESSION_EXPIRE_TIME.js'; @@ -114,7 +120,7 @@ export class ServerConfigurationFormPart extends FormPart Promise; + login: (linkUser: boolean, provider?: AuthProvider, configuration?: AuthProviderConfiguration, hasOldHash?: boolean) => Promise; federatedLogin: (provider: AuthProvider, configuration: AuthProviderConfiguration) => Promise; } @@ -226,7 +227,7 @@ export function useAuthDialogState(accessRequest: boolean, providerId: string | if (provider.federated && configuration) { await this.federatedLogin(provider, configuration); } else { - await authInfoService.login(provider.id, { + const options: ILoginOptions = { configurationId: configuration?.id, credentials: { ...state.credentials, @@ -238,7 +239,20 @@ export function useAuthDialogState(accessRequest: boolean, providerId: string | }, forceSessionsLogout: state.forceSessionsLogout, linkUser, - }); + }; + + try { + await authInfoService.login(provider.id, options); + } catch (exception: any) { + const gqlError = errorOf(exception, GQLError); + + if (gqlError?.errorCode === EServerErrorCode.invalidCredentials) { + await authInfoService.login(provider.id, { + ...options, + hasOldHash: true, + }); + } + } } } catch (exception: any) { const gqlError = errorOf(exception, GQLError);