[ https://issues.apache.org/jira/browse/METRON-1895?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=16707228#comment-16707228 ]
ASF GitHub Bot commented on METRON-1895: ---------------------------------------- Github user justinleet commented on a diff in the pull request: https://github.com/apache/metron/pull/1281#discussion_r237922637 --- Diff: metron-interface/metron-rest/src/main/java/org/apache/metron/rest/config/KnoxSSOAuthenticationFilter.java --- @@ -0,0 +1,314 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.metron.rest.config; + +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jwt.SignedJWT; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ldap.core.AttributesMapper; +import org.springframework.ldap.core.LdapTemplate; +import org.springframework.ldap.support.LdapNameBuilder; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetails; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +import static org.springframework.ldap.query.LdapQueryBuilder.query; + +/** + * This class is a Servlet Filter that authenticates a Knox SSO token. The token is stored in a cookie and is + * verified against a public Knox key. The token expiration and begin time are also validated. Upon successful validation, + * a Spring Authentication object is built from the user name and user groups queried from LDAP. Currently, user groups are + * mapped directly to Spring roles and prepended with "ROLE_". + */ +public class KnoxSSOAuthenticationFilter implements Filter { + private static final Logger LOG = LoggerFactory.getLogger(KnoxSSOAuthenticationFilter.class); + + private String userSearchBase; + private Path knoxKeyFile; + private String knoxKeyString; + private String knoxCookie; + private LdapTemplate ldapTemplate; + + public KnoxSSOAuthenticationFilter(String userSearchBase, + Path knoxKeyFile, + String knoxKeyString, + String knoxCookie, + LdapTemplate ldapTemplate) throws IOException, CertificateException { + this.userSearchBase = userSearchBase; + this.knoxKeyFile = knoxKeyFile; + this.knoxKeyString = knoxKeyString; + this.knoxCookie = knoxCookie; + if (ldapTemplate == null) { + throw new IllegalStateException("KnoxSSO requires LDAP. You must add 'ldap' to the active profiles."); + } + this.ldapTemplate = ldapTemplate; + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void destroy() { + } + + /** + * Extracts the Knox token from the configured cookie. If basic authentication headers are present, SSO authentication + * is skipped. + * @param request + * @param response + * @param chain + * @throws IOException + * @throws ServletException + */ + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + + // If a basic authentication header is present, use that to authenticate and skip SSO + String authHeader = httpRequest.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Basic")) { + String serializedJWT = getJWTFromCookie(httpRequest); + if (serializedJWT != null) { + SignedJWT jwtToken = null; + try { + jwtToken = SignedJWT.parse(serializedJWT); + String userName = jwtToken.getJWTClaimsSet().getSubject(); + LOG.info("SSO login user : {} ", userName); + if (isValid(jwtToken, userName)) { + Authentication authentication = getAuthentication(userName, httpRequest); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (ParseException e) { + LOG.warn("Unable to parse the JWT token", e); + } + } + } + chain.doFilter(request, response); + } + + /** + * Validates a Knox token with expiration and begin times and verifies the token with a public Knox key. + * @param jwtToken Knox token + * @param userName User name associated with the token + * @return Whether a token is valid or not + * @throws ParseException + */ + protected boolean isValid(SignedJWT jwtToken, String userName) throws ParseException { + // Verify the user name is present + if (userName == null || userName.isEmpty()) { + LOG.info("Could not find user name in SSO token"); + return false; + } + + Date now = new Date(); + + // Verify the token has not expired + Date expirationTime = jwtToken.getJWTClaimsSet().getExpirationTime(); + if (expirationTime != null && now.after(expirationTime)) { + LOG.info("SSO token expired: {} ", userName); + return false; + } + + // Verify the token is not before time + Date notBeforeTime = jwtToken.getJWTClaimsSet().getNotBeforeTime(); + if (notBeforeTime != null && now.before(notBeforeTime)) { + LOG.info("SSO token not yet valid: {} ", userName); + return false; + } + + return validateSignature(jwtToken); + } + + /** + * Verify the signature of the JWT token in this method. This method depends on + * the public key that was established during init based upon the provisioned + * public key. Override this method in subclasses in order to customize the + * signature verification behavior. + * + * @param jwtToken + * the token that contains the signature to be validated + * @return valid true if signature verifies successfully; false otherwise + */ + protected boolean validateSignature(SignedJWT jwtToken) { + // Verify the token signature algorithm was as expected + String receivedSigAlg = jwtToken.getHeader().getAlgorithm().getName(); + if (!receivedSigAlg.equals("RS256")) { + return false; + } + + // Verify the token has been properly signed + if (JWSObject.State.SIGNED == jwtToken.getState()) { + LOG.debug("SSO token is in a SIGNED state"); + if (jwtToken.getSignature() != null) { + LOG.debug("SSO token signature is not null"); + try { + JWSVerifier verifier = new RSASSAVerifier(parseRSAPublicKey(getKnoxKey())); + if (jwtToken.verify(verifier)) { + LOG.debug("SSO token has been successfully verified"); + return true; + } else { + LOG.warn("SSO signature verification failed. Please check the public key."); + } + } catch (Exception e) { + LOG.warn("Error while validating signature", e); + } + } + } + return false; + } + + /** + * Encapsulate the acquisition of the JWT token from HTTP cookies within the + * request. + * + * Taken from + * + * @param req + * servlet request to get the JWT token from + * @return serialized JWT token + */ + protected String getJWTFromCookie(HttpServletRequest req) { + String serializedJWT = null; + Cookie[] cookies = req.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + LOG.debug(String.format("Found cookie: %s [%s]", cookie.getName(), cookie.getValue())); + if (knoxCookie.equals(cookie.getName())) { + if (LOG.isDebugEnabled()) { + LOG.debug(knoxCookie + " cookie has been found and is being processed"); + } + serializedJWT = cookie.getValue(); + break; + } + } + } else { + if (LOG.isDebugEnabled()) { + LOG.debug(knoxCookie + " not found"); + } + } + return serializedJWT; + } + + /** + * A public Knox key can either be passed in directly or read from a file. + * @return Public Knox key + * @throws IOException + */ + private String getKnoxKey() throws IOException { + String knoxKey; + if ((this.knoxKeyString == null || this.knoxKeyString.isEmpty()) && this.knoxKeyFile != null) { + List<String> keyLines = Files.readAllLines(knoxKeyFile, StandardCharsets.UTF_8); + if (keyLines != null) { + knoxKey = String.join("", keyLines); + } else { + knoxKey = ""; + } + } else { + knoxKey = this.knoxKeyString; + } + return knoxKey; + } + + public static RSAPublicKey parseRSAPublicKey(String pem) + throws CertificateException, UnsupportedEncodingException { + String PEM_HEADER = "-----BEGIN CERTIFICATE-----\n"; + String PEM_FOOTER = "\n-----END CERTIFICATE-----"; + String fullPem = (pem.startsWith(PEM_HEADER) && pem.endsWith(PEM_FOOTER)) ? pem : PEM_HEADER + pem + PEM_FOOTER; + PublicKey key = null; + try { + CertificateFactory fact = CertificateFactory.getInstance("X.509"); + ByteArrayInputStream is = new ByteArrayInputStream(fullPem.getBytes("UTF8")); + X509Certificate cer = (X509Certificate) fact.generateCertificate(is); + key = cer.getPublicKey(); + } catch (CertificateException ce) { + String message = null; + if (pem.startsWith(PEM_HEADER)) { + message = "CertificateException - be sure not to include PEM header " + + "and footer in the PEM configuration element."; + } else { + message = "CertificateException - PEM may be corrupt"; + } + throw new CertificateException(message, ce); + } + return (RSAPublicKey) key; + } + + /** + * Builds the Spring Authentication object using the supplied user name and groups looked up from LDAP. Groups are currently + * mapped directly to Spring roles by converting to upper case and prepending the name with "ROLE_". + * @param userName + * @param httpRequest + * @return + */ + private Authentication getAuthentication(String userName, HttpServletRequest httpRequest) { + String ldapName = LdapNameBuilder.newInstance().add(userSearchBase).add("uid", userName).build().toString(); + + // Search ldap for a user's groups and convert to a Spring role + List<GrantedAuthority> grantedAuths = ldapTemplate.search(query() + .where("objectclass") + .is("groupOfNames") + .and("member") + .is(ldapName), (AttributesMapper<String>) attrs -> (String) attrs.get("cn").get()) + .stream() + .map(group -> String.format("ROLE_%s", group.toUpperCase())) --- End diff -- Is there any way to retrieve the "ROLE_" prefix programmatically? It's not a big deal, but if we can avoid hardcoding it, I'd like to. > Add Knox SSO as an option in Metron > ----------------------------------- > > Key: METRON-1895 > URL: https://issues.apache.org/jira/browse/METRON-1895 > Project: Metron > Issue Type: New Feature > Reporter: Ryan Merriman > Priority: Major > > This feature will enable accessing Metron REST and the UIs through Knox's SSO > mechanism. -- This message was sent by Atlassian JIRA (v7.6.3#76005)