| /* |
| * 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.nifi.registry.web.security.authentication.jwt; |
| |
| import io.jsonwebtoken.Claims; |
| import io.jsonwebtoken.ExpiredJwtException; |
| import io.jsonwebtoken.Jws; |
| import io.jsonwebtoken.JwsHeader; |
| import io.jsonwebtoken.JwtException; |
| import io.jsonwebtoken.Jwts; |
| import io.jsonwebtoken.MalformedJwtException; |
| import io.jsonwebtoken.SignatureAlgorithm; |
| import io.jsonwebtoken.SignatureException; |
| import io.jsonwebtoken.SigningKeyResolverAdapter; |
| import io.jsonwebtoken.UnsupportedJwtException; |
| import org.apache.commons.lang3.StringUtils; |
| import org.apache.nifi.registry.security.authentication.AuthenticationResponse; |
| import org.apache.nifi.registry.security.key.Key; |
| import org.apache.nifi.registry.security.key.KeyService; |
| import org.slf4j.LoggerFactory; |
| import org.springframework.beans.factory.annotation.Autowired; |
| import org.springframework.stereotype.Service; |
| |
| import java.nio.charset.StandardCharsets; |
| import java.text.SimpleDateFormat; |
| import java.util.Calendar; |
| import java.util.concurrent.TimeUnit; |
| |
| // TODO, look into replacing this JwtService service with Apache Licensed JJWT library |
| @Service |
| public class JwtService { |
| |
| private static final org.slf4j.Logger logger = LoggerFactory.getLogger(JwtService.class); |
| |
| private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256; |
| private static final String KEY_ID_CLAIM = "kid"; |
| private static final String USERNAME_CLAIM = "preferred_username"; |
| |
| private final KeyService keyService; |
| |
| @Autowired |
| public JwtService(final KeyService keyService) { |
| this.keyService = keyService; |
| } |
| |
| public String getAuthenticationFromToken(final String base64EncodedToken) throws JwtException { |
| // The library representations of the JWT should be kept internal to this service. |
| try { |
| final Jws<Claims> jws = parseTokenFromBase64EncodedString(base64EncodedToken); |
| |
| if (jws == null) { |
| throw new JwtException("Unable to parse token"); |
| } |
| |
| // Additional validation that subject is present |
| if (StringUtils.isEmpty(jws.getBody().getSubject())) { |
| throw new JwtException("No subject available in token"); |
| } |
| |
| // TODO: Validate issuer against active IdentityProvider? |
| if (StringUtils.isEmpty(jws.getBody().getIssuer())) { |
| throw new JwtException("No issuer available in token"); |
| } |
| return jws.getBody().getSubject(); |
| } catch (JwtException e) { |
| logger.debug("The Base64 encoded JWT: " + base64EncodedToken); |
| final String errorMessage = "There was an error validating the JWT"; |
| logger.error(errorMessage, e); |
| throw e; |
| } |
| } |
| |
| private Jws<Claims> parseTokenFromBase64EncodedString(final String base64EncodedToken) throws JwtException { |
| try { |
| return Jwts.parser().setSigningKeyResolver(new SigningKeyResolverAdapter() { |
| @Override |
| public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) { |
| final String identity = claims.getSubject(); |
| |
| // Get the key based on the key id in the claims |
| final String keyId = claims.get(KEY_ID_CLAIM, String.class); |
| final Key key = keyService.getKey(keyId); |
| |
| // Ensure we were able to find a key that was previously issued by this key service for this user |
| if (key == null || key.getKey() == null) { |
| throw new UnsupportedJwtException("Unable to determine signing key for " + identity + " [kid: " + keyId + "]"); |
| } |
| |
| return key.getKey().getBytes(StandardCharsets.UTF_8); |
| } |
| }).parseClaimsJws(base64EncodedToken); |
| } catch (final MalformedJwtException | UnsupportedJwtException | SignatureException | ExpiredJwtException | IllegalArgumentException e) { |
| // TODO: Exercise all exceptions to ensure none leak key material to logs |
| final String errorMessage = "Unable to validate the access token."; |
| throw new JwtException(errorMessage, e); |
| } |
| } |
| |
| /** |
| * Generates a signed JWT token from the provided IdentityProvider AuthenticationResponse |
| * |
| * @param authenticationResponse an instance issued by an IdentityProvider after identity claim has been verified as authentic |
| * @return a signed JWT containing the user identity and the identity provider, Base64-encoded |
| * @throws JwtException if there is a problem generating the signed token |
| */ |
| public String generateSignedToken(final AuthenticationResponse authenticationResponse) throws JwtException { |
| if (authenticationResponse == null) { |
| throw new IllegalArgumentException("Cannot generate a JWT for a null authenticationResponse"); |
| } |
| |
| return generateSignedToken( |
| authenticationResponse.getIdentity(), |
| authenticationResponse.getUsername(), |
| authenticationResponse.getIssuer(), |
| authenticationResponse.getIssuer(), |
| authenticationResponse.getExpiration()); |
| } |
| |
| public String generateSignedToken(String identity, String preferredUsername, String issuer, String audience, long expirationMillis) throws JwtException { |
| |
| if (identity == null || StringUtils.isEmpty(identity)) { |
| String errorMessage = "Cannot generate a JWT for a token with an empty identity"; |
| errorMessage = issuer != null ? errorMessage + " issued by " + issuer + "." : "."; |
| logger.error(errorMessage); |
| throw new IllegalArgumentException(errorMessage); |
| } |
| |
| // Compute expiration |
| final Calendar now = Calendar.getInstance(); |
| long expirationMillisRelativeToNow = validateTokenExpiration(expirationMillis, identity); |
| long expirationMillisSinceEpoch = now.getTimeInMillis() + expirationMillisRelativeToNow; |
| final Calendar expiration = new Calendar.Builder().setInstant(expirationMillisSinceEpoch).build(); |
| |
| try { |
| // Get/create the key for this user |
| final Key key = keyService.getOrCreateKey(identity); |
| final byte[] keyBytes = key.getKey().getBytes(StandardCharsets.UTF_8); |
| |
| //logger.trace("Generating JWT for " + describe(authenticationResponse)); |
| |
| // TODO: Implement "jti" claim with nonce to prevent replay attacks and allow blacklisting of revoked tokens |
| // Build the token |
| return Jwts.builder().setSubject(identity) |
| .setIssuer(issuer) |
| .setAudience(audience) |
| .claim(USERNAME_CLAIM, preferredUsername) |
| .claim(KEY_ID_CLAIM, key.getId()) |
| .setIssuedAt(now.getTime()) |
| .setExpiration(expiration.getTime()) |
| .signWith(SIGNATURE_ALGORITHM, keyBytes).compact(); |
| } catch (NullPointerException e) { |
| final String errorMessage = "Could not retrieve the signing key for JWT for " + identity; |
| logger.error(errorMessage, e); |
| throw new JwtException(errorMessage, e); |
| } |
| |
| } |
| |
| private static long validateTokenExpiration(long proposedTokenExpiration, String identity) { |
| final long maxExpiration = TimeUnit.MILLISECONDS.convert(12, TimeUnit.HOURS); |
| final long minExpiration = TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES); |
| |
| if (proposedTokenExpiration > maxExpiration) { |
| logger.warn(String.format("Max token expiration exceeded. Setting expiration to %s from %s for %s", maxExpiration, |
| proposedTokenExpiration, identity)); |
| proposedTokenExpiration = maxExpiration; |
| } else if (proposedTokenExpiration < minExpiration) { |
| logger.warn(String.format("Min token expiration not met. Setting expiration to %s from %s for %s", minExpiration, |
| proposedTokenExpiration, identity)); |
| proposedTokenExpiration = minExpiration; |
| } |
| |
| return proposedTokenExpiration; |
| } |
| |
| private static String describe(AuthenticationResponse authenticationResponse) { |
| Calendar expirationTime = Calendar.getInstance(); |
| expirationTime.setTimeInMillis(authenticationResponse.getExpiration()); |
| long remainingTime = expirationTime.getTimeInMillis() - Calendar.getInstance().getTimeInMillis(); |
| |
| SimpleDateFormat dateFormat = new SimpleDateFormat("dd-MM-yyyy HH:mm:ss.SSS"); |
| dateFormat.setTimeZone(expirationTime.getTimeZone()); |
| String expirationTimeString = dateFormat.format(expirationTime.getTime()); |
| |
| return new StringBuilder("LoginAuthenticationToken for ") |
| .append(authenticationResponse.getUsername()) |
| .append(" issued by ") |
| .append(authenticationResponse.getIssuer()) |
| .append(" expiring at ") |
| .append(expirationTimeString) |
| .append(" [") |
| .append(authenticationResponse.getExpiration()) |
| .append(" ms, ") |
| .append(remainingTime) |
| .append(" ms remaining]") |
| .toString(); |
| } |
| } |