Commit c5e82ada authored by PECQUOT's avatar PECQUOT
Browse files

[enh] add ActiveDirectory authentication provider

parent f6238971
......@@ -3,7 +3,7 @@
<groupId>net.sumaris</groupId>
<artifactId>sumaris-pod</artifactId>
<version>1.8.8-SNAPSHOT</version>
<version>1.9.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>SUMARiS</name>
<description>SUMARiS :: Maven parent</description>
......
......@@ -5,7 +5,7 @@
<parent>
<groupId>net.sumaris</groupId>
<artifactId>sumaris-pod</artifactId>
<version>1.8.8-SNAPSHOT</version>
<version>1.9.0-SNAPSHOT</version>
</parent>
<artifactId>sumaris-core-extraction</artifactId>
......
......@@ -3,7 +3,7 @@
<parent>
<artifactId>sumaris-pod</artifactId>
<groupId>net.sumaris</groupId>
<version>1.8.8-SNAPSHOT</version>
<version>1.9.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
......
......@@ -3,7 +3,7 @@
<parent>
<artifactId>sumaris-pod</artifactId>
<groupId>net.sumaris</groupId>
<version>1.8.8-SNAPSHOT</version>
<version>1.9.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
......
......@@ -4,7 +4,7 @@
<parent>
<groupId>net.sumaris</groupId>
<artifactId>sumaris-pod</artifactId>
<version>1.8.8-SNAPSHOT</version>
<version>1.9.0-SNAPSHOT</version>
</parent>
<artifactId>sumaris-core-shared</artifactId>
......
......@@ -4,7 +4,7 @@
<parent>
<groupId>net.sumaris</groupId>
<artifactId>sumaris-pod</artifactId>
<version>1.8.8-SNAPSHOT</version>
<version>1.9.0-SNAPSHOT</version>
</parent>
<artifactId>sumaris-core</artifactId>
......
......@@ -4,7 +4,7 @@
<parent>
<groupId>net.sumaris</groupId>
<artifactId>sumaris-pod</artifactId>
<version>1.8.8-SNAPSHOT</version>
<version>1.9.0-SNAPSHOT</version>
</parent>
<artifactId>sumaris-server</artifactId>
......
......@@ -23,6 +23,11 @@ spring.main.allow-bean-definition-overriding=true
#spring.security.ldap.enabled=true
#spring.security.ldap.baseDn=ou=annuaire
#spring.security.ldap.url=ldap://localhost:1389/dc=ifremer,dc=fr
# - using ActiveDirectory (default: false)
#spring.security.ad.enabled=true
#spring.security.ad.baseDn=ou=annuaire
#spring.security.ad.url=ldap://localhost:1389
#spring.security.ad.domain=ifremer.fr
# Enable Technical table updates (default: false)
#sumaris.persistence.technicalTables.update=true
......
......@@ -111,11 +111,12 @@ public class SumarisServerConfiguration extends SumarisConfiguration {
}
public boolean enableAuthToken() {
return applicationConfig.getOptionAsBoolean(SumarisServerConfigurationOption.SECURITY_AUTHENTICATION_TOKEN_ENABLE.getKey());
return applicationConfig.getOptionAsBoolean(SumarisServerConfigurationOption.SECURITY_AUTHENTICATION_TOKEN_ENABLED.getKey());
}
public boolean enableAuthBasic() {
return applicationConfig.getOptionAsBoolean(SumarisServerConfigurationOption.SECURITY_AUTHENTICATION_BASIC_ENABLE.getKey());
return applicationConfig.getOptionAsBoolean(SumarisServerConfigurationOption.SECURITY_AUTHENTICATION_LDAP_ENABLED.getKey())
|| applicationConfig.getOptionAsBoolean(SumarisServerConfigurationOption.SECURITY_AUTHENTICATION_AD_ENABLED.getKey());
}
/**
......
......@@ -138,22 +138,22 @@ public enum SumarisServerConfigurationOption implements ConfigOptionDef {
AUTH_ROLE_NOT_SELF_EXTRACTION_ACCESS(ExtractionWebConfigurationOption.AUTH_ROLE_NOT_SELF_EXTRACTION_ACCESS),
SECURITY_LDAP_ENABLED(
"spring.security.ldap.enabled",
n("sumaris.config.option.spring.security.ldap.enabled.description"),
"false",
Boolean.class),
SECURITY_AUTHENTICATION_TOKEN_ENABLE(
SECURITY_AUTHENTICATION_TOKEN_ENABLED(
"spring.security.token.enabled",
n("sumaris.config.option.spring.security.token.enabled.description"),
"true",
Boolean.class),
SECURITY_AUTHENTICATION_BASIC_ENABLE(
"spring.security.basic.enabled",
n("sumaris.config.option.spring.security.basic.enabled.description"),
"${spring.security.ldap.enabled}",
SECURITY_AUTHENTICATION_LDAP_ENABLED(
"spring.security.ldap.enabled",
n("sumaris.config.option.spring.security.ldap.enabled.description"),
"false",
Boolean.class),
SECURITY_AUTHENTICATION_AD_ENABLED(
"spring.security.ad.enabled",
n("sumaris.config.option.spring.security.ad.enabled.description"),
"false",
Boolean.class),
AUTH_TOKEN_TYPE(
......
......@@ -88,7 +88,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// No provider: error
if (CollectionUtils.isEmpty(delegates)) {
throw new BeanInitializationException("No authentication provider found! Please set 'spring.security.token.enabled' or 'spring.security.ldap.enabled'");
throw new BeanInitializationException("No authentication provider found! Please set 'spring.security.token.enabled' or/and 'spring.security.ldap.enabled' or/and 'spring.security.ad.enabled'");
}
delegates.forEach(auth::authenticationProvider);
......
package net.sumaris.server.http.security.ad;
/*-
* #%L
* Quadrige3 Core :: Server
* %%
* Copyright (C) 2017 - 2020 Ifremer
* %%
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
import net.sumaris.server.http.security.AuthService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
@Configuration
@ConditionalOnProperty(name = "spring.security.ad.enabled", havingValue = "true")
@EnableConfigurationProperties({AdAuthenticationProperties.class})
public class AdAuthenticationConfiguration {
private final AdAuthenticationProperties adAuthenticationProperties;
private final AuthService authService;
public AdAuthenticationConfiguration(AdAuthenticationProperties adAuthenticationProperties, AuthService authService) {
this.adAuthenticationProperties = adAuthenticationProperties;
this.authService = authService;
}
@Bean
AuthenticationProvider adAuthenticationProvider() {
AdAuthenticationProvider provider = new AdAuthenticationProvider(
adAuthenticationProperties.getDomain(),
adAuthenticationProperties.getUrl(),
adAuthenticationProperties.getBaseDn(),
authService
);
provider.setConvertSubErrorCodesToExceptions(true);
provider.setUseAuthenticationRequestCredentials(true);
return provider;
}
}
package net.sumaris.server.http.security.ad;
/*-
* #%L
* Quadrige3 Core :: Server
* %%
* Copyright (C) 2017 - 2021 Ifremer
* %%
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.ldap.authentication.ad.ActiveDirectoryAuthenticationException;
/**
* Copy of final class {@link ActiveDirectoryAuthenticationException}
*/
public class AdAuthenticationException extends AuthenticationException {
private final String dataCode;
AdAuthenticationException(String dataCode, String message, Throwable cause) {
super(message, cause);
this.dataCode = dataCode;
}
public String getDataCode() {
return dataCode;
}
}
package net.sumaris.server.http.security.ad;
/*-
* #%L
* Quadrige3 Core :: Server
* %%
* Copyright (C) 2017 - 2020 Ifremer
* %%
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(
prefix = "spring.security.ad"
)
@Data
public class AdAuthenticationProperties {
/**
* Main property for Active Directory authentication: the AD server url
* ex: ldap://localhost:389
*/
private String url;
/**
* The Active Directory domain
*/
private String domain;
/**
* Base distinguished name for authentication/user
* ex: cn=Users
*/
private String baseDn;
}
package net.sumaris.server.http.security.ad;
/*-
* #%L
* Quadrige3 Core :: Server
* %%
* Copyright (C) 2017 - 2021 Ifremer
* %%
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
import net.sumaris.server.http.security.AnonymousUserDetails;
import net.sumaris.server.http.security.AuthService;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.ldap.CommunicationException;
import org.springframework.ldap.core.DirContextOperations;
import org.springframework.ldap.core.DistinguishedName;
import org.springframework.ldap.core.support.DefaultDirObjectFactory;
import org.springframework.ldap.support.LdapUtils;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.ldap.SpringSecurityLdapTemplate;
import org.springframework.security.ldap.authentication.AbstractLdapAuthenticationProvider;
import org.springframework.security.ldap.userdetails.LdapUserDetails;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import javax.naming.AuthenticationException;
import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.OperationNotSupportedException;
import javax.naming.directory.DirContext;
import javax.naming.directory.SearchControls;
import javax.naming.ldap.InitialLdapContext;
import java.io.Serializable;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Copy of final class {@link org.springframework.security.ldap.authentication.ad.ActiveDirectoryLdapAuthenticationProvider}
* authenticate method overridden to create authentication token
*/
public class AdAuthenticationProvider extends AbstractLdapAuthenticationProvider {
private static final Pattern SUB_ERROR_CODE = Pattern.compile(".*data\\s([0-9a-f]{3,4}).*");
// Error codes
private static final int USERNAME_NOT_FOUND = 0x525;
private static final int INVALID_PASSWORD = 0x52e;
private static final int NOT_PERMITTED = 0x530;
private static final int PASSWORD_EXPIRED = 0x532;
private static final int ACCOUNT_DISABLED = 0x533;
private static final int ACCOUNT_EXPIRED = 0x701;
private static final int PASSWORD_NEEDS_RESET = 0x773;
private static final int ACCOUNT_LOCKED = 0x775;
private final String domain;
private final String rootDn;
private final String url;
private final AuthService authService;
private boolean convertSubErrorCodesToExceptions;
private String searchFilter = "(&(objectClass=user)(userPrincipalName={0}))";
private Map<String, Object> contextEnvironmentProperties = new HashMap<>();
// Only used to allow tests to substitute a mock LdapContext
ContextFactory contextFactory = new ContextFactory();
/**
* @param domain the domain name (may be null or empty)
* @param url an LDAP url (or multiple URLs)
* @param rootDn the root DN (may be null or empty)
* @param authService
*/
public AdAuthenticationProvider(String domain, String url, String rootDn, AuthService authService) {
Assert.isTrue(StringUtils.hasText(url), "Url cannot be empty");
this.domain = StringUtils.hasText(domain) ? domain.toLowerCase() : null;
this.url = url;
this.rootDn = StringUtils.hasText(rootDn) ? rootDn.toLowerCase() : null;
this.authService = authService;
}
@Override
public Authentication authenticate(Authentication authentication) throws org.springframework.security.core.AuthenticationException {
// First check anonymous user
if (AnonymousUserDetails.TOKEN.equals(authentication.getPrincipal())) return authentication;
authentication = super.authenticate(authentication);
// Extract user login, to use as principal
Object principal = authentication.getPrincipal();
if (principal instanceof LdapUserDetails) {
LdapUserDetails ldapUserDetails = (LdapUserDetails) principal;
String username = ldapUserDetails.getUsername();
UsernamePasswordAuthenticationToken userToken = new UsernamePasswordAuthenticationToken(
username,
authentication.getCredentials(),
authentication.getAuthorities());
UserDetails userDetails = authService.authenticateByUsername(username, userToken);
userToken.setDetails(userDetails);
return userToken;
}
return authentication;
}
@Override
protected DirContextOperations doAuthentication(UsernamePasswordAuthenticationToken auth) {
String username = auth.getName();
String password = (String) auth.getCredentials();
DirContext ctx = null;
try {
ctx = bindAsUser(username, password);
return searchForUser(ctx, username);
} catch (CommunicationException e) {
throw badLdapConnection(e);
} catch (NamingException e) {
logger.error("Failed to locate directory entry for authenticated user: " + username, e);
throw badCredentials(e);
} finally {
LdapUtils.closeContext(ctx);
}
}
/**
* Creates the user authority list from the values of the {@code memberOf} attribute
* obtained from the user's Active Directory entry.
*/
@Override
protected Collection<? extends GrantedAuthority> loadUserAuthorities(
DirContextOperations userData, String username, String password) {
String[] groups = userData.getStringAttributes("memberOf");
if (groups == null) {
logger.debug("No values for 'memberOf' attribute.");
return AuthorityUtils.NO_AUTHORITIES;
}
if (logger.isDebugEnabled()) {
logger.debug("'memberOf' attribute values: " + Arrays.asList(groups));
}
ArrayList<GrantedAuthority> authorities = new ArrayList<>(groups.length);
for (String group : groups) {
authorities.add(new SimpleGrantedAuthority(new DistinguishedName(group).removeLast().getValue()));
}
return authorities;
}
private DirContext bindAsUser(String username, String password) {
Hashtable<String, Object> env = new Hashtable<>();
env.put(Context.SECURITY_AUTHENTICATION, "simple");
String bindPrincipal = createBindPrincipal(username);
env.put(Context.SECURITY_PRINCIPAL, bindPrincipal);
env.put(Context.PROVIDER_URL, url);
env.put(Context.SECURITY_CREDENTIALS, password);
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.OBJECT_FACTORIES, DefaultDirObjectFactory.class.getName());
env.putAll(this.contextEnvironmentProperties);
try {
return contextFactory.createContext(env);
} catch (NamingException e) {
if ((e instanceof AuthenticationException) || (e instanceof OperationNotSupportedException)) {
handleBindException(bindPrincipal, e);
throw badCredentials(e);
} else {
throw LdapUtils.convertLdapException(e);
}
}
}
private void handleBindException(String bindPrincipal, NamingException exception) {
if (logger.isDebugEnabled()) {
logger.debug("Authentication for " + bindPrincipal + " failed:" + exception);
}
handleResolveObj(exception);
int subErrorCode = parseSubErrorCode(exception.getMessage());
if (subErrorCode <= 0) {
logger.debug("Failed to locate AD-specific sub-error code in message");
return;
}
logger.info("Active Directory authentication failed: " + subCodeToLogMessage(subErrorCode));
if (convertSubErrorCodesToExceptions) {
raiseExceptionForErrorCode(subErrorCode, exception);
}
}
private void handleResolveObj(NamingException exception) {
Object resolvedObj = exception.getResolvedObj();
boolean serializable = resolvedObj instanceof Serializable;
if (resolvedObj != null && !serializable) {
exception.setResolvedObj(null);
}
}
private int parseSubErrorCode(String message) {
Matcher m = SUB_ERROR_CODE.matcher(message);
if (m.matches()) {
return Integer.parseInt(m.group(1), 16);
}
return -1;
}
private void raiseExceptionForErrorCode(int code, NamingException exception) {
String hexString = Integer.toHexString(code);
Throwable cause = new AdAuthenticationException(hexString, exception.getMessage(), exception);
switch (code) {
case PASSWORD_EXPIRED:
throw new CredentialsExpiredException(messages.getMessage(
"LdapAuthenticationProvider.credentialsExpired",
"User credentials have expired"), cause);
case ACCOUNT_DISABLED:
throw new DisabledException(messages.getMessage(
"LdapAuthenticationProvider.disabled", "User is disabled"), cause);
case ACCOUNT_EXPIRED:
throw new AccountExpiredException(messages.getMessage(
"LdapAuthenticationProvider.expired", "User account has expired"),
cause);
case ACCOUNT_LOCKED:
throw new LockedException(messages.getMessage(
"LdapAuthenticationProvider.locked", "User account is locked"), cause);
default:
throw badCredentials(cause);
}
}
private String subCodeToLogMessage(int code) {
switch (code) {
case USERNAME_NOT_FOUND:
return "User was not found in directory";
case INVALID_PASSWORD:
return "Supplied password was invalid";
case NOT_PERMITTED:
return "User not permitted to logon at this time";
case PASSWORD_EXPIRED:
return "Password has expired";
case ACCOUNT_DISABLED:
return "Account is disabled";
case ACCOUNT_EXPIRED:
return "Account expired";
case PASSWORD_NEEDS_RESET:
return "User must reset password";
case ACCOUNT_LOCKED:
return "Account locked";
}
return "Unknown (error code " + Integer.toHexString(code) + ")";