[Basic auth fixes] Added check to prevent brute-force attack (#7803)

* [Basic Auth] Updated messages , and added changepasswor

* checkstyleFix

* Added ChangePassword feature in user page

* unpushed file

* changes as per comments

* unpushed file

* close the modal on save

* Added Admin Create User Password + Logout

* revert test

* revert yaml

* Fix failing test

* Remove token on logout

* fix for test

* added api for random pwd

* update error messages as per UI

* Added password generator in user creation

* Fix issue with CP

* Fix issue with CP

* [Basic Auth] Added Brute forcing check , blocks user login for 10 mins + send update on mail

* Review Comments updated

Co-authored-by: mohitdeuex <105265192+mohitdeuex@users.noreply.github.com>
Co-authored-by: Ashish Gupta <ashish@getcollate.io>
This commit is contained in:
Mohit Yadav 2022-10-01 02:03:43 +05:30 committed by GitHub
parent 595e5c1b89
commit d04ce5341d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 242 additions and 45 deletions

View File

@ -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}

View File

@ -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{"

View File

@ -65,6 +65,15 @@ public class UserRepository extends EntityRepository<User> {
this.secretsManager = secretsManager;
}
public final Fields getFieldsWithUserAuth(String fields) {
if (fields != null && fields.equals("*")) {
List<String> tempFields = getAllowedFieldsCopy();
tempFields.add("authenticationMechanism");
return new Fields(allowedFields, String.join(",", tempFields));
}
return new Fields(allowedFields, fields);
}
public UserRepository(CollectionDAO dao) {
this(dao, null);
}

View File

@ -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<User, UserRepository> {
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<User, UserRepository> {
ConfigurationHolder.getInstance()
.getConfig(ConfigurationHolder.ConfigurationType.AUTHENTICATIONCONFIG, AuthenticationConfiguration.class)
.getProvider();
this.loginAttemptCache = new LoginAttemptCache();
}
public static class UserList extends ResultList<User> {
@ -939,20 +943,7 @@ public class UserResource extends EntityResource<User, UserRepository> {
tokenRepository.deleteTokenByUserAndType(storedUser.getId().toString(), PASSWORD_RESET.toString());
// Update user about Password Change
try {
Map<String, String> 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<User, UserRepository> {
"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<User, UserRepository> {
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<User, UserRepository> {
storedBasicAuthMechanism.setPassword(newHashedPassword);
storedUser.getAuthenticationMechanism().setConfig(storedBasicAuthMechanism);
RestUtil.PutResponse<User> 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<User, UserRepository> {
})
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<String, String> storedData =
(LinkedHashMap<String, String>) 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<String, String> storedData =
(LinkedHashMap<String, String>) 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<User, UserRepository> {
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<User, UserRepository> {
tokenRepository.insertToken(emailVerificationToken);
}
private void sendAccountStatus(User user, String action, String status) throws IOException, TemplateException {
Map<String, String> 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();

View File

@ -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<String, Integer> 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;
}
}
}

View File

@ -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.");
}
}
}

View File

@ -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));
}
}

View File

@ -193,3 +193,7 @@ elasticsearch:
slackChat:
slackUrl: "http://localhost:8080"
login:
maxLoginFailAttempts: 3
accessBlockTime: 600

View File

@ -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
}