diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml index c71fd2b3f2a..f2d172be794 100644 --- a/conf/openmetadata.yaml +++ b/conf/openmetadata.yaml @@ -253,3 +253,7 @@ email: sandboxModeEnabled: ${SANDBOX_MODE_ENABLED:-false} slackChat: slackUrl: ${SLACK_CHAT_SLACK_URL:-""} + +login: + maxLoginFailAttempts: ${OM_MAX_FAILED_LOGIN_ATTEMPTS:-3} + accessBlockTime: ${OM_LOGIN_ACCESS_BLOCKTIME:-600} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplicationConfig.java b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplicationConfig.java index e6f7b387732..57dd2ae4178 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplicationConfig.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplicationConfig.java @@ -22,6 +22,7 @@ import javax.validation.Valid; import javax.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; +import org.openmetadata.schema.api.configuration.LoginConfiguration; import org.openmetadata.schema.api.configuration.airflow.AirflowConfiguration; import org.openmetadata.schema.api.configuration.elasticsearch.ElasticSearchConfiguration; import org.openmetadata.schema.api.configuration.events.EventHandlerConfiguration; @@ -94,6 +95,9 @@ public class OpenMetadataApplicationConfig extends Configuration { @JsonProperty("email") private SmtpSettings smtpSettings; + @JsonProperty("login") + private LoginConfiguration loginSettings; + @Override public String toString() { return "catalogConfig{" diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/UserRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/UserRepository.java index 6a1c861232d..7a11aac5636 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/UserRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/UserRepository.java @@ -65,6 +65,15 @@ public class UserRepository extends EntityRepository { this.secretsManager = secretsManager; } + public final Fields getFieldsWithUserAuth(String fields) { + if (fields != null && fields.equals("*")) { + List tempFields = getAllowedFieldsCopy(); + tempFields.add("authenticationMechanism"); + return new Fields(allowedFields, String.join(",", tempFields)); + } + return new Fields(allowedFields, fields); + } + public UserRepository(CollectionDAO dao) { this(dao, null); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java index 7da889cde5d..29ff363b52c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java @@ -112,6 +112,7 @@ import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.secrets.SecretsManager; import org.openmetadata.service.security.AuthorizationException; import org.openmetadata.service.security.Authorizer; +import org.openmetadata.service.security.auth.LoginAttemptCache; import org.openmetadata.service.security.jwt.JWTTokenGenerator; import org.openmetadata.service.security.policyevaluator.OperationContext; import org.openmetadata.service.security.policyevaluator.ResourceContext; @@ -145,6 +146,8 @@ public class UserResource extends EntityResource { private final String providerType; + private final LoginAttemptCache loginAttemptCache; + @Override public User addHref(UriInfo uriInfo, User user) { Entity.withHref(uriInfo, user.getTeams()); @@ -166,6 +169,7 @@ public class UserResource extends EntityResource { ConfigurationHolder.getInstance() .getConfig(ConfigurationHolder.ConfigurationType.AUTHENTICATIONCONFIG, AuthenticationConfiguration.class) .getProvider(); + this.loginAttemptCache = new LoginAttemptCache(); } public static class UserList extends ResultList { @@ -939,20 +943,7 @@ public class UserResource extends EntityResource { tokenRepository.deleteTokenByUserAndType(storedUser.getId().toString(), PASSWORD_RESET.toString()); // Update user about Password Change try { - Map templatePopulator = new HashMap<>(); - templatePopulator.put(EmailUtil.ENTITY, EmailUtil.getInstance().getEmailingEntity()); - templatePopulator.put(EmailUtil.SUPPORTURL, EmailUtil.getInstance().getSupportUrl()); - templatePopulator.put(EmailUtil.USERNAME, storedUser.getName()); - templatePopulator.put(EmailUtil.ACTIONKEY, "Update Password"); - templatePopulator.put(EmailUtil.ACTIONSTATUSKEY, "Change Successful"); - - EmailUtil.getInstance() - .sendMail( - EmailUtil.getInstance().getAccountStatusChangeSubject(), - templatePopulator, - storedUser.getEmail(), - EmailUtil.EMAILTEMPLATEBASEPATH, - EmailUtil.ACCOUNTSTATUSTEMPLATEFILE); + sendAccountStatus(storedUser, "Update Password", "Change Successful"); } catch (Exception ex) { LOG.error("Error in sending Password Change Mail to User. Reason : " + ex.getMessage()); return Response.status(424) @@ -960,6 +951,7 @@ public class UserResource extends EntityResource { "Password updated successfully. There is some problem in sending mail. Please contact your administrator.") .build(); } + loginAttemptCache.recordSuccessfulLogin(request.getUsername()); return Response.status(response.getStatus()).entity("Password Changed Successfully").build(); } @@ -1000,6 +992,8 @@ public class UserResource extends EntityResource { storedUser.getAuthenticationMechanism().setConfig(storedBasicAuthMechanism); dao.createOrUpdate(uriInfo, storedUser); // it has to be 200 since we already fetched user , and we don't want to return any other data + // remove login/details from cache + loginAttemptCache.recordSuccessfulLogin(securityContext.getUserPrincipal().getName()); return Response.status(200).entity("Password Updated Successfully").build(); } else { return Response.status(403).entity(new ErrorMessage(403, "Old Password is not correct")).build(); @@ -1014,6 +1008,8 @@ public class UserResource extends EntityResource { storedBasicAuthMechanism.setPassword(newHashedPassword); storedUser.getAuthenticationMechanism().setConfig(storedBasicAuthMechanism); RestUtil.PutResponse response = dao.createOrUpdate(uriInfo, storedUser); + // remove login/details from cache + loginAttemptCache.recordSuccessfulLogin(request.getUsername()); try { sendInviteMailToUser( uriInfo, @@ -1083,41 +1079,55 @@ public class UserResource extends EntityResource { }) public Response loginUserWithPassword( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid LoginRequest loginRequest) - throws IOException { + throws IOException, TemplateException { String userName = loginRequest.getEmail().contains("@") ? loginRequest.getEmail().split("@")[0] : loginRequest.getEmail(); - User storedUser; - try { - storedUser = dao.getByName(uriInfo, userName, new Fields(List.of(USER_PROTECTED_FIELDS), USER_PROTECTED_FIELDS)); - } catch (IOException ex) { - throw new BadRequestException("You have entered an invalid username or password."); - } + if (!loginAttemptCache.isLoginBlocked(userName)) { + User storedUser; + try { + storedUser = + dao.getByName(uriInfo, userName, new Fields(List.of(USER_PROTECTED_FIELDS), USER_PROTECTED_FIELDS)); + } catch (IOException ex) { + throw new BadRequestException("You have entered an invalid username or password."); + } - if (storedUser != null && storedUser.getIsBot() != null && storedUser.getIsBot()) { - throw new IllegalArgumentException("You have entered an invalid username or password."); - } + if (storedUser != null && storedUser.getIsBot() != null && storedUser.getIsBot()) { + throw new IllegalArgumentException("You have entered an invalid username or password."); + } - LinkedHashMap storedData = - (LinkedHashMap) storedUser.getAuthenticationMechanism().getConfig(); - String requestPassword = loginRequest.getPassword(); - String storedHashPassword = storedData.get("password"); - if (BCrypt.verifyer().verify(requestPassword.toCharArray(), storedHashPassword).verified) { - // successfully verified create a jwt token for frontend - RefreshToken refreshToken = createRefreshTokenForLogin(storedUser.getId()); - JWTAuthMechanism jwtAuthMechanism = - jwtTokenGenerator.generateJWTToken( - storedUser.getName(), storedUser.getEmail(), JWTTokenExpiry.OneHour, false); + LinkedHashMap storedData = + (LinkedHashMap) storedUser.getAuthenticationMechanism().getConfig(); + String requestPassword = loginRequest.getPassword(); + String storedHashPassword = storedData.get("password"); + if (BCrypt.verifyer().verify(requestPassword.toCharArray(), storedHashPassword).verified) { + // successfully verified create a jwt token for frontend + RefreshToken refreshToken = createRefreshTokenForLogin(storedUser.getId()); + JWTAuthMechanism jwtAuthMechanism = + jwtTokenGenerator.generateJWTToken( + storedUser.getName(), storedUser.getEmail(), JWTTokenExpiry.OneHour, false); - JwtResponse response = new JwtResponse(); - response.setTokenType("Bearer"); - response.setAccessToken(jwtAuthMechanism.getJWTToken()); - response.setRefreshToken(refreshToken.getToken().toString()); - response.setExpiryDuration(jwtAuthMechanism.getJWTTokenExpiresAt()); - return Response.status(200).entity(response).build(); + JwtResponse response = new JwtResponse(); + response.setTokenType("Bearer"); + response.setAccessToken(jwtAuthMechanism.getJWTToken()); + response.setRefreshToken(refreshToken.getToken().toString()); + response.setExpiryDuration(jwtAuthMechanism.getJWTTokenExpiresAt()); + return Response.status(200).entity(response).build(); + } else { + loginAttemptCache.recordFailedLogin(userName); + int failedLoginAttempt = loginAttemptCache.getUserFailedLoginCount(userName); + if (failedLoginAttempt == 3) { + // send a mail to the user + sendAccountStatus( + storedUser, "Multiple Failed Login Attempts.", "Login Blocked for 10 mins. Please change your password."); + } + return Response.status(403) + .entity(new ErrorMessage(403, "You have entered an invalid username or password.")) + .build(); + } } else { - return Response.status(403) - .entity(new ErrorMessage(403, "You have entered an invalid username or password.")) + return Response.status(500) + .entity(new ErrorMessage(500, "Failed Login Attempts Exceeded. Please try after some time.")) .build(); } } @@ -1210,7 +1220,8 @@ public class UserResource extends EntityResource { if (emailVerificationToken == null) { throw new EntityNotFoundException("Invalid Token. Please issue a new Request"); } - User registeredUser = dao.get(uriInfo, emailVerificationToken.getUserId(), getFields("*")); + User registeredUser = + dao.get(uriInfo, emailVerificationToken.getUserId(), userRepository.getFieldsWithUserAuth("*")); if (registeredUser.getIsEmailVerified()) { LOG.info("User [{}] already registered.", emailToken); return; @@ -1281,6 +1292,22 @@ public class UserResource extends EntityResource { tokenRepository.insertToken(emailVerificationToken); } + private void sendAccountStatus(User user, String action, String status) throws IOException, TemplateException { + Map templatePopulator = new HashMap<>(); + templatePopulator.put(EmailUtil.ENTITY, EmailUtil.getInstance().getEmailingEntity()); + templatePopulator.put(EmailUtil.SUPPORTURL, EmailUtil.getInstance().getSupportUrl()); + templatePopulator.put(EmailUtil.USERNAME, user.getName()); + templatePopulator.put(EmailUtil.ACTIONKEY, action); + templatePopulator.put(EmailUtil.ACTIONSTATUSKEY, status); + EmailUtil.getInstance() + .sendMail( + EmailUtil.getInstance().getAccountStatusChangeSubject(), + templatePopulator, + user.getEmail(), + EmailUtil.EMAILTEMPLATEBASEPATH, + EmailUtil.ACCOUNTSTATUSTEMPLATEFILE); + } + private void sendPasswordResetLink(UriInfo uriInfo, User user, String subject, String templateFilePath) throws IOException, TemplateException { UUID mailVerificationToken = UUID.randomUUID(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LoginAttemptCache.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LoginAttemptCache.java new file mode 100644 index 00000000000..d7c3f045074 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LoginAttemptCache.java @@ -0,0 +1,78 @@ +package org.openmetadata.service.security.auth; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.openmetadata.schema.api.configuration.LoginConfiguration; +import org.openmetadata.service.util.ConfigurationHolder; + +public class LoginAttemptCache { + private final int MAX_ATTEMPT; + private final LoadingCache attemptsCache; + + public LoginAttemptCache() { + super(); + LoginConfiguration loginConfiguration = + ConfigurationHolder.getInstance() + .getConfig(ConfigurationHolder.ConfigurationType.LOGINCONFIG, LoginConfiguration.class); + MAX_ATTEMPT = 3; + attemptsCache = + CacheBuilder.newBuilder() + .maximumSize(1000) + .expireAfterWrite(loginConfiguration.getAccessBlockTime(), TimeUnit.SECONDS) + .build( + new CacheLoader<>() { + public Integer load(String key) { + return 0; + } + }); + } + + public LoginAttemptCache(int maxAttempt, int blockTimeInSec) { + super(); + MAX_ATTEMPT = maxAttempt; + attemptsCache = + CacheBuilder.newBuilder() + .maximumSize(1000) + .expireAfterWrite(blockTimeInSec, TimeUnit.SECONDS) + .build( + new CacheLoader<>() { + public Integer load(String key) { + return 0; + } + }); + } + + public void recordSuccessfulLogin(String key) { + attemptsCache.invalidate(key); + } + + public void recordFailedLogin(String key) { + int attempts = 0; + try { + attempts = attemptsCache.get(key); + } catch (ExecutionException e) { + attempts = 0; + } + attempts++; + attemptsCache.put(key, attempts); + } + + public boolean isLoginBlocked(String key) { + try { + return attemptsCache.get(key) >= MAX_ATTEMPT; + } catch (ExecutionException e) { + return false; + } + } + + public int getUserFailedLoginCount(String key) { + try { + return attemptsCache.get(key); + } catch (ExecutionException e) { + return -1; + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/ConfigurationHolder.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/ConfigurationHolder.java index 707a842a784..07d754c1971 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/ConfigurationHolder.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/ConfigurationHolder.java @@ -12,7 +12,9 @@ public class ConfigurationHolder { AUTHORIZERCONFIG("authorizerConfiguration"), AUTHENTICATIONCONFIG("authenticationConfiguration"), SMTPCONFIG("email"), - ELASTICSEARCHCONFIG("elasticsearch"); + ELASTICSEARCHCONFIG("elasticsearch"), + LOGINCONFIG("login"); + private String value; ConfigurationType(String value) { @@ -62,8 +64,11 @@ public class ConfigurationHolder { case ELASTICSEARCHCONFIG: CONFIG_MAP.put(ConfigurationType.ELASTICSEARCHCONFIG, config.getElasticSearchConfiguration()); break; + case LOGINCONFIG: + CONFIG_MAP.put(ConfigurationType.LOGINCONFIG, config.getLoginSettings()); + break; default: - LOG.info("Currently AuthorizerConfig, AuthenticatioConfig, SMTP and ES these can be added"); + LOG.error("Invalid Setting Type Given."); } } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/util/LoginAttemptCacheTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/util/LoginAttemptCacheTest.java new file mode 100644 index 00000000000..5bcb090af22 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/util/LoginAttemptCacheTest.java @@ -0,0 +1,45 @@ +package org.openmetadata.service.util; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.openmetadata.service.security.auth.LoginAttemptCache; + +@Slf4j +public class LoginAttemptCacheTest { + @Test + void testFailedLogin() throws InterruptedException { + String testKey = "test"; + LoginAttemptCache cache = new LoginAttemptCache(3, 1); + + // Check Failed Login + cache.recordFailedLogin(testKey); + assertFalse(cache.isLoginBlocked(testKey)); + cache.recordFailedLogin(testKey); + assertFalse(cache.isLoginBlocked(testKey)); + cache.recordFailedLogin(testKey); + assertTrue(cache.isLoginBlocked(testKey)); + cache.recordFailedLogin(testKey); + assertTrue(cache.isLoginBlocked(testKey)); + + // Check Eviction + Thread.sleep(2000); + assertFalse(cache.isLoginBlocked(testKey)); + + // Check Successful Login + cache.recordFailedLogin(testKey); + assertFalse(cache.isLoginBlocked(testKey)); + cache.recordFailedLogin(testKey); + assertFalse(cache.isLoginBlocked(testKey)); + cache.recordFailedLogin(testKey); + assertTrue(cache.isLoginBlocked(testKey)); + cache.recordSuccessfulLogin(testKey); + assertFalse(cache.isLoginBlocked(testKey)); + + // Check Eviction + Thread.sleep(2000); + assertFalse(cache.isLoginBlocked(testKey)); + } +} diff --git a/openmetadata-service/src/test/resources/openmetadata-secure-test.yaml b/openmetadata-service/src/test/resources/openmetadata-secure-test.yaml index 3a428b96260..c78a3bff85a 100644 --- a/openmetadata-service/src/test/resources/openmetadata-secure-test.yaml +++ b/openmetadata-service/src/test/resources/openmetadata-secure-test.yaml @@ -193,3 +193,7 @@ elasticsearch: slackChat: slackUrl: "http://localhost:8080" + +login: + maxLoginFailAttempts: 3 + accessBlockTime: 600 diff --git a/openmetadata-spec/src/main/resources/json/schema/configuration/loginConfiguration.json b/openmetadata-spec/src/main/resources/json/schema/configuration/loginConfiguration.json new file mode 100644 index 00000000000..edc43f9fcfc --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/configuration/loginConfiguration.json @@ -0,0 +1,21 @@ +{ + "$id": "https://open-metadata.org/schema/entity/configuration/loginConfiguration.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LoginConfiguration", + "description": "This schema defines the Login Configuration", + "type": "object", + "javaType": "org.openmetadata.schema.api.configuration.LoginConfiguration", + "properties": { + "maxLoginFailAttempts": { + "description": "Failed Login Attempts allowed for user.", + "type": "integer", + "default": 3 + }, + "accessBlockTime": { + "description": "Access Block time for user on exceeding failed attempts(in seconds)", + "type": "integer", + "default": 600 + } + }, + "additionalProperties": false +}