mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-12-27 07:28:30 +00:00
[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:
parent
595e5c1b89
commit
d04ce5341d
@ -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}
|
||||
|
||||
@ -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{"
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -193,3 +193,7 @@ elasticsearch:
|
||||
|
||||
slackChat:
|
||||
slackUrl: "http://localhost:8080"
|
||||
|
||||
login:
|
||||
maxLoginFailAttempts: 3
|
||||
accessBlockTime: 600
|
||||
|
||||
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user