TokenProviderImpl.java

package jasper.security.jwt;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import jasper.component.ConfigCache;
import jasper.config.Props;
import jasper.domain.User;
import jasper.domain.proj.Tag;
import jasper.errors.UserTagInUseException;
import jasper.management.SecurityMetersService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.BadJwtException;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.JwtValidationException;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.web.client.RestTemplate;

import javax.crypto.spec.SecretKeySpec;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

import static jasper.domain.proj.HasOrigin.formatOrigin;
import static jasper.domain.proj.Tag.localTag;
import static jasper.domain.proj.Tag.matchesPublic;
import static jasper.domain.proj.Tag.prefix;
import static jasper.security.Auth.USER_TAG_HEADER;
import static jasper.security.Auth.getHeader;
import static jasper.security.AuthoritiesConstants.ADMIN;
import static jasper.security.AuthoritiesConstants.MOD;
import static jasper.security.AuthoritiesConstants.PRIVATE;
import static jasper.util.Logging.getMessage;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.springframework.util.StringUtils.hasText;

public class TokenProviderImpl extends AbstractTokenProvider implements TokenProvider {
	private final Logger logger = LoggerFactory.getLogger(TokenProviderImpl.class);

	private static final String INVALID_JWT_TOKEN = "Invalid JWT token.";

	private static final String[] ROOT_ROLES_ALLOWED = new String[]{ MOD, ADMIN };

	Map<String, JwtDecoder> jwtDecoders = new ConcurrentHashMap<>();

	private final SecurityMetersService securityMetersService;
	private final RestTemplate restTemplate;

	public TokenProviderImpl(Props props, ConfigCache caches, SecurityMetersService securityMetersService, RestTemplate restTemplate) {
		super(props, caches);
		this.securityMetersService = securityMetersService;
		this.restTemplate = restTemplate;
	}

	public Authentication getAuthentication(String token, String origin) {
		var claims = new Claims(getDecoder(origin).decode(token).getClaims());
		var principal = getUsername(claims, origin);
		User user;
		try {
			user = getUser(localTag(principal), claims, origin);
		} catch (UserTagInUseException e) {
			principal = localTag(principal) + "." + (int) Math.floor(Math.random() * 1000) + origin;
			user = getUser(localTag(principal), claims, origin);
		}
		logger.debug("{} Token Auth {}", origin, principal);
		return new JwtAuthentication(principal, user, claims, getAuthorities(claims, user, origin));
	}

	JwtDecoder getDecoder(String origin) {
		return jwtDecoders.computeIfAbsent(origin, o -> {
			var security = configs.security(o);
			switch (security.getMode()) {
				case "jwt":
					var secret = security.getSecretBytes();
					return noClockSkew(NimbusJwtDecoder
						.withSecretKey(new SecretKeySpec(secret, "HmacSHA512"))
						.macAlgorithm(MacAlgorithm.HS512)
						.jwtProcessorCustomizer(p -> p.setJWSKeySelector(new JWSVerificationKeySelector<SecurityContext>(
							Set.of(JWSAlgorithm.HS256, JWSAlgorithm.HS384, JWSAlgorithm.HS512),
							new ImmutableSecret<>(secret))))
						.build());
				case "jwks":
					return noClockSkew(NimbusJwtDecoder
						.withJwkSetUri(security.getJwksUri())
						.restOperations(restTemplate)
						.jwsAlgorithms(algs -> algs.addAll(List.of(SignatureAlgorithm.RS256, SignatureAlgorithm.RS384, SignatureAlgorithm.RS512)))
						.build());
				default:
					return null;
			}
		});
	}

	private static NimbusJwtDecoder noClockSkew(NimbusJwtDecoder decoder) {
		decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(new JwtTimestampValidator(Duration.ZERO)));
		return decoder;
	}

	Collection<? extends GrantedAuthority> getAuthorities(Claims claims, User user, String origin) {
		var auth = getPartialAuthorities(claims, origin);
		if (user != null && user.getRole() != null) {
			logger.debug("{} User Roles: {}", origin, user.getRole());
			if (User.ROLES.contains(user.getRole().trim())) {
				auth.add(new SimpleGrantedAuthority(user.getRole().trim()));
			}
		} else {
			logger.debug("{} No User", origin);
		}
		return auth;
	}

	List<SimpleGrantedAuthority> getPartialAuthorities(Claims claims, String origin) {
		var auth = getPartialAuthorities(origin);
		var authClaim = claims.getString(configs.security(origin).getAuthoritiesClaim());
		if (isNotBlank(authClaim)) {
			Arrays.stream(authClaim.split(","))
				.filter(r -> !r.isBlank())
				.map(String::trim)
				.map(SimpleGrantedAuthority::new)
				.forEach(auth::add);
		}
		return auth;
	}

	String getUsername(Claims claims, String origin) {
		var userTagHeader = getHeader(USER_TAG_HEADER);
		if (isBlank(userTagHeader) || !userTagHeader.matches(User.REGEX)) {
			userTagHeader = "";
		} else {
			userTagHeader = userTagHeader.toLowerCase();
		}
		var security = configs.security(origin);
		var principal = claims.getString(security.getUsernameClaim());
		logger.debug("{} User tag set by JWT claim {}: ({})", origin, security.getUsernameClaim(), principal);
		if (props.isAllowUserTagHeader() && isNotBlank(userTagHeader)) {
			principal = userTagHeader;
			logger.debug("{} User tag set by header: {}", origin, principal);
		} else if (security.isExternalId()) {
			var user = configs.getUserByExternalId(origin, principal);
			if (user.isPresent()) {
				logger.debug("{} Username: {} (external ID: {})", origin, user.get().getTag(), principal);
				if (isBlank(userTagHeader)) {
					return user.get().getTag() + origin;
				} else if (matchesPublic(principal, userTagHeader)) {
					logger.debug("{} User tag set by header: {}", origin, userTagHeader);
					return userTagHeader + origin;
				}
			}
		}
		logger.debug("{} Principal: {}", origin, principal);
		if (principal != null && principal.contains("@")) {
			var emailDomain = principal.substring(principal.indexOf("@") + 1);
			principal = principal.substring(0, principal.indexOf("@"));
			if (security.isEmailDomainInUsername() && !emailDomain.equals(security.getRootEmailDomain())) {
				principal = emailDomain + "/" + principal;
			}
		}
		var authorities = getPartialAuthorities(claims, origin);
		if (isBlank(principal) ||
			!principal.matches(Tag.QTAG_REGEX) ||
			principal.equals("+user") ||
			principal.equals("_user")) {
			logger.debug("{} Invalid principal {}.", origin, principal);
			if (authorities.stream().noneMatch(a ->
				Arrays.stream(ROOT_ROLES_ALLOWED).anyMatch(r -> a.getAuthority().equals(r)))) {
				// Invalid username and can't fall back to root user
				logger.debug("{} Root role not allowed.", origin);
				return null;
			}
			// The root user has access to every other user.
			// Only assign to mods or higher when username is missing.
			if (!"+user".equals(principal)) {
				// Default to private user if +user is not exactly specified
				principal = "_user";
			}
		} else if (!matchesPublic("+user", principal)) {
			var isPrivate = authorities.stream().map(GrantedAuthority::getAuthority).anyMatch(a -> a.equals(PRIVATE));
			principal = prefix(isPrivate ? "_user" : "+user", principal);
		}
		if (isNotBlank(userTagHeader) && (matchesPublic(principal, userTagHeader) || matchesPublic(security.getDefaultUser(), userTagHeader))) {
			logger.debug("{} User tag set by header: {}", origin, userTagHeader);
			principal = userTagHeader;
		}
		logger.debug("{} Username: {}", origin, principal);
		return principal + origin;
	}

	@Override
	public boolean validateToken(String authToken, String origin) {
		if (!hasText(authToken)) return false;
		var security = configs.security(origin);
		if (isBlank(security.getMode())) {
			logger.error("{} No client for origin {} in security settings", origin, formatOrigin(origin));
			return false;
		}
		try {
			var decoder = getDecoder(origin);
			if (decoder == null) {
				logger.error("{} No client for origin {} in security settings", origin, formatOrigin(origin));
				return false;
			}
			var jwt = decoder.decode(authToken);
			var audience = jwt.getAudience();
			if (isBlank(security.getClientId()) &&
				audience != null &&
				(!audience.contains("") || !audience.isEmpty())) {
				securityMetersService.trackTokenInvalidAudience();
				logger.trace(INVALID_JWT_TOKEN + " Invalid Audience");
			} else if (isNotBlank(security.getClientId()) &&
				(audience == null || !audience.contains(security.getClientId()) || audience.size() != 1)) {
				// TODO: add method to whitelist extra audiences
				securityMetersService.trackTokenInvalidAudience();
				logger.trace(INVALID_JWT_TOKEN + " Invalid Audience");
			} else if (isNotBlank(security.getVerifiedEmailClaim()) && jwt.getClaims().getOrDefault(security.getVerifiedEmailClaim(), Boolean.FALSE).equals(false)) {
				securityMetersService.trackUnverifiedEmail();
				logger.trace(INVALID_JWT_TOKEN + " Email is not verified");
			} else {
				return true;
			}
		} catch (JwtValidationException e) {
			if (e.getErrors().stream().anyMatch(err -> err.getDescription() != null && err.getDescription().contains("expired"))) {
				securityMetersService.trackTokenExpired();
			}
			logger.trace(INVALID_JWT_TOKEN, e);
		} catch (BadJwtException e) {
			var message = getMessageChain(e);
			if (message.contains("Invalid signature")) {
				securityMetersService.trackTokenInvalidSignature();
			} else if (message.contains("Another algorithm expected") || message.contains("Unsupported") || message.contains("not a valid JSON")) {
				securityMetersService.trackTokenUnsupported();
			} else {
				securityMetersService.trackTokenMalformed();
			}
			logger.trace(INVALID_JWT_TOKEN, e);
		} catch (JwtException e) {
			logger.trace(INVALID_JWT_TOKEN, e);
		} catch (IllegalArgumentException e) {
			logger.error("{} Token validation error {}", origin, getMessage(e));
		}
        return false;
	}

	private static String getMessageChain(Throwable e) {
		var sb = new StringBuilder();
		while (e != null) {
			if (e.getMessage() != null) sb.append(e.getMessage()).append(" ");
			e = e.getCause();
		}
		return sb.toString();
	}
}