diff --git a/para-core/src/main/java/com/erudika/para/core/User.java b/para-core/src/main/java/com/erudika/para/core/User.java index ab8b8645..7a3ee7cd 100644 --- a/para-core/src/main/java/com/erudika/para/core/User.java +++ b/para-core/src/main/java/com/erudika/para/core/User.java @@ -447,6 +447,15 @@ public boolean isMicrosoftUser() { return StringUtils.startsWithIgnoreCase(identifier, Config.MICROSOFT_PREFIX); } + /** + * Is the main identifier a Slack account id. + * @return true if user is signed in with a Microsoft account + */ + @JsonIgnore + public boolean isSlackUser() { + return StringUtils.startsWithIgnoreCase(identifier, Config.SLACK_PREFIX); + } + /** * Is the main identifier a LDAP account. * @return true if user is signed in with a LDAP account @@ -509,6 +518,8 @@ public String getIdentityProvider() { return "linkedin"; } else if (isMicrosoftUser()) { return "microsoft"; + } else if (isSlackUser()) { + return "slack"; } else if (isLDAPUser()) { return "ldap"; } else if (isSAMLUser()) { diff --git a/para-core/src/main/java/com/erudika/para/utils/Config.java b/para-core/src/main/java/com/erudika/para/utils/Config.java index 190e7551..2e566ae8 100644 --- a/para-core/src/main/java/com/erudika/para/utils/Config.java +++ b/para-core/src/main/java/com/erudika/para/utils/Config.java @@ -102,6 +102,8 @@ private Config() { } public static final String GITHUB_PREFIX = "gh" + SEPARATOR; /** Microsoft prefix - defaults to 'ms:'. */ public static final String MICROSOFT_PREFIX = "ms" + SEPARATOR; + /** Slack prefix - defaults to 'sl:'. */ + public static final String SLACK_PREFIX = "sl" + SEPARATOR; /** OAuth2 generic prefix - defaults to 'oa2:'. */ public static final String OAUTH2_PREFIX = "oa2" + SEPARATOR; /** LDAP prefix - defaults to 'ldap:'. */ @@ -194,6 +196,14 @@ private Config() { } * Microsoft app secret (for authentication). */ public static final String MICROSOFT_SECRET = getConfigParam("ms_secret", ""); + /** + * Slack app id (for authentication). + */ + public static final String SLACK_APP_ID = getConfigParam("sl_app_id", ""); + /** + * Slack app secret (for authentication). + */ + public static final String SLACK_SECRET = getConfigParam("sl_secret", ""); /** * The identifier of the first administrator (can be email, OpenID, or Facebook user id). */ diff --git a/para-server/src/main/java/com/erudika/para/security/JWTRestfulAuthFilter.java b/para-server/src/main/java/com/erudika/para/security/JWTRestfulAuthFilter.java index 18dd1aa1..833f284f 100644 --- a/para-server/src/main/java/com/erudika/para/security/JWTRestfulAuthFilter.java +++ b/para-server/src/main/java/com/erudika/para/security/JWTRestfulAuthFilter.java @@ -31,6 +31,7 @@ import com.erudika.para.core.User; import com.erudika.para.rest.RestUtils; import com.erudika.para.security.filters.LdapAuthFilter; +import com.erudika.para.security.filters.SlackAuthFilter; import com.erudika.para.utils.Config; import com.erudika.para.utils.Utils; import com.nimbusds.jwt.SignedJWT; @@ -74,6 +75,7 @@ public class JWTRestfulAuthFilter extends GenericFilterBean { private LinkedInAuthFilter linkedinAuth; private TwitterAuthFilter twitterAuth; private MicrosoftAuthFilter microsoftAuth; + private SlackAuthFilter slackAuth; private GenericOAuth2Filter oauth2Auth; private LdapAuthFilter ldapAuth; private PasswordAuthFilter passwordAuth; @@ -290,6 +292,8 @@ private UserAuthentication getOrCreateUser(App app, String identityProvider, Str return twitterAuth.getOrCreateUser(app, accessToken); } else if ("microsoft".equalsIgnoreCase(identityProvider)) { return microsoftAuth.getOrCreateUser(app, accessToken); + } else if ("slack".equalsIgnoreCase(identityProvider)) { + return slackAuth.getOrCreateUser(app, accessToken); } else if ("oauth2".equalsIgnoreCase(identityProvider)) { return oauth2Auth.getOrCreateUser(app, accessToken); } else if ("ldap".equalsIgnoreCase(identityProvider)) { diff --git a/para-server/src/main/java/com/erudika/para/security/SecurityConfig.java b/para-server/src/main/java/com/erudika/para/security/SecurityConfig.java index 8c5234d6..20f8e1f6 100644 --- a/para-server/src/main/java/com/erudika/para/security/SecurityConfig.java +++ b/para-server/src/main/java/com/erudika/para/security/SecurityConfig.java @@ -30,6 +30,7 @@ import com.erudika.para.security.filters.LdapAuthFilter; import com.erudika.para.security.filters.SAMLAuthFilter; import com.erudika.para.security.filters.SAMLMetadataFilter; +import com.erudika.para.security.filters.SlackAuthFilter; import com.erudika.para.utils.Config; import com.typesafe.config.ConfigList; import com.typesafe.config.ConfigObject; @@ -76,6 +77,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { private final TwitterAuthFilter twitterFilter; private final GitHubAuthFilter githubFilter; private final MicrosoftAuthFilter microsoftFilter; + private final SlackAuthFilter slackFilter; private final GenericOAuth2Filter oauth2Filter; private final LdapAuthFilter ldapFilter; private final SAMLAuthFilter samlFilter; @@ -96,6 +98,7 @@ public SecurityConfig() { twitterFilter = getInstance(TwitterAuthFilter.class); githubFilter = getInstance(GitHubAuthFilter.class); microsoftFilter = getInstance(MicrosoftAuthFilter.class); + slackFilter = getInstance(SlackAuthFilter.class); oauth2Filter = getInstance(GenericOAuth2Filter.class); ldapFilter = getInstance(LdapAuthFilter.class); samlFilter = getInstance(SAMLAuthFilter.class); @@ -232,6 +235,11 @@ private void registerAuthFilters(HttpSecurity http) throws Exception { http.addFilterAfter(microsoftFilter, BasicAuthenticationFilter.class); } + if (slackFilter != null) { + slackFilter.setAuthenticationManager(authenticationManager()); + http.addFilterAfter(slackFilter, BasicAuthenticationFilter.class); + } + if (oauth2Filter != null) { oauth2Filter.setAuthenticationManager(authenticationManager()); http.addFilterAfter(oauth2Filter, BasicAuthenticationFilter.class); diff --git a/para-server/src/main/java/com/erudika/para/security/SecurityModule.java b/para-server/src/main/java/com/erudika/para/security/SecurityModule.java index e6f42453..09f479ed 100644 --- a/para-server/src/main/java/com/erudika/para/security/SecurityModule.java +++ b/para-server/src/main/java/com/erudika/para/security/SecurityModule.java @@ -30,6 +30,7 @@ import com.erudika.para.security.filters.LdapAuthFilter; import com.erudika.para.security.filters.SAMLAuthFilter; import com.erudika.para.security.filters.SAMLMetadataFilter; +import com.erudika.para.security.filters.SlackAuthFilter; import com.erudika.para.utils.Config; import com.google.inject.AbstractModule; import com.google.inject.Provides; @@ -56,6 +57,7 @@ public class SecurityModule extends AbstractModule { private TwitterAuthFilter twitterFilter; private GitHubAuthFilter githubFilter; private MicrosoftAuthFilter microsoftFilter; + private SlackAuthFilter slackFilter; private GenericOAuth2Filter oauth2Filter; private LdapAuthFilter ldapFilter; private SAMLAuthFilter samlFilter; @@ -322,6 +324,27 @@ public void setMicrosoftFilter(MicrosoftAuthFilter microsoftFilter) { this.microsoftFilter = microsoftFilter; } + /** + * @return filter + */ + @Provides + public SlackAuthFilter getSlackFilter() { + if (slackFilter == null) { + slackFilter = new SlackAuthFilter("/" + SlackAuthFilter.SLACK_ACTION); + slackFilter.setAuthenticationSuccessHandler(getSuccessHandler()); + slackFilter.setAuthenticationFailureHandler(getFailureHandler()); + slackFilter.setRememberMeServices(getRemembeMeServices()); + } + return slackFilter; + } + + /** + * @param slackFilter filter + */ + public void setMicrosoftFilter(SlackAuthFilter slackFilter) { + this.slackFilter = slackFilter; + } + /** * @return filter */ diff --git a/para-server/src/main/java/com/erudika/para/security/filters/SlackAuthFilter.java b/para-server/src/main/java/com/erudika/para/security/filters/SlackAuthFilter.java new file mode 100644 index 00000000..f7117518 --- /dev/null +++ b/para-server/src/main/java/com/erudika/para/security/filters/SlackAuthFilter.java @@ -0,0 +1,230 @@ +/* + * Copyright 2013-2019 Erudika. https://erudika.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * For issues and patches go to: https://slack.com/erudika + */ +package com.erudika.para.security.filters; + +import com.erudika.para.Para; +import com.erudika.para.core.App; +import com.erudika.para.core.utils.ParaObjectUtils; +import com.erudika.para.core.User; +import com.erudika.para.security.AuthenticatedUserDetails; +import com.erudika.para.security.SecurityUtils; +import com.erudika.para.security.UserAuthentication; +import com.erudika.para.utils.Config; +import com.erudika.para.utils.Utils; +import com.fasterxml.jackson.databind.ObjectReader; +import java.io.IOException; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpEntity; +import org.apache.http.HttpHeaders; +import org.apache.http.client.config.CookieSpecs; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.NoConnectionReuseStrategy; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; + +/** + * A filter that handles authentication requests to Slack. + * @author Alex Bogdanovski [alex@erudika.com] + */ +public class SlackAuthFilter extends AbstractAuthenticationProcessingFilter { + + private final CloseableHttpClient httpclient; + private final ObjectReader jreader; + private static final String PROFILE_URL = "https://slack.com/api/users.identity?token={0}"; + private static final String TOKEN_URL = "https://slack.com/api/oauth.access"; + private static final String PAYLOAD = "code={0}&redirect_uri={1}&client_id={2}&client_secret={3}"; + + /** + * The default filter mapping. + */ + public static final String SLACK_ACTION = "slack_auth"; + + /** + * Default constructor. + * @param defaultFilterProcessesUrl the url of the filter + */ + public SlackAuthFilter(final String defaultFilterProcessesUrl) { + super(defaultFilterProcessesUrl); + this.jreader = ParaObjectUtils.getJsonReader(Map.class); + int timeout = 30 * 1000; + this.httpclient = HttpClientBuilder.create(). + setConnectionReuseStrategy(new NoConnectionReuseStrategy()). + setDefaultRequestConfig(RequestConfig.custom(). + setConnectTimeout(timeout). + setConnectionRequestTimeout(timeout). + setCookieSpec(CookieSpecs.STANDARD). + setSocketTimeout(timeout). + build()). + build(); + } + + /** + * Handles an authentication request. + * @param request HTTP request + * @param response HTTP response + * @return an authentication object that contains the principal object if successful. + * @throws IOException ex + */ + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws IOException { + final String requestURI = request.getRequestURI(); + UserAuthentication userAuth = null; + + if (requestURI.endsWith(SLACK_ACTION)) { + String authCode = request.getParameter("code"); + if (!StringUtils.isBlank(authCode)) { + String appid = SecurityUtils.getAppidFromAuthRequest(request); + String redirectURI = SecurityUtils.getRedirectUrl(request); + App app = Para.getDAO().read(App.id(appid == null ? Config.getRootAppIdentifier() : appid)); + String[] keys = SecurityUtils.getOAuthKeysForApp(app, Config.SLACK_PREFIX); + String entity = Utils.formatMessage(PAYLOAD, authCode, Utils.urlEncode(redirectURI), keys[0], keys[1]); + + HttpPost tokenPost = new HttpPost(TOKEN_URL); + tokenPost.setHeader(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded"); + tokenPost.setHeader(HttpHeaders.ACCEPT, "application/json"); + tokenPost.setEntity(new StringEntity(entity, "UTF-8")); + try (CloseableHttpResponse resp1 = httpclient.execute(tokenPost)) { + if (resp1 != null && resp1.getEntity() != null) { + Map token = jreader.readValue(resp1.getEntity().getContent()); + if (token != null && token.containsKey("access_token")) { + userAuth = getOrCreateUser(app, (String) token.get("access_token")); + } + EntityUtils.consumeQuietly(resp1.getEntity()); + } + } + } + } + + return SecurityUtils.checkIfActive(userAuth, SecurityUtils.getAuthenticatedUser(userAuth), true); + } + + /** + * Calls the Slack API to get the user profile using a given access token. + * @param app the app where the user will be created, use null for root app + * @param accessToken access token + * @return {@link UserAuthentication} object or null if something went wrong + * @throws IOException ex + */ + @SuppressWarnings("unchecked") + public UserAuthentication getOrCreateUser(App app, String accessToken) throws IOException { + UserAuthentication userAuth = null; + User user = new User(); + if (accessToken != null) { + HttpGet profileGet = new HttpGet(Utils.formatMessage(PROFILE_URL, accessToken)); + profileGet.setHeader(HttpHeaders.ACCEPT, "application/json"); + Map profile = null; + + try (CloseableHttpResponse resp2 = httpclient.execute(profileGet)) { + HttpEntity respEntity = resp2.getEntity(); + if (respEntity != null) { + profile = jreader.readValue(respEntity.getContent()); + EntityUtils.consumeQuietly(respEntity); + } + } + + if (profile != null && profile.containsKey("user")) { + Map userData = (Map) profile.get("user"); + Map teamData = (Map) profile.get("team"); + String slackId = (String) userData.get("id"); + String pic = (String) userData.get("image_512"); + String email = (String) userData.get("email"); + String name = (String) userData.get("name"); + String team = "default"; + if (teamData != null && teamData.containsKey("id")) { + team = (String) teamData.get("id"); + if (teamData.containsKey("name")) { + team = team.concat(Config.SEPARATOR).concat(Utils.base64enc(((String) + teamData.get("name")).getBytes(Config.DEFAULT_ENCODING))); + } + } + + user.setAppid(getAppid(app)); + user.setIdentifier(Config.SLACK_PREFIX + slackId + Config.SEPARATOR + team); + user.setEmail(email); + user = User.readUserForIdentifier(user); + if (user == null) { + //user is new + user = new User(); + user.setActive(true); + user.setAppid(getAppid(app)); + user.setEmail(StringUtils.isBlank(email) ? Utils.getNewId() + "@slack.com" : email); + user.setName(StringUtils.isBlank(name) ? "No Name" : name); + user.setPassword(Utils.generateSecurityToken()); + user.setPicture(getPicture(pic)); + user.setIdentifier(Config.SLACK_PREFIX + slackId); + String id = user.create(); + if (id == null) { + throw new AuthenticationServiceException("Authentication failed: cannot create new user."); + } + } else { + if (updateUserInfo(user, pic, email, name)) { + user.update(); + } + } + userAuth = new UserAuthentication(new AuthenticatedUserDetails(user)); + } + } + return SecurityUtils.checkIfActive(userAuth, user, false); + } + + private boolean updateUserInfo(User user, String pic, String email, String name) { + String picture = getPicture(pic); + boolean update = false; + if (!StringUtils.equals(user.getPicture(), picture)) { + user.setPicture(picture); + update = true; + } + if (!StringUtils.isBlank(email) && !StringUtils.equals(user.getEmail(), email)) { + user.setEmail(email); + update = true; + } + if (!StringUtils.isBlank(name) && !StringUtils.equals(user.getName(), name)) { + user.setName(name); + update = true; + } + return update; + } + + private static String getPicture(String pic) { + if (pic != null) { + if (pic.contains("?")) { + // user picture migth contain size parameters - remove them + return pic.substring(0, pic.indexOf('?')); + } else { + return pic; + } + } + return null; + } + + private String getAppid(App app) { + return (app == null) ? null : app.getAppIdentifier(); + } +}